Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions packages/core/src/store/use-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const useScene: UseSceneStore = create<SceneState>()(
nodes: patchedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
// Mark all nodes as dirty to trigger re-validation
Object.values(patchedNodes).forEach((node) => {
Expand Down
141 changes: 141 additions & 0 deletions packages/core/src/utils/clone-scene-graph.ts
Original file line number Diff line number Diff line change
@@ -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<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[]
collections?: Record<CollectionId, Collection>
}

/**
* 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<string, string>()

// 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<AnyNodeId, AnyNode>

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<string, unknown>).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<string, unknown>).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<CollectionId, Collection> | undefined
if (collections) {
clonedCollections = {} as Record<CollectionId, Collection>
const collectionIdMap = new Map<string, CollectionId>()

// 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<string, unknown>
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 }),
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -358,6 +381,11 @@ export function SettingsPanel({
Load Build
</Button>

<Button className="w-full justify-start gap-2" onClick={handleTestClone} variant="outline">
<Copy className="size-4" />
Test Clone Scene
</Button>

<input
accept="application/json"
className="hidden"
Expand Down