sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)

Squash merge of 3 commits:
1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes
2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels)
3. Missing files fix (materials.ts, merged-outline-node.ts, type fix)

75 files changed, ~6K additions.
This commit is contained in:
Pascal
2026-04-07 19:21:10 -04:00
committed by GitHub
parent e8ad92592d
commit 0a46a9deb4
77 changed files with 6890 additions and 2062 deletions
+5
View File
@@ -10,6 +10,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./clone-scene-graph": {
"types": "./dist/utils/clone-scene-graph.d.ts",
"import": "./dist/utils/clone-scene-graph.js",
"default": "./dist/utils/clone-scene-graph.js"
}
},
"files": [
@@ -1,3 +1,5 @@
'use client'
import { useLayoutEffect } from 'react'
import type * as THREE from 'three'
+3 -8
View File
@@ -1,5 +1,3 @@
// Store
export type {
BuildingEvent,
CameraControlEvent,
@@ -20,9 +18,7 @@ export type {
WindowEvent,
ZoneEvent,
} from './events/bus'
// Events
export { emitter, eventSuffixes } from './events/bus'
// Hooks
export {
sceneRegistry,
useRegistry,
@@ -33,24 +29,22 @@ export {
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
// Asset storage
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
// Space detection
export {
detectSpacesForLevel,
initSpaceDetectionSync,
type Space,
wallTouchesOthers,
} from './lib/space-detection'
// Schema
export { baseMaterial, glassMaterial } from './materials'
export * from './schema'
export {
type ControlValue,
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene'
// Systems
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system'
export { ItemSystem } from './systems/item/item-system'
@@ -71,5 +65,6 @@ export {
} from './systems/wall/wall-mitering'
export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system'
export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types'
+24
View File
@@ -0,0 +1,24 @@
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
/**
* Shared base material for structural elements: walls, frames, slabs, roof.
*/
export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed',
roughness: 0.5,
metalness: 0,
})
/**
* Shared glass material for windows, glazed door panels, and glass items.
*/
export const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.35,
side: DoubleSide,
depthWrite: false,
})
+1 -1
View File
@@ -1,5 +1,5 @@
// Base
export { BaseNode, generateId, nodeType, objectId } from './base'
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
// Camera
export { CameraSchema } from './camera'
// Collections
+16 -30
View File
@@ -64,7 +64,6 @@ export const updateNodesAction = (
) => {
if (get().readOnly) return
const parentsToUpdate = new Set<AnyNodeId>()
const idsToMarkDirty = new Set<AnyNodeId>()
set((state) => {
const nextNodes = { ...state.nodes }
@@ -105,26 +104,19 @@ export const updateNodesAction = (
return { nodes: nextNodes }
})
// Collect all IDs that need to be marked dirty
// Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates
for (const u of updates) {
idsToMarkDirty.add(u.id)
pendingUpdates.add(u.id)
}
for (const pId of parentsToUpdate) {
idsToMarkDirty.add(pId)
pendingUpdates.add(pId)
}
// Add to pending updates set
for (const id of idsToMarkDirty) {
pendingUpdates.add(id)
}
// Cancel any pending RAF and schedule a new one
if (pendingRafId !== null) {
cancelAnimationFrame(pendingRafId)
}
pendingRafId = requestAnimationFrame(() => {
// Mark all pending updates as dirty
pendingUpdates.forEach((id) => {
get().markDirty(id)
})
@@ -146,32 +138,26 @@ export const deleteNodesAction = (
const nextCollections = { ...state.collections }
let nextRootIds = [...state.rootNodeIds]
// Collect all IDs to delete (including descendants) in a first pass
// This avoids issues with recursive calls during state mutation
const allIdsToDelete = new Set<AnyNodeId>()
const collectDescendants = (id: AnyNodeId) => {
// Collect all ids to delete (the requested ids + all their descendants) before
// mutating anything, so the recursive walk reads consistent state.
const allIds = new Set<AnyNodeId>()
const collect = (id: AnyNodeId) => {
if (allIds.has(id)) return
allIds.add(id)
const node = nextNodes[id]
if (!node) return
allIdsToDelete.add(id)
if ('children' in node && node.children) {
for (const childId of node.children as AnyNodeId[]) {
collectDescendants(childId)
}
if (node && 'children' in node) {
for (const cid of node.children as AnyNodeId[]) collect(cid)
}
}
for (const id of ids) collect(id)
for (const id of ids) {
collectDescendants(id)
}
// Now process all nodes for deletion
for (const id of allIdsToDelete) {
for (const id of allIds) {
const node = nextNodes[id]
if (!node) continue
// 1. Remove reference from Parent
// 1. Remove reference from parent — only if the parent itself is NOT also being deleted
const parentId = node.parentId as AnyNodeId | null
if (parentId && nextNodes[parentId]) {
if (parentId && nextNodes[parentId] && !allIds.has(parentId)) {
const parent = nextNodes[parentId] as AnyContainerNode
if (parent.children) {
nextNodes[parent.id] = {
@@ -182,7 +168,7 @@ export const deleteNodesAction = (
}
}
// 2. Remove from Root list
// 2. Remove from root list
nextRootIds = nextRootIds.filter((rid) => rid !== id)
// 3. Remove from any collections it belongs to
@@ -0,0 +1,38 @@
// Ephemeral live transform state for nodes being actively dragged/moved.
// This decouples 2D (floorplan) and 3D (viewer) so neither needs to peek
// into the other's scene graph during drag operations.
import { create } from 'zustand'
export type LiveTransform = {
position: [number, number, number]
rotation: number // Y-axis rotation (plan-view rotation)
}
type LiveTransformState = {
transforms: Map<string, LiveTransform>
set(nodeId: string, transform: LiveTransform): void
get(nodeId: string): LiveTransform | undefined
clear(nodeId: string): void
clearAll(): void
}
const useLiveTransforms = create<LiveTransformState>((set, get) => ({
transforms: new Map(),
set: (nodeId, transform) =>
set((state) => {
const next = new Map(state.transforms)
next.set(nodeId, transform)
return { transforms: next }
}),
get: (nodeId) => get().transforms.get(nodeId),
clear: (nodeId) =>
set((state) => {
const next = new Map(state.transforms)
next.delete(nodeId)
return { transforms: next }
}),
clearAll: () => set({ transforms: new Map() }),
}))
export default useLiveTransforms
+20 -18
View File
@@ -123,11 +123,6 @@ const useScene: UseSceneStore = create<SceneState>()(
setReadOnly: (readOnly: boolean) => set({ readOnly }),
unloadScene: () => {
// Clear temporal tracking to prevent memory leaks from stale node references
prevPastLength = 0
prevFutureLength = 0
prevNodesSnapshot = null
set({
nodes: {},
rootNodeIds: [],
@@ -145,14 +140,29 @@ const useScene: UseSceneStore = create<SceneState>()(
// Apply backward compatibility migrations
const patchedNodes = migrateNodes(nodes)
// Remove orphans: nodes whose parentId points to a non-existent node
const cleanedNodes = { ...patchedNodes }
for (const node of Object.values(cleanedNodes)) {
if (node.parentId && !cleanedNodes[node.parentId]) {
console.warn(
'[Scene] Removing orphan node',
node.id,
'(parentId',
node.parentId,
'not found)',
)
delete cleanedNodes[node.id]
}
}
set({
nodes: patchedNodes,
nodes: cleanedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
// Mark all nodes as dirty to trigger re-validation
Object.values(patchedNodes).forEach((node) => {
Object.values(cleanedNodes).forEach((node) => {
get().markDirty(node.id)
})
},
@@ -292,7 +302,7 @@ const useScene: UseSceneStore = create<SceneState>()(
if (!col) return state
const nextCollections = {
...state.collections,
[id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
}
const node = state.nodes[nodeId]
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
@@ -324,21 +334,13 @@ let prevPastLength = 0
let prevFutureLength = 0
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
/**
* Clears temporal history tracking variables to prevent memory leaks.
* Should be called when unloading a scene to release node references.
*/
export function clearTemporalTracking() {
export function clearSceneHistory() {
useScene.temporal.getState().clear()
prevPastLength = 0
prevFutureLength = 0
prevNodesSnapshot = null
}
export function clearSceneHistory() {
useScene.temporal.getState().clear()
clearTemporalTracking()
}
// Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state) => {
const currentPastLength = state.pastStates.length
+1 -19
View File
@@ -1,28 +1,10 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, DoorNode } from '../../schema'
import useScene from '../../store/use-scene'
const baseMaterial = new MeshStandardNodeMaterial({
name: 'door-base',
color: '#f2f0ed',
roughness: 0.5,
metalness: 0,
})
const glassMaterial = new MeshStandardNodeMaterial({
name: 'door-glass',
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.35,
side: DoubleSide,
depthWrite: false,
})
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -10,8 +10,15 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
csgEvaluator.attributes = ['position', 'normal']
function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree
brush.geometry.computeBoundsTree({ maxLeafSize: 10 })
brush.updateMatrixWorld()
}
// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry
const _matrix = new THREE.Matrix4()
const _position = new THREE.Vector3()
@@ -78,6 +85,8 @@ export const RoofSystem = () => {
mesh.rotation.y = node.rotation
}
clearDirty(id as AnyNodeId)
} else {
clearDirty(id as AnyNodeId)
}
// Queue the parent roof for a merged geometry update
if (node.parentId) {
@@ -179,6 +188,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush
totalShinSlab.geometry.dispose()
brushes.shinSlab.geometry.dispose()
prepareBrushForCSG(next)
totalShinSlab = next
} else {
totalShinSlab = brushes.shinSlab
@@ -188,6 +198,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush
totalDeckSlab.geometry.dispose()
brushes.deckSlab.geometry.dispose()
prepareBrushForCSG(next)
totalDeckSlab = next
} else {
totalDeckSlab = brushes.deckSlab
@@ -197,6 +208,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush
totalWall.geometry.dispose()
brushes.wallBrush.geometry.dispose()
prepareBrushForCSG(next)
totalWall = next
} else {
totalWall = brushes.wallBrush
@@ -206,6 +218,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush
totalInner.geometry.dispose()
brushes.innerBrush.geometry.dispose()
prepareBrushForCSG(next)
totalInner = next
} else {
totalInner = brushes.innerBrush
@@ -505,6 +518,10 @@ export function getRoofSegmentBrushes(
const toBrush = (geo: THREE.BufferGeometry): Brush | null => {
if (!geo?.attributes.position || geo.attributes.position.count === 0) return null
if (!geo.index) return null
// Strip zero-count groups — three-bvh-csg crashes with groupIndices[i] undefined
// when a group exists but covers no triangles (can happen after mergeVertices)
geo.groups = geo.groups.filter((g) => g.count > 0)
if (geo.groups.length === 0) return null
geo.computeBoundsTree = computeBoundsTree
geo.computeBoundsTree({ maxLeafSize: 10 })
const brush = new Brush(geo, dummyMats)
+79 -34
View File
@@ -42,6 +42,11 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
mesh.geometry.dispose()
mesh.geometry = newGeo
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
const elevation = node.elevation ?? 0.05
mesh.position.y = elevation < 0 ? elevation : 0
}
/** Half of default wall thickness — used to extend slab geometry under walls */
@@ -102,54 +107,94 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
* Generates extruded slab geometry from polygon
*/
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const elevation = slabNode.elevation ?? 0.05
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode)
}
/**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
*/
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
const elevation = slabNode.elevation ?? 0.05
if (polygon.length < 3) {
return new THREE.BufferGeometry()
}
if (polygon.length < 3) return new THREE.BufferGeometry()
// Create shape from polygon
// Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion
const shape = new THREE.Shape()
const firstPt = polygon[0]!
// Negate Y (which becomes Z) to get correct orientation after rotation
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < polygon.length; i++) {
const pt = polygon[i]!
shape.lineTo(pt[0], -pt[1])
}
shape.moveTo(polygon[0]![0], -polygon[0]![1])
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
shape.closePath()
// Add holes to the shape
const holes = slabNode.holes || []
for (const holePolygon of holes) {
for (const holePolygon of slabNode.holes ?? []) {
if (holePolygon.length < 3) continue
const holePath = new THREE.Path()
const holeFirstPt = holePolygon[0]!
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
holePath.lineTo(pt[0], -pt[1])
}
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
for (let i = 1; i < holePolygon.length; i++)
holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1])
holePath.closePath()
shape.holes.push(holePath)
}
// Extrude the shape by elevation
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: elevation,
bevelEnabled: false,
})
// Rotate so extrusion direction (Z) becomes height direction (Y)
const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false })
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
/**
* Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
* No top cap — the opening at ground level is handled by the ground occluder hole.
* mesh.position.y must be set to elevation so the floor sits at the correct world Y.
*
* Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
*/
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
const depth = Math.abs(slabNode.elevation ?? 0.05)
if (polygon.length < 3) return new THREE.BufferGeometry()
const positions: number[] = []
const indices: number[] = []
const n = polygon.length
// --- Floor at Y=0 ---
for (const [x, z] of polygon) positions.push(x!, 0, z!)
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of slabNode.holes ?? []) {
for (const [x, z] of hole) positions.push(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane
indices.push(tri[0]!, tri[2]!, tri[1]!)
}
// --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]!
const vBase = positions.length / 3
positions.push(x0!, 0, z0!) // v0 — floor level
positions.push(x1!, 0, z1!) // v1 — floor level
positions.push(x1!, depth, z1!) // v2 — ground level
positions.push(x0!, depth, z0!) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3)
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
}
@@ -22,6 +22,7 @@ const csgEvaluator = new Evaluator()
// WALL SYSTEM
// ============================================================================
let useFrameNb = 0
export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
@@ -34,6 +35,7 @@ export const WallSystem = () => {
// Collect dirty walls and their levels
const dirtyWallsByLevel = new Map<string, Set<string>>()
useFrameNb += 1
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'wall') return
@@ -1,28 +1,10 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, WindowNode } from '../../schema'
import useScene from '../../store/use-scene'
const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.3,
side: DoubleSide,
depthWrite: false,
})
const frameMaterial = new MeshStandardNodeMaterial({
name: 'window-frame',
color: '#e8e8e8',
roughness: 0.6,
metalness: 0,
})
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -108,7 +90,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Top / bottom — full width
addBox(
mesh,
frameMaterial,
baseMaterial,
width,
frameThickness,
frameDepth,
@@ -118,7 +100,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
)
addBox(
mesh,
frameMaterial,
baseMaterial,
width,
frameThickness,
frameDepth,
@@ -129,7 +111,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Left / right — inner height to avoid corner overlap
addBox(
mesh,
frameMaterial,
baseMaterial,
frameThickness,
innerH,
frameDepth,
@@ -139,7 +121,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
)
addBox(
mesh,
frameMaterial,
baseMaterial,
frameThickness,
innerH,
frameDepth,
@@ -184,7 +166,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
cx += colWidths[c]!
addBox(
mesh,
frameMaterial,
baseMaterial,
columnDividerThickness,
innerH,
frameDepth,
@@ -203,7 +185,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
for (let c = 0; c < numCols; c++) {
addBox(
mesh,
frameMaterial,
baseMaterial,
colWidths[c]!,
rowDividerThickness,
frameDepth,
@@ -239,7 +221,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
const sillZ = frameDepth / 2 + sillDepth / 2
addBox(
mesh,
frameMaterial,
baseMaterial,
sillW,
sillThickness,
sillDepth,
+27 -6
View File
@@ -21,8 +21,8 @@ function extractIdPrefix(id: string): string {
* parent-child relationships and other internal references.
*
* This is useful for:
* - Duplicating a project (host app creates a new project record, then loads the cloned scene)
* - 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 {
@@ -42,7 +42,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId
// structuredClone to avoid shared references between original and clone
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId
@@ -50,10 +49,23 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null
}
// Remap children array (walls, levels, buildings, sites, items can have children)
// Remap children array (buildings, levels, walls, items, etc.)
// Children can be either string IDs or embedded node objects (with an `id` property).
// Normalize both forms to remapped string IDs.
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as string[])
.map((childId) => idMap.get(childId))
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[])
.map((child) => {
if (typeof child === 'string') return idMap.get(child)
if (
child &&
typeof child === 'object' &&
'id' in child &&
typeof (child as any).id === 'string'
) {
return idMap.get((child as any).id)
}
return undefined
})
.filter((id): id is string => id !== undefined)
}
@@ -78,7 +90,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
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'))
}
@@ -166,6 +177,9 @@ export function cloneLevelSubtree(
const newLevelId = idMap.get(levelId)! as AnyNodeId
// Clone each node with remapped references.
// Use JSON roundtrip instead of structuredClone because live runtime nodes may
// carry non-serializable properties (Three.js Object3D refs, functions, etc.)
// that structuredClone would throw on.
const clonedNodes: AnyNode[] = []
for (const oldId of subtreeIds) {
const node = nodes[oldId]
@@ -178,6 +192,7 @@ export function cloneLevelSubtree(
;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself
// (the level's parentId points to the building, which is outside the subtree)
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
}
@@ -219,6 +234,7 @@ export function cloneLevelSubtree(
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections } = sceneGraph
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
const excludedNodeIds = new Set<string>()
for (const [nodeId, node] of Object.entries(nodes)) {
if (node.type === 'scan' || node.type === 'guide') {
@@ -226,12 +242,15 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
}
}
// Build a filtered scene graph without scan nodes
const filteredNodes = {} as Record<AnyNodeId, AnyNode>
for (const [nodeId, node] of Object.entries(nodes)) {
if (excludedNodeIds.has(nodeId)) continue
const clonedNode = structuredClone(node) as AnyNode
// Remove scan children from any parent that references them.
// Children can be string IDs or embedded node objects.
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
(child) => {
@@ -251,6 +270,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
// Filter collections to remove references to scan nodes
let filteredCollections: Record<CollectionId, Collection> | undefined
if (collections) {
filteredCollections = {} as Record<CollectionId, Collection>
@@ -269,6 +289,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
}
}
// Now clone the filtered graph with new IDs
return cloneSceneGraph({
nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds,