Files
editor/packages/nodes/src/dormer/tool.tsx
T

131 lines
4.3 KiB
TypeScript

'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<string, unknown>): number {
const used = new Set<number>()
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 (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
ghost={<DormerPreview node={previewNode} invalid />}
onInvalidTarget={clearPreview}
/>
{activeBuildingId && segmentXform && hitLocal && (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
{hitSegment && (
<DormerPlacementGuides
center={hitLocal}
depth={previewNode.depth}
rotation={ghostRotation}
segment={hitSegment}
width={previewNode.width}
/>
)}
<group position={hitLocal}>
<group rotation-y={ghostRotation}>
<DormerPreview node={previewNode} />
</group>
</group>
</group>
)}
</>
)
}
export default DormerTool