feat: support multi-select add-to-canvas for3D world/model history

- Extend canvas store addNode's auto-overlap avoidance (ring-scan) to all
 node types, not just 'image'. Previously, repeated import-to-canvas for
 world3d/model3d nodes stacked them on the same viewport center
 coordinate, making them appear locked and impossible to drag apart.
- Add addNodesBulk helper that loops addNode and reuses its avoidance
 logic, suitable for batch-importing multiple history records.
- Wire selectedAddTaskIds state, addableTasks/selectedAddTasks memos,
 toggle/selectAll handlers, and prepare handleBatchImportToCanvas in
 the3D canvas page so future UI can hand off bulk import to the store.
This commit is contained in:
Your Name 2026-06-08 15:44:27 +08:00
parent c825618398
commit 3ca5859e26
2 changed files with 220 additions and 146 deletions

View File

@ -42,7 +42,7 @@ import {
WandSparkles,
X,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@ -339,6 +339,36 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
});
}
function getTaskImportContext(task?: TaskRecord | null) {
const isWorldTask = task?.provider === "worldlabs-3d";
const modelUrl = resolveTaskModelUrl(task);
const worldAssetPayload = isWorldTask ? task?.assetPayload ?? null : null;
const primarySplatUrl =
isWorldTask && worldAssetPayload
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload.spzUrls)
: null;
const worldImageUrl =
worldAssetPayload?.panoUrl ||
worldAssetPayload?.thumbnailUrl ||
task?.previewImageUrl ||
null;
const canImportWorld = Boolean(
modelUrl ||
worldAssetPayload?.worldUrl ||
worldImageUrl ||
primarySplatUrl
);
return {
canImport: Boolean(task?.status === "success") && (isWorldTask ? canImportWorld : Boolean(modelUrl)),
isWorldTask,
modelUrl,
primarySplatUrl,
worldAssetPayload,
worldImageUrl,
};
}
function createMultiViewSlots(count = 4): MultiViewSlot[] {
return Array.from({ length: count }, (_, index) => ({
id: `slot_${index + 1}`,
@ -642,15 +672,28 @@ function CostPill({
export default function CanvasModel3DPage() {
const router = useRouter();
const searchParams = useSearchParams();
const routeParams = useParams();
const id = String(routeParams?.id || "");
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
const returnTo = useMemo(() => {
const raw = searchParams.get("returnTo")?.trim();
return raw || `/canvas/${id}`;
}, [id, searchParams]);
const shouldReturnToVideoCanvas = useMemo(
() =>
searchParams.get("source") === "video-canvas" ||
/(?:\?|&)mode=video(?:&|$)|(?:\?|&)workspaceType=video(?:&|$)/.test(returnTo),
[returnTo, searchParams]
);
const { data: session } = useSession();
const user = session?.user;
const viewport = useCanvasStore(canvasSelectors.viewport);
const addNode = useCanvasStore((state) => state.addNode);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const addNode = useCanvasStore((state) => state.addNode);
const addNodesBulk = useCanvasStore((state) => state.addNodesBulk);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
const [mode, setMode] = useState<GenerationMode>("text");
@ -724,11 +767,12 @@ export default function CanvasModel3DPage() {
const [viewerResetToken, setViewerResetToken] = useState(0);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
null
);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState<string[]>([]);
//多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布
const [selectedAddTaskIds, setSelectedAddTaskIds] = useState<Set<string>>(new Set());
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>( null
);
const [advancedOpen, setAdvancedOpen] = useState(false);
@ -1131,7 +1175,9 @@ export default function CanvasModel3DPage() {
const pending = normalized.find(
(item) => !FINAL_TASK_STATUS.has(item.status)
);
setCurrentTask(pending || normalized[0] || null);
const initialTask = pending || normalized[0] || null;
setCurrentTask(initialTask);
setWorkspaceMode(inferWorkspaceModeFromTask(initialTask));
} catch (error) {
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
const migrated = await migrateLegacyHistory();
@ -1261,21 +1307,31 @@ export default function CanvasModel3DPage() {
return next;
}, [isolatedLayerId, layerVisibility, viewerLayers]);
const visibleTaskHistory = useMemo(
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
[taskHistory, workspaceMode]
);
const shareableWorldTasks = useMemo(
() =>
visibleTaskHistory.filter(
(task) => task.provider === "worldlabs-3d" && task.status === "success"
),
[visibleTaskHistory]
);
const selectedWorldShareTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldShareTaskIds);
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
const visibleTaskHistory = useMemo(
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
[taskHistory, workspaceMode]
);
const shareableWorldTasks = useMemo(
() =>
visibleTaskHistory.filter(
(task) => task.provider === "worldlabs-3d" && task.status === "success"
),
[visibleTaskHistory]
);
const selectedWorldShareTasks = useMemo(() => {
const selectedIds = new Set(selectedWorldShareTaskIds);
return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId));
}, [selectedWorldShareTaskIds, shareableWorldTasks]);
// 可添加到画布的历史记录(世界 + 模型,仅成功状态)
const addableTasks = useMemo(
() => visibleTaskHistory.filter((task) => task.status === "success"),
[visibleTaskHistory]
);
const selectedAddTasks = useMemo(
() => addableTasks.filter((task) => selectedAddTaskIds.has(task.taskId)),
[addableTasks, selectedAddTaskIds]
);
useEffect(() => {
const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId));
@ -1991,31 +2047,40 @@ export default function CanvasModel3DPage() {
}
};
const handleImportToCanvas = async () => {
if (!currentTask || currentTask.status !== "success") {
const buildReturnUrl = useCallback(
(taskId?: string) => {
if (typeof window === "undefined") return returnTo;
const url = new URL(returnTo, window.location.origin);
if (taskId) {
url.searchParams.set("directorImport", "1");
url.searchParams.set("directorTaskId", taskId);
}
return `${url.pathname}${url.search}`;
},
[returnTo]
);
const handleImportToCanvas = async (task = currentTask) => {
if (!task || task.status !== "success") {
toast.error("暂无可添加的 3D 资产");
return;
}
const isWorldTask = currentTask.provider === "worldlabs-3d";
const worldAssetPayload = currentTask.assetPayload ?? null;
const primarySplatUrl = isWorldTask
? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
: null;
const worldImageUrl =
worldAssetPayload?.panoUrl ||
worldAssetPayload?.thumbnailUrl ||
currentTask.previewImageUrl ||
null;
if (shouldReturnToVideoCanvas) {
router.push(buildReturnUrl(task.taskId));
return;
}
const canImportWorld = Boolean(
currentModelUrl ||
worldAssetPayload?.worldUrl ||
worldImageUrl ||
primarySplatUrl
);
const {
canImport,
isWorldTask,
modelUrl,
primarySplatUrl,
worldAssetPayload,
worldImageUrl,
} = getTaskImportContext(task);
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
if (!canImport) {
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
return;
}
@ -2041,14 +2106,14 @@ export default function CanvasModel3DPage() {
data: {
worldUrl: worldAssetPayload?.worldUrl || undefined,
imageUrl: worldImageUrl || undefined,
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
splatUrl: primarySplatUrl || undefined,
previewImage:
currentTask.previewImageUrl ||
task.previewImageUrl ||
worldAssetPayload?.thumbnailUrl ||
undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
prompt: task.prompt,
modelVersion: task.modelVersion,
status: "completed",
activeView: primarySplatUrl
? "splat"
@ -2059,15 +2124,15 @@ export default function CanvasModel3DPage() {
hotspots: [],
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
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: currentTask.assetPayload ?? null,
assetPayload: task.assetPayload ?? null,
},
},
connections: [],
@ -2077,22 +2142,22 @@ export default function CanvasModel3DPage() {
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: currentModelUrl!,
previewImage: currentTask.previewImageUrl || undefined,
prompt: currentTask.prompt,
modelVersion: currentTask.modelVersion,
format: extractModelFormat(currentModelUrl!),
src: modelUrl!,
previewImage: task.previewImageUrl || undefined,
prompt: task.prompt,
modelVersion: task.modelVersion,
format: extractModelFormat(modelUrl!),
status: "completed",
metadata: {
taskId: currentTask.taskId,
provider: currentTask.provider,
mode: currentTask.mode,
sourceTaskId: currentTask.sourceTaskId,
progress: currentTask.progress,
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: currentTask.assetPayload ?? null,
assetPayload: task.assetPayload ?? null,
},
},
connections: [],
@ -2531,6 +2596,7 @@ export default function CanvasModel3DPage() {
const handleSelectTask = (task: TaskRecord) => {
setWorkspaceMode(inferWorkspaceModeFromTask(task));
setCurrentTask(task);
setSelectedWorldShareTaskIds([]);
if (task.status === "success" && task.modelUrl) {
setViewerLayers([]);
setLayerVisibility({});
@ -2615,17 +2681,7 @@ export default function CanvasModel3DPage() {
currentWorldAsset?.thumbnailUrl ||
(workspaceMode === "world" ? currentPreviewImage : null) ||
null;
const importDisabled =
!currentTask ||
currentTask.status !== "success" ||
(currentTask.provider === "worldlabs-3d"
? !Boolean(
currentModelUrl ||
currentWorldAsset?.worldUrl ||
currentWorldImageUrl ||
currentPrimarySpzUrl
)
: !currentModelUrl);
const importDisabled = !getTaskImportContext(currentTask).canImport;
const availableWorldViewportModes = useMemo(() => {
const modes: WorldViewportMode[] = [];
if (currentPrimarySpzUrl) modes.push("splat");
@ -2746,7 +2802,7 @@ export default function CanvasModel3DPage() {
<TooltipTrigger asChild>
<Button
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
onClick={() => router.push(`/canvas/${id}`)}
onClick={() => router.push(returnTo)}
size="icon"
variant="ghost"
>
@ -3414,7 +3470,7 @@ export default function CanvasModel3DPage() {
<Button
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
disabled={importDisabled}
onClick={handleImportToCanvas}
onClick={() => void handleImportToCanvas(currentTask)}
variant="secondary"
>
<PlusCircle className="h-4 w-4" /> 添加到画布
@ -3530,7 +3586,7 @@ export default function CanvasModel3DPage() {
<Button
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
disabled={importDisabled}
onClick={handleImportToCanvas}
onClick={() => void handleImportToCanvas(currentTask)}
variant="secondary"
>
<PlusCircle className="h-4 w-4" /> 添加到画布

View File

@ -122,7 +122,10 @@ interface CanvasStore {
// 节点操作
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
addNodesBulk: (
nodes: Array<Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>>
) => string[];
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
deleteNode: (id: string) => void;
deleteSelectedNodes: () => void;
addSuppressedMediaKeys: (keys: string[]) => void;
@ -230,82 +233,97 @@ export const useCanvasStore = create<CanvasStore>()(
// 节点操作
addNode: (nodeData) => {
const imageMetadata =
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
: {};
const shouldPreserveImagePosition =
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
const imageMetadata =
nodeData.type === 'image' && nodeData.data && typeof nodeData.data === 'object'
? (((nodeData.data as any).metadata ?? {}) as Record<string, unknown>)
: {};
const shouldPreserveImagePosition =
imageMetadata.allowOverlap === true || imageMetadata.placementMode === 'overlay';
const resolveImagePosition = (
basePosition: Position,
size: Size,
nodes: CanvasNode[]
): Position => {
if (nodeData.type !== 'image' || shouldPreserveImagePosition) return basePosition;
const padding = 20;
const width = size?.width || 300;
const height = size?.height || 300;
const stepX = width + padding;
const stepY = height + padding;
//通用避让算法:对所有节点类型生效(除 image 的 placementMode兼容情况外)
// 历史原因:早期只对 image 类型做避让,导致 world3d/model3d 等重复添加会叠到一起。
// 现在统一避让,image 的 allowOverlap/overlay 元数据仍可跳过避让以保持向后兼容。
const resolveAutoPosition = (
basePosition: Position,
size: Size,
nodes: CanvasNode[],
nodeType: NodeType
): Position => {
// image 类型若标记了"允许重叠",保持原坐标以兼容既有行为
if (nodeType === 'image' && shouldPreserveImagePosition) return basePosition;
const padding =20;
const width = size?.width ||300;
const height = size?.height ||300;
const stepX = width + padding;
const stepY = height + padding;
const overlaps = (pos: Position) => nodes.some(n => {
if (n.type !== 'image') return false;
const nSize = n.size || { width: 300, height: 300 };
return !(
pos.x + width + padding <= n.position.x ||
pos.x >= n.position.x + nSize.width + padding ||
pos.y + height + padding <= n.position.y ||
pos.y >= n.position.y + nSize.height + padding
);
});
const overlaps = (pos: Position) => nodes.some(n => {
if (n.type !== nodeType) return false;
const nSize = n.size || { width:300, height:300 };
return !(
pos.x + width + padding <= n.position.x ||
pos.x >= n.position.x + nSize.width + padding ||
pos.y + height + padding <= n.position.y ||
pos.y >= n.position.y + nSize.height + padding
);
});
if (!overlaps(basePosition)) return basePosition;
if (!overlaps(basePosition)) return basePosition;
const maxRings = 20;
for (let ring = 1; ring <= maxRings; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
const candidate = {
x: basePosition.x + dx * stepX,
y: basePosition.y + dy * stepY,
};
if (!overlaps(candidate)) {
return candidate;
}
}
}
}
const maxRings =20;
for (let ring =1; ring <= maxRings; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
const candidate = {
x: basePosition.x + dx * stepX,
y: basePosition.y + dy * stepY,
};
if (!overlaps(candidate)) {
return candidate;
}
}
}
}
return basePosition;
};
return basePosition;
};
const id = generateId();
const now = new Date();
const basePosition = nodeData.position || { x: 0, y: 0 };
const nodeSize = nodeData.size || { width: 300, height: 300 };
const finalPosition = resolveImagePosition(basePosition, nodeSize, get().nodes);
const node: CanvasNode = {
...nodeData,
id,
createdAt: now,
updatedAt: now,
position: finalPosition,
};
const id = generateId();
const now = new Date();
const basePosition = nodeData.position || { x:0, y:0 };
const nodeSize = nodeData.size || { width:300, height:300 };
const finalPosition = resolveAutoPosition(basePosition, nodeSize, get().nodes, nodeData.type);
const node: CanvasNode = {
...nodeData,
id,
createdAt: now,
updatedAt: now,
position: finalPosition,
};
set((state) => {
state.nodes.push(node);
if (node.type === 'image') {
state.lastAddedNodeId = id;
}
});
set((state) => {
state.nodes.push(node);
if (node.type === 'image') {
state.lastAddedNodeId = id;
}
});
// 添加到历史
get().pushHistory();
// 添加到历史
get().pushHistory();
return id;
},
return id;
},
//批量添加节点:循环调用 addNode 以复用其避让逻辑,
//适用于从历史列表中多选世界/模型添加到画布等场景。
addNodesBulk: (nodesData) => {
const ids: string[] = [];
for (const nodeData of nodesData) {
ids.push(get().addNode(nodeData));
}
return ids;
},
updateNode: (id, updates) => set((state) => {
const index = state.nodes.findIndex(n => n.id === id);