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
@@ -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 }),
})
}