"use client"; import React, { useMemo, useState } from "react"; import { AlertCircle, Box, Copy, Download, ExternalLink, Globe, ImageIcon, Loader2, MoreVertical, Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import useSWR from "swr"; import { toast } from "sonner"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; import { Model3DViewer, type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; import { getCanvasToolbarItem, type CanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, Model3DNodeData } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; interface Model3DNodeProps { node: CanvasNode; isSelected: boolean; canvasToolbarConfig?: CanvasToolbarConfig; } const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = { cameraPreset: "iso", envPreset: "studio", lightIntensity: 1, autoRotate: false, showGrid: true, wireframe: false, }; function inferModelFileName(data: Model3DNodeData, nodeId: string) { const raw = String(data.src || "").trim(); if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`; try { const url = new URL(raw); const filename = url.pathname.split("/").filter(Boolean).pop(); if (filename) return decodeURIComponent(filename); } catch {} return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`; } function inferModelFormat(data: Model3DNodeData) { if (data.format) return data.format.toUpperCase(); const src = String(data.src || "").toLowerCase(); if (src.endsWith(".gltf")) return "GLTF"; if (src.endsWith(".glb")) return "GLB"; if (src.endsWith(".usdz")) return "USDZ"; if (src.endsWith(".fbx")) return "FBX"; if (src.endsWith(".obj")) return "OBJ"; if (src.endsWith(".stl")) return "STL"; if (src.endsWith(".3mf")) return "3MF"; 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, canvasToolbarConfig, }: Model3DNodeProps) { const data = node.data as Model3DNodeData; const [isHovered, setIsHovered] = useState(false); const deleteNode = useCanvasStore((state) => state.deleteNode); const model3dOpen3dItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpen3d" ); const model3dOpenWorldItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpenWorld" ); const model3dMoreItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dMore" ); const model3dOpenMeshItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpenMesh" ); const model3dOpenImageItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpenImage" ); const model3dDownloadItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dDownload" ); const model3dCopyItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dCopy" ); const model3dDeleteItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dDelete" ); const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; }>("/api/canvas/model-aliases", fetcher); const providerLabel = data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果"; const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; const modelVersionLabel = useMemo( () => resolveThreeDModelDisplayName( data.modelVersion, publicModelAliasConfig.tripoModels, providerLabel, ), [data.modelVersion, providerLabel, publicModelAliasConfig.tripoModels], ); 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 primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem; const primaryOpenLabel = primaryOpenItem.label; const shouldRenderLiveViewer = isSelected && data.status === "completed" && Boolean(data.src); const placeholderSizeScale = useMemo( () => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76), [node.size.height, node.size.width] ); const placeholderViewportScale = useMemo( () => Math.min( 1.55, Math.max(0.72, placeholderSizeScale / Math.max(viewportZoom, 0.42)) ), [placeholderSizeScale, viewportZoom] ); const placeholderCenterStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "center center", }), [placeholderViewportScale] ); const placeholderBadgeStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "top left", }), [placeholderViewportScale] ); const handleOpen = () => { if (!primaryOpenUrl) { toast.error("暂无可打开的地址"); return; } window.open(primaryOpenUrl, "_blank", "noopener,noreferrer"); }; const handleOpenMesh = () => { if (!data.src) { 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 文件"); return; } const link = document.createElement("a"); link.href = data.src; link.download = inferModelFileName(data, node.id); link.click(); }; const handleCopy = async () => { if (!primaryOpenUrl) { toast.error("暂无可复制的地址"); return; } try { await navigator.clipboard.writeText(primaryOpenUrl); toast.success(worldUrl ? "已复制 World 链接" : "已复制 3D 链接"); } catch { toast.error("复制失败"); } }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {data.status === "generating" ? (
{data.metadata?.provider === "worldlabs-3d" ? "3D 世界生成中..." : "3D 模型生成中..."} {data.modelVersion ? `模型:${modelVersionLabel}` : providerLabel}
) : data.status === "failed" ? (
{data.metadata?.provider === "worldlabs-3d" ? "3D 世界生成失败" : "3D 模型生成失败"} 可以重新选择模型后再试一次
) : (
{shouldRenderLiveViewer ? (
event.stopPropagation()} >
) : data.previewImage ? ( {data.prompt ) : (
)}
{inferModelFormat(data)}

{data.prompt || inferModelFileName(data, node.id)}

{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}

)} {isHovered && data.status === "completed" && (
{primaryOpenLabel} {worldUrl && data.src && data.src !== worldUrl && ( {model3dOpenMeshItem.label} )} {worldImageUrl && ( {model3dOpenImageItem.label} )} {model3dDownloadItem.label} {model3dCopyItem.label} deleteNode(node.id)}> {model3dDeleteItem.label}
)}
); }