'use client' import { type AnyNode, type AnyNodeId, type AttachmentSide, 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 { 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 selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined, ) // Boolean selector — re-renders only when this segment's position among the // parent stair's children flips to/from "first". const isFirstSegment = useScene((s) => { if (!node?.parentId) return true const parent = s.nodes[node.parentId as AnyNodeId] if (!parent || parent.type !== 'stair') return true const children = (parent as any).children ?? [] return children[0] === node.id }) const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) }, [selectedId, updateNode], ) const 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' && selectedId)) return null return ( { const updates: Partial = { segmentType: v } if (v === 'landing') { updates.height = 0 updates.stepCount = 0 updates.length = 1.0 } else { updates.height = 2.5 updates.stepCount = 10 updates.length = 3.0 } handleUpdate(updates) }} options={SEGMENT_TYPE_OPTIONS} value={node.segmentType} /> {!isFirstSegment && ( handleUpdate({ attachmentSide: v })} options={ATTACHMENT_SIDE_OPTIONS} value={node.attachmentSide} /> )} handleUpdate({ width: v })} precision={2} step={0.1} unit="m" value={Math.round(node.width * 100) / 100} /> handleUpdate({ length: v })} precision={2} step={0.1} unit="m" value={Math.round(node.length * 100) / 100} /> {node.segmentType === 'stair' && ( <> handleUpdate({ height: v })} precision={2} step={0.1} unit="m" value={Math.round(node.height * 100) / 100} /> handleUpdate({ stepCount: Math.round(v) })} precision={0} step={1} unit="" value={node.stepCount} /> )}
Fill to floor
{!node.fillToFloor && ( handleUpdate({ thickness: v })} precision={2} step={0.05} unit="m" value={Math.round((node.thickness ?? 0.25) * 100) / 100} /> )}
{ const pos = [...node.position] as [number, number, number] pos[0] = v handleUpdate({ position: pos }) }} precision={2} step={0.05} unit="m" value={Math.round(node.position[0] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[1] = v handleUpdate({ position: pos }) }} precision={2} step={0.05} unit="m" value={Math.round(node.position[1] * 100) / 100} /> { const pos = [...node.position] as [number, number, number] pos[2] = v handleUpdate({ position: pos }) }} precision={2} step={0.05} unit="m" value={Math.round(node.position[2] * 100) / 100} /> { handleUpdate({ rotation: (degrees * Math.PI) / 180 }) }} precision={0} step={1} unit="°" value={Math.round((node.rotation * 180) / Math.PI)} />
{ sfxEmitter.emit('sfx:item-rotate') handleUpdate({ rotation: node.rotation - Math.PI / 4 }) }} /> { sfxEmitter.emit('sfx:item-rotate') handleUpdate({ rotation: node.rotation + Math.PI / 4 }) }} />
} label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} />
) }