'use client' import { type AnyNode, type AnyNodeId, RidgeVentNode as RidgeVentSchema, type RoofSegmentNode, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, PanelSection, PanelWrapper, SegmentedControl, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' import type { RidgeVentNode } from './schema' /** * Inspector panel for a placed ridge vent. Same structure as box-vent's * panel — sliders for style / dimensions / position + Move / Duplicate / * Delete actions that route through the kind-owned ghost-drag flow. */ export default function RidgeVentPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RidgeVentNode | undefined) : undefined, ) const segment = useScene((s) => node?.roofSegmentId ? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) : undefined, ) 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?.roofSegmentId) { setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] }) } }, [node?.roofSegmentId, setSelection]) const handleMove = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') // Type-union escape — see box-vent panel. setMovingNode(node as never) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDuplicate = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') const parentId = node.roofSegmentId as AnyNodeId | undefined if (!parentId) return const state = useScene.getState() const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) : {} const cloneInput = { ...node, id: undefined, metadata: { ...meta, isNew: true }, } as Record const cloned = RidgeVentSchema.parse(cloneInput) as RidgeVentNode state.createNode(cloned, parentId) state.dirtyNodes.add(parentId) setMovingNode(cloned as never) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return triggerSFX('sfx:item-delete') const segmentId = node.roofSegmentId if (segmentId) { const state = useScene.getState() const seg = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined if (seg) { state.updateNode(segmentId as AnyNode['id'], { children: (seg.children ?? []).filter((id) => id !== selectedId), }) } } deleteNode(selectedId as AnyNodeId) if (segmentId) { useScene.getState().dirtyNodes.add(segmentId as AnyNodeId) setSelection({ selectedIds: [segmentId as AnyNode['id']] }) } else { setSelection({ selectedIds: [] }) } }, [selectedId, node, deleteNode, setSelection]) if (!(node && node.type === 'ridge-vent' && selectedId)) return null // Ridge runs along segment-X axis, so the length slider's max ties to // segment.width and the across-ridge X-position to the same span. Z // is the across-slope position — for ridge vents this should stay // near zero (the ridge line), so clamp it to a narrow window around // the segment's center. const halfW = Math.round(((segment?.width ?? 10) / 2) * 100) / 100 const halfD = Math.round(((segment?.depth ?? 10) / 2) * 100) / 100 return ( handleUpdate({ style: v as RidgeVentNode['style'] })} options={[ { label: 'Standard', value: 'standard' }, { label: 'Shingled', value: 'shingled' }, { label: 'Flanged', value: 'metal' }, ]} value={node.style ?? 'standard'} /> handleUpdate({ endCaps: v === 'yes' })} options={[ { label: 'End Caps', value: 'yes' }, { label: 'Open', value: 'no' }, ]} value={(node.endCaps ?? true) ? 'yes' : 'no'} /> handleUpdate({ length: v })} onCommit={(v) => handleUpdate({ length: v })} precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round(node.length * 100) / 100} /> handleUpdate({ width: v })} onCommit={(v) => handleUpdate({ width: v })} precision={2} restoreOnCommit={false} step={0.01} unit="m" value={Math.round(node.width * 100) / 100} /> handleUpdate({ height: v })} onCommit={(v) => handleUpdate({ height: v })} precision={3} restoreOnCommit={false} step={0.005} unit="m" value={Math.round(node.height * 1000) / 1000} /> handleUpdate({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0], }) } onCommit={(v) => handleUpdate({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0], }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round((node.position[0] ?? 0) * 100) / 100} /> handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0], }) } onCommit={(v) => handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0], }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round((node.position[1] ?? 0) * 100) / 100} /> handleUpdate({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v], }) } onCommit={(v) => handleUpdate({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v], }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" value={Math.round((node.position[2] ?? 0) * 100) / 100} /> handleUpdate({ rotation: (deg * Math.PI) / 180 })} onCommit={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })} precision={0} restoreOnCommit={false} step={1} unit="°" value={Math.round(((node.rotation ?? 0) * 180) / Math.PI)} /> } label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} /> ) }