From de9eb6fa78abe25d00af82dc86f4ad9f71c09251 Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 25 Mar 2026 22:05:07 -0400 Subject: [PATCH 1/6] feat: add scene graph cloning utility --- packages/core/src/index.ts | 3 + packages/core/src/utils/clone-scene-graph.ts | 119 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 packages/core/src/utils/clone-scene-graph.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5dc96e61..3bd7ecc4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,3 +69,6 @@ export { export { WallSystem } from './systems/wall/wall-system' export { WindowSystem } from './systems/window/window-system' export { isObject } from './utils/types' +export { + cloneSceneGraph, +} from './utils/clone-scene-graph' 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..c8b11c8d --- /dev/null +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -0,0 +1,119 @@ +import type { AnyNode, AnyNodeId } from '../schema' +import type { Collection, CollectionId } from '../schema/collections' +import { generateId } from '../schema/base' + +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) +} + +/** + * 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((childId) => idMap.get(childId as string)) + .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 }), + } +} \ No newline at end of file From a87fe56d530a6fe73c8452321e034291170267e3 Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 25 Mar 2026 22:33:07 -0400 Subject: [PATCH 2/6] fix: clear collections in setScene and apply cloned collections in test button - setScene now clears collections to avoid stale references - Test clone button applies cloned collections after setScene Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/core/src/store/use-scene.ts | 1 + .../sidebar/panels/settings-panel/index.tsx | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) 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/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 + + Date: Wed, 25 Mar 2026 23:43:25 -0400 Subject: [PATCH 3/6] fix: childern <> parent link --- packages/core/src/utils/clone-scene-graph.ts | 24 +++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index c8b11c8d..ff2a2f61 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -16,6 +16,25 @@ function extractIdPrefix(id: string): string { 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. @@ -52,7 +71,10 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { // 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((childId) => idMap.get(childId as string)) + .map((childRef) => { + const childId = resolveChildRefId(childRef) + return childId ? idMap.get(childId) : undefined + }) .filter((id): id is string => id !== undefined) as string[] } From b47d4a6a15fc11c0dd3bf9a4866b5220c7a7a44e Mon Sep 17 00:00:00 2001 From: John Doe Date: Wed, 25 Mar 2026 23:57:40 -0400 Subject: [PATCH 4/6] revert: remove test clone scene button from settings panel Reverts changes from commit a87fe56d530a6fe73c8452321e034291170267e3 to remove the Test Clone Scene functionality from the settings panel. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../sidebar/panels/settings-panel/index.tsx | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) 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 59cb8e16..60151896 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 { cloneSceneGraph, emitter, useScene } from '@pascal-app/core' +import { emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' -import { Camera, Copy, Download, Save, Trash2, Upload } from 'lucide-react' +import { Camera, Download, Save, Trash2, Upload } from 'lucide-react' import { type KeyboardEvent, type SyntheticEvent, @@ -250,29 +250,6 @@ 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) @@ -381,11 +358,6 @@ export function SettingsPanel({ Load Build - - Date: Thu, 26 Mar 2026 00:02:03 -0400 Subject: [PATCH 5/6] Revert "revert: remove test clone scene button from settings panel" This reverts commit b47d4a6a15fc11c0dd3bf9a4866b5220c7a7a44e. --- .../sidebar/panels/settings-panel/index.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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 + + Date: Thu, 26 Mar 2026 00:28:51 -0400 Subject: [PATCH 6/6] style: fix imports --- packages/core/src/index.ts | 4 +--- packages/core/src/utils/clone-scene-graph.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3bd7ecc4..d5a64c18 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,7 +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' -export { - cloneSceneGraph, -} from './utils/clone-scene-graph' diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index ff2a2f61..eac38192 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,6 +1,6 @@ import type { AnyNode, AnyNodeId } from '../schema' -import type { Collection, CollectionId } from '../schema/collections' import { generateId } from '../schema/base' +import type { Collection, CollectionId } from '../schema/collections' export type SceneGraph = { nodes: Record @@ -138,4 +138,4 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { rootNodeIds: clonedRootNodeIds, ...(clonedCollections && { collections: clonedCollections }), } -} \ No newline at end of file +}