feat: stair system, scene graph utilities, read-only mode, viewer state improvements (#210)

Stair system (full stack):
- New StairNode + StairSegmentNode schemas with flights, landings, L/U-shapes
- StairSystem: geometry generation with throttled per-frame updates
- Stair tool, edit system, panels, tree node, and renderers
- Event bus types, scene registry, and command palette entries

Scene graph utilities:
- cloneLevelSubtree: deep-clone a level with remapped IDs
- forkSceneGraph: clone + strip scan/guide nodes for project forking

Core improvements:
- Read-only mode on scene store (blocks create/update/delete when locked)
- readOnly guards on node-actions and collection actions
- Upload store for scan/guide file upload handling

Viewer state:
- previewSelectedIds for box-select live preview
- hoverHighlightMode (default/delete) for delete-mode hover outline
This commit is contained in:
Pascal
2026-04-05 01:20:43 -04:00
committed by GitHub
parent 08f93f527a
commit 0bd0cab533
34 changed files with 1914 additions and 7 deletions
+6
View File
@@ -10,6 +10,8 @@ import type {
RoofSegmentNode,
SiteNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
ZoneNode,
@@ -41,6 +43,8 @@ export type SlabEvent = NodeEvent<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
@@ -104,6 +108,8 @@ type EditorEvents = GridEvents &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
CameraControlEvents &
@@ -18,6 +18,8 @@ export const sceneRegistry = {
zone: new Set<string>(),
roof: new Set<string>(),
'roof-segment': new Set<string>(),
stair: new Set<string>(),
'stair-segment': new Set<string>(),
scan: new Set<string>(),
guide: new Set<string>(),
window: new Set<string>(),
+4 -1
View File
@@ -14,6 +14,8 @@ export type {
RoofSegmentEvent,
SiteEvent,
SlabEvent,
StairEvent,
StairSegmentEvent,
WallEvent,
WindowEvent,
ZoneEvent,
@@ -54,6 +56,7 @@ export { DoorSystem } from './systems/door/door-system'
export { ItemSystem } from './systems/item/item-system'
export { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system'
export { StairSystem } from './systems/stair/stair-system'
export {
DEFAULT_WALL_HEIGHT,
DEFAULT_WALL_THICKNESS,
@@ -68,5 +71,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 { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types'
+2
View File
@@ -36,6 +36,8 @@ export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export { StairNode } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone'
+2
View File
@@ -6,6 +6,7 @@ import { GuideNode } from './guide'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { SlabNode } from './slab'
import { StairNode } from './stair'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
@@ -20,6 +21,7 @@ export const LevelNode = BaseNode.extend({
SlabNode.shape.id,
CeilingNode.shape.id,
RoofNode.shape.id,
StairNode.shape.id,
ScanNode.shape.id,
GuideNode.shape.id,
]),
@@ -0,0 +1,53 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
export const StairSegmentType = z.enum(['stair', 'landing'])
export type StairSegmentType = z.infer<typeof StairSegmentType>
export const AttachmentSide = z.enum(['front', 'left', 'right'])
export type AttachmentSide = z.infer<typeof AttachmentSide>
export const StairSegmentNode = BaseNode.extend({
id: objectId('sseg'),
type: nodeType('stair-segment'),
material: MaterialSchema.optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Stair or landing
segmentType: StairSegmentType.default('stair'),
// Width of the stair flight / landing
width: z.number().default(1.0),
// Horizontal run (depth along travel direction)
length: z.number().default(3.0),
// Vertical rise (0 for landings)
height: z.number().default(2.5),
// Number of steps (only used for stair type)
stepCount: z.number().default(10),
// Which side of the previous segment to attach to
attachmentSide: AttachmentSide.default('front'),
// Whether to fill the underside down to floor level
fillToFloor: z.boolean().default(true),
// Thickness of the stair slab when not filled to floor
thickness: z.number().default(0.25),
}).describe(
dedent`
Stair segment node - an individual flight or landing within a stair group.
Each segment generates a complete stair/landing geometry.
Multiple segments chain together to form complex staircase shapes (L-shape, U-shape, etc.).
- segmentType: stair (with steps) or landing (flat platform)
- width: width of the flight/landing
- length: horizontal run distance
- height: vertical rise (0 for landings)
- stepCount: number of steps (stair type only)
- attachmentSide: front, left, or right - which side of the previous segment to attach to
- fillToFloor: whether to fill the underside down to the absolute floor level
- thickness: slab thickness when not filled to floor
`,
)
export type StairSegmentNode = z.infer<typeof StairSegmentNode>
+27
View File
@@ -0,0 +1,27 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment'
export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchema.optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Child stair segment IDs
children: z.array(StairSegmentNode.shape.id).default([]),
}).describe(
dedent`
Stair node - a container for stair segments.
Acts as a group that holds one or more StairSegmentNodes (flights and landings).
Segments chain together based on their attachmentSide to form complex staircase shapes.
- position: center position of the stair group
- rotation: rotation around Y axis
- children: array of StairSegmentNode IDs
`,
)
export type StairNode = z.infer<typeof StairNode>
+4
View File
@@ -10,6 +10,8 @@ import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window'
import { ZoneNode } from './nodes/zone'
@@ -25,6 +27,8 @@ export const AnyNode = z.discriminatedUnion('type', [
CeilingNode,
RoofNode,
RoofSegmentNode,
StairNode,
StairSegmentNode,
ScanNode,
GuideNode,
WindowNode,
@@ -13,6 +13,7 @@ export const createNodesAction = (
get: () => SceneState,
ops: { node: AnyNode; parentId?: AnyNodeId }[],
) => {
if (get().readOnly) return
set((state) => {
const nextNodes = { ...state.nodes }
const nextRootIds = [...state.rootNodeIds]
@@ -61,6 +62,7 @@ export const updateNodesAction = (
get: () => SceneState,
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
) => {
if (get().readOnly) return
const parentsToUpdate = new Set<AnyNodeId>()
const idsToMarkDirty = new Set<AnyNodeId>()
@@ -136,6 +138,7 @@ export const deleteNodesAction = (
get: () => SceneState,
ids: AnyNodeId[],
) => {
if (get().readOnly) return
const parentsToMarkDirty = new Set<AnyNodeId>()
set((state) => {
+14 -1
View File
@@ -67,6 +67,10 @@ export type SceneState = {
// 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection>
// 5. Read-only lock — when true all create/update/delete operations are no-ops
readOnly: boolean
setReadOnly: (readOnly: boolean) => void
// Actions
loadScene: () => void
clearScene: () => void
@@ -114,6 +118,10 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
// 5. Read-only lock
readOnly: false,
setReadOnly: (readOnly: boolean) => set({ readOnly }),
unloadScene: () => {
// Clear temporal tracking to prevent memory leaks from stale node references
prevPastLength = 0
@@ -208,6 +216,7 @@ const useScene: UseSceneStore = create<SceneState>()(
// --- COLLECTIONS ---
createCollection: (name, nodeIds = []) => {
if (get().readOnly) return '' as CollectionId
const id = generateCollectionId()
const collection: Collection = { id, name, nodeIds }
set((state) => {
@@ -227,6 +236,7 @@ const useScene: UseSceneStore = create<SceneState>()(
},
deleteCollection: (id) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
const nextCollections = { ...state.collections }
@@ -246,6 +256,7 @@ const useScene: UseSceneStore = create<SceneState>()(
},
updateCollection: (id, data) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
if (!col) return state
@@ -254,6 +265,7 @@ const useScene: UseSceneStore = create<SceneState>()(
},
addToCollection: (id, nodeId) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
if (!col || col.nodeIds.includes(nodeId)) return state
@@ -274,12 +286,13 @@ const useScene: UseSceneStore = create<SceneState>()(
},
removeFromCollection: (id, nodeId) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
if (!col) return state
const nextCollections = {
...state.collections,
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
[id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },
}
const node = state.nodes[nodeId]
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
@@ -0,0 +1,371 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import useScene from '../../store/use-scene'
const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2
const MAX_SEGMENTS_PER_FRAME = 4
// ============================================================================
// STAIR SYSTEM
// ============================================================================
export const StairSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const rootNodeIds = useScene((state) => state.rootNodeIds)
useFrame(() => {
if (rootNodeIds.length === 0) {
pendingStairUpdates.clear()
return
}
if (dirtyNodes.size === 0 && pendingStairUpdates.size === 0) return
const nodes = useScene.getState().nodes
// --- Pass 1: Process dirty stair-segments (throttled) ---
// Collect parent stair IDs that need segment transform recomputation
const parentsNeedingSegmentSync = new Set<AnyNodeId>()
let segmentsProcessed = 0
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node) return
if (node.type === 'stair-segment') {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) {
const isVisible = mesh.parent?.visible !== false
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
// Geometry will be updated; chained position is applied in the parent sync pass below
updateStairSegmentGeometry(node as StairSegmentNode, mesh)
if (node.parentId) parentsNeedingSegmentSync.add(node.parentId as AnyNodeId)
segmentsProcessed++
} else if (isVisible) {
return // Over budget — keep dirty, process next frame
} else if (mesh.geometry.type === 'BoxGeometry') {
// Replace BoxGeometry placeholder with empty geometry
mesh.geometry.dispose()
const placeholder = new THREE.BufferGeometry()
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
mesh.geometry = placeholder
}
clearDirty(id as AnyNodeId)
} else {
clearDirty(id as AnyNodeId)
}
// Queue the parent stair for a merged geometry update
if (node.parentId) {
pendingStairUpdates.add(node.parentId as AnyNodeId)
}
} else if (node.type === 'stair') {
pendingStairUpdates.add(id as AnyNodeId)
// Also sync individual segment positions when in edit mode
parentsNeedingSegmentSync.add(id as AnyNodeId)
clearDirty(id as AnyNodeId)
}
})
// --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
for (const stairId of parentsNeedingSegmentSync) {
const stairNode = nodes[stairId]
if (!stairNode || stairNode.type !== 'stair') continue
syncSegmentMeshTransforms(stairNode as StairNode, nodes)
}
// --- Pass 2: Process pending merged-stair updates (throttled) ---
let stairsProcessed = 0
for (const id of pendingStairUpdates) {
if (stairsProcessed >= MAX_STAIRS_PER_FRAME) break
const node = nodes[id]
if (!node || node.type !== 'stair') {
pendingStairUpdates.delete(id)
continue
}
const group = sceneRegistry.nodes.get(id) as THREE.Group
if (group) {
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
if (mergedMesh?.visible !== false) {
updateMergedStairGeometry(node as StairNode, group, nodes)
stairsProcessed++
}
}
pendingStairUpdates.delete(id)
}
}, 5)
return null
}
// ============================================================================
// SEGMENT GEOMETRY
// ============================================================================
/**
* Generates the step/landing profile as a THREE.Shape (in the XY plane),
* then extrudes along Z for the segment width.
*/
function generateStairSegmentGeometry(
segment: StairSegmentNode,
absoluteHeight: number,
): THREE.BufferGeometry {
const { width, length, height, stepCount, segmentType, fillToFloor, thickness } = segment
const shape = new THREE.Shape()
if (segmentType === 'landing') {
shape.moveTo(0, 0)
shape.lineTo(length, 0)
if (fillToFloor) {
shape.lineTo(length, -absoluteHeight)
shape.lineTo(0, -absoluteHeight)
} else {
shape.lineTo(length, -thickness)
shape.lineTo(0, -thickness)
}
} else {
const riserHeight = height / stepCount
const treadDepth = length / stepCount
shape.moveTo(0, 0)
// Draw step profile
for (let i = 0; i < stepCount; i++) {
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
}
if (fillToFloor) {
shape.lineTo(length, -absoluteHeight)
shape.lineTo(0, -absoluteHeight)
} else {
// Sloped bottom with consistent thickness
const angle = Math.atan(riserHeight / treadDepth)
const vOff = thickness / Math.cos(angle)
// Bottom-back corner
shape.lineTo(length, height - vOff)
if (absoluteHeight === 0) {
// Ground floor: slope hits the ground (y=0)
const m = riserHeight / treadDepth
const xGround = length - (height - vOff) / m
if (xGround > 0) {
shape.lineTo(xGround, 0)
}
} else {
// Floating: parallel slope
shape.lineTo(0, -vOff)
}
}
}
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: width,
bevelEnabled: false,
})
// Rotate so extrusion is along X (width), and the shape is in the XZ plane
// Shape is drawn in XY, extruded along Z → rotate -90° around Y then offset
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(width / 2, 0, 0)
geometry.applyMatrix4(matrix)
return geometry
}
function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
// Compute absolute height from parent chain
const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
mesh.geometry.dispose()
mesh.geometry = newGeometry
// NOTE: position/rotation are NOT set here — they're set by syncSegmentMeshTransforms
// which computes the chained position based on segment order and attachmentSide.
}
/**
* Applies chained transforms to individual segment meshes (edit mode).
* Each segment's world position is determined by the chain of previous segments,
* not by the node's stored position field.
*/
function syncSegmentMeshTransforms(stairNode: StairNode, nodes: Record<string, AnyNode>) {
const segments = (stairNode.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
if (segments.length === 0) return
const transforms = computeSegmentTransforms(segments)
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!
const transform = transforms[i]!
const mesh = sceneRegistry.nodes.get(segment.id) as THREE.Mesh | undefined
if (mesh) {
mesh.position.set(transform.position[0], transform.position[1], transform.position[2])
mesh.rotation.y = transform.rotation
}
}
}
// ============================================================================
// MERGED STAIR GEOMETRY
// ============================================================================
const _matrix = new THREE.Matrix4()
const _position = new THREE.Vector3()
const _quaternion = new THREE.Quaternion()
const _scale = new THREE.Vector3(1, 1, 1)
const _yAxis = new THREE.Vector3(0, 1, 0)
function updateMergedStairGeometry(
stairNode: StairNode,
group: THREE.Group,
nodes: Record<string, AnyNode>,
) {
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
if (!mergedMesh) return
const children = stairNode.children ?? []
const segments = children
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
if (segments.length === 0) {
mergedMesh.geometry.dispose()
mergedMesh.geometry = new THREE.BufferGeometry()
mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return
}
// Compute chained transforms for segments
const transforms = computeSegmentTransforms(segments)
const geometries: THREE.BufferGeometry[] = []
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!
const transform = transforms[i]!
const absoluteHeight = transform.position[1]
const geo = generateStairSegmentGeometry(segment, absoluteHeight)
// Apply segment transform (position + rotation) relative to parent stair
_position.set(transform.position[0], transform.position[1], transform.position[2])
_quaternion.setFromAxisAngle(_yAxis, transform.rotation)
_matrix.compose(_position, _quaternion, _scale)
geo.applyMatrix4(_matrix)
geometries.push(geo)
}
const merged = mergeGeometries(geometries, false)
if (merged) {
mergedMesh.geometry.dispose()
mergedMesh.geometry = merged
}
// Dispose individual geometries
for (const geo of geometries) {
geo.dispose()
}
}
// ============================================================================
// SEGMENT CHAINING
// ============================================================================
interface SegmentTransform {
position: [number, number, number]
rotation: number
}
/**
* Computes world-relative transforms for each segment by chaining
* based on attachmentSide. This mirrors the prototype's StairSystem logic.
*/
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
const transforms: SegmentTransform[] = []
let currentPos = new THREE.Vector3(0, 0, 0)
let currentRot = 0
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!
if (i === 0) {
transforms.push({
position: [currentPos.x, currentPos.y, currentPos.z],
rotation: currentRot,
})
} else {
const prev = segments[i - 1]!
const localAttachPos = new THREE.Vector3()
let rotChange = 0
switch (segment.attachmentSide) {
case 'front':
localAttachPos.set(0, prev.height, prev.length)
rotChange = 0
break
case 'left':
localAttachPos.set(prev.width / 2, prev.height, prev.length / 2)
rotChange = Math.PI / 2
break
case 'right':
localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2)
rotChange = -Math.PI / 2
break
}
// Rotate local attachment point by previous global rotation
localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot)
currentPos = currentPos.clone().add(localAttachPos)
currentRot += rotChange
transforms.push({
position: [currentPos.x, currentPos.y, currentPos.z],
rotation: currentRot,
})
}
}
return transforms
}
/**
* Computes the absolute Y height of a segment by traversing the stair's segment chain.
*/
function computeAbsoluteHeight(node: StairSegmentNode): number {
const nodes = useScene.getState().nodes
if (!node.parentId) return 0
const parent = nodes[node.parentId as AnyNodeId]
if (!parent || parent.type !== 'stair') return 0
const stair = parent as StairNode
const segments = (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
const transforms = computeSegmentTransforms(segments)
const index = segments.findIndex((s) => s.id === node.id)
if (index < 0) return 0
return transforms[index]?.position[1] ?? 0
}
@@ -118,3 +118,160 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
...(clonedCollections && { collections: clonedCollections }),
}
}
/**
* Deep clones a level node and all its descendants with fresh IDs.
* All internal references (parentId, children, wallId) are remapped to the new IDs.
* The cloned level node's parentId is preserved (building ID) — not remapped.
*
* Unlike `cloneSceneGraph` (which operates on serialized data), this function works
* on live runtime nodes that may have non-serializable properties (Three.js objects,
* etc.). It uses JSON roundtrip to safely strip them.
*
* @returns clonedNodes - flat array of all cloned nodes (level + descendants)
* @returns newLevelId - the ID of the cloned level node
* @returns idMap - old ID → new ID mapping
*/
export function cloneLevelSubtree(
nodes: Record<AnyNodeId, AnyNode>,
levelId: AnyNodeId,
): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map<string, string> } {
const levelNode = nodes[levelId]
if (!levelNode || levelNode.type !== 'level') {
throw new Error(`Node "${levelId}" is not a level`)
}
// Recursively collect the level node + all descendants via children arrays
const subtreeIds = new Set<AnyNodeId>()
const collect = (id: AnyNodeId) => {
if (subtreeIds.has(id)) return
const node = nodes[id]
if (!node) return
subtreeIds.add(id)
if ('children' in node && Array.isArray(node.children)) {
for (const childId of node.children as AnyNodeId[]) {
collect(childId)
}
}
}
collect(levelId)
// Build ID mapping: old → new
const idMap = new Map<string, string>()
for (const oldId of subtreeIds) {
const prefix = extractIdPrefix(oldId)
idMap.set(oldId, generateId(prefix))
}
const newLevelId = idMap.get(levelId)! as AnyNodeId
// Clone each node with remapped references.
const clonedNodes: AnyNode[] = []
for (const oldId of subtreeIds) {
const node = nodes[oldId]
if (!node) continue
const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
}
// Remap children array
if ('children' in cloned && Array.isArray(cloned.children)) {
;(cloned as Record<string, unknown>).children = (cloned.children as unknown[])
.map((child) => {
if (typeof child === 'string') return idMap.get(child) ?? child
if (
child &&
typeof child === 'object' &&
'id' in child &&
typeof (child as any).id === 'string'
) {
return idMap.get((child as any).id) ?? (child as any).id
}
return child
})
.filter((id): id is string => typeof id === 'string')
}
// Remap wallId (doors/windows attached to walls)
if ('wallId' in cloned && typeof cloned.wallId === 'string') {
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
}
clonedNodes.push(cloned)
}
return { clonedNodes, newLevelId, idMap }
}
/**
* Forks a scene graph for use as a new project: clones with new IDs and strips
* scan and guide nodes (and their references) since those contain user-uploaded
* imagery that shouldn't carry over to a forked project.
*/
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections } = sceneGraph
const excludedNodeIds = new Set<string>()
for (const [nodeId, node] of Object.entries(nodes)) {
if (node.type === 'scan' || node.type === 'guide') {
excludedNodeIds.add(nodeId)
}
}
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
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
(child) => {
const childId =
typeof child === 'string'
? child
: child && typeof child === 'object' && 'id' in child
? (child as any).id
: null
return childId ? !excludedNodeIds.has(childId) : true
},
)
}
filteredNodes[nodeId as AnyNodeId] = clonedNode
}
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
let filteredCollections: Record<CollectionId, Collection> | undefined
if (collections) {
filteredCollections = {} as Record<CollectionId, Collection>
for (const [collectionId, collection] of Object.entries(collections)) {
const filteredNodeIds = collection.nodeIds.filter((id) => !excludedNodeIds.has(id))
if (filteredNodeIds.length > 0) {
filteredCollections[collectionId as CollectionId] = {
...collection,
nodeIds: filteredNodeIds as AnyNodeId[],
controlNodeId:
collection.controlNodeId && excludedNodeIds.has(collection.controlNodeId)
? undefined
: collection.controlNodeId,
}
}
}
}
return cloneSceneGraph({
nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds,
...(filteredCollections && { collections: filteredCollections }),
})
}