diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 8d3d2b791..c263e5039 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -3,45 +3,46 @@ import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; import dynamic from "next/dynamic"; -import { - ArrowLeft, - Bone, - Box, - Boxes, - Check, - ChevronDown, - ChevronUp, - Crosshair, - Download, - Eye, - EyeOff, - Grid3X3, - Home, - ImagePlus, - Images, - Layers3, - LayoutGrid, - Loader2, - Maximize, - Package, - Palette, - PenLine, - Play, - PlusCircle, - RefreshCw, - RotateCw, - Scissors, - Settings2, - Sparkles, - Sun, - Globe, - ImageIcon, - Link2, - PanelTop, - Video, - WandSparkles, - X, -} from "lucide-react"; + import { + ArrowLeft, + Bone, + Box, + Boxes, + Check, + ChevronDown, + ChevronUp, + Crosshair, + Download, + Eye, + EyeOff, + Grid3X3, + Home, + ImagePlus, + Images, + Layers3, + LayoutGrid, + Loader2, + Maximize, + Package, + Palette, + PenLine, + Play, + Plus, + PlusCircle, + RefreshCw, + RotateCw, + Scissors, + Settings2, + Sparkles, + Sun, + Globe, + ImageIcon, + Link2, + PanelTop, + Video, + WandSparkles, + X, + } from "lucide-react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -2283,51 +2284,255 @@ export default function CanvasModel3DPage() { ); }, [shareableWorldTasks]); - const handleShareSelectedWorlds = useCallback(async () => { - if (selectedWorldShareTasks.length < 2) { - toast.error("请至少选择 2 个成功生成的 3D 世界"); - return; - } + const handleShareSelectedWorlds = useCallback(async () => { + if (selectedWorldShareTasks.length <2) { + toast.error("请至少选择2 个成功生成的3D 世界"); + return; + } - try { - const response = await fetch(`/api/canvas/${id}/model3d-assets`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - taskIds: selectedWorldShareTasks.map((task) => task.taskId), - shareEnabled: true, - }), - }); - const payload = (await response.json().catch(() => ({}))) as { - success?: boolean; - error?: string; - items?: TaskRecordApiEntity[]; - shareToken?: string | null; - }; - if (!response.ok || payload?.success === false) { - throw new Error(payload?.error || "批量分享世界失败"); - } + try { + const response = await fetch(`/api/canvas/${id}/model3d-assets`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskIds: selectedWorldShareTasks.map((task) => task.taskId), + shareEnabled: true, + }), + }); + const payload = (await response.json().catch(() => ({}))) as { + success?: boolean; + error?: string; + items?: TaskRecordApiEntity[]; + shareToken?: string | null; + }; + if (!response.ok || payload?.success === false) { + throw new Error(payload?.error || "批量分享世界失败"); + } - const updatedTasks = Array.isArray(payload.items) - ? payload.items - .map((item) => mapApiEntityToTaskRecord(item)) - .filter((item): item is TaskRecord => Boolean(item)) - : []; - for (const updatedTask of updatedTasks) { - upsertTaskHistory(updatedTask, { persist: false }); - } + const updatedTasks = Array.isArray(payload.items) + ? payload.items + .map((item) => mapApiEntityToTaskRecord(item)) + .filter((item): item is TaskRecord => Boolean(item)) + : []; + for (const updatedTask of updatedTasks) { + upsertTaskHistory(updatedTask, { persist: false }); + } - const shareToken = payload.shareToken || updatedTasks[0]?.shareToken; - const shareUrl = buildWorldShareUrl(shareToken); - if (!shareUrl) { - throw new Error("分享链接生成失败"); - } - await navigator.clipboard.writeText(shareUrl); - toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`); - } catch (error) { - toast.error(error instanceof Error ? error.message : "批量分享世界失败"); - } - }, [id, selectedWorldShareTasks, upsertTaskHistory]); + const shareToken = payload.shareToken || updatedTasks[0]?.shareToken; + const shareUrl = buildWorldShareUrl(shareToken); + if (!shareUrl) { + throw new Error("分享链接生成失败"); + } + await navigator.clipboard.writeText(shareUrl); + toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "批量分享世界失败"); + } + }, [id, selectedWorldShareTasks, upsertTaskHistory]); + + //多选添加到画布的 handlers + const handleToggleAddSelection = useCallback( + (task: TaskRecord, event?: MouseEvent) => { + event?.stopPropagation(); + if (task.status !== "success") { + toast.error("仅成功生成的世界/模型可添加到画布"); + return; + } + setSelectedAddTaskIds((prev) => { + const next = new Set(prev); + if (next.has(task.taskId)) { + next.delete(task.taskId); + } else { + next.add(task.taskId); + } + return next; + }); + }, + [] + ); + + const handleSelectAllForAdd = useCallback(() => { + setSelectedAddTaskIds((prev) => { + if (prev.size === addableTasks.length) { + return new Set(); + } + return new Set(addableTasks.map((task) => task.taskId)); + }); + }, [addableTasks]); + + //清理已不存在的选中项 + useEffect(() => { + const addableIds = new Set(addableTasks.map((task) => task.taskId)); + setSelectedAddTaskIds((prev) => { + const next = new Set(); + for (const id of prev) { + if (addableIds.has(id)) next.add(id); + } + return next.size === prev.size ? prev : next; + }); + }, [addableTasks]); + + //批量添加到画布:为每个 task 计算节点配置,复用 addNode 的 ring-scan避让逻辑 + const handleBatchImportToCanvas = useCallback(async () => { + const targets = selectedAddTasks; + if (targets.length ===0) { + toast.error("请至少选择1 个成功生成的世界/模型"); + return; + } + + const viewportX = viewport.x ||0; + const viewportY = viewport.y ||0; + const viewportZoom = viewport.zoom ||1; + const stageWidth = + typeof window !== "undefined" ? window.innerWidth :1280; + const stageHeight = + typeof window !== "undefined" ? window.innerHeight :720; + const targetWidth =380; + const targetHeight =300; + + const baseX = + (stageWidth *0.48 - viewportX) / viewportZoom - targetWidth /2; + const baseY = + (stageHeight *0.45 - viewportY) / viewportZoom - targetHeight /2; + + //收集每个 task 的节点配置(让 store内部做位置避让) + const nodeConfigs: Array< + Parameters[0] + > = []; + + for (const task of targets) { + const { + canImport, + isWorldTask, + modelUrl, + primarySplatUrl, + worldAssetPayload, + worldImageUrl, + } = getTaskImportContext(task); + + if (!canImport) { + continue; + } + + nodeConfigs.push( + isWorldTask + ? { + type: "world3d", + position: { x: baseX, y: baseY }, + size: { width: targetWidth, height: targetHeight }, + data: { + worldUrl: worldAssetPayload?.worldUrl || undefined, + imageUrl: worldImageUrl || undefined, + meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined, + splatUrl: primarySplatUrl || undefined, + previewImage: + task.previewImageUrl || + worldAssetPayload?.thumbnailUrl || + undefined, + prompt: task.prompt, + modelVersion: task.modelVersion, + status: "completed", + activeView: primarySplatUrl + ? "splat" + : worldImageUrl + ? "image" + : "mesh", + firstPerson: false, + hotspots: [], + semanticsMetadata: + worldAssetPayload?.semanticsMetadata || undefined, + metadata: { + taskId: task.taskId, + provider: task.provider, + mode: task.mode, + sourceTaskId: task.sourceTaskId, + progress: task.progress, + triangles: viewerStats.triangles, + vertices: viewerStats.vertices, + meshCount: viewerStats.meshCount, + assetPayload: task.assetPayload ?? null, + }, + }, + connections: [], + } + : { + type: "model3d", + position: { x: baseX, y: baseY }, + size: { width: targetWidth, height: targetHeight }, + data: { + src: modelUrl!, + previewImage: task.previewImageUrl || undefined, + prompt: task.prompt, + modelVersion: task.modelVersion, + format: extractModelFormat(modelUrl!), + status: "completed", + metadata: { + taskId: task.taskId, + provider: task.provider, + mode: task.mode, + sourceTaskId: task.sourceTaskId, + progress: task.progress, + triangles: viewerStats.triangles, + vertices: viewerStats.vertices, + meshCount: viewerStats.meshCount, + assetPayload: task.assetPayload ?? null, + }, + }, + connections: [], + } + ); + } + + if (nodeConfigs.length ===0) { + toast.error("选中的资产没有可添加到画布的内容"); + return; + } + + //复用 addNodesBulk(循环 addNode → 自动避让) + const createdNodeIds = addNodesBulk(nodeConfigs); + + //选中新增的节点,让用户立刻看到位置 + if (createdNodeIds.length >0) { + selectNodes(createdNodeIds); + } + + // 清空选中状态,提示结果 + setSelectedAddTaskIds(new Set()); + const skipped = targets.length - nodeConfigs.length; + const worldCount = nodeConfigs.filter((c) => c.type === "world3d").length; + const modelCount = nodeConfigs.length - worldCount; + const summary = [ + worldCount >0 ? `${worldCount} 个世界` : "", + modelCount >0 ? `${modelCount} 个模型` : "", + ] + .filter(Boolean) + .join(" + "); + toast.success( + skipped >0 + ? `已批量添加 ${summary}(跳过 ${skipped} 个无资源项)` + : `已批量添加 ${summary}到画布` + ); + + try { + await canvasService.saveCanvasState(id, getCanvasState()); + } catch (error) { + console.error("[Model3DPage] 保存批量画布失败:", error); + } + }, [ + addNode, + addNodesBulk, + getCanvasState, + getTaskImportContext, + id, + selectNodes, + selectedAddTasks, + viewerStats.meshCount, + viewerStats.triangles, + viewerStats.vertices, + viewport.x, + viewport.y, + viewport.zoom, +]); const handleDisableCurrentShare = useCallback(async () => { if (!currentTask || currentTask.provider !== "worldlabs-3d") { @@ -4080,62 +4285,150 @@ export default function CanvasModel3DPage() { - {workspaceMode === "world" ? ( -
-
- 多世界分享 - 已选 {selectedWorldShareTasks.length} -
-
- - -
-
- ) : null} + {workspaceMode === "world" ? ( +
+
+ 多世界分享 + 已选 {selectedWorldShareTasks.length} +
+
+ + +
+
+ ) : null} + {addableTasks.length >0 ? ( +
+
+ 批量添加到画布 + 已选 {selectedAddTaskIds.size} +
+
+ + +
+
+ ) : null}
- {visibleTaskHistory.map((task) => { - const canShareWorld = - task.provider === "worldlabs-3d" && task.status === "success"; - const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId); + {visibleTaskHistory.map((task) => { + const canShareWorld = + task.provider === "worldlabs-3d" && task.status === "success"; + const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId); + const isSelectedForAdd = + task.status === "success" && selectedAddTaskIds.has(task.taskId); + const canAddToCanvas = task.status === "success"; - return ( - + ) : null} + {canShareWorld ? ( + + ) : null} + {canAddToCanvas ? ( + + ) : null} {canShareWorld ? (