"use client"; import React, { useCallback, useMemo, useState } from "react"; import { AlertCircle, Box, Camera, Copy, Crosshair, Download, ImageIcon, Loader2, MoreVertical, Sparkles, Trash2, Waypoints, type LucideIcon, } from "lucide-react"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; 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 useSWR from "swr"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { getCanvasToolbarItem, type CanvasToolbarConfig, type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { toast } from "sonner"; import { imageGenerationService } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; interface World3DNodeProps { node: CanvasNode; isSelected: boolean; canvasToolbarConfig?: CanvasToolbarConfig; } const VIEW_META: Array<{ id: World3DViewMode; label: string; icon: LucideIcon; toolbarKey: CanvasToolbarItemKey; }> = [ { id: "splat", label: "W", icon: Sparkles, toolbarKey: "world3dViewSplat" }, { id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" }, { id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" }, ]; function getWorldNodeViewLabel(view: World3DViewMode) { switch (view) { case "splat": return "WORLD"; case "image": return "IMAGE"; case "mesh": return "MESH"; case "world": default: return "ONLINE"; } } function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; if (data.splatUrl) views.push("splat"); 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.splatUrl || data.meshUrl || data.imageUrl || null; case "image": return data.imageUrl || data.previewImage || data.meshUrl || data.splatUrl || null; case "mesh": return data.meshUrl || data.splatUrl || data.imageUrl || null; case "splat": default: return data.splatUrl || data.imageUrl || data.meshUrl || null; } } function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export function World3DNode({ node, isSelected, canvasToolbarConfig, }: World3DNodeProps) { const data = node.data as World3DNodeData; const [isHovered, setIsHovered] = useState(false); const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate"); const [viewerCapture, setViewerCapture] = useState<(() => Promise) | null>( null ); const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const addNode = useCanvasStore((state) => state.addNode); const selectNode = useCanvasStore((state) => state.selectNode); const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const world3dFirstPersonItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dFirstPerson" ); const world3dHotspotItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dHotspot" ); const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore"); const world3dOpenMeshItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dOpenMesh" ); const world3dOpenImageItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dOpenImage" ); const world3dDownloadItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dDownload" ); const world3dCopyItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dCopy"); const world3dDeleteItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dDelete" ); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; }>("/api/canvas/model-aliases", fetcher); const activeView = useMemo(() => resolveActiveView(data), [data]); const availableViews = useMemo(() => resolveAvailableViews(data), [data]); const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; const worldModelLabel = useMemo( () => resolveThreeDModelDisplayName( data.modelVersion, publicModelAliasConfig.worldModels, "原生世界节点", ), [data.modelVersion, publicModelAliasConfig.worldModels], ); const primaryUrl = resolvePrimaryUrl(data, activeView); const downloadTarget = inferDownloadTarget(data, activeView); const hotspotCount = data.hotspots?.length || 0; const placeholderSizeScale = useMemo( () => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76), [node.size.height, node.size.width] ); const overlayChromeScale = useMemo( () => clampNumber(getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48), 0.48, 1), [node.size.height, node.size.width] ); const placeholderViewportScale = useMemo( () => clampNumber( placeholderSizeScale / Math.max(viewportZoom, 0.42), 0.72, 1.55 ), [placeholderSizeScale, viewportZoom] ); const placeholderCenterStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "center center", }), [placeholderViewportScale] ); const placeholderBadgeStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "top left", }), [placeholderViewportScale] ); const topLeftControlsStyle = useMemo( () => ({ transform: `scale(${overlayChromeScale})`, transformOrigin: "top left", }), [overlayChromeScale] ); const topRightControlsStyle = useMemo( () => ({ transform: `scale(${overlayChromeScale})`, transformOrigin: "top right", }), [overlayChromeScale] ); const bottomOverlayStyle = useMemo( () => ({ paddingLeft: `${12 * overlayChromeScale}px`, paddingRight: `${12 * overlayChromeScale}px`, paddingBottom: `${12 * overlayChromeScale}px`, paddingTop: `${48 * overlayChromeScale}px`, }), [overlayChromeScale] ); const bottomOverlayContentStyle = useMemo( () => ({ transform: `scale(${overlayChromeScale})`, transformOrigin: "bottom left", width: `${100 / overlayChromeScale}%`, }), [overlayChromeScale] ); const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh"); 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 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(); }; const handleViewerCaptureReady = useCallback( (capture: (() => Promise) | null) => { setViewerCapture(() => capture); }, [] ); const handleCaptureSnapshot = useCallback(async () => { if (!viewerCapture) { toast.error("当前视图暂不支持拍照"); return; } setIsCapturingSnapshot(true); try { const blob = await viewerCapture(); if (!blob) { throw new Error("当前世界画面抓取失败"); } const file = new File([blob], `world-snapshot-${Date.now()}.png`, { type: blob.type || "image/png", }); const uploadResult = await imageGenerationService.uploadImage(file); if (!uploadResult.success || !uploadResult.data?.url) { throw new Error(uploadResult.error || "上传拍照结果失败"); } const snapshotWidth = Math.max(320, Math.min(640, Math.round(node.size.width * 0.9))); const snapshotHeight = Math.max( 220, Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1))) ); const newNodeId = addNode({ type: "image", position: { x: node.position.x + node.size.width + 24, y: node.position.y + 12, }, size: { width: snapshotWidth, height: snapshotHeight }, data: { src: uploadResult.data.url, assetId: uploadResult.data.assetId, title: `${(data.prompt || "3D世界").trim() || "3D世界"}-拍照`, prompt: data.prompt || "3D 世界拍照", status: "completed", originalSrc: uploadResult.data.url, metadata: { sourceWorldNodeId: node.id, sourceWorldView: activeView, sourceWorldPrompt: data.prompt || "", captureMode: "world3d", }, }, connections: [node.id], }); selectNode(newNodeId); toast.success("已将当前世界视角添加到画布"); } catch (error) { const message = error instanceof Error ? error.message : "世界拍照失败"; toast.error(message); } finally { setIsCapturingSnapshot(false); } }, [ activeView, addNode, data.prompt, node.id, node.position.x, node.position.y, node.size.height, node.size.width, selectNode, viewerCapture, ]); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {data.status === "generating" ? (
3D 世界生成中... {worldModelLabel || "世界资产正在构建中"}
) : data.status === "failed" ? (
3D 世界生成失败 可以重新选择模型后再试一次
) : ( <>
event.stopPropagation()} >

{data.prompt || "3D 世界"}

3D 世界 {hotspotCount ? ( {hotspotCount} 个热点 ) : null} {getWorldNodeViewLabel(activeView)}

{worldModelLabel}

)}
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => ( (() => { const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey); return ( ); })() ))}
{activeView === "splat" && data.status === "completed" ? (
) : null} {captureEnabled ? (
) : null}
{isHovered && data.status === "completed" && (
{data.meshUrl ? ( window.open(data.meshUrl, "_blank", "noopener,noreferrer")}> {world3dOpenMeshItem.label} ) : null} {data.imageUrl ? ( window.open(data.imageUrl, "_blank", "noopener,noreferrer")}> {world3dOpenImageItem.label} ) : null} {world3dDownloadItem.label} {world3dCopyItem.label} deleteNode(node.id)}> {world3dDeleteItem.label}
)}
); }