feat: add native world3d canvas workflow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-04 22:59:00 +08:00
parent 670a50c4d0
commit 67602b0af7
10 changed files with 1361 additions and 129 deletions

View File

@ -51,6 +51,7 @@ import {
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -99,6 +100,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
type GenerationMode = "text" | "image" | "multiview" | "batch";
type WorkspaceMode = "model" | "world";
type WorldGenerationMode = "text" | "image" | "multi-image" | "video";
type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
type TaskProvider = "tripo-3d" | "worldlabs-3d";
type TaskMode =
| GenerationMode
@ -576,6 +578,8 @@ export default function CanvasModel3DPage() {
const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState<string | null>(
null
);
const [worldViewportMode, setWorldViewportMode] =
useState<WorldViewportMode>("world");
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
@ -1538,8 +1542,33 @@ export default function CanvasModel3DPage() {
};
const handleImportToCanvas = async () => {
if (!currentModelUrl || !currentTask || currentTask.status !== "success") {
toast.error("暂无可导入的 3D 模型");
if (!currentTask || currentTask.status !== "success") {
toast.error("暂无可导入的世界资产");
return;
}
const isWorldTask = currentTask.provider === "worldlabs-3d";
const worldAssetPayload = currentTask.assetPayload ?? null;
const primarySplatUrl = isWorldTask
? Object.values(worldAssetPayload?.spzUrls || {}).find(
(value): value is string => typeof value === "string" && value.trim().length > 0
) || null
: null;
const worldImageUrl =
worldAssetPayload?.panoUrl ||
worldAssetPayload?.thumbnailUrl ||
currentTask.previewImageUrl ||
null;
const canImportWorld = Boolean(
currentModelUrl ||
worldAssetPayload?.worldUrl ||
worldImageUrl ||
primarySplatUrl
);
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
toast.error(isWorldTask ? "暂无可导入的 3D 世界" : "暂无可导入的 3D 模型");
return;
}
@ -1555,38 +1584,81 @@ export default function CanvasModel3DPage() {
const y =
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
addNode({
type: "model3d",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: currentModelUrl,
previewImage: currentTask.previewImageUrl || undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
format: extractModelFormat(currentModelUrl),
status: "completed",
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
},
},
connections: [],
});
addNode(
isWorldTask
? {
type: "world3d",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined,
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined,
previewImage:
currentTask.previewImageUrl ||
worldAssetPayload?.thumbnailUrl ||
undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
status: "completed",
activeView: primarySplatUrl
? "splat"
: worldAssetPayload?.worldUrl
? "world"
: worldImageUrl
? "image"
: "mesh",
firstPerson: false,
hotspots: [],
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
},
},
connections: [],
}
: {
type: "model3d",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: currentModelUrl!,
previewImage: currentTask.previewImageUrl || undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
format: extractModelFormat(currentModelUrl!),
status: "completed",
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
triangles: viewerStats.triangles,
vertices: viewerStats.vertices,
meshCount: viewerStats.meshCount,
assetPayload: currentTask.assetPayload ?? null,
},
},
connections: [],
}
);
try {
await canvasService.saveCanvasState(id, getCanvasState());
} catch (error) {
console.error("[Model3DPage] 保存画布失败:", error);
}
toast.success("3D 模型已导入画布");
toast.success(isWorldTask ? "3D 世界已导入画布" : "3D 模型已导入画布");
};
const handleDownloadCurrent = () => {
@ -1612,12 +1684,15 @@ export default function CanvasModel3DPage() {
};
const handleOpenPanoUrl = () => {
const panoUrl = currentTask?.assetPayload?.panoUrl;
if (!panoUrl) {
toast.error("当前任务暂无可打开的全景图");
const imageUrl =
currentTask?.assetPayload?.panoUrl ||
currentTask?.assetPayload?.thumbnailUrl ||
currentPreviewImage;
if (!imageUrl) {
toast.error("当前任务暂无可打开的图像预览");
return;
}
window.open(panoUrl, "_blank", "noopener,noreferrer");
window.open(imageUrl, "_blank", "noopener,noreferrer");
};
const handleConvertAndDownload = async () => {
@ -1907,25 +1982,86 @@ export default function CanvasModel3DPage() {
);
}, []);
const currentWorldAsset =
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
const currentWorldId =
currentWorldAsset?.worldId || currentTask?.sourceTaskId || null;
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
const currentPrimarySpzUrl =
currentSpzEntries.find(([, url]) => typeof url === "string" && url.trim())?.[1] || null;
const currentWorldImageUrl =
currentWorldAsset?.panoUrl ||
currentWorldAsset?.thumbnailUrl ||
(workspaceMode === "world" ? currentPreviewImage : null) ||
null;
const importDisabled =
!currentTask || currentTask.status !== "success" || !currentModelUrl;
!currentTask ||
currentTask.status !== "success" ||
(currentTask.provider === "worldlabs-3d"
? !Boolean(
currentModelUrl ||
currentWorldAsset?.worldUrl ||
currentWorldImageUrl ||
currentPrimarySpzUrl
)
: !currentModelUrl);
const activeStatusLabel = currentTask
? statusLabel(currentTask.status)
: "未开始";
const activePromptSummary = currentTask?.prompt?.trim() || "尚未创建任务";
const activePromptSummary =
currentTask?.prompt?.trim() ||
(workspaceMode === "world" ? "尚未创建世界任务" : "尚未创建模型任务");
const activeProviderLabel =
currentTask?.provider === "worldlabs-3d" ? "World Labs" : "Tripo3D";
currentTask?.provider === "worldlabs-3d" || workspaceMode === "world"
? "World Labs"
: "Tripo3D";
const activeRelativeTime = currentTask
? formatDistanceToNow(new Date(currentTask.createdAt), {
addSuffix: true,
locale: zhCN,
})
: null;
const currentWorldAsset =
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
const currentWorldId =
currentWorldAsset?.worldId || currentTask?.sourceTaskId || null;
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
const availableWorldViewportModes = useMemo(() => {
const modes: WorldViewportMode[] = [];
if (currentPrimarySpzUrl) modes.push("splat");
if (currentWorldAsset?.worldUrl) modes.push("world");
if (currentWorldImageUrl) modes.push("imagery");
if (currentModelUrl) modes.push("mesh");
return modes;
}, [currentModelUrl, currentPrimarySpzUrl, currentWorldAsset?.worldUrl, currentWorldImageUrl]);
const preferredWorldViewportMode: WorldViewportMode =
currentPrimarySpzUrl
? "splat"
: currentWorldAsset?.worldUrl
? "world"
: currentWorldImageUrl
? "imagery"
: currentModelUrl
? "mesh"
: "world";
const activeWorkspaceTitle =
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
const viewerEmptyStateTitle =
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
const waitingTaskLabel =
workspaceMode === "world" ? "等待创建世界任务" : "等待创建模型任务";
useEffect(() => {
if (workspaceMode !== "world") return;
setWorldViewportMode(preferredWorldViewportMode);
}, [workspaceMode, currentTask?.taskId, preferredWorldViewportMode]);
useEffect(() => {
if (workspaceMode !== "world") return;
if (availableWorldViewportModes.length === 0) return;
if (!availableWorldViewportModes.includes(worldViewportMode)) {
setWorldViewportMode(preferredWorldViewportMode);
}
}, [
availableWorldViewportModes,
preferredWorldViewportMode,
workspaceMode,
worldViewportMode,
]);
return (
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
@ -1962,7 +2098,7 @@ export default function CanvasModel3DPage() {
className="h-8 gap-1 px-2 text-zinc-400 hover:bg-white/5 hover:text-white"
variant="ghost"
>
3D 工作台
{activeWorkspaceTitle}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</nav>
@ -2764,11 +2900,11 @@ export default function CanvasModel3DPage() {
</Button>
<Button
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
disabled={!currentWorldAsset?.panoUrl}
disabled={!currentWorldImageUrl}
onClick={handleOpenPanoUrl}
variant="outline"
>
<PanelTop className="mr-2 h-4 w-4" /> 打开全景
<ImageIcon className="mr-2 h-4 w-4" /> 打开图像
</Button>
</div>
@ -2776,7 +2912,7 @@ export default function CanvasModel3DPage() {
<CardHeader className="space-y-1 pb-3">
<CardTitle className="text-sm">当前世界资产</CardTitle>
<CardDescription className="text-xs text-zinc-500">
第一版支持将 World Labs 结果以 mesh 形式导入画布,并保留 World 与全景链接。
当前支持以原生 world3d 节点导入画布,并保留 World、Splat、图像与 Mesh 入口。
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-xs text-zinc-400">
@ -2821,16 +2957,14 @@ export default function CanvasModel3DPage() {
</div>
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
<span className="flex items-center gap-2">
<PanelTop className="h-3.5 w-3.5" />
Panorama
<ImageIcon className="h-3.5 w-3.5" />
图像预览
</span>
<Badge
className="text-[10px]"
variant={
currentWorldAsset?.panoUrl ? "default" : "secondary"
}
variant={currentWorldImageUrl ? "default" : "secondary"}
>
{currentWorldAsset?.panoUrl ? "可用" : "暂无"}
{currentWorldImageUrl ? "可用" : "暂无"}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
@ -2967,9 +3101,9 @@ export default function CanvasModel3DPage() {
</div>
</div>
<div className="rounded-md bg-white/5 px-3 py-2">
<div className="text-[11px] text-zinc-500">全景图</div>
<div className="text-[11px] text-zinc-500">图像预览</div>
<div className="truncate text-zinc-200">
{currentWorldAsset?.panoUrl ? "可访问" : "暂无"}
{currentWorldImageUrl ? "可访问" : "暂无"}
</div>
</div>
<div className="rounded-md bg-white/5 px-3 py-2">
@ -3031,7 +3165,7 @@ export default function CanvasModel3DPage() {
>
{activeProviderLabel}
</Badge>
{currentTask?.provider === "worldlabs-3d" && (
{workspaceMode === "world" && (
<Badge
className="border-sky-500/30 bg-sky-500/10 text-sky-300"
variant="outline"
@ -3045,7 +3179,7 @@ export default function CanvasModel3DPage() {
</p>
<div className="mt-2 flex items-center gap-2 text-[11px] text-zinc-500">
<History className="h-3.5 w-3.5" />
<span>{activeRelativeTime || "等待创建任务"}</span>
<span>{activeRelativeTime || waitingTaskLabel}</span>
{currentWorldId && (
<>
<span className="text-zinc-700">•</span>
@ -3054,73 +3188,136 @@ export default function CanvasModel3DPage() {
)}
</div>
</div>
<Model3DViewer
layerVisibility={effectiveLayerVisibility}
modelUrl={currentModelUrl}
onLayersChange={handleLayersChange}
onStatsChange={setViewerStats}
previewImageUrl={currentPreviewImage}
settings={viewerSettings}
/>
{workspaceMode === "world" && (
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/75 p-2 shadow-2xl backdrop-blur-md">
{[
{
id: "splat" as WorldViewportMode,
label: "Splat",
icon: Sparkles,
disabled: !currentPrimarySpzUrl,
},
{
id: "world" as WorldViewportMode,
label: "World",
icon: Globe,
disabled: !currentWorldAsset?.worldUrl,
},
{
id: "imagery" as WorldViewportMode,
label: "图像",
icon: ImageIcon,
disabled: !currentWorldImageUrl,
},
{
id: "mesh" as WorldViewportMode,
label: "Mesh",
icon: Box,
disabled: !currentModelUrl,
},
].map((view) => (
<Button
className="gap-2 rounded-full"
disabled={view.disabled}
key={view.id}
onClick={() => setWorldViewportMode(view.id)}
size="sm"
variant={worldViewportMode === view.id ? "secondary" : "ghost"}
>
<view.icon className="h-4 w-4" />
{view.label}
</Button>
))}
</div>
)}
{workspaceMode === "world" ? (
<World3DViewer
emptyStateTitle={viewerEmptyStateTitle}
imageUrl={currentWorldImageUrl}
meshLayerVisibility={effectiveLayerVisibility}
meshSettings={viewerSettings}
meshUrl={currentModelUrl}
mode={worldViewportMode === "imagery" ? "image" : worldViewportMode}
onMeshLayersChange={handleLayersChange}
onMeshStatsChange={setViewerStats}
previewImageUrl={currentPreviewImage}
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
splatUrl={currentPrimarySpzUrl}
worldUrl={currentWorldAsset?.worldUrl}
/>
) : (
<Model3DViewer
emptyStateTitle={viewerEmptyStateTitle}
layerVisibility={effectiveLayerVisibility}
modelUrl={currentModelUrl}
onLayersChange={handleLayersChange}
onStatsChange={setViewerStats}
previewImageUrl={currentPreviewImage}
settings={viewerSettings}
/>
)}
{/* Floating Toolbar */}
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
onClick={() =>
{(workspaceMode !== "world" || worldViewportMode === "mesh") && (
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
onClick={() =>
setViewerSettings((prev) => ({
...prev,
showGrid: !prev.showGrid,
}))
}
size="icon"
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
>
<Grid3X3 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>网格</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
onClick={() =>
setViewerSettings((prev) => ({
...prev,
autoRotate: !prev.autoRotate,
}))
}
size="icon"
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
>
<RotateCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>自动旋转</TooltipContent>
</Tooltip>
<Separator className="h-4 bg-white/20" orientation="vertical" />
<div className="flex items-center gap-2 px-2">
<Sun className="h-3 w-3 text-zinc-400" />
<Slider
className="w-20"
max={2}
min={0.2}
onValueChange={(v) =>
setViewerSettings((prev) => ({
...prev,
showGrid: !prev.showGrid,
lightIntensity: v[0] || 1,
}))
}
size="icon"
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
>
<Grid3X3 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>网格</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
onClick={() =>
setViewerSettings((prev) => ({
...prev,
autoRotate: !prev.autoRotate,
}))
}
size="icon"
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
>
<RotateCw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>自动旋转</TooltipContent>
</Tooltip>
<Separator className="h-4 bg-white/20" orientation="vertical" />
<div className="flex items-center gap-2 px-2">
<Sun className="h-3 w-3 text-zinc-400" />
<Slider
className="w-20"
max={2}
min={0.2}
onValueChange={(v) =>
setViewerSettings((prev) => ({
...prev,
lightIntensity: v[0] || 1,
}))
}
step={0.1}
value={[viewerSettings.lightIntensity]}
/>
</div>
</TooltipProvider>
</div>
step={0.1}
value={[viewerSettings.lightIntensity]}
/>
</div>
</TooltipProvider>
</div>
)}
</div>
{/* 4. Right Assets Panel */}

View File

@ -9,6 +9,7 @@ import { ImageNode } from "./nodes/ImageNode";
import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode";
import { VideoNode } from "./nodes/VideoNode";
import { World3DNode } from "./nodes/World3DNode";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@ -1036,6 +1037,9 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
{node.type === 'model3d' && (
<Model3DNode node={node} isSelected={isSelected} />
)}
{node.type === 'world3d' && (
<World3DNode node={node} isSelected={isSelected} />
)}
{node.type === 'message' && (
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
<span className="text-sm">{(node.data as any).content}</span>

View File

@ -48,6 +48,7 @@ export type Model3DViewerSettings = {
type Model3DViewerProps = {
modelUrl?: string | null;
previewImageUrl?: string | null;
emptyStateTitle?: string;
settings: Model3DViewerSettings;
layerVisibility?: Record<string, boolean>;
onLayersChange?: (layers: Model3DLayer[]) => void;
@ -189,7 +190,13 @@ function isGlbOrGltf(url?: string | null) {
return /\.(gltf|glb)(\?|#|$)/.test(value);
}
function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
function EmptyState({
previewImageUrl,
title,
}: {
previewImageUrl?: string | null;
title: string;
}) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/50 p-4">
{previewImageUrl ? (
@ -208,7 +215,7 @@ function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/50 shadow-inner">
<Box className="h-10 w-10 opacity-20" />
</div>
<p className="font-medium text-sm">准备生成 3D 资产</p>
<p className="font-medium text-sm">{title}</p>
</div>
)}
</div>
@ -218,6 +225,7 @@ function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
export function Model3DViewer({
modelUrl,
previewImageUrl,
emptyStateTitle = "准备生成 3D 资产",
settings,
layerVisibility,
onLayersChange,
@ -239,7 +247,12 @@ export function Model3DViewer({
}, [modelUrl]);
if (!modelUrl) {
return <EmptyState previewImageUrl={previewImageUrl} />;
return (
<EmptyState
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
);
}
if (!isGlbOrGltf(modelUrl)) {

View File

@ -7,6 +7,8 @@ import {
Copy,
Download,
ExternalLink,
Globe,
ImageIcon,
Loader2,
MoreVertical,
Trash2,
@ -53,21 +55,54 @@ function inferModelFormat(data: Model3DNodeData) {
return "3D";
}
function readWorldAssetPayload(data: Model3DNodeData) {
const raw = data.metadata?.assetPayload;
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
}
function readOptionalUrl(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
const data = node.data as Model3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const providerLabel =
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
const worldAssetPayload = readWorldAssetPayload(data);
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
const worldImageUrl =
readOptionalUrl(worldAssetPayload?.panoUrl) ||
readOptionalUrl(worldAssetPayload?.thumbnailUrl) ||
readOptionalUrl(data.previewImage);
const primaryOpenUrl = worldUrl || data.src || null;
const primaryOpenLabel = worldUrl ? "打开 World" : "打开 3D";
const handleOpen = () => {
if (!primaryOpenUrl) {
toast.error("暂无可打开的地址");
return;
}
window.open(primaryOpenUrl, "_blank", "noopener,noreferrer");
};
const handleOpenMesh = () => {
if (!data.src) {
toast.error("暂无可打开的 3D 地址");
toast.error("暂无可打开的 Mesh 地址");
return;
}
window.open(data.src, "_blank", "noopener,noreferrer");
};
const handleOpenImage = () => {
if (!worldImageUrl) {
toast.error("暂无可打开的图像预览");
return;
}
window.open(worldImageUrl, "_blank", "noopener,noreferrer");
};
const handleDownload = () => {
if (!data.src) {
toast.error("暂无可下载的 3D 文件");
@ -80,13 +115,13 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
};
const handleCopy = async () => {
if (!data.src) {
if (!primaryOpenUrl) {
toast.error("暂无可复制的地址");
return;
}
try {
await navigator.clipboard.writeText(data.src);
toast.success("已复制 3D 链接");
await navigator.clipboard.writeText(primaryOpenUrl);
toast.success(worldUrl ? "已复制 World 链接" : "已复制 3D 链接");
} catch {
toast.error("复制失败");
}
@ -104,12 +139,20 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
{data.status === "generating" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">3D 生成中...</span>
<span className="text-xs text-muted-foreground">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成中..."
: "3D 生成中..."}
</span>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
<AlertCircle className="h-7 w-7 text-destructive" />
<span className="text-xs text-destructive">3D 生成失败</span>
<span className="text-xs text-destructive">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成失败"
: "3D 生成失败"}
</span>
</div>
) : (
<div className="flex h-full w-full flex-col">
@ -148,7 +191,7 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
size="icon"
className="h-7 w-7"
onClick={handleOpen}
title="打开3D"
title={primaryOpenLabel}
>
<ExternalLink className="h-4 w-4" />
</Button>
@ -160,9 +203,25 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleOpen}>
<ExternalLink className="mr-2 h-4 w-4" />
打开 3D
{worldUrl ? (
<Globe className="mr-2 h-4 w-4" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{primaryOpenLabel}
</DropdownMenuItem>
{worldUrl && data.src && data.src !== worldUrl && (
<DropdownMenuItem onClick={handleOpenMesh}>
<Box className="mr-2 h-4 w-4" />
打开 Mesh
</DropdownMenuItem>
)}
{worldImageUrl && (
<DropdownMenuItem onClick={handleOpenImage}>
<ImageIcon className="mr-2 h-4 w-4" />
打开图像
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
下载

View File

@ -0,0 +1,373 @@
"use client";
import React, { useMemo, useState } from "react";
import {
AlertCircle,
Box,
Copy,
Crosshair,
Download,
ExternalLink,
Globe,
ImageIcon,
Loader2,
MoreVertical,
Sparkles,
Trash2,
Waypoints,
} from "lucide-react";
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface World3DNodeProps {
node: CanvasNode;
isSelected: boolean;
}
const VIEW_META: Array<{
id: World3DViewMode;
label: string;
icon: React.ComponentType<{ className?: string }>;
}> = [
{ id: "splat", label: "S", icon: Sparkles },
{ id: "world", label: "W", icon: Globe },
{ id: "image", label: "I", icon: ImageIcon },
{ id: "mesh", label: "M", icon: Box },
];
function resolveAvailableViews(data: World3DNodeData) {
const views: World3DViewMode[] = [];
if (data.splatUrl) views.push("splat");
if (data.worldUrl) views.push("world");
if (data.imageUrl || data.previewImage) views.push("image");
if (data.meshUrl) views.push("mesh");
return views;
}
function resolveActiveView(data: World3DNodeData) {
const availableViews = resolveAvailableViews(data);
if (data.activeView && availableViews.includes(data.activeView)) {
return data.activeView;
}
return availableViews[0] || "splat";
}
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
if (activeView === "splat" && data.splatUrl) {
return { url: data.splatUrl, filename: "world.spz" };
}
if (activeView === "mesh" && data.meshUrl) {
return { url: data.meshUrl, filename: "world.glb" };
}
if (data.splatUrl) {
return { url: data.splatUrl, filename: "world.spz" };
}
if (data.meshUrl) {
return { url: data.meshUrl, filename: "world.glb" };
}
if (data.imageUrl) {
return { url: data.imageUrl, filename: "world.jpg" };
}
if (data.worldUrl) {
return { url: data.worldUrl, filename: "world.url" };
}
return null;
}
function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
switch (activeView) {
case "world":
return data.worldUrl || data.splatUrl || data.meshUrl || data.imageUrl || null;
case "image":
return data.imageUrl || data.previewImage || data.worldUrl || data.meshUrl || null;
case "mesh":
return data.meshUrl || data.splatUrl || data.worldUrl || null;
case "splat":
default:
return data.splatUrl || data.worldUrl || data.meshUrl || data.imageUrl || null;
}
}
export function World3DNode({ node, isSelected }: World3DNodeProps) {
const data = node.data as World3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const activeView = useMemo(() => resolveActiveView(data), [data]);
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
const primaryUrl = resolvePrimaryUrl(data, activeView);
const downloadTarget = inferDownloadTarget(data, activeView);
const hotspotCount = data.hotspots?.length || 0;
const handleSetView = (view: World3DViewMode) => {
updateNode(node.id, {
data: {
...data,
activeView: view,
} as World3DNodeData,
});
};
const handleToggleFirstPerson = () => {
const nextFirstPerson = !data.firstPerson;
updateNode(node.id, {
data: {
...data,
firstPerson: nextFirstPerson,
} as World3DNodeData,
});
if (nextFirstPerson) {
setInteractionMode("navigate");
}
};
const handleToggleHotspotMode = () => {
const nextMode = interactionMode === "hotspot" ? "navigate" : "hotspot";
setInteractionMode(nextMode);
if (nextMode === "hotspot" && data.firstPerson) {
updateNode(node.id, {
data: {
...data,
firstPerson: false,
} as World3DNodeData,
});
}
};
const handlePick = (pick: { point: [number, number, number] }) => {
if (interactionMode !== "hotspot") return;
const nextHotspot: World3DHotspot = {
id: crypto.randomUUID(),
label: `热点 ${hotspotCount + 1}`,
position: {
x: pick.point[0],
y: pick.point[1],
z: pick.point[2],
},
createdAt: new Date().toISOString(),
};
updateNode(node.id, {
data: {
...data,
hotspots: [...(data.hotspots || []), nextHotspot],
} as World3DNodeData,
});
setInteractionMode("navigate");
toast.success("已添加世界热点");
};
const handleOpen = () => {
if (!primaryUrl) {
toast.error("暂无可打开的世界资产");
return;
}
window.open(primaryUrl, "_blank", "noopener,noreferrer");
};
const handleCopy = async () => {
if (!primaryUrl) {
toast.error("暂无可复制的世界地址");
return;
}
try {
await navigator.clipboard.writeText(primaryUrl);
toast.success("已复制世界链接");
} catch {
toast.error("复制失败");
}
};
const handleDownload = () => {
if (!downloadTarget) {
toast.error("暂无可下载的世界资产");
return;
}
const link = document.createElement("a");
link.href = downloadTarget.url;
link.download = downloadTarget.filename;
link.click();
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-xl border bg-card shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">3D 世界生成中...</span>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
<AlertCircle className="h-7 w-7 text-destructive" />
<span className="text-xs text-destructive">3D 世界生成失败</span>
</div>
) : (
<>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={data.meshUrl}
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={data.splatUrl}
worldUrl={data.worldUrl}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-3 pb-3 pt-12">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">
{data.prompt || "World Labs 世界"}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge className="border-white/10 bg-black/55 text-zinc-200" variant="outline">
World Labs
</Badge>
{hotspotCount ? (
<Badge className="border-amber-500/30 bg-amber-500/10 text-amber-200" variant="outline">
{hotspotCount} 个热点
</Badge>
) : null}
<Badge className="border-white/10 bg-black/55 text-zinc-300" variant="outline">
{activeView.toUpperCase()}
</Badge>
</div>
</div>
<p className="text-[11px] text-zinc-400">
{data.modelVersion || "原生世界节点"}
</p>
</div>
</div>
</>
)}
<div className="absolute left-3 top-3 z-20 flex items-center gap-2">
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => (
<Button
className="h-7 min-w-7 rounded-full px-2 text-[10px]"
key={view.id}
onClick={() => handleSetView(view.id)}
size="sm"
type="button"
variant={activeView === view.id ? "secondary" : "ghost"}
>
{view.label}
</Button>
))}
</div>
{activeView === "splat" && data.status === "completed" ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
<Button
className="h-7 rounded-full px-2"
onClick={handleToggleFirstPerson}
size="sm"
type="button"
variant={data.firstPerson ? "secondary" : "ghost"}
>
<Waypoints className="h-3.5 w-3.5" />
</Button>
<Button
className="h-7 rounded-full px-2"
onClick={handleToggleHotspotMode}
size="sm"
type="button"
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
>
<Crosshair className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
</div>
{isHovered && data.status === "completed" && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-1">
<Button
className="h-7 w-7 rounded-full"
onClick={handleOpen}
size="icon"
title="打开世界"
variant="secondary"
>
<ExternalLink className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-7 w-7 rounded-full" size="icon" variant="secondary">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{data.worldUrl ? (
<DropdownMenuItem onClick={() => window.open(data.worldUrl, "_blank", "noopener,noreferrer")}>
<Globe className="mr-2 h-4 w-4" />
打开 World
</DropdownMenuItem>
) : null}
{data.splatUrl ? (
<DropdownMenuItem onClick={() => window.open(data.splatUrl, "_blank", "noopener,noreferrer")}>
<Sparkles className="mr-2 h-4 w-4" />
打开 Splat
</DropdownMenuItem>
) : null}
{data.meshUrl ? (
<DropdownMenuItem onClick={() => window.open(data.meshUrl, "_blank", "noopener,noreferrer")}>
<Box className="mr-2 h-4 w-4" />
打开 Mesh
</DropdownMenuItem>
) : null}
{data.imageUrl ? (
<DropdownMenuItem onClick={() => window.open(data.imageUrl, "_blank", "noopener,noreferrer")}>
<ImageIcon className="mr-2 h-4 w-4" />
打开图像
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
下载当前资产
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
复制链接
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,534 @@
"use client";
/* eslint-disable @next/next/no-img-element */
import { Html, OrbitControls, PointerLockControls } from "@react-three/drei";
import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber";
import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
import {
AlertCircle,
Box,
Crosshair,
Globe,
ImageIcon,
Loader2,
Sparkles,
} from "lucide-react";
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import {
type Model3DLayer,
type Model3DStats,
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { World3DHotspot, World3DViewMode } from "@/types/canvas";
type World3DPickResult = {
point: [number, number, number];
};
type World3DViewerProps = {
mode: World3DViewMode;
worldUrl?: string | null;
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
previewImageUrl?: string | null;
emptyStateTitle?: string;
meshSettings?: Model3DViewerSettings;
meshLayerVisibility?: Record<string, boolean>;
onMeshLayersChange?: (layers: Model3DLayer[]) => void;
onMeshStatsChange?: (stats: Model3DStats) => void;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
className?: string;
};
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
envPreset: "city",
lightIntensity: 1,
autoRotate: false,
showGrid: true,
wireframe: false,
};
function getSemanticFacts(metadata?: Record<string, unknown> | null) {
if (!metadata) return [];
const facts: Array<{ label: string; value: string }> = [];
const scaleFactor = metadata.metric_scale_factor;
if (typeof scaleFactor === "number" && Number.isFinite(scaleFactor)) {
facts.push({
label: "比例尺",
value: `${scaleFactor.toFixed(3)}x`,
});
}
const groundOffset = metadata.ground_plane_offset;
if (typeof groundOffset === "number" && Number.isFinite(groundOffset)) {
facts.push({
label: "地平面偏移",
value: `${groundOffset.toFixed(2)}m`,
});
}
return facts;
}
function WorldEmptyState({
icon: Icon,
title,
description,
previewImageUrl,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
previewImageUrl?: string | null;
}) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/70 p-6 text-center">
{previewImageUrl ? (
<div className="relative aspect-video w-full max-w-xl overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-2xl">
<img
alt={title}
className="h-full w-full object-cover opacity-50"
src={previewImageUrl}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30 backdrop-blur-[1px]">
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
</div>
</div>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
<Icon className="h-10 w-10 text-zinc-700" />
</div>
)}
<div className="space-y-2">
<p className="font-medium text-lg text-zinc-200">{title}</p>
<p className="max-w-md text-sm text-zinc-500">{description}</p>
</div>
</div>
);
}
function FirstPersonMovement({ enabled }: { enabled: boolean }) {
const { camera } = useThree();
const pressedKeysRef = useRef<Record<string, boolean>>({});
const directionRef = useRef(new THREE.Vector3());
const rightRef = useRef(new THREE.Vector3());
const upRef = useRef(new THREE.Vector3());
useEffect(() => {
if (!enabled) {
pressedKeysRef.current = {};
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
pressedKeysRef.current[event.code] = true;
};
const handleKeyUp = (event: KeyboardEvent) => {
pressedKeysRef.current[event.code] = false;
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [enabled]);
useFrame((_, delta) => {
if (!enabled) return;
const pressed = pressedKeysRef.current;
const moveSpeed = pressed.ShiftLeft || pressed.ShiftRight ? 6.5 : 3.4;
const distance = moveSpeed * delta;
camera.getWorldDirection(directionRef.current);
directionRef.current.normalize();
rightRef.current.crossVectors(directionRef.current, camera.up).normalize();
upRef.current.copy(camera.up).normalize();
if (pressed.KeyW) camera.position.addScaledVector(directionRef.current, distance);
if (pressed.KeyS) camera.position.addScaledVector(directionRef.current, -distance);
if (pressed.KeyA) camera.position.addScaledVector(rightRef.current, -distance);
if (pressed.KeyD) camera.position.addScaledVector(rightRef.current, distance);
if (pressed.KeyQ) camera.position.addScaledVector(upRef.current, distance);
if (pressed.KeyE) camera.position.addScaledVector(upRef.current, -distance);
});
if (!enabled) return null;
return <PointerLockControls makeDefault />;
}
function HotspotBillboard({
hotspot,
onSelect,
}: {
hotspot: World3DHotspot;
onSelect?: (hotspotId: string) => void;
}) {
return (
<group position={[hotspot.position.x, hotspot.position.y, hotspot.position.z]}>
<mesh
onClick={(event) => {
event.stopPropagation();
onSelect?.(hotspot.id);
}}
>
<sphereGeometry args={[0.07, 24, 24]} />
<meshStandardMaterial color="#facc15" emissive="#ca8a04" emissiveIntensity={0.8} />
</mesh>
<Html center distanceFactor={8} position={[0, 0.14, 0]}>
<div className="rounded-full border border-yellow-400/30 bg-black/70 px-2 py-1 text-[10px] font-medium text-yellow-200 shadow-lg backdrop-blur-sm">
{hotspot.label}
</div>
</Html>
</group>
);
}
function WorldSplatScene({
splatUrl,
firstPerson = false,
hotspots = [],
interactionMode = "navigate",
onPick,
onHotspotSelect,
semanticsMetadata,
onLoadStateChange,
}: {
splatUrl: string;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
onLoadStateChange?: (state: "ready" | "error") => void;
}) {
const renderer = useThree((state) => state.gl);
const semanticFacts = useMemo(
() => getSemanticFacts(semanticsMetadata),
[semanticsMetadata]
);
const sparkRenderer = useMemo(
() =>
new SparkRenderer({
renderer,
blurAmount: 0.2,
maxStdDev: Math.sqrt(7),
}),
[renderer]
);
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
useEffect(() => {
sparkRenderer.frustumCulled = false;
sparkRenderer.renderOrder = 10;
return () => {
sparkRenderer.removeFromParent();
};
}, [sparkRenderer]);
useEffect(() => {
splatMesh.frustumCulled = false;
let cancelled = false;
splatMesh.initialized
.then(() => {
if (!cancelled) onLoadStateChange?.("ready");
})
.catch(() => {
if (!cancelled) onLoadStateChange?.("error");
});
return () => {
cancelled = true;
splatMesh.dispose();
};
}, [onLoadStateChange, splatMesh]);
useFrame(() => {
sparkRenderer.needsUpdate = true;
});
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? semanticsMetadata.ground_plane_offset
: null;
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
if (interactionMode !== "hotspot") return;
event.stopPropagation();
onPick?.({
point: [event.point.x, event.point.y, event.point.z],
});
};
return (
<>
<color args={["#020617"]} attach="background" />
<fog attach="fog" args={["#020617", 10, 45]} />
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{groundPlaneOffset !== null ? (
<gridHelper
args={[18, 18, "#334155", "#0f172a"]}
position={[0, groundPlaneOffset, 0]}
/>
) : null}
<Suspense fallback={null}>
<primitive object={sparkRenderer}>
<primitive object={splatMesh} onPointerDown={handlePointerDown} />
</primitive>
</Suspense>
{hotspots.map((hotspot) => (
<HotspotBillboard
hotspot={hotspot}
key={hotspot.id}
onSelect={onHotspotSelect}
/>
))}
{!firstPerson ? (
<OrbitControls
autoRotate={false}
enablePan
enableRotate
enableZoom
makeDefault
maxDistance={16}
minDistance={0.5}
/>
) : (
<FirstPersonMovement enabled />
)}
<Html className="pointer-events-none" fullscreen>
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex items-start justify-between">
<div className="flex gap-2">
<Badge className="border-cyan-500/30 bg-cyan-500/10 text-cyan-200" variant="outline">
SPZ
</Badge>
{semanticFacts.map((fact) => (
<Badge
className="border-white/10 bg-black/50 text-zinc-200"
key={fact.label}
variant="outline"
>
{fact.label} {fact.value}
</Badge>
))}
</div>
{interactionMode === "hotspot" ? (
<div className="rounded-full border border-yellow-400/30 bg-black/65 px-3 py-1 text-[11px] text-yellow-100">
点击场景放置热点
</div>
) : null}
</div>
<div className="flex items-end justify-between">
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-zinc-300 backdrop-blur-sm">
{firstPerson ? "WASD 移动 · 鼠标观察 · Shift 加速 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 原生 SPZ 渲染"}
</div>
{(firstPerson || interactionMode === "hotspot") && (
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
<Crosshair className="h-5 w-5" />
</div>
)}
</div>
</div>
</Html>
</>
);
}
function WorldSplatViewer({
splatUrl,
previewImageUrl,
firstPerson,
hotspots,
interactionMode,
onPick,
onHotspotSelect,
semanticsMetadata,
}: {
splatUrl?: string | null;
previewImageUrl?: string | null;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
}) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
setLoadState("loading");
}, [splatUrl]);
if (!splatUrl) {
return (
<WorldEmptyState
description="当前结果没有返回可用的 SPZ 世界资产。"
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="准备加载 Splat 世界"
/>
);
}
if (loadState === "error") {
return (
<WorldEmptyState
description="SPZ 资源暂时无法加载,可以切换到 World、图像或 Mesh 视图继续查看。"
icon={AlertCircle}
previewImageUrl={previewImageUrl}
title="Splat 视图加载失败"
/>
);
}
return (
<div className="relative h-full w-full">
<Canvas
camera={{ position: [0, 1.6, 4], fov: 55 }}
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
frameloop="always"
gl={{ antialias: false }}
>
<WorldSplatScene
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState}
onPick={onPick}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
/>
</Canvas>
{loadState === "loading" ? (
<div className="absolute inset-0">
<WorldEmptyState
description="正在初始化 Gaussian Splat 资源,这一步会比普通 Mesh 略慢。"
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="正在加载 Splat 世界"
/>
</div>
) : null}
</div>
);
}
export function World3DViewer({
mode,
worldUrl,
imageUrl,
meshUrl,
splatUrl,
previewImageUrl,
emptyStateTitle = "准备生成 3D 世界",
meshSettings,
meshLayerVisibility,
onMeshLayersChange,
onMeshStatsChange,
firstPerson = false,
hotspots = [],
interactionMode = "navigate",
onPick,
onHotspotSelect,
semanticsMetadata,
className,
}: World3DViewerProps) {
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
if (mode === "mesh") {
return (
<div className={cn("h-full w-full", className)}>
<Model3DViewer
emptyStateTitle={emptyStateTitle}
layerVisibility={meshLayerVisibility}
modelUrl={meshUrl}
onLayersChange={onMeshLayersChange}
onStatsChange={onMeshStatsChange}
previewImageUrl={previewImageUrl}
settings={meshSettings || DEFAULT_MESH_SETTINGS}
/>
</div>
);
}
if (mode === "world") {
return (
<div className={cn("h-full w-full overflow-hidden bg-zinc-950", className)}>
{worldUrl ? (
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
allowFullScreen
className="h-full w-full border-0"
referrerPolicy="no-referrer-when-downgrade"
src={worldUrl}
title="World Labs 世界预览"
/>
) : (
<WorldEmptyState
description="当前结果还没有可嵌入的 World 页面链接,可以先切换到 Splat、图像或 Mesh 视图。"
icon={Globe}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
)}
</div>
);
}
if (mode === "image") {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(39,39,42,0.95),_rgba(2,6,23,1)_68%)] p-6",
className
)}
>
{resolvedImageUrl ? (
<img
alt="世界图像预览"
className="max-h-full w-full rounded-2xl object-contain shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
src={resolvedImageUrl}
/>
) : (
<WorldEmptyState
description="当前结果还没有可用的图像资产,可以先切换到 Splat 或 Mesh 视图。"
icon={ImageIcon}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
/>
)}
</div>
);
}
return (
<div className={cn("h-full w-full", className)}>
<WorldSplatViewer
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
onHotspotSelect={onHotspotSelect}
onPick={onPick}
previewImageUrl={previewImageUrl}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}
/>
</div>
);
}

View File

@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
},
// 外部包配置(Next.js 16+)
serverExternalPackages: ["sharp"],
transpilePackages: ["@sparkjsdev/spark"],
// 实验性功能:增加代理请求体大小限制以支持大文件上传(视频等)
experimental: {
proxyClientMaxBodySize: "500mb", // 代理/中间件请求体限制

View File

@ -49,6 +49,7 @@
"@radix-ui/react-visually-hidden": "^1.1.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@sparkjsdev/spark": "^0.1.10",
"@types/form-data": "^2.5.2",
"@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1",

10
pnpm-lock.yaml generated
View File

@ -95,6 +95,9 @@ importers:
'@react-three/fiber':
specifier: ^9.5.0
version: 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
'@sparkjsdev/spark':
specifier: ^0.1.10
version: 0.1.10
'@types/form-data':
specifier: ^2.5.2
version: 2.5.2
@ -2859,6 +2862,9 @@ packages:
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
engines: {node: '>=18.0.0'}
'@sparkjsdev/spark@0.1.10':
resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@ -8925,6 +8931,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@sparkjsdev/spark@0.1.10':
dependencies:
fflate: 0.8.2
'@standard-schema/spec@1.0.0': {}
'@standard-schema/spec@1.1.0': {}

View File

@ -2,7 +2,14 @@ import type { AgentWorkflowPersistedState } from "@/types/agent";
// 画布节点类型定义
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d';
export type NodeType =
| 'message'
| 'image'
| 'video'
| 'text'
| 'scene-fusion'
| 'model3d'
| 'world3d';
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
@ -115,6 +122,38 @@ export interface Model3DNodeData {
metadata?: Record<string, any>;
}
export type World3DViewMode = 'world' | 'image' | 'mesh' | 'splat';
export interface World3DHotspot {
id: string;
label: string;
position: {
x: number;
y: number;
z: number;
};
description?: string;
createdAt?: string;
}
// 3D 世界节点数据
export interface World3DNodeData {
worldUrl?: string;
imageUrl?: string;
meshUrl?: string;
splatUrl?: string;
assetId?: string;
previewImage?: string;
prompt?: string;
modelVersion?: string;
status: NodeStatus;
activeView?: World3DViewMode;
firstPerson?: boolean;
hotspots?: World3DHotspot[];
semanticsMetadata?: Record<string, any>;
metadata?: Record<string, any>;
}
// 画布节点
export interface CanvasNode {
id: string;
@ -127,7 +166,8 @@ export interface CanvasNode {
| VideoNodeData
| TextNodeData
| SceneFusionNodeData
| Model3DNodeData;
| Model3DNodeData
| World3DNodeData;
connections: string[]; // 连接的节点 ID
parentId?: string; // 父节点 ID(用于对话上下文)
createdAt: Date;