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:
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user