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
+6
View File
@@ -10,6 +10,8 @@ import type {
RoofSegmentNode, RoofSegmentNode,
SiteNode, SiteNode,
SlabNode, SlabNode,
StairNode,
StairSegmentNode,
WallNode, WallNode,
WindowNode, WindowNode,
ZoneNode, ZoneNode,
@@ -41,6 +43,8 @@ export type SlabEvent = NodeEvent<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode> export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode> export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode> export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
@@ -104,6 +108,8 @@ type EditorEvents = GridEvents &
NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> & NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'window', WindowEvent> & NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
CameraControlEvents & CameraControlEvents &
@@ -18,6 +18,8 @@ export const sceneRegistry = {
zone: new Set<string>(), zone: new Set<string>(),
roof: new Set<string>(), roof: new Set<string>(),
'roof-segment': new Set<string>(), 'roof-segment': new Set<string>(),
stair: new Set<string>(),
'stair-segment': new Set<string>(),
scan: new Set<string>(), scan: new Set<string>(),
guide: new Set<string>(), guide: new Set<string>(),
window: new Set<string>(), window: new Set<string>(),
+4 -1
View File
@@ -14,6 +14,8 @@ export type {
RoofSegmentEvent, RoofSegmentEvent,
SiteEvent, SiteEvent,
SlabEvent, SlabEvent,
StairEvent,
StairSegmentEvent,
WallEvent, WallEvent,
WindowEvent, WindowEvent,
ZoneEvent, ZoneEvent,
@@ -54,6 +56,7 @@ export { DoorSystem } from './systems/door/door-system'
export { ItemSystem } from './systems/item/item-system' export { ItemSystem } from './systems/item/item-system'
export { RoofSystem } from './systems/roof/roof-system' export { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system' export { SlabSystem } from './systems/slab/slab-system'
export { StairSystem } from './systems/stair/stair-system'
export { export {
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
DEFAULT_WALL_THICKNESS, DEFAULT_WALL_THICKNESS,
@@ -68,5 +71,5 @@ export {
} from './systems/wall/wall-mitering' } from './systems/wall/wall-mitering'
export { WallSystem } from './systems/wall/wall-system' export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-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' export { isObject } from './utils/types'
+2
View File
@@ -36,6 +36,8 @@ export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export { StairNode } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { WallNode } from './nodes/wall' export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
+2
View File
@@ -6,6 +6,7 @@ import { GuideNode } from './guide'
import { RoofNode } from './roof' import { RoofNode } from './roof'
import { ScanNode } from './scan' import { ScanNode } from './scan'
import { SlabNode } from './slab' import { SlabNode } from './slab'
import { StairNode } from './stair'
import { WallNode } from './wall' import { WallNode } from './wall'
import { ZoneNode } from './zone' import { ZoneNode } from './zone'
@@ -20,6 +21,7 @@ export const LevelNode = BaseNode.extend({
SlabNode.shape.id, SlabNode.shape.id,
CeilingNode.shape.id, CeilingNode.shape.id,
RoofNode.shape.id, RoofNode.shape.id,
StairNode.shape.id,
ScanNode.shape.id, ScanNode.shape.id,
GuideNode.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 { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site' import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab' import { SlabNode } from './nodes/slab'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { WallNode } from './nodes/wall' import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window' import { WindowNode } from './nodes/window'
import { ZoneNode } from './nodes/zone' import { ZoneNode } from './nodes/zone'
@@ -25,6 +27,8 @@ export const AnyNode = z.discriminatedUnion('type', [
CeilingNode, CeilingNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
StairNode,
StairSegmentNode,
ScanNode, ScanNode,
GuideNode, GuideNode,
WindowNode, WindowNode,
@@ -13,6 +13,7 @@ export const createNodesAction = (
get: () => SceneState, get: () => SceneState,
ops: { node: AnyNode; parentId?: AnyNodeId }[], ops: { node: AnyNode; parentId?: AnyNodeId }[],
) => { ) => {
if (get().readOnly) return
set((state) => { set((state) => {
const nextNodes = { ...state.nodes } const nextNodes = { ...state.nodes }
const nextRootIds = [...state.rootNodeIds] const nextRootIds = [...state.rootNodeIds]
@@ -61,6 +62,7 @@ export const updateNodesAction = (
get: () => SceneState, get: () => SceneState,
updates: { id: AnyNodeId; data: Partial<AnyNode> }[], updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
) => { ) => {
if (get().readOnly) return
const parentsToUpdate = new Set<AnyNodeId>() const parentsToUpdate = new Set<AnyNodeId>()
const idsToMarkDirty = new Set<AnyNodeId>() const idsToMarkDirty = new Set<AnyNodeId>()
@@ -136,6 +138,7 @@ export const deleteNodesAction = (
get: () => SceneState, get: () => SceneState,
ids: AnyNodeId[], ids: AnyNodeId[],
) => { ) => {
if (get().readOnly) return
const parentsToMarkDirty = new Set<AnyNodeId>() const parentsToMarkDirty = new Set<AnyNodeId>()
set((state) => { set((state) => {
+14 -1
View File
@@ -67,6 +67,10 @@ export type SceneState = {
// 4. Relational metadata — not nodes // 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection> 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 // Actions
loadScene: () => void loadScene: () => void
clearScene: () => void clearScene: () => void
@@ -114,6 +118,10 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections // 4. Collections
collections: {} as Record<CollectionId, Collection>, collections: {} as Record<CollectionId, Collection>,
// 5. Read-only lock
readOnly: false,
setReadOnly: (readOnly: boolean) => set({ readOnly }),
unloadScene: () => { unloadScene: () => {
// Clear temporal tracking to prevent memory leaks from stale node references // Clear temporal tracking to prevent memory leaks from stale node references
prevPastLength = 0 prevPastLength = 0
@@ -208,6 +216,7 @@ const useScene: UseSceneStore = create<SceneState>()(
// --- COLLECTIONS --- // --- COLLECTIONS ---
createCollection: (name, nodeIds = []) => { createCollection: (name, nodeIds = []) => {
if (get().readOnly) return '' as CollectionId
const id = generateCollectionId() const id = generateCollectionId()
const collection: Collection = { id, name, nodeIds } const collection: Collection = { id, name, nodeIds }
set((state) => { set((state) => {
@@ -227,6 +236,7 @@ const useScene: UseSceneStore = create<SceneState>()(
}, },
deleteCollection: (id) => { deleteCollection: (id) => {
if (get().readOnly) return
set((state) => { set((state) => {
const col = state.collections[id] const col = state.collections[id]
const nextCollections = { ...state.collections } const nextCollections = { ...state.collections }
@@ -246,6 +256,7 @@ const useScene: UseSceneStore = create<SceneState>()(
}, },
updateCollection: (id, data) => { updateCollection: (id, data) => {
if (get().readOnly) return
set((state) => { set((state) => {
const col = state.collections[id] const col = state.collections[id]
if (!col) return state if (!col) return state
@@ -254,6 +265,7 @@ const useScene: UseSceneStore = create<SceneState>()(
}, },
addToCollection: (id, nodeId) => { addToCollection: (id, nodeId) => {
if (get().readOnly) return
set((state) => { set((state) => {
const col = state.collections[id] const col = state.collections[id]
if (!col || col.nodeIds.includes(nodeId)) return state if (!col || col.nodeIds.includes(nodeId)) return state
@@ -274,12 +286,13 @@ const useScene: UseSceneStore = create<SceneState>()(
}, },
removeFromCollection: (id, nodeId) => { removeFromCollection: (id, nodeId) => {
if (get().readOnly) return
set((state) => { set((state) => {
const col = state.collections[id] const col = state.collections[id]
if (!col) return state if (!col) return state
const nextCollections = { const nextCollections = {
...state.collections, ...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] const node = state.nodes[nodeId]
if (!(node && 'collectionIds' in node)) return { collections: nextCollections } 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 }), ...(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 }),
})
}
@@ -19,6 +19,7 @@ import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system'
import { StairEditSystem } from '../systems/stair/stair-edit-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
import { ZoneSystem } from '../systems/zone/zone-system' import { ZoneSystem } from '../systems/zone/zone-system'
import { BoxSelectTool } from '../tools/select/box-select-tool' import { BoxSelectTool } from '../tools/select/box-select-tool'
@@ -600,6 +601,7 @@ export default function Editor({
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />} {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem /> <CeilingSystem />
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem />
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />} {!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
{!isLoading && !isFirstPersonMode && <ToolManager />} {!isLoading && !isFirstPersonMode && <ToolManager />}
<CustomCameraControls /> <CustomCameraControls />
@@ -617,6 +619,7 @@ export default function Editor({
<ViewerZoneSystem /> <ViewerZoneSystem />
<CeilingSystem /> <CeilingSystem />
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem />
<CustomCameraControls /> <CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} /> <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator /> <PresetThumbnailGenerator />
@@ -32,6 +32,8 @@ type SelectableNodeType =
| 'ceiling' | 'ceiling'
| 'roof' | 'roof'
| 'roof-segment' | 'roof-segment'
| 'stair'
| 'stair-segment'
| 'window' | 'window'
| 'door' | 'door'
@@ -0,0 +1,69 @@
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
/**
* Imperatively toggles the Three.js visibility of stair objects based on the
* editor selection — without causing React re-renders in StairRenderer.
*
* When a stair (or one of its segments) is selected:
* - merged-stair mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so StairSystem rebuilds their geometry
*
* When deselected:
* - merged-stair mesh is shown
* - segments-wrapper group is hidden
*/
export const StairEditSystem = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const prevActiveStairIds = useRef(new Set<string>())
useEffect(() => {
const nodes = useScene.getState().nodes
// Collect which stair nodes should be in "edit mode"
const activeStairIds = new Set<string>()
for (const id of selectedIds) {
const node = nodes[id as AnyNodeId]
if (!node) continue
if (node.type === 'stair') {
activeStairIds.add(id)
} else if (node.type === 'stair-segment' && node.parentId) {
activeStairIds.add(node.parentId)
}
}
// Update all stairs that are currently active OR were previously active
const stairIdsToUpdate = new Set([...activeStairIds, ...prevActiveStairIds.current])
for (const stairId of stairIdsToUpdate) {
const group = sceneRegistry.nodes.get(stairId)
if (!group) continue
const mergedMesh = group.getObjectByName('merged-stair')
const segmentsWrapper = group.getObjectByName('segments-wrapper')
const isActive = activeStairIds.has(stairId)
if (mergedMesh) mergedMesh.visible = !isActive
if (segmentsWrapper) segmentsWrapper.visible = isActive
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
if (stairNode?.children?.length) {
const wasActive = prevActiveStairIds.current.has(stairId)
if (isActive !== wasActive) {
// Entering edit mode: rebuild individual segment geometries
// Exiting edit mode: sync transforms + rebuild merged mesh
const { markDirty } = useScene.getState()
for (const childId of stairNode.children) {
markDirty(childId as AnyNodeId)
}
}
}
}
prevActiveStairIds.current = activeStairIds
}, [selectedIds])
return null
}
@@ -0,0 +1,190 @@
import {
type AnyNode,
emitter,
type GridEvent,
type LevelNode,
StairNode,
StairSegmentNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const GRID_OFFSET = 0.02
// Default stair segment dimensions
const DEFAULT_WIDTH = 1.0
const DEFAULT_LENGTH = 3.0
const DEFAULT_HEIGHT = 2.5
const DEFAULT_STEP_COUNT = 10
/**
* Generates the step-profile geometry for the ghost preview.
* Same algorithm as StairSystem's generateStairSegmentGeometry.
*/
function createStairPreviewGeometry(): THREE.BufferGeometry {
const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT
const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT
const shape = new THREE.Shape()
shape.moveTo(0, 0)
for (let i = 0; i < DEFAULT_STEP_COUNT; i++) {
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
}
// Fill to floor (absoluteHeight = 0)
shape.lineTo(DEFAULT_LENGTH, 0)
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: DEFAULT_WIDTH,
bevelEnabled: false,
})
// Rotate so extrusion is along X (width), shape profile in XZ plane
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0)
geometry.applyMatrix4(matrix)
return geometry
}
/**
* Creates a stair group with one default stair segment at the given position/rotation.
*/
function commitStairPlacement(
levelId: LevelNode['id'],
position: [number, number, number],
rotation: number,
): void {
const { createNodes, nodes } = useScene.getState()
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
const name = `Staircase ${stairCount + 1}`
const segment = StairSegmentNode.parse({
segmentType: 'stair',
width: DEFAULT_WIDTH,
length: DEFAULT_LENGTH,
height: DEFAULT_HEIGHT,
stepCount: DEFAULT_STEP_COUNT,
attachmentSide: 'front',
fillToFloor: true,
position: [0, 0, 0],
})
const stair = StairNode.parse({
name,
position,
rotation,
children: [segment.id],
})
createNodes([
{ node: stair, parentId: levelId },
{ node: segment, parentId: stair.id },
])
sfxEmitter.emit('sfx:structure-build')
}
export const StairTool: React.FC = () => {
const cursorRef = useRef<THREE.Group>(null)
const previewRef = useRef<THREE.Group>(null)
const rotationRef = useRef(0)
const previousGridPosRef = useRef<[number, number] | null>(null)
const currentLevelId = useViewer((state) => state.selection.levelId)
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
useEffect(() => {
if (!currentLevelId) return
// Reset rotation when tool activates
rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 0
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
if (cursorRef.current) {
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
}
if (previewRef.current) {
previewRef.current.position.set(gridX, y, gridZ)
}
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
}
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta
if (previewRef.current) {
previewRef.current.rotation.y = rotationRef.current
}
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
window.addEventListener('keydown', onKeyDown)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
window.removeEventListener('keydown', onKeyDown)
}
}, [currentLevelId])
return (
<group>
<CursorSphere ref={cursorRef} />
{/* 3D ghost preview — position/rotation updated imperatively */}
<group ref={previewRef}>
<mesh castShadow geometry={previewGeometry}>
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
</mesh>
</group>
</group>
)
}
@@ -12,6 +12,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool' import { SlabTool } from './slab/slab-tool'
import { StairTool } from './stair/stair-tool'
import { WallTool } from './wall/wall-tool' import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool' import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -26,6 +27,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
slab: SlabTool, slab: SlabTool,
ceiling: CeilingTool, ceiling: CeilingTool,
roof: RoofTool, roof: RoofTool,
stair: StairTool,
door: DoorTool, door: DoorTool,
item: ItemTool, item: ItemTool,
zone: ZoneTool, zone: ZoneTool,
@@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
@@ -115,6 +115,14 @@ export function EditorCommands() {
keywords: ['furniture', 'object', 'asset', 'furnish'], keywords: ['furniture', 'object', 'asset', 'furnish'],
execute: () => activateTool('item'), execute: () => activateTool('item'),
}, },
{
id: 'editor.tool.stair',
label: 'Stair Tool',
group: 'Scene',
icon: <ArrowRight className="h-4 w-4" />,
keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'],
execute: () => activateTool('stair'),
},
{ {
id: 'editor.tool.zone', id: 'editor.tool.zone',
label: 'Zone Tool', label: 'Zone Tool',
@@ -342,7 +350,7 @@ export function EditorCommands() {
icon: <Box className="h-4 w-4" />, icon: <Box className="h-4 w-4" />,
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'], keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
execute: () => run(() => exportScene()), execute: () => run(() => exportScene()),
}, } as const,
] ]
: []), : []),
{ {
@@ -10,6 +10,8 @@ import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel' import { RoofSegmentPanel } from './roof-segment-panel'
import { SlabPanel } from './slab-panel' import { SlabPanel } from './slab-panel'
import { StairPanel } from './stair-panel'
import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel' import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel' import { WindowPanel } from './window-panel'
@@ -37,6 +39,10 @@ export function PanelManager() {
return <RoofSegmentPanel /> return <RoofSegmentPanel />
case 'slab': case 'slab':
return <SlabPanel /> return <SlabPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'ceiling': case 'ceiling':
return <CeilingPanel /> return <CeilingPanel />
case 'wall': case 'wall':
@@ -0,0 +1,304 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type MaterialSchema,
type StairNode,
StairNode as StairNodeSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function StairPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<StairNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const getLastSegmentFillDefaults = useCallback(() => {
if (!node) return { fillToFloor: true }
const children = node.children ?? []
const lastChildId = children[children.length - 1]
if (lastChildId) {
const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
if (lastChild?.type === 'stair-segment') {
return { fillToFloor: lastChild.fillToFloor }
}
}
return { fillToFloor: true }
}, [node, nodes])
const handleAddFlight = useCallback(() => {
if (!node) return
const { fillToFloor } = getLastSegmentFillDefaults()
const segment = StairSegmentNodeSchema.parse({
segmentType: 'stair',
width: 1.0,
length: 3.0,
height: 2.5,
stepCount: 10,
attachmentSide: 'front',
fillToFloor,
thickness: 0.25,
position: [0, 0, 0],
})
createNode(segment, node.id as AnyNodeId)
}, [node, createNode, getLastSegmentFillDefaults])
const handleAddLanding = useCallback(() => {
if (!node) return
const { fillToFloor } = getLastSegmentFillDefaults()
const segment = StairSegmentNodeSchema.parse({
segmentType: 'landing',
width: 1.0,
length: 1.0,
height: 0,
stepCount: 0,
attachmentSide: 'front',
fillToFloor,
thickness: 0.32,
position: [0, 0, 0],
})
createNode(segment, node.id as AnyNodeId)
}, [node, createNode, getLastSegmentFillDefaults])
const handleSelectSegment = useCallback(
(segmentId: string) => {
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
},
[setSelection],
)
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = StairNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
// Also duplicate all child segments
const nodesState = useScene.getState().nodes
const children = node.children || []
for (const childId of children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
}
}
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
} catch (e) {
console.error('Failed to duplicate stair', e)
}
}, [node, setSelection, setMovingNode])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
}
setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection])
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
return (
<PanelWrapper
icon="/icons/stairs.png"
onClose={handleClose}
title={node.name || 'Staircase'}
width={300}
>
<PanelSection title="Segments">
<div className="flex flex-col gap-1">
{segments.map((seg, i) => (
<button
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
key={seg.id}
onClick={() => handleSelectSegment(seg.id)}
type="button"
>
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
</button>
))}
</div>
<div className="flex gap-1.5">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add flight"
onClick={handleAddFlight}
/>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add landing"
onClick={handleAddLanding}
/>
</div>
</PanelSection>
<PanelSection title="Position">
<MetricControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<MetricControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<MetricControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<ActionButton
className="hover:bg-red-500/20"
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,339 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type AttachmentSide,
type MaterialSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
type StairSegmentType,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
const SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [
{ label: 'Flight', value: 'stair' },
{ label: 'Landing', value: 'landing' },
]
const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
{ label: 'Front', value: 'front' },
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]
export function StairSegmentPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined)
: undefined
// Check if this is the first segment in the parent stair
const isFirstSegment = (() => {
if (!node?.parentId) return true
const parent = nodes[node.parentId as AnyNodeId]
if (!parent || parent.type !== 'stair') return true
const children = (parent as any).children ?? []
return children[0] === node.id
})()
const handleUpdate = useCallback(
(updates: Partial<StairSegmentNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleBack = useCallback(() => {
if (node?.parentId) {
setSelection({ selectedIds: [node.parentId] })
}
}, [node?.parentId, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = StairSegmentNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}, [node, setSelection, setMovingNode])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
const parentId = node.parentId
useScene.getState().deleteNode(selectedId as AnyNodeId)
if (parentId) {
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
setSelection({ selectedIds: [parentId] })
} else {
setSelection({ selectedIds: [] })
}
}, [selectedId, node, setSelection])
if (!node || node.type !== 'stair-segment' || selectedIds.length !== 1) return null
return (
<PanelWrapper
icon="/icons/stairs.png"
onBack={handleBack}
onClose={handleClose}
title={node.name || 'Stair Segment'}
width={300}
>
<PanelSection title="Type">
<SegmentedControl
onChange={(v) => {
const updates: Partial<StairSegmentNode> = { segmentType: v }
if (v === 'landing') {
updates.height = 0
updates.stepCount = 0
updates.length = 1.0
} else {
updates.height = 2.5
updates.stepCount = 10
updates.length = 3.0
}
handleUpdate(updates)
}}
options={SEGMENT_TYPE_OPTIONS}
value={node.segmentType}
/>
</PanelSection>
{!isFirstSegment && (
<PanelSection title="Attachment">
<SegmentedControl
onChange={(v) => handleUpdate({ attachmentSide: v })}
options={ATTACHMENT_SIDE_OPTIONS}
value={node.attachmentSide}
/>
</PanelSection>
)}
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={5}
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
<SliderControl
label="Length"
max={10}
min={0.5}
onChange={(v) => handleUpdate({ length: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.length * 100) / 100}
/>
{node.segmentType === 'stair' && (
<>
<SliderControl
label="Height"
max={10}
min={0.5}
onChange={(v) => handleUpdate({ height: v })}
precision={2}
step={0.1}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
<SliderControl
label="Steps"
max={30}
min={2}
onChange={(v) => handleUpdate({ stepCount: Math.round(v) })}
precision={0}
step={1}
unit=""
value={node.stepCount}
/>
</>
)}
</PanelSection>
<PanelSection title="Structure">
<div className="flex items-center justify-between px-1 py-1">
<span className="text-muted-foreground text-xs">Fill to floor</span>
<button
className={`relative h-5 w-10 rounded-full transition-colors ${
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'
}`}
onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}
type="button"
>
<div
className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${
node.fillToFloor ? 'left-6' : 'left-1'
}`}
/>
</button>
</div>
{!node.fillToFloor && (
<SliderControl
label="Thickness"
max={1}
min={0.05}
onChange={(v) => handleUpdate({ thickness: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
</PanelSection>
<PanelSection title="Position">
<MetricControl
label="X"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<MetricControl
label="Y"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<MetricControl
label="Z"
max={50}
min={-50}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.position[2] * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
}}
precision={0}
step={1}
unit="°"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
}}
/>
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<ActionButton
className="hover:bg-red-500/20"
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,216 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence } from 'motion/react'
import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react'
import useEditor from '../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
interface StairTreeNodeProps {
node: StairNode
depth: number
isLast?: boolean
}
export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const nodes = useScene((state) => state.nodes)
const { drag, dropTarget } = useTreeNodeDrag()
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
if (!handled && useEditor.getState().phase === 'furnish') {
useEditor.getState().setPhase('structure')
}
}
const handleDoubleClick = () => {
focusTreeNode(node.id)
}
const handleMouseEnter = () => {
setHoveredId(node.id)
}
const handleMouseLeave = () => {
setHoveredId(null)
}
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
useEffect(() => {
if (isSelected || hasSelectedChild) {
setExpanded(true)
}
}, [isSelected, hasSelectedChild])
// Auto-expand when a segment is being dragged over this stair
const isDropTarget = drag !== null && dropTarget?.parentId === node.id
useEffect(() => {
if (isDropTarget && !expanded) {
setExpanded(true)
}
}, [isDropTarget, expanded])
const segmentCount = segments.length
const defaultName = `Staircase (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`
// Hide the dragged segment from every stair while dragging
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
const isValidDropTarget = drag !== null && drag.nodeId !== node.id
return (
<div data-drop-target={node.id}>
<TreeNodeWrapper
actions={<TreeNodeActions node={node} />}
depth={depth}
expanded={expanded}
hasChildren={segments.length > 0}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
}
isDropTarget={isValidDropTarget && isDropTarget}
isHovered={isHovered || isDropTarget}
isLast={isLast && !expanded}
isSelected={isSelected}
isVisible={node.visible !== false}
label={
<InlineRenameInput
defaultName={defaultName}
isEditing={isEditing}
node={node}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={node.id}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onToggle={() => setExpanded(!expanded)}
>
{visibleSegments.map((seg, i) => {
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
const showIndicatorAfter =
isDropTarget &&
i === visibleSegments.length - 1 &&
dropTarget?.insertIndex !== undefined &&
dropTarget.insertIndex > i
return (
<div key={seg.id}>
<AnimatePresence>
{showIndicatorBefore && <DropIndicatorLine key="indicator-before" />}
</AnimatePresence>
<StairSegmentTreeNode
depth={depth + 1}
isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}
node={seg}
/>
<AnimatePresence>
{showIndicatorAfter && <DropIndicatorLine key="indicator-after" />}
</AnimatePresence>
</div>
)
})}
<AnimatePresence>
{isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}
</AnimatePresence>
</TreeNodeWrapper>
</div>
)
}
function StairSegmentTreeNode({
node,
depth,
isLast,
}: {
node: StairSegmentNode
depth: number
isLast?: boolean
}) {
const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const isSelected = selectedIds.includes(node.id)
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const { startDrag, isDragging } = useTreeNodeDrag()
const handleClick = (e: React.MouseEvent) => {
if (isDragging) return
e.stopPropagation()
handleTreeSelection(e, node.id, selectedIds, setSelection)
}
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (e.button !== 0) return
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
const label = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)
},
[node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
)
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
return (
<div data-drop-child={node.id}>
<TreeNodeWrapper
actions={<TreeNodeActions node={node} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/stairs.png"
width={14}
/>
}
isDraggable
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={node.visible !== false}
label={
<InlineRenameInput
defaultName={defaultName}
isEditing={isEditing}
node={node}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={node.id}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(node.id)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
onPointerDown={handlePointerDown}
onToggle={() => {}}
/>
</div>
)
}
@@ -61,6 +61,7 @@ import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node' import { LevelTreeNode } from './level-tree-node'
import { RoofTreeNode } from './roof-tree-node' import { RoofTreeNode } from './roof-tree-node'
import { SlabTreeNode } from './slab-tree-node' import { SlabTreeNode } from './slab-tree-node'
import { StairTreeNode } from './stair-tree-node'
import { WallTreeNode } from './wall-tree-node' import { WallTreeNode } from './wall-tree-node'
import { WindowTreeNode } from './window-tree-node' import { WindowTreeNode } from './window-tree-node'
import { ZoneTreeNode } from './zone-tree-node' import { ZoneTreeNode } from './zone-tree-node'
@@ -89,6 +90,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
return <WallTreeNode depth={depth} isLast={isLast} node={node as any} /> return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'roof': case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} /> return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'stair':
return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'item': case 'item':
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} /> return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'door': case 'door':
+3 -1
View File
@@ -9,6 +9,8 @@ import {
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type Space, type Space,
type StairNode,
type StairSegmentNode,
useScene, useScene,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -79,7 +81,7 @@ type EditorState = {
setCatalogCategory: (category: CatalogCategory | null) => void setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void setSelectedItem: (item: AssetInput) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null
setMovingNode: ( setMovingNode: (
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
) => void ) => void
+13
View File
@@ -11,8 +11,18 @@ export interface UploadEntry {
resultUrl: string | null resultUrl: string | null
} }
export type UploadHandler = (
projectId: string,
levelId: string,
file: File,
type: 'scan' | 'guide',
) => void
interface UploadState { interface UploadState {
uploads: Record<string, UploadEntry> uploads: Record<string, UploadEntry>
uploadHandler: UploadHandler | null
registerUploadHandler: (handler: UploadHandler) => void
unregisterUploadHandler: () => void
startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void
setProgress: (levelId: string, progress: number) => void setProgress: (levelId: string, progress: number) => void
setStatus: (levelId: string, status: UploadStatus) => void setStatus: (levelId: string, status: UploadStatus) => void
@@ -23,6 +33,9 @@ interface UploadState {
export const useUploadStore = create<UploadState>((set) => ({ export const useUploadStore = create<UploadState>((set) => ({
uploads: {}, uploads: {},
uploadHandler: null,
registerUploadHandler: (handler) => set({ uploadHandler: handler }),
unregisterUploadHandler: () => set({ uploadHandler: null }),
startUpload: (levelId, assetType, fileName) => startUpload: (levelId, assetType, fileName) =>
set((s) => ({ set((s) => ({
@@ -12,6 +12,8 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { ScanRenderer } from './scan/scan-renderer' import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer' import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer' import { SlabRenderer } from './slab/slab-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { WallRenderer } from './wall/wall-renderer' import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer' import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-renderer' import { ZoneRenderer } from './zone/zone-renderer'
@@ -35,6 +37,8 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'zone' && <ZoneRenderer node={node} />} {node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />} {node.type === 'roof' && <RoofRenderer node={node} />}
{node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />} {node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}
{node.type === 'stair' && <StairRenderer node={node} />}
{node.type === 'stair-segment' && <StairSegmentRenderer node={node} />}
{node.type === 'scan' && <ScanRenderer node={node} />} {node.type === 'scan' && <ScanRenderer node={node} />}
{node.type === 'guide' && <GuideRenderer node={node} />} {node.type === 'guide' && <GuideRenderer node={node} />}
</> </>
@@ -0,0 +1,37 @@
import { type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
useRegistry(node.id, 'stair-segment', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'stair-segment')
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<mesh
material={material}
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{/* StairSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
@@ -0,0 +1,43 @@
import { type StairNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer'
export const StairRenderer = ({ node }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(node.id, 'stair', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'stair')
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<group
position={node.position}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
<mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} />
</mesh>
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
</group>
)
}
@@ -6,6 +6,7 @@ import {
ItemSystem, ItemSystem,
RoofSystem, RoofSystem,
SlabSystem, SlabSystem,
StairSystem,
WallSystem, WallSystem,
WindowSystem, WindowSystem,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -142,6 +143,7 @@ const Viewer: React.FC<ViewerProps> = ({
<ItemSystem /> <ItemSystem />
<RoofSystem /> <RoofSystem />
<SlabSystem /> <SlabSystem />
<StairSystem />
<WallSystem /> <WallSystem />
<WindowSystem /> <WindowSystem />
<ZoneSystem /> <ZoneSystem />
@@ -19,6 +19,10 @@ import {
type SiteNode, type SiteNode,
type SlabEvent, type SlabEvent,
type SlabNode, type SlabNode,
type StairEvent,
type StairNode,
type StairSegmentEvent,
type StairSegmentNode,
type WallEvent, type WallEvent,
type WallNode, type WallNode,
type WindowEvent, type WindowEvent,
@@ -40,6 +44,8 @@ type NodeConfig = {
ceiling: { node: CeilingNode; event: CeilingEvent } ceiling: { node: CeilingNode; event: CeilingEvent }
roof: { node: RoofNode; event: RoofEvent } roof: { node: RoofNode; event: RoofEvent }
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
stair: { node: StairNode; event: StairEvent }
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
window: { node: WindowNode; event: WindowEvent } window: { node: WindowNode; event: WindowEvent }
door: { node: DoorNode; event: DoorEvent } door: { node: DoorNode; event: DoorEvent }
} }
+3 -2
View File
@@ -35,8 +35,8 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
} }
export function createDefaultMaterial( export function createDefaultMaterial(
color: string = '#ffffff', color = '#ffffff',
roughness: number = 0.9, roughness = 0.9,
): THREE.MeshStandardMaterial { ): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ return new THREE.MeshStandardMaterial({
color, color,
@@ -59,6 +59,7 @@ export const DEFAULT_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({
}) })
export const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95) export const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95)
export const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85) export const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85)
export const DEFAULT_STAIR_MATERIAL = createDefaultMaterial('#ffffff', 0.9)
export function disposeMaterial(material: THREE.Material): void { export function disposeMaterial(material: THREE.Material): void {
material.dispose() material.dispose()
+4
View File
@@ -12,6 +12,10 @@ type Outliner = {
} }
type ViewerState = { type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete'
setHoverHighlightMode: (mode: 'default' | 'delete') => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
cameraMode: 'perspective' | 'orthographic' cameraMode: 'perspective' | 'orthographic'
+10 -1
View File
@@ -20,6 +20,10 @@ type Outliner = {
type ViewerState = { type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete'
setHoverHighlightMode: (mode: 'default' | 'delete') => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
@@ -75,6 +79,10 @@ const useViewer = create<ViewerState>()(
persist( persist(
(set) => ({ (set) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
previewSelectedIds: [],
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
hoverHighlightMode: 'default',
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),
hoveredId: null, hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }), setHoveredId: (id) => set({ hoveredId: id }),
@@ -164,7 +172,7 @@ const useViewer = create<ViewerState>()(
if (updates.selectedIds === undefined) newSelection.selectedIds = [] if (updates.selectedIds === undefined) newSelection.selectedIds = []
} }
return { selection: newSelection } return { selection: newSelection, previewSelectedIds: [] }
}), }),
resetSelection: () => resetSelection: () =>
@@ -175,6 +183,7 @@ const useViewer = create<ViewerState>()(
zoneId: null, zoneId: null,
selectedIds: [], selectedIds: [],
}, },
previewSelectedIds: [],
}), }),
outliner: { selectedObjects: [], hoveredObjects: [] }, outliner: { selectedObjects: [], hoveredObjects: [] },