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
@@ -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) => ({