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