diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5dc96e61..d5a64c18 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,4 +68,5 @@ export { } from './systems/wall/wall-mitering' export { WallSystem } from './systems/wall/wall-system' export { WindowSystem } from './systems/window/window-system' +export { cloneSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index e3773a59..2810db32 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -136,6 +136,7 @@ const useScene: UseSceneStore = create()( nodes: patchedNodes, rootNodeIds, dirtyNodes: new Set(), + collections: {}, }) // Mark all nodes as dirty to trigger re-validation Object.values(patchedNodes).forEach((node) => { diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts new file mode 100644 index 00000000..eac38192 --- /dev/null +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -0,0 +1,141 @@ +import type { AnyNode, AnyNodeId } from '../schema' +import { generateId } from '../schema/base' +import type { Collection, CollectionId } from '../schema/collections' + +export type SceneGraph = { + nodes: Record + rootNodeIds: AnyNodeId[] + collections?: Record +} + +/** + * Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall") + */ +function extractIdPrefix(id: string): string { + const underscoreIndex = id.indexOf('_') + return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex) +} + +/** + * Resolves a child reference to a node ID. + * Supports both string IDs and embedded child node objects with an `id` field. + */ +function resolveChildRefId(child: unknown): string | undefined { + if (typeof child === 'string') { + return child + } + + if (child && typeof child === 'object' && 'id' in child) { + const id = (child as { id?: unknown }).id + if (typeof id === 'string') { + return id + } + } + + return undefined +} + +/** + * Deep clones a scene graph with all node IDs regenerated while preserving + * parent-child relationships and other internal references. + * + * This is useful for: + * - Copying nodes between different projects + * - Duplicating a subset of a scene within the same project + * - Multi-scene in-memory scenarios + */ +export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { + const { nodes, rootNodeIds, collections } = sceneGraph + + // Build ID mapping: old ID -> new ID + const idMap = new Map() + + // Pass 1: Generate new IDs for all nodes + for (const nodeId of Object.keys(nodes)) { + const prefix = extractIdPrefix(nodeId) + idMap.set(nodeId, generateId(prefix)) + } + + // Pass 2: Clone nodes with remapped references + const clonedNodes = {} as Record + + for (const [oldId, node] of Object.entries(nodes)) { + const newId = idMap.get(oldId)! as AnyNodeId + const clonedNode = { ...node, id: newId } as AnyNode + + // Remap parentId + if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { + clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null + } + + // Remap children array (walls, levels, buildings, sites, items can have children) + if ('children' in clonedNode && Array.isArray(clonedNode.children)) { + ;(clonedNode as Record).children = clonedNode.children + .map((childRef) => { + const childId = resolveChildRefId(childRef) + return childId ? idMap.get(childId) : undefined + }) + .filter((id): id is string => id !== undefined) as string[] + } + + // Remap wallId (items/doors/windows attached to walls) + if ('wallId' in clonedNode && typeof clonedNode.wallId === 'string') { + ;(clonedNode as Record).wallId = idMap.get(clonedNode.wallId) as + | string + | undefined + } + + clonedNodes[newId] = clonedNode + } + + // Remap root node IDs + const clonedRootNodeIds = rootNodeIds + .map((id) => idMap.get(id)) + .filter((id): id is string => id !== undefined) as AnyNodeId[] + + // Clone and remap collections if present + let clonedCollections: Record | undefined + if (collections) { + clonedCollections = {} as Record + const collectionIdMap = new Map() + + // Generate new collection IDs + for (const collectionId of Object.keys(collections)) { + collectionIdMap.set(collectionId, generateId('collection')) + } + + for (const [oldCollectionId, collection] of Object.entries(collections)) { + const newCollectionId = collectionIdMap.get(oldCollectionId)! + clonedCollections[newCollectionId] = { + ...collection, + id: newCollectionId, + nodeIds: collection.nodeIds + .map((nodeId) => idMap.get(nodeId)) + .filter((id): id is string => id !== undefined) as AnyNodeId[], + controlNodeId: collection.controlNodeId + ? (idMap.get(collection.controlNodeId) as AnyNodeId | undefined) + : undefined, + } + + // Update collectionIds on nodes that reference this collection + for (const oldNodeId of collection.nodeIds) { + const newNodeId = idMap.get(oldNodeId) + if (newNodeId && clonedNodes[newNodeId as AnyNodeId]) { + const node = clonedNodes[newNodeId as AnyNodeId] as Record + if ('collectionIds' in node && Array.isArray(node.collectionIds)) { + const oldColIds = node.collectionIds as string[] + node.collectionIds = oldColIds + .map((cid) => collectionIdMap.get(cid)) + .filter((id): id is CollectionId => id !== undefined) + } + } + } + } + } + + return { + nodes: clonedNodes, + rootNodeIds: clonedRootNodeIds, + ...(clonedCollections && { collections: clonedCollections }), + } +} diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 60151896..59cb8e16 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,7 +1,7 @@ -import { emitter, useScene } from '@pascal-app/core' +import { cloneSceneGraph, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' -import { Camera, Download, Save, Trash2, Upload } from 'lucide-react' +import { Camera, Copy, Download, Save, Trash2, Upload } from 'lucide-react' import { type KeyboardEvent, type SyntheticEvent, @@ -250,6 +250,29 @@ export function SettingsPanel({ setPhase('site') } + const handleTestClone = () => { + const collections = useScene.getState().collections + const sceneGraph = { nodes, rootNodeIds, ...(collections && { collections }) } + const cloned = cloneSceneGraph(sceneGraph) + + // Show before/after comparison + const oldIds = Object.keys(nodes).slice(0, 5) + const newIds = Object.keys(cloned.nodes).slice(0, 5) + const comparison = oldIds.map((oldId, i) => `${oldId} → ${newIds[i]}`).join('\n') + + // Apply cloned scene (setScene clears collections, so apply them after) + setScene(cloned.nodes, cloned.rootNodeIds) + if (cloned.collections) { + useScene.setState({ collections: cloned.collections }) + } + resetSelection() + setPhase('site') + + alert( + `Scene cloned successfully!\n\nNode count: ${Object.keys(nodes).length} → ${Object.keys(cloned.nodes).length}\n\nSample ID mapping:\n${comparison}\n\nCheck the Scene Graph explorer to verify new IDs.` + ) + } + const handleGenerateThumbnail = () => { if (!projectId) return setIsGeneratingThumbnail(true) @@ -358,6 +381,11 @@ export function SettingsPanel({ Load Build + +