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