'use client' import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core' import { usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { dormerDefinition } from './definition' import { DormerPlacementGuides } from './placement-guides' import DormerPreview from './preview' import { useDormerPlacement } from './use-dormer-placement' /** * Pick the smallest free integer suffix for a new dormer name so the * scene tree doesn't end up with multiple `Dormer 3`s after deletes. */ function nextDormerNumber(nodes: Record): number { const used = new Set() for (const node of Object.values(nodes)) { if (!node || typeof node !== 'object') continue const n = node as { type?: string; name?: string } if (n.type !== 'dormer') continue const m = n.name?.match(/^Dormer (\d+)$/) if (m?.[1]) used.add(Number.parseInt(m[1], 10)) } let n = 1 while (used.has(n)) n++ return n } /** * Placement tool for a fresh dormer. The dormer sits UPRIGHT on the * host segment at segment-local `y = 0` (the host wall foot) — the * CSG inside `generateDormerGeometry` carves the dormer against the * host roof's slope, so we don't tilt or lift it here. The ghost is * mounted on the hit segment's world transform (extracted via the * registry) so the user sees exactly where the dormer will land. */ const DormerTool = () => { const setSelection = useViewer((s) => s.setSelection) const previewNode = useMemo( () => DormerNode.parse({ ...dormerDefinition.defaults(), name: 'Dormer', position: [0, 0, 0], rotation: 0, }), [], ) const { activeBuildingId, clearPreview, segmentXform, hitSegment, hitLocal, ghostRotation } = useDormerPlacement({ onCommit: (hit, rotation) => { const state = useScene.getState() const dormer = DormerNode.parse({ ...dormerDefinition.defaults(), name: `Dormer ${nextDormerNumber(state.nodes)}`, roofSegmentId: hit.segment.id, parentId: hit.segment.id, // Anchor at the slope height so the renderer matches the ghost. // The CSG still carves cleanly because it inverts T(position) // when bringing the host into dormer-local. position: [hit.localX, hit.localY, hit.localZ], rotation, }) state.createNode(dormer, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [dormer.id] }) usePlacementPreview.getState().clear() }, }) useEffect(() => { const placementPreview = usePlacementPreview.getState() if (!(hitSegment && hitLocal)) { if (placementPreview.node?.id === previewNode.id) placementPreview.clear() return } placementPreview.set( DormerNode.parse({ ...previewNode, parentId: hitSegment.id, position: hitLocal, roofSegmentId: hitSegment.id, rotation: ghostRotation, }), hitSegment, ) }, [ghostRotation, hitLocal, hitSegment, previewNode]) useEffect( () => () => { const placementPreview = usePlacementPreview.getState() if (placementPreview.node?.id === previewNode.id) placementPreview.clear() }, [previewNode.id], ) return ( <> } onInvalidTarget={clearPreview} /> {activeBuildingId && segmentXform && hitLocal && ( {hitSegment && ( )} )} ) } export default DormerTool