'use client' import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, holeEditScope, PanelSection, PanelWrapper, SliderControl, triggerSFX, useEditingHole, useEditor, useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef } from 'react' /** * Phase 5 Stage E — ceiling inspector (kind-owned). * * 1:1 port of the legacy `CeilingPanel`. Mounted via * `parametrics.customPanel`. Same rationale as slab/panel.tsx — the * holes list + height presets need richer field kinds before this * panel can collapse into auto-derived groups. */ export function CeilingPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined, ) // Panel slider-drag fix recipe (plans/editor-node-registry.md): stable // handler refs so slider drags don't trigger Maximum update depth. const nodeRef = useRef(node) nodeRef.current = node const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return useScene.getState().updateNode(selectedId as AnyNode['id'], updates) }, [selectedId], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope .getState() .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') }, [setSelection]) useEffect(() => { if (!node) { useInteractionScope .getState() .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } }, [node]) useEffect(() => { return () => { useInteractionScope .getState() .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } }, []) const handleAddHole = useCallback(() => { if (!(node && selectedId)) return const polygon = node.polygon let cx = 0 let cz = 0 for (const [x, z] of polygon) { cx += x cz += z } cx /= polygon.length cz /= polygon.length const holeSize = 0.5 const newHole: Array<[number, number]> = [ [cx - holeSize, cz - holeSize], [cx + holeSize, cz - holeSize], [cx + holeSize, cz + holeSize], [cx - holeSize, cz + holeSize], ] const currentHoles = node?.holes || [] const currentMetadata = currentHoles.map( (_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const }, ) handleUpdate({ holes: [...currentHoles, newHole], holeMetadata: [...currentMetadata, { source: 'manual' }], }) useInteractionScope .getState() .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length })) }, [node, selectedId, handleUpdate]) const handleEditHole = useCallback( (index: number) => { if (!selectedId) return useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index })) }, [selectedId], ) const handleDeleteHole = useCallback( (index: number) => { if (!selectedId) return const currentHoles = node?.holes || [] if ((node?.holeMetadata?.[index]?.source ?? 'manual') !== 'manual') return const newHoles = currentHoles.filter((_, i) => i !== index) const currentMetadata = currentHoles.map( (_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const }, ) const newMetadata = currentMetadata.filter((_, i) => i !== index) handleUpdate({ holes: newHoles, holeMetadata: newMetadata }) if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { useInteractionScope .getState() .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole') } }, [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole], ) const handleMove = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) if (!(node && node.type === 'ceiling' && selectedId)) return null const calculateArea = (polygon: Array<[number, number]>): number => { if (polygon.length < 3) return 0 let area = 0 const n = polygon.length for (let i = 0; i < n; i++) { const j = (i + 1) % n const current = polygon[i]! const next = polygon[j]! area += current[0] * next[1] area -= next[0] * current[1] } return Math.abs(area) / 2 } const area = calculateArea(node.polygon) return ( handleUpdate({ height: v })} precision={3} step={0.01} unit="m" value={Math.round(node.height * 1000) / 1000} />
handleUpdate({ height: 2.4 })} /> handleUpdate({ height: 2.5 })} /> handleUpdate({ height: 3.0 })} />
Area {area.toFixed(2)} m²
{node.holes && node.holes.length > 0 ? (
{node.holes.map((hole, index) => { const holeArea = calculateArea(hole) const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const source = node.holeMetadata?.[index]?.source ?? 'manual' const isAutoHole = source !== 'manual' const autoLabel = source === 'elevator' ? 'Auto elevator cutout' : 'Auto stair cutout' return (

Hole {index + 1} {isEditing && '(Editing)'}

{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} {isAutoHole ? autoLabel : 'Manual'}

{isEditing ? ( useInteractionScope .getState() .endIf( (scope) => scope.kind === 'reshaping' && scope.reshape === 'hole', ) } /> ) : isAutoHole ? (
Auto
) : ( <> )}
) })}
) : (
No holes
)}
} label="Add Hole" onClick={handleAddHole} />
} label="Move" onClick={handleMove} />
) } export default CeilingPanel