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