From 67602b0af7cce9cbe459fabd7169b1698ab24796 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 4 Apr 2026 22:59:00 +0800 Subject: [PATCH] feat: add native world3d canvas workflow --- app/canvas/[id]/model3d/page.tsx | 427 +++++++++++----- components/canvas/CanvasWorkspace.tsx | 4 + components/canvas/model3d/Model3DViewer.tsx | 19 +- components/canvas/nodes/Model3DNode.tsx | 77 ++- components/canvas/nodes/World3DNode.tsx | 373 ++++++++++++++ components/canvas/world3d/World3DViewer.tsx | 534 ++++++++++++++++++++ next.config.ts | 1 + package.json | 1 + pnpm-lock.yaml | 10 + types/canvas/index.ts | 44 +- 10 files changed, 1361 insertions(+), 129 deletions(-) create mode 100644 components/canvas/nodes/World3DNode.tsx create mode 100644 components/canvas/world3d/World3DViewer.tsx diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index e591bc277..476d975fd 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -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( null ); + const [worldViewportMode, setWorldViewportMode] = + useState("world"); const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(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 (
@@ -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} @@ -2764,11 +2900,11 @@ export default function CanvasModel3DPage() {
@@ -2776,7 +2912,7 @@ export default function CanvasModel3DPage() { 当前世界资产 - 第一版支持将 World Labs 结果以 mesh 形式导入画布,并保留 World 与全景链接。 + 当前支持以原生 world3d 节点导入画布,并保留 World、Splat、图像与 Mesh 入口。 @@ -2821,16 +2957,14 @@ export default function CanvasModel3DPage() {
- - Panorama + + 图像预览 - {currentWorldAsset?.panoUrl ? "可用" : "暂无"} + {currentWorldImageUrl ? "可用" : "暂无"}
@@ -2967,9 +3101,9 @@ export default function CanvasModel3DPage() {
-
全景图
+
图像预览
- {currentWorldAsset?.panoUrl ? "可访问" : "暂无"} + {currentWorldImageUrl ? "可访问" : "暂无"}
@@ -3031,7 +3165,7 @@ export default function CanvasModel3DPage() { > {activeProviderLabel} - {currentTask?.provider === "worldlabs-3d" && ( + {workspaceMode === "world" && (
- {activeRelativeTime || "等待创建任务"} + {activeRelativeTime || waitingTaskLabel} {currentWorldId && ( <> • @@ -3054,73 +3188,136 @@ export default function CanvasModel3DPage() { )}
- + {workspaceMode === "world" && ( +
+ {[ + { + 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) => ( + + ))} +
+ )} + + {workspaceMode === "world" ? ( + + ) : ( + + )} {/* Floating Toolbar */} -
- - - - + + 网格 + + + + + + 自动旋转 + + +
+ + setViewerSettings((prev) => ({ ...prev, - showGrid: !prev.showGrid, + lightIntensity: v[0] || 1, })) } - size="icon" - variant={viewerSettings.showGrid ? "secondary" : "ghost"} - > - - - - 网格 - - - - - - 自动旋转 - - -
- - - setViewerSettings((prev) => ({ - ...prev, - lightIntensity: v[0] || 1, - })) - } - step={0.1} - value={[viewerSettings.lightIntensity]} - /> -
- -
+ step={0.1} + value={[viewerSettings.lightIntensity]} + /> +
+ + + )} {/* 4. Right Assets Panel */} diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index efaaf7e90..bb417bc83 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -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' && ( )} + {node.type === 'world3d' && ( + + )} {node.type === 'message' && (
{(node.data as any).content} diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index da7126a01..9c1822cbf 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -48,6 +48,7 @@ export type Model3DViewerSettings = { type Model3DViewerProps = { modelUrl?: string | null; previewImageUrl?: string | null; + emptyStateTitle?: string; settings: Model3DViewerSettings; layerVisibility?: Record; 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 (
{previewImageUrl ? ( @@ -208,7 +215,7 @@ function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
-

准备生成 3D 资产

+

{title}

)}
@@ -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 ; + return ( + + ); } if (!isGlbOrGltf(modelUrl)) { diff --git a/components/canvas/nodes/Model3DNode.tsx b/components/canvas/nodes/Model3DNode.tsx index 39f449de6..bf78ca67b 100644 --- a/components/canvas/nodes/Model3DNode.tsx +++ b/components/canvas/nodes/Model3DNode.tsx @@ -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) : 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" ? (
- 3D 生成中... + + {data.metadata?.provider === "worldlabs-3d" + ? "3D 世界生成中..." + : "3D 生成中..."} +
) : data.status === "failed" ? (
- 3D 生成失败 + + {data.metadata?.provider === "worldlabs-3d" + ? "3D 世界生成失败" + : "3D 生成失败"} +
) : (
@@ -148,7 +191,7 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) { size="icon" className="h-7 w-7" onClick={handleOpen} - title="打开3D" + title={primaryOpenLabel} > @@ -160,9 +203,25 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) { - - 打开 3D + {worldUrl ? ( + + ) : ( + + )} + {primaryOpenLabel} + {worldUrl && data.src && data.src !== worldUrl && ( + + + 打开 Mesh + + )} + {worldImageUrl && ( + + + 打开图像 + + )} 下载 diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx new file mode 100644 index 000000000..2c7191f0a --- /dev/null +++ b/components/canvas/nodes/World3DNode.tsx @@ -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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {data.status === "generating" ? ( +
+ + 3D 世界生成中... +
+ ) : data.status === "failed" ? ( +
+ + 3D 世界生成失败 +
+ ) : ( + <> + + +
+
+
+

+ {data.prompt || "World Labs 世界"} +

+
+ + World Labs + + {hotspotCount ? ( + + {hotspotCount} 个热点 + + ) : null} + + {activeView.toUpperCase()} + +
+
+

+ {data.modelVersion || "原生世界节点"} +

+
+
+ + )} + +
+
+ {VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => ( + + ))} +
+ + {activeView === "splat" && data.status === "completed" ? ( +
+ + +
+ ) : null} +
+ + {isHovered && data.status === "completed" && ( +
+ + + + + + + {data.worldUrl ? ( + window.open(data.worldUrl, "_blank", "noopener,noreferrer")}> + + 打开 World + + ) : null} + {data.splatUrl ? ( + window.open(data.splatUrl, "_blank", "noopener,noreferrer")}> + + 打开 Splat + + ) : null} + {data.meshUrl ? ( + window.open(data.meshUrl, "_blank", "noopener,noreferrer")}> + + 打开 Mesh + + ) : null} + {data.imageUrl ? ( + window.open(data.imageUrl, "_blank", "noopener,noreferrer")}> + + 打开图像 + + ) : null} + + + 下载当前资产 + + + + 复制链接 + + + deleteNode(node.id)}> + + 删除 + + + +
+ )} +
+ ); +} + diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx new file mode 100644 index 000000000..f917bf595 --- /dev/null +++ b/components/canvas/world3d/World3DViewer.tsx @@ -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; + 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 | null; + className?: string; +}; + +const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { + envPreset: "city", + lightIntensity: 1, + autoRotate: false, + showGrid: true, + wireframe: false, +}; + +function getSemanticFacts(metadata?: Record | 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 ( +
+ {previewImageUrl ? ( +
+ {title} +
+ +
+
+ ) : ( +
+ +
+ )} +
+

{title}

+

{description}

+
+
+ ); +} + +function FirstPersonMovement({ enabled }: { enabled: boolean }) { + const { camera } = useThree(); + const pressedKeysRef = useRef>({}); + 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 ; +} + +function HotspotBillboard({ + hotspot, + onSelect, +}: { + hotspot: World3DHotspot; + onSelect?: (hotspotId: string) => void; +}) { + return ( + + { + event.stopPropagation(); + onSelect?.(hotspot.id); + }} + > + + + + +
+ {hotspot.label} +
+ +
+ ); +} + +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 | 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) => { + if (interactionMode !== "hotspot") return; + event.stopPropagation(); + onPick?.({ + point: [event.point.x, event.point.y, event.point.z], + }); + }; + + return ( + <> + + + + + {groundPlaneOffset !== null ? ( + + ) : null} + + + + + + {hotspots.map((hotspot) => ( + + ))} + {!firstPerson ? ( + + ) : ( + + )} + +
+
+
+ + SPZ + + {semanticFacts.map((fact) => ( + + {fact.label} {fact.value} + + ))} +
+ {interactionMode === "hotspot" ? ( +
+ 点击场景放置热点 +
+ ) : null} +
+
+
+ {firstPerson ? "WASD 移动 · 鼠标观察 · Shift 加速 · Esc 退出" : "拖拽旋转 · 滚轮缩放 · 原生 SPZ 渲染"} +
+ {(firstPerson || interactionMode === "hotspot") && ( +
+ +
+ )} +
+
+ + + ); +} + +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 | null; +}) { + const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); + useEffect(() => { + setLoadState("loading"); + }, [splatUrl]); + + if (!splatUrl) { + return ( + + ); + } + + if (loadState === "error") { + return ( + + ); + } + + return ( +
+ + + + {loadState === "loading" ? ( +
+ +
+ ) : null} +
+ ); +} + +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 ( +
+ +
+ ); + } + + if (mode === "world") { + return ( +
+ {worldUrl ? ( +