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,
SiteNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
ZoneNode,
@@ -41,6 +43,8 @@ export type SlabEvent = NodeEvent<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
@@ -104,6 +108,8 @@ type EditorEvents = GridEvents &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
CameraControlEvents &
@@ -18,6 +18,8 @@ export const sceneRegistry = {
zone: new Set<string>(),
roof: new Set<string>(),
'roof-segment': new Set<string>(),
stair: new Set<string>(),
'stair-segment': new Set<string>(),
scan: new Set<string>(),
guide: new Set<string>(),
window: new Set<string>(),
+4 -1
View File
@@ -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'
+2
View File
@@ -36,6 +36,8 @@ export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export { StairNode } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone'
+2
View File
@@ -6,6 +6,7 @@ import { GuideNode } from './guide'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { SlabNode } from './slab'
import { StairNode } from './stair'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
@@ -20,6 +21,7 @@ export const LevelNode = BaseNode.extend({
SlabNode.shape.id,
CeilingNode.shape.id,
RoofNode.shape.id,
StairNode.shape.id,
ScanNode.shape.id,
GuideNode.shape.id,
]),
@@ -0,0 +1,53 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
export const StairSegmentType = z.enum(['stair', 'landing'])
export type StairSegmentType = z.infer<typeof StairSegmentType>
export const AttachmentSide = z.enum(['front', 'left', 'right'])
export type AttachmentSide = z.infer<typeof AttachmentSide>
export const StairSegmentNode = BaseNode.extend({
id: objectId('sseg'),
type: nodeType('stair-segment'),
material: MaterialSchema.optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Stair or landing
segmentType: StairSegmentType.default('stair'),
// Width of the stair flight / landing
width: z.number().default(1.0),
// Horizontal run (depth along travel direction)
length: z.number().default(3.0),
// Vertical rise (0 for landings)
height: z.number().default(2.5),
// Number of steps (only used for stair type)
stepCount: z.number().default(10),
// Which side of the previous segment to attach to
attachmentSide: AttachmentSide.default('front'),
// Whether to fill the underside down to floor level
fillToFloor: z.boolean().default(true),
// Thickness of the stair slab when not filled to floor
thickness: z.number().default(0.25),
}).describe(
dedent`
Stair segment node - an individual flight or landing within a stair group.
Each segment generates a complete stair/landing geometry.
Multiple segments chain together to form complex staircase shapes (L-shape, U-shape, etc.).
- segmentType: stair (with steps) or landing (flat platform)
- width: width of the flight/landing
- length: horizontal run distance
- height: vertical rise (0 for landings)
- stepCount: number of steps (stair type only)
- attachmentSide: front, left, or right - which side of the previous segment to attach to
- fillToFloor: whether to fill the underside down to the absolute floor level
- thickness: slab thickness when not filled to floor
`,
)
export type StairSegmentNode = z.infer<typeof StairSegmentNode>
+27
View File
@@ -0,0 +1,27 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment'
export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchema.optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Child stair segment IDs
children: z.array(StairSegmentNode.shape.id).default([]),
}).describe(
dedent`
Stair node - a container for stair segments.
Acts as a group that holds one or more StairSegmentNodes (flights and landings).
Segments chain together based on their attachmentSide to form complex staircase shapes.
- position: center position of the stair group
- rotation: rotation around Y axis
- children: array of StairSegmentNode IDs
`,
)
export type StairNode = z.infer<typeof StairNode>
+4
View File
@@ -10,6 +10,8 @@ import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window'
import { ZoneNode } from './nodes/zone'
@@ -25,6 +27,8 @@ export const AnyNode = z.discriminatedUnion('type', [
CeilingNode,
RoofNode,
RoofSegmentNode,
StairNode,
StairSegmentNode,
ScanNode,
GuideNode,
WindowNode,
@@ -13,6 +13,7 @@ export const createNodesAction = (
get: () => SceneState,
ops: { node: AnyNode; parentId?: AnyNodeId }[],
) => {
if (get().readOnly) return
set((state) => {
const nextNodes = { ...state.nodes }
const nextRootIds = [...state.rootNodeIds]
@@ -61,6 +62,7 @@ export const updateNodesAction = (
get: () => SceneState,
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
) => {
if (get().readOnly) return
const parentsToUpdate = new Set<AnyNodeId>()
const idsToMarkDirty = new Set<AnyNodeId>()
@@ -136,6 +138,7 @@ export const deleteNodesAction = (
get: () => SceneState,
ids: AnyNodeId[],
) => {
if (get().readOnly) return
const parentsToMarkDirty = new Set<AnyNodeId>()
set((state) => {
+14 -1
View File
@@ -67,6 +67,10 @@ export type SceneState = {
// 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection>
// 5. Read-only lock — when true all create/update/delete operations are no-ops
readOnly: boolean
setReadOnly: (readOnly: boolean) => void
// Actions
loadScene: () => void
clearScene: () => void
@@ -114,6 +118,10 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
// 5. Read-only lock
readOnly: false,
setReadOnly: (readOnly: boolean) => set({ readOnly }),
unloadScene: () => {
// Clear temporal tracking to prevent memory leaks from stale node references
prevPastLength = 0
@@ -208,6 +216,7 @@ const useScene: UseSceneStore = create<SceneState>()(
// --- COLLECTIONS ---
createCollection: (name, nodeIds = []) => {
if (get().readOnly) return '' as CollectionId
const id = generateCollectionId()
const collection: Collection = { id, name, nodeIds }
set((state) => {
@@ -227,6 +236,7 @@ const useScene: UseSceneStore = create<SceneState>()(
},
deleteCollection: (id) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
const nextCollections = { ...state.collections }
@@ -246,6 +256,7 @@ const useScene: UseSceneStore = create<SceneState>()(
},
updateCollection: (id, data) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
if (!col) return state
@@ -254,6 +265,7 @@ const useScene: UseSceneStore = create<SceneState>()(
},
addToCollection: (id, nodeId) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
if (!col || col.nodeIds.includes(nodeId)) return state
@@ -274,12 +286,13 @@ const useScene: UseSceneStore = create<SceneState>()(
},
removeFromCollection: (id, nodeId) => {
if (get().readOnly) return
set((state) => {
const col = state.collections[id]
if (!col) return state
const nextCollections = {
...state.collections,
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
[id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },
}
const node = state.nodes[nodeId]
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
@@ -0,0 +1,371 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import useScene from '../../store/use-scene'
const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2
const MAX_SEGMENTS_PER_FRAME = 4
// ============================================================================
// STAIR SYSTEM
// ============================================================================
export const StairSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const rootNodeIds = useScene((state) => state.rootNodeIds)
useFrame(() => {
if (rootNodeIds.length === 0) {
pendingStairUpdates.clear()
return
}
if (dirtyNodes.size === 0 && pendingStairUpdates.size === 0) return
const nodes = useScene.getState().nodes
// --- Pass 1: Process dirty stair-segments (throttled) ---
// Collect parent stair IDs that need segment transform recomputation
const parentsNeedingSegmentSync = new Set<AnyNodeId>()
let segmentsProcessed = 0
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node) return
if (node.type === 'stair-segment') {
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
if (mesh) {
const isVisible = mesh.parent?.visible !== false
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
// Geometry will be updated; chained position is applied in the parent sync pass below
updateStairSegmentGeometry(node as StairSegmentNode, mesh)
if (node.parentId) parentsNeedingSegmentSync.add(node.parentId as AnyNodeId)
segmentsProcessed++
} else if (isVisible) {
return // Over budget — keep dirty, process next frame
} else if (mesh.geometry.type === 'BoxGeometry') {
// Replace BoxGeometry placeholder with empty geometry
mesh.geometry.dispose()
const placeholder = new THREE.BufferGeometry()
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
mesh.geometry = placeholder
}
clearDirty(id as AnyNodeId)
} else {
clearDirty(id as AnyNodeId)
}
// Queue the parent stair for a merged geometry update
if (node.parentId) {
pendingStairUpdates.add(node.parentId as AnyNodeId)
}
} else if (node.type === 'stair') {
pendingStairUpdates.add(id as AnyNodeId)
// Also sync individual segment positions when in edit mode
parentsNeedingSegmentSync.add(id as AnyNodeId)
clearDirty(id as AnyNodeId)
}
})
// --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
for (const stairId of parentsNeedingSegmentSync) {
const stairNode = nodes[stairId]
if (!stairNode || stairNode.type !== 'stair') continue
syncSegmentMeshTransforms(stairNode as StairNode, nodes)
}
// --- Pass 2: Process pending merged-stair updates (throttled) ---
let stairsProcessed = 0
for (const id of pendingStairUpdates) {
if (stairsProcessed >= MAX_STAIRS_PER_FRAME) break
const node = nodes[id]
if (!node || node.type !== 'stair') {
pendingStairUpdates.delete(id)
continue
}
const group = sceneRegistry.nodes.get(id) as THREE.Group
if (group) {
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
if (mergedMesh?.visible !== false) {
updateMergedStairGeometry(node as StairNode, group, nodes)
stairsProcessed++
}
}
pendingStairUpdates.delete(id)
}
}, 5)
return null
}
// ============================================================================
// SEGMENT GEOMETRY
// ============================================================================
/**
* Generates the step/landing profile as a THREE.Shape (in the XY plane),
* then extrudes along Z for the segment width.
*/
function generateStairSegmentGeometry(
segment: StairSegmentNode,
absoluteHeight: number,
): THREE.BufferGeometry {
const { width, length, height, stepCount, segmentType, fillToFloor, thickness } = segment
const shape = new THREE.Shape()
if (segmentType === 'landing') {
shape.moveTo(0, 0)
shape.lineTo(length, 0)
if (fillToFloor) {
shape.lineTo(length, -absoluteHeight)
shape.lineTo(0, -absoluteHeight)
} else {
shape.lineTo(length, -thickness)
shape.lineTo(0, -thickness)
}
} else {
const riserHeight = height / stepCount
const treadDepth = length / stepCount
shape.moveTo(0, 0)
// Draw step profile
for (let i = 0; i < stepCount; i++) {
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
}
if (fillToFloor) {
shape.lineTo(length, -absoluteHeight)
shape.lineTo(0, -absoluteHeight)
} else {
// Sloped bottom with consistent thickness
const angle = Math.atan(riserHeight / treadDepth)
const vOff = thickness / Math.cos(angle)
// Bottom-back corner
shape.lineTo(length, height - vOff)
if (absoluteHeight === 0) {
// Ground floor: slope hits the ground (y=0)
const m = riserHeight / treadDepth
const xGround = length - (height - vOff) / m
if (xGround > 0) {
shape.lineTo(xGround, 0)
}
} else {
// Floating: parallel slope
shape.lineTo(0, -vOff)
}
}
}
shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, {
steps: 1,
depth: width,
bevelEnabled: false,
})
// Rotate so extrusion is along X (width), and the shape is in the XZ plane
// Shape is drawn in XY, extruded along Z → rotate -90° around Y then offset
const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(width / 2, 0, 0)
geometry.applyMatrix4(matrix)
return geometry
}
function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
// Compute absolute height from parent chain
const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
mesh.geometry.dispose()
mesh.geometry = newGeometry
// NOTE: position/rotation are NOT set here — they're set by syncSegmentMeshTransforms
// which computes the chained position based on segment order and attachmentSide.
}
/**
* Applies chained transforms to individual segment meshes (edit mode).
* Each segment's world position is determined by the chain of previous segments,
* not by the node's stored position field.
*/
function syncSegmentMeshTransforms(stairNode: StairNode, nodes: Record<string, AnyNode>) {
const segments = (stairNode.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
if (segments.length === 0) return
const transforms = computeSegmentTransforms(segments)
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!
const transform = transforms[i]!
const mesh = sceneRegistry.nodes.get(segment.id) as THREE.Mesh | undefined
if (mesh) {
mesh.position.set(transform.position[0], transform.position[1], transform.position[2])
mesh.rotation.y = transform.rotation
}
}
}
// ============================================================================
// MERGED STAIR GEOMETRY
// ============================================================================
const _matrix = new THREE.Matrix4()
const _position = new THREE.Vector3()
const _quaternion = new THREE.Quaternion()
const _scale = new THREE.Vector3(1, 1, 1)
const _yAxis = new THREE.Vector3(0, 1, 0)
function updateMergedStairGeometry(
stairNode: StairNode,
group: THREE.Group,
nodes: Record<string, AnyNode>,
) {
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
if (!mergedMesh) return
const children = stairNode.children ?? []
const segments = children
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
if (segments.length === 0) {
mergedMesh.geometry.dispose()
mergedMesh.geometry = new THREE.BufferGeometry()
mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return
}
// Compute chained transforms for segments
const transforms = computeSegmentTransforms(segments)
const geometries: THREE.BufferGeometry[] = []
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!
const transform = transforms[i]!
const absoluteHeight = transform.position[1]
const geo = generateStairSegmentGeometry(segment, absoluteHeight)
// Apply segment transform (position + rotation) relative to parent stair
_position.set(transform.position[0], transform.position[1], transform.position[2])
_quaternion.setFromAxisAngle(_yAxis, transform.rotation)
_matrix.compose(_position, _quaternion, _scale)
geo.applyMatrix4(_matrix)
geometries.push(geo)
}
const merged = mergeGeometries(geometries, false)
if (merged) {
mergedMesh.geometry.dispose()
mergedMesh.geometry = merged
}
// Dispose individual geometries
for (const geo of geometries) {
geo.dispose()
}
}
// ============================================================================
// SEGMENT CHAINING
// ============================================================================
interface SegmentTransform {
position: [number, number, number]
rotation: number
}
/**
* Computes world-relative transforms for each segment by chaining
* based on attachmentSide. This mirrors the prototype's StairSystem logic.
*/
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
const transforms: SegmentTransform[] = []
let currentPos = new THREE.Vector3(0, 0, 0)
let currentRot = 0
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!
if (i === 0) {
transforms.push({
position: [currentPos.x, currentPos.y, currentPos.z],
rotation: currentRot,
})
} else {
const prev = segments[i - 1]!
const localAttachPos = new THREE.Vector3()
let rotChange = 0
switch (segment.attachmentSide) {
case 'front':
localAttachPos.set(0, prev.height, prev.length)
rotChange = 0
break
case 'left':
localAttachPos.set(prev.width / 2, prev.height, prev.length / 2)
rotChange = Math.PI / 2
break
case 'right':
localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2)
rotChange = -Math.PI / 2
break
}
// Rotate local attachment point by previous global rotation
localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot)
currentPos = currentPos.clone().add(localAttachPos)
currentRot += rotChange
transforms.push({
position: [currentPos.x, currentPos.y, currentPos.z],
rotation: currentRot,
})
}
}
return transforms
}
/**
* Computes the absolute Y height of a segment by traversing the stair's segment chain.
*/
function computeAbsoluteHeight(node: StairSegmentNode): number {
const nodes = useScene.getState().nodes
if (!node.parentId) return 0
const parent = nodes[node.parentId as AnyNodeId]
if (!parent || parent.type !== 'stair') return 0
const stair = parent as StairNode
const segments = (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
const transforms = computeSegmentTransforms(segments)
const index = segments.findIndex((s) => s.id === node.id)
if (index < 0) return 0
return transforms[index]?.position[1] ?? 0
}
@@ -118,3 +118,160 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
...(clonedCollections && { collections: clonedCollections }),
}
}
/**
* Deep clones a level node and all its descendants with fresh IDs.
* All internal references (parentId, children, wallId) are remapped to the new IDs.
* The cloned level node's parentId is preserved (building ID) — not remapped.
*
* Unlike `cloneSceneGraph` (which operates on serialized data), this function works
* on live runtime nodes that may have non-serializable properties (Three.js objects,
* etc.). It uses JSON roundtrip to safely strip them.
*
* @returns clonedNodes - flat array of all cloned nodes (level + descendants)
* @returns newLevelId - the ID of the cloned level node
* @returns idMap - old ID → new ID mapping
*/
export function cloneLevelSubtree(
nodes: Record<AnyNodeId, AnyNode>,
levelId: AnyNodeId,
): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map<string, string> } {
const levelNode = nodes[levelId]
if (!levelNode || levelNode.type !== 'level') {
throw new Error(`Node "${levelId}" is not a level`)
}
// Recursively collect the level node + all descendants via children arrays
const subtreeIds = new Set<AnyNodeId>()
const collect = (id: AnyNodeId) => {
if (subtreeIds.has(id)) return
const node = nodes[id]
if (!node) return
subtreeIds.add(id)
if ('children' in node && Array.isArray(node.children)) {
for (const childId of node.children as AnyNodeId[]) {
collect(childId)
}
}
}
collect(levelId)
// Build ID mapping: old → new
const idMap = new Map<string, string>()
for (const oldId of subtreeIds) {
const prefix = extractIdPrefix(oldId)
idMap.set(oldId, generateId(prefix))
}
const newLevelId = idMap.get(levelId)! as AnyNodeId
// Clone each node with remapped references.
const clonedNodes: AnyNode[] = []
for (const oldId of subtreeIds) {
const node = nodes[oldId]
if (!node) continue
const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
}
// Remap children array
if ('children' in cloned && Array.isArray(cloned.children)) {
;(cloned as Record<string, unknown>).children = (cloned.children as unknown[])
.map((child) => {
if (typeof child === 'string') return idMap.get(child) ?? child
if (
child &&
typeof child === 'object' &&
'id' in child &&
typeof (child as any).id === 'string'
) {
return idMap.get((child as any).id) ?? (child as any).id
}
return child
})
.filter((id): id is string => typeof id === 'string')
}
// Remap wallId (doors/windows attached to walls)
if ('wallId' in cloned && typeof cloned.wallId === 'string') {
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
}
clonedNodes.push(cloned)
}
return { clonedNodes, newLevelId, idMap }
}
/**
* Forks a scene graph for use as a new project: clones with new IDs and strips
* scan and guide nodes (and their references) since those contain user-uploaded
* imagery that shouldn't carry over to a forked project.
*/
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections } = sceneGraph
const excludedNodeIds = new Set<string>()
for (const [nodeId, node] of Object.entries(nodes)) {
if (node.type === 'scan' || node.type === 'guide') {
excludedNodeIds.add(nodeId)
}
}
const filteredNodes = {} as Record<AnyNodeId, AnyNode>
for (const [nodeId, node] of Object.entries(nodes)) {
if (excludedNodeIds.has(nodeId)) continue
const clonedNode = structuredClone(node) as AnyNode
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
(child) => {
const childId =
typeof child === 'string'
? child
: child && typeof child === 'object' && 'id' in child
? (child as any).id
: null
return childId ? !excludedNodeIds.has(childId) : true
},
)
}
filteredNodes[nodeId as AnyNodeId] = clonedNode
}
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
let filteredCollections: Record<CollectionId, Collection> | undefined
if (collections) {
filteredCollections = {} as Record<CollectionId, Collection>
for (const [collectionId, collection] of Object.entries(collections)) {
const filteredNodeIds = collection.nodeIds.filter((id) => !excludedNodeIds.has(id))
if (filteredNodeIds.length > 0) {
filteredCollections[collectionId as CollectionId] = {
...collection,
nodeIds: filteredNodeIds as AnyNodeId[],
controlNodeId:
collection.controlNodeId && excludedNodeIds.has(collection.controlNodeId)
? undefined
: collection.controlNodeId,
}
}
}
}
return cloneSceneGraph({
nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds,
...(filteredCollections && { collections: filteredCollections }),
})
}
@@ -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 ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
<RoofEditSystem />
<StairEditSystem />
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
{!isLoading && !isFirstPersonMode && <ToolManager />}
<CustomCameraControls />
@@ -617,6 +619,7 @@ export default function Editor({
<ViewerZoneSystem />
<CeilingSystem />
<RoofEditSystem />
<StairEditSystem />
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator />
@@ -32,6 +32,8 @@ type SelectableNodeType =
| 'ceiling'
| 'roof'
| 'roof-segment'
| 'stair'
| 'stair-segment'
| 'window'
| '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 { 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<Phase, Partial<Record<Tool, React.FC>>> = {
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
stair: StairTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
@@ -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' },
@@ -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: <ArrowRight className="h-4 w-4" />,
keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'],
execute: () => activateTool('stair'),
},
{
id: 'editor.tool.zone',
label: 'Zone Tool',
@@ -342,7 +350,7 @@ export function EditorCommands() {
icon: <Box className="h-4 w-4" />,
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
execute: () => run(() => exportScene()),
},
} as const,
]
: []),
{
@@ -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 <RoofSegmentPanel />
case 'slab':
return <SlabPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'ceiling':
return <CeilingPanel />
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 { 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 <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'stair':
return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'item':
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
case 'door':
+3 -1
View File
@@ -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
+13
View File
@@ -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<string, UploadEntry>
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<UploadState>((set) => ({
uploads: {},
uploadHandler: null,
registerUploadHandler: (handler) => set({ uploadHandler: handler }),
unregisterUploadHandler: () => set({ uploadHandler: null }),
startUpload: (levelId, assetType, fileName) =>
set((s) => ({
@@ -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' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer 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 === '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,
RoofSystem,
SlabSystem,
StairSystem,
WallSystem,
WindowSystem,
} from '@pascal-app/core'
@@ -142,6 +143,7 @@ const Viewer: React.FC<ViewerProps> = ({
<ItemSystem />
<RoofSystem />
<SlabSystem />
<StairSystem />
<WallSystem />
<WindowSystem />
<ZoneSystem />
@@ -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 }
}
+3 -2
View File
@@ -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()
+4
View File
@@ -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'
+10 -1
View File
@@ -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<ViewerState>()(
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<ViewerState>()(
if (updates.selectedIds === undefined) newSelection.selectedIds = []
}
return { selection: newSelection }
return { selection: newSelection, previewSelectedIds: [] }
}),
resetSelection: () =>
@@ -175,6 +183,7 @@ const useViewer = create<ViewerState>()(
zoneId: null,
selectedIds: [],
},
previewSelectedIds: [],
}),
outliner: { selectedObjects: [], hoveredObjects: [] },