"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 { extractModelAssetFormat, resolvePreferredModelAssetUrl, } from "@/lib/model3d/model-url"; 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(modelUrl: string | null, nodeId: string) { const raw = String(modelUrl || "").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(modelUrl: string | null, explicitFormat?: string) { if (explicitFormat) return explicitFormat.toUpperCase(); return extractModelAssetFormat(modelUrl).toUpperCase(); } 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 sourceModelUrl = readOptionalUrl(worldAssetPayload?.sourceModelUrl); const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl); const worldImageUrl = readOptionalUrl(worldAssetPayload?.panoUrl) || readOptionalUrl(worldAssetPayload?.thumbnailUrl) || readOptionalUrl(data.previewImage); const resolvedModelUrl = useMemo( () => resolvePreferredModelAssetUrl({ primaryUrl: data.src, sourceUrl: sourceModelUrl, }), [data.src, sourceModelUrl] ); const modelFileName = useMemo( () => inferModelFileName(resolvedModelUrl || data.src || null, node.id), [data.src, node.id, resolvedModelUrl] ); const modelFormatLabel = useMemo( () => inferModelFormat(resolvedModelUrl || data.src || null, data.format), [data.format, data.src, resolvedModelUrl] ); const primaryOpenUrl = worldUrl || resolvedModelUrl || null; const primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem; const primaryOpenLabel = primaryOpenItem.label; const shouldRenderLiveViewer = isSelected && data.status === "completed" && Boolean(resolvedModelUrl); 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 (!resolvedModelUrl) { toast.error("暂无可打开的 Mesh 地址"); return; } window.open(resolvedModelUrl, "_blank", "noopener,noreferrer"); }; const handleOpenImage = () => { if (!worldImageUrl) { toast.error("暂无可打开的图像预览"); return; } window.open(worldImageUrl, "_blank", "noopener,noreferrer"); }; const handleDownload = () => { if (!resolvedModelUrl) { toast.error("暂无可下载的 3D 文件"); return; } const link = document.createElement("a"); link.href = resolvedModelUrl; link.download = modelFileName; 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 ) : (
)}
{modelFormatLabel}

{data.prompt || modelFileName}

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

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