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
+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,