From 0bd0cab533a6043eacc365b7de05c1e1d85d258a Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 5 Apr 2026 01:20:43 -0400 Subject: [PATCH] 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 --- packages/core/src/events/bus.ts | 6 + .../hooks/scene-registry/scene-registry.ts | 2 + packages/core/src/index.ts | 5 +- packages/core/src/schema/index.ts | 2 + packages/core/src/schema/nodes/level.ts | 2 + .../core/src/schema/nodes/stair-segment.ts | 53 +++ packages/core/src/schema/nodes/stair.ts | 27 ++ packages/core/src/schema/types.ts | 4 + .../core/src/store/actions/node-actions.ts | 3 + packages/core/src/store/use-scene.ts | 15 +- .../core/src/systems/stair/stair-system.tsx | 371 ++++++++++++++++++ packages/core/src/utils/clone-scene-graph.ts | 157 ++++++++ .../editor/src/components/editor/index.tsx | 3 + .../components/editor/selection-manager.tsx | 2 + .../systems/stair/stair-edit-system.tsx | 69 ++++ .../src/components/tools/stair/stair-tool.tsx | 190 +++++++++ .../src/components/tools/tool-manager.tsx | 2 + .../ui/action-menu/structure-tools.tsx | 1 + .../ui/command-palette/editor-commands.tsx | 10 +- .../components/ui/panels/panel-manager.tsx | 6 + .../src/components/ui/panels/stair-panel.tsx | 304 ++++++++++++++ .../ui/panels/stair-segment-panel.tsx | 339 ++++++++++++++++ .../panels/site-panel/stair-tree-node.tsx | 216 ++++++++++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + packages/editor/src/store/use-editor.tsx | 4 +- packages/editor/src/store/use-upload.ts | 13 + .../components/renderers/node-renderer.tsx | 4 + .../stair-segment/stair-segment-renderer.tsx | 37 ++ .../renderers/stair/stair-renderer.tsx | 43 ++ .../viewer/src/components/viewer/index.tsx | 2 + packages/viewer/src/hooks/use-node-events.ts | 6 + packages/viewer/src/lib/materials.ts | 5 +- packages/viewer/src/store/use-viewer.d.ts | 4 + packages/viewer/src/store/use-viewer.ts | 11 +- 34 files changed, 1914 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/schema/nodes/stair-segment.ts create mode 100644 packages/core/src/schema/nodes/stair.ts create mode 100644 packages/core/src/systems/stair/stair-system.tsx create mode 100644 packages/editor/src/components/systems/stair/stair-edit-system.tsx create mode 100644 packages/editor/src/components/tools/stair/stair-tool.tsx create mode 100644 packages/editor/src/components/ui/panels/stair-panel.tsx create mode 100644 packages/editor/src/components/ui/panels/stair-segment-panel.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx create mode 100644 packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx create mode 100644 packages/viewer/src/components/renderers/stair/stair-renderer.tsx diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 65d1ec31..eddc2a83 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -10,6 +10,8 @@ import type { RoofSegmentNode, SiteNode, SlabNode, + StairNode, + StairSegmentNode, WallNode, WindowNode, ZoneNode, @@ -41,6 +43,8 @@ export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent +export type StairEvent = NodeEvent +export type StairSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent @@ -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 & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 02cb06b6..09eab853 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -18,6 +18,8 @@ export const sceneRegistry = { zone: new Set(), roof: new Set(), 'roof-segment': new Set(), + stair: new Set(), + 'stair-segment': new Set(), scan: new Set(), guide: new Set(), window: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5a64c18..5a37f19d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 9cf6b33b..1430dd4a 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -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' diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 11523fbe..9a834750 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -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, ]), diff --git a/packages/core/src/schema/nodes/stair-segment.ts b/packages/core/src/schema/nodes/stair-segment.ts new file mode 100644 index 00000000..241372df --- /dev/null +++ b/packages/core/src/schema/nodes/stair-segment.ts @@ -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 + +export const AttachmentSide = z.enum(['front', 'left', 'right']) + +export type AttachmentSide = z.infer + +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 diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts new file mode 100644 index 00000000..2901771e --- /dev/null +++ b/packages/core/src/schema/nodes/stair.ts @@ -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 diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index da0df16c..d7b3538e 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -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, diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index e0cce687..82c2a1af 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -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 }[], ) => { + if (get().readOnly) return const parentsToUpdate = new Set() const idsToMarkDirty = new Set() @@ -136,6 +138,7 @@ export const deleteNodesAction = ( get: () => SceneState, ids: AnyNodeId[], ) => { + if (get().readOnly) return const parentsToMarkDirty = new Set() set((state) => { diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index e7455bfa..f55fac59 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -67,6 +67,10 @@ export type SceneState = { // 4. Relational metadata — not nodes collections: Record + // 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()( // 4. Collections collections: {} as Record, + // 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()( // --- 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()( }, 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()( }, 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()( }, 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()( }, 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 } diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx new file mode 100644 index 00000000..bfa5091b --- /dev/null +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -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() +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() + + 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) { + 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, +) { + 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 +} diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index e4aa55b2..87111aed 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -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, + levelId: AnyNodeId, +): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map } { + 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() + 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() + 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).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).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).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() + for (const [nodeId, node] of Object.entries(nodes)) { + if (node.type === 'scan' || node.type === 'guide') { + excludedNodeIds.add(nodeId) + } + } + + const filteredNodes = {} as Record + 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).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 | undefined + if (collections) { + filteredCollections = {} as Record + 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 }), + }) +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 7b134986..eeb841ad 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -19,6 +19,7 @@ import { initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { CeilingSystem } from '../systems/ceiling/ceiling-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 { ZoneSystem } from '../systems/zone/zone-system' import { BoxSelectTool } from '../tools/select/box-select-tool' @@ -600,6 +601,7 @@ export default function Editor({ {isFirstPersonMode ? : } + {!isLoading && !isFirstPersonMode && } {!isLoading && !isFirstPersonMode && } @@ -617,6 +619,7 @@ export default function Editor({ + diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 3f801a2a..f35631f5 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -32,6 +32,8 @@ type SelectableNodeType = | 'ceiling' | 'roof' | 'roof-segment' + | 'stair' + | 'stair-segment' | 'window' | 'door' diff --git a/packages/editor/src/components/systems/stair/stair-edit-system.tsx b/packages/editor/src/components/systems/stair/stair-edit-system.tsx new file mode 100644 index 00000000..0edd1797 --- /dev/null +++ b/packages/editor/src/components/systems/stair/stair-edit-system.tsx @@ -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()) + + useEffect(() => { + const nodes = useScene.getState().nodes + + // Collect which stair nodes should be in "edit mode" + const activeStairIds = new Set() + 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 +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx new file mode 100644 index 00000000..46b2365f --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -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(null) + const previewRef = useRef(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 ( + + + + {/* 3D ghost preview — position/rotation updated imperatively */} + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 52d8cf4d..bf82bae5 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -12,6 +12,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' +import { StairTool } from './stair/stair-tool' import { WallTool } from './wall/wall-tool' import { WindowTool } from './window/window-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' @@ -26,6 +27,7 @@ const tools: Record>> = { slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, + stair: StairTool, door: DoorTool, item: ItemTool, zone: ZoneTool, diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index af54c82b..6e731cae 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { 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: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 00a954f5..f0bbcc29 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -115,6 +115,14 @@ export function EditorCommands() { keywords: ['furniture', 'object', 'asset', 'furnish'], execute: () => activateTool('item'), }, + { + id: 'editor.tool.stair', + label: 'Stair Tool', + group: 'Scene', + icon: , + keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'], + execute: () => activateTool('stair'), + }, { id: 'editor.tool.zone', label: 'Zone Tool', @@ -342,7 +350,7 @@ export function EditorCommands() { icon: , keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'], execute: () => run(() => exportScene()), - }, + } as const, ] : []), { diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index d646fe54..15c3f65d 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -10,6 +10,8 @@ import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { RoofSegmentPanel } from './roof-segment-panel' import { SlabPanel } from './slab-panel' +import { StairPanel } from './stair-panel' +import { StairSegmentPanel } from './stair-segment-panel' import { WallPanel } from './wall-panel' import { WindowPanel } from './window-panel' @@ -37,6 +39,10 @@ export function PanelManager() { return case 'slab': return + case 'stair': + return + case 'stair-segment': + return case 'ceiling': return case 'wall': diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx new file mode 100644 index 00000000..a6dd60f9 --- /dev/null +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -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) => { + 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 ( + + +
+ {segments.map((seg, i) => ( + + ))} +
+
+ } + label="Add flight" + onClick={handleAddFlight} + /> + } + label="Add landing" + onClick={handleAddLanding} + /> +
+
+ + + { + 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} + /> + { + 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} + /> + { + 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} + /> + { + handleUpdate({ rotation: (degrees * Math.PI) / 180 }) + }} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> +
+ { + sfxEmitter.emit('sfx:item-rotate') + handleUpdate({ rotation: node.rotation - Math.PI / 4 }) + }} + /> + { + sfxEmitter.emit('sfx:item-rotate') + handleUpdate({ rotation: node.rotation + Math.PI / 4 }) + }} + /> +
+
+ + + + } label="Move" onClick={handleMove} /> + } + label="Duplicate" + onClick={handleDuplicate} + /> + } + label="Delete" + onClick={handleDelete} + /> + + + + + +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx new file mode 100644 index 00000000..98cc4ef7 --- /dev/null +++ b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx @@ -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) => { + 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 ( + + + { + const updates: Partial = { 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} + /> + + + {!isFirstSegment && ( + + handleUpdate({ attachmentSide: v })} + options={ATTACHMENT_SIDE_OPTIONS} + value={node.attachmentSide} + /> + + )} + + + handleUpdate({ width: v })} + precision={2} + step={0.1} + unit="m" + value={Math.round(node.width * 100) / 100} + /> + handleUpdate({ length: v })} + precision={2} + step={0.1} + unit="m" + value={Math.round(node.length * 100) / 100} + /> + {node.segmentType === 'stair' && ( + <> + handleUpdate({ height: v })} + precision={2} + step={0.1} + unit="m" + value={Math.round(node.height * 100) / 100} + /> + handleUpdate({ stepCount: Math.round(v) })} + precision={0} + step={1} + unit="" + value={node.stepCount} + /> + + )} + + + +
+ Fill to floor + +
+ {!node.fillToFloor && ( + handleUpdate({ thickness: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.thickness ?? 0.25) * 100) / 100} + /> + )} +
+ + + { + 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} + /> + { + 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} + /> + { + 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} + /> + { + handleUpdate({ rotation: (degrees * Math.PI) / 180 }) + }} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> +
+ { + sfxEmitter.emit('sfx:item-rotate') + handleUpdate({ rotation: node.rotation - Math.PI / 4 }) + }} + /> + { + sfxEmitter.emit('sfx:item-rotate') + handleUpdate({ rotation: node.rotation + Math.PI / 4 }) + }} + /> +
+
+ + + + } label="Move" onClick={handleMove} /> + } + label="Duplicate" + onClick={handleDuplicate} + /> + } + label="Delete" + onClick={handleDelete} + /> + + + + + +
+ ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx new file mode 100644 index 00000000..2810a496 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx @@ -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 ( +
+ } + depth={depth} + expanded={expanded} + hasChildren={segments.length > 0} + icon={ + + } + isDropTarget={isValidDropTarget && isDropTarget} + isHovered={isHovered || isDropTarget} + isLast={isLast && !expanded} + isSelected={isSelected} + isVisible={node.visible !== false} + label={ + 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 ( +
+ + {showIndicatorBefore && } + + + + {showIndicatorAfter && } + +
+ ) + })} + + {isDropTarget && visibleSegments.length === 0 && } + +
+
+ ) +} + +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 ( +
+ } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isDraggable + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={node.visible !== false} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={node.id} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(node.id)} + onMouseEnter={() => setHoveredId(node.id)} + onMouseLeave={() => setHoveredId(null)} + onPointerDown={handlePointerDown} + onToggle={() => {}} + /> +
+ ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index d955c259..eb3c88e8 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -61,6 +61,7 @@ import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' import { SlabTreeNode } from './slab-tree-node' +import { StairTreeNode } from './stair-tree-node' import { WallTreeNode } from './wall-tree-node' import { WindowTreeNode } from './window-tree-node' import { ZoneTreeNode } from './zone-tree-node' @@ -89,6 +90,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { return case 'roof': return + case 'stair': + return case 'item': return case 'door': diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 819d79cb..8a1d6b5c 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -9,6 +9,8 @@ import { type RoofNode, type RoofSegmentNode, type Space, + type StairNode, + type StairSegmentNode, useScene, type WindowNode, } from '@pascal-app/core' @@ -79,7 +81,7 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null + movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null setMovingNode: ( node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, ) => void diff --git a/packages/editor/src/store/use-upload.ts b/packages/editor/src/store/use-upload.ts index 9c4d1a03..50c5235c 100644 --- a/packages/editor/src/store/use-upload.ts +++ b/packages/editor/src/store/use-upload.ts @@ -11,8 +11,18 @@ export interface UploadEntry { resultUrl: string | null } +export type UploadHandler = ( + projectId: string, + levelId: string, + file: File, + type: 'scan' | 'guide', +) => void + interface UploadState { uploads: Record + uploadHandler: UploadHandler | null + registerUploadHandler: (handler: UploadHandler) => void + unregisterUploadHandler: () => void startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void setProgress: (levelId: string, progress: number) => void setStatus: (levelId: string, status: UploadStatus) => void @@ -23,6 +33,9 @@ interface UploadState { export const useUploadStore = create((set) => ({ uploads: {}, + uploadHandler: null, + registerUploadHandler: (handler) => set({ uploadHandler: handler }), + unregisterUploadHandler: () => set({ uploadHandler: null }), startUpload: (levelId, assetType, fileName) => set((s) => ({ diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 3d70ef95..1347e372 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -12,6 +12,8 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-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 { WindowRenderer } from './window/window-renderer' import { ZoneRenderer } from './zone/zone-renderer' @@ -35,6 +37,8 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'zone' && } {node.type === 'roof' && } {node.type === 'roof-segment' && } + {node.type === 'stair' && } + {node.type === 'stair-segment' && } {node.type === 'scan' && } {node.type === 'guide' && } diff --git a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx new file mode 100644 index 00000000..5dd7e233 --- /dev/null +++ b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx @@ -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(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 ( + + {/* StairSystem will replace this geometry in the next frame */} + + + ) +} diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx new file mode 100644 index 00000000..e3a659b9 --- /dev/null +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -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(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 ( + + + + + + {(node.children ?? []).map((childId) => ( + + ))} + + + ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 893446a8..ada0067e 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -6,6 +6,7 @@ import { ItemSystem, RoofSystem, SlabSystem, + StairSystem, WallSystem, WindowSystem, } from '@pascal-app/core' @@ -142,6 +143,7 @@ const Viewer: React.FC = ({ + diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 0a1aa298..46cec9d3 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -19,6 +19,10 @@ import { type SiteNode, type SlabEvent, type SlabNode, + type StairEvent, + type StairNode, + type StairSegmentEvent, + type StairSegmentNode, type WallEvent, type WallNode, type WindowEvent, @@ -40,6 +44,8 @@ type NodeConfig = { ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } + stair: { node: StairNode; event: StairEvent } + 'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent } window: { node: WindowNode; event: WindowEvent } door: { node: DoorNode; event: DoorEvent } } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 369ec7e0..8b20f1e4 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -35,8 +35,8 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat } export function createDefaultMaterial( - color: string = '#ffffff', - roughness: number = 0.9, + color = '#ffffff', + roughness = 0.9, ): THREE.MeshStandardMaterial { return new THREE.MeshStandardMaterial({ 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_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85) +export const DEFAULT_STAIR_MATERIAL = createDefaultMaterial('#ffffff', 0.9) export function disposeMaterial(material: THREE.Material): void { material.dispose() diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 80ea92cc..4dde28a6 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -12,6 +12,10 @@ type Outliner = { } type ViewerState = { selection: SelectionPath + previewSelectedIds: BaseNode['id'][] + setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + hoverHighlightMode: 'default' | 'delete' + setHoverHighlightMode: (mode: 'default' | 'delete') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 43d510ab..fd8c4889 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -20,6 +20,10 @@ type Outliner = { type ViewerState = { selection: SelectionPath + previewSelectedIds: BaseNode['id'][] + setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + hoverHighlightMode: 'default' | 'delete' + setHoverHighlightMode: (mode: 'default' | 'delete') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -75,6 +79,10 @@ const useViewer = create()( persist( (set) => ({ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, + previewSelectedIds: [], + setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), + hoverHighlightMode: 'default', + setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), hoveredId: null, setHoveredId: (id) => set({ hoveredId: id }), @@ -164,7 +172,7 @@ const useViewer = create()( if (updates.selectedIds === undefined) newSelection.selectedIds = [] } - return { selection: newSelection } + return { selection: newSelection, previewSelectedIds: [] } }), resetSelection: () => @@ -175,6 +183,7 @@ const useViewer = create()( zoneId: null, selectedIds: [], }, + previewSelectedIds: [], }), outliner: { selectedObjects: [], hoveredObjects: [] },