'use client' import { type AnyNode, type AnyNodeId, type BoxVentNode, type ChimneyNode, createDefaultRidgeVentsForSegment, type DormerNode, type GutterNode, type RidgeVentNode, type RoofNode, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type SkylightNode, type SolarPanelNode, type TurbineVentNode, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, duplicateRoofSubtree, PanelSection, PanelWrapper, SegmentedControl, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useState } from 'react' import { useShallow } from 'zustand/react/shallow' export default function RoofPanel() { const [ventType, setVentType] = useState<'box-vent' | 'ridge-vent' | 'turbine-vent'>('box-vent') const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, ) // Shallow selector — only re-renders when the segment list content changes. const segments = useScene( useShallow((s) => { if (!node) return [] return (node.children ?? []) .map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined) .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment') }), ) // Flatten roof accessories hosted by any segment of this roof. Each // selector re-runs only when the relevant child list changes. const segmentIdSet = useScene( useShallow((s) => { if (!node) return new Set() return new Set((node.children ?? []) as string[]) }), ) const chimneys = useScene( useShallow((s) => { if (segmentIdSet.size === 0) return [] const out: ChimneyNode[] = [] for (const n of Object.values(s.nodes)) { if (n?.type === 'chimney' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { out.push(n as ChimneyNode) } } return out }), ) const skylights = useScene( useShallow((s) => { if (segmentIdSet.size === 0) return [] const out: SkylightNode[] = [] for (const n of Object.values(s.nodes)) { if (n?.type === 'skylight' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { out.push(n as SkylightNode) } } return out }), ) const solarPanels = useScene( useShallow((s) => { if (segmentIdSet.size === 0) return [] const out: SolarPanelNode[] = [] for (const n of Object.values(s.nodes)) { if (n?.type === 'solar-panel' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { out.push(n as SolarPanelNode) } } return out }), ) const dormers = useScene( useShallow((s) => { if (segmentIdSet.size === 0) return [] const out: DormerNode[] = [] for (const n of Object.values(s.nodes)) { if (n?.type === 'dormer' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { out.push(n as DormerNode) } } return out }), ) const gutters = useScene( useShallow((s) => { if (segmentIdSet.size === 0) return [] const out: GutterNode[] = [] for (const n of Object.values(s.nodes)) { if (n?.type === 'gutter' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { out.push(n as GutterNode) } } return out }), ) // Box vents and ridge vents share the "Vents" UI group — same list, // type shown as the right-side label, and an `Add Vent` button with // a Box/Ridge segmented picker. const vents = useScene( useShallow((s) => { if (segmentIdSet.size === 0) return [] const out: (BoxVentNode | RidgeVentNode | TurbineVentNode)[] = [] for (const n of Object.values(s.nodes)) { if ( (n?.type === 'box-vent' || n?.type === 'ridge-vent' || n?.type === 'turbine-vent') && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId) ) { out.push(n as BoxVentNode | RidgeVentNode | TurbineVentNode) } } return out }), ) const handleSelectElement = useCallback( (id: string) => { setSelection({ selectedIds: [id as AnyNode['id']] }) }, [setSelection], ) const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) }, [selectedId, updateNode], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleAddSegment = useCallback(() => { if (!node) return const segment = RoofSegmentNodeSchema.parse({ width: 6, depth: 6, wallHeight: 0.5, pitch: 40, roofType: 'gable', position: [2, 0, 2], }) const ridgeVents = createDefaultRidgeVentsForSegment(segment) createNodes([ { node: segment, parentId: node.id as AnyNodeId }, ...ridgeVents.map((ridgeVent) => ({ node: ridgeVent, parentId: segment.id as AnyNodeId, })), ]) }, [node, createNodes]) const handleSelectSegment = useCallback( (segmentId: string) => { setSelection({ selectedIds: [segmentId as AnyNode['id']] }) }, [setSelection], ) const handleDuplicate = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') try { duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' }) } catch (e) { console.error('Failed to duplicate roof', e) } }, [node]) const handleMove = useCallback(() => { if (node) { triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) } }, [node, setMovingNode, setSelection]) const handleDelete = useCallback(() => { if (!(selectedId && node)) return triggerSFX('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]) // Each "Add" button activates the kind's registered placement tool // via `setTool(kind)`. The tool listens for `roof:*` events and // commits a new node parented to whichever segment the user clicks. // Same code path as the top palette — see `tool-manager.tsx:28`'s // `nodeRegistry.get(tool)?.tool` dispatch. const activateTool = useCallback( ( kind: | 'box-vent' | 'ridge-vent' | 'turbine-vent' | 'cupola' | 'eyebrow-vent' | 'chimney' | 'solar-panel' | 'skylight' | 'dormer' | 'gutter' | 'downspout', ) => { triggerSFX('sfx:item-pick') useEditor.getState().setTool(kind) if (useEditor.getState().mode !== 'build') { useEditor.getState().setMode('build') } }, [], ) if (!(node && node.type === 'roof' && selectedId)) return null return (
{segments.map((seg, i) => ( ))}
} label="Add Segment" onClick={handleAddSegment} />
{ 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)} />
{ triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation - Math.PI / 4 }) }} /> { triggerSFX('sfx:item-rotate') handleUpdate({ rotation: node.rotation + Math.PI / 4 }) }} />
{chimneys.map((chimney, i) => ( ))} } label="Add Chimney" onClick={() => activateTool('chimney')} />
{dormers.map((dormer, i) => ( ))} } label="Add Dormer" onClick={() => activateTool('dormer')} />
{skylights.map((skylight, i) => ( ))} } label="Add Skylight" onClick={() => activateTool('skylight')} />
{solarPanels.map((panel, i) => ( ))} } label="Add Solar Panel" onClick={() => activateTool('solar-panel')} />
{vents.map((vent, i) => ( ))} onChange={setVentType} options={[ { label: 'Box', value: 'box-vent' }, { label: 'Ridge', value: 'ridge-vent' }, { label: 'Turbine', value: 'turbine-vent' }, ]} value={ventType} /> } label="Add Vent" onClick={() => activateTool(ventType)} />
} label="Add Cupola" onClick={() => activateTool('cupola')} />
} label="Add Eyebrow Vent" onClick={() => activateTool('eyebrow-vent')} />
{gutters.map((gutter, i) => ( ))} } label="Add Gutter" onClick={() => activateTool('gutter')} />
} label="Move" onClick={handleMove} /> } label="Duplicate" onClick={handleDuplicate} /> } label="Delete" onClick={handleDelete} />
) }