Aymeric Rabot aab48e053f
fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
2026-06-11 13:03:34 -04:00

162 lines
5.4 KiB
TypeScript

'use client'
import {
type AnyNodeId,
emitter,
GutterNode,
type RoofEvent,
type RoofNode,
useScene,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { gutterDefinition } from './definition'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
type PreviewTarget = {
roof: { position: [number, number, number]; rotation: number }
segment: { position: [number, number, number]; rotation: number }
snap: EaveSnap
}
/**
* Gutter placement tool. Cursor preview snaps to the OUTER eave — the
* drip edge of the roof, NOT the wall line. The eave sits at
* `Z = ±(depth/2 + overhang)` in segment-local frame; the gutter
* mounts against the fascia there, hanging outward from the building.
*
* Which eave: `eave-snap.ts` picks the side closest to the cursor
* (roof-type aware — 4-way for hip/flat, low side only for shed, ±Z
* for the rest) and returns the segment-local snap pose. The same
* snap drives the ghost AND the commit, so picking-up + putting-down
* land at identical world coordinates.
*
* Ghost transform: we mount the GutterPreview under the exact same
* chain the GutterRenderer applies — roof.position + roof.rotation →
* segment.position + segment.rotation → snap.eave + snap.rotation.
* No `worldToBuildingLocal` + `previewYaw`-sum shortcut: that
* collapses three Y rotations into one scalar and converts world
* coords back into building-local, which is mathematically
* equivalent for pure-Y stacks but drifts under any future non-Y
* roof/segment transform. Sharing the renderer's chain means the
* ghost and the placed mesh are guaranteed pixel-identical.
*/
const GutterTool = () => {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
const setSelection = useViewer((s) => s.setSelection)
const [target, setTarget] = useState<PreviewTarget | null>(null)
const lastSnapRef = useRef<[number, number] | null>(null)
const previewNode = useMemo(
() =>
GutterNode.parse({
...gutterDefinition.defaults(),
name: 'Gutter',
position: [0, 0, 0],
rotation: 0,
}),
[],
)
useEffect(() => {
if (!activeBuildingId) return
const updatePreview = (event: RoofEvent) => {
const roof = event.node as RoofNode
const hit = resolveRoofSegmentHit(
roof,
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
// Grid-snap chime fires when the segment-local snap moves to a
// new 5 cm cell along the eave — keeps SFX in lockstep with what
// the commit will actually store.
const sx = Math.round(snap.eaveX * 20) / 20
const sz = Math.round(snap.eaveZ * 20) / 20
const prev = lastSnapRef.current
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) {
triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz]
}
setTarget({
roof: {
position: (roof.position ?? [0, 0, 0]) as [number, number, number],
rotation: roof.rotation ?? 0,
},
segment: {
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
rotation: hit.segment.rotation ?? 0,
},
snap,
})
event.stopPropagation()
}
const onClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit(
event.node as RoofNode,
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
const state = useScene.getState()
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
const gutter = GutterNode.parse({
...gutterDefinition.defaults(),
name: 'Gutter',
roofSegmentId: hit.segment.id,
// (X, Y, Z) all come from the eave snap — on ±Z eaves X stays
// free along the cursor; on ±X eaves Z stays free instead.
// Rotation orients the gutter's outward axis away from the
// building on whichever side the click landed.
position: [snap.eaveX, snap.eaveY, snap.eaveZ],
rotation: snap.rotation,
})
state.createNode(gutter, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [gutter.id] })
triggerSFX('sfx:item-place')
event.stopPropagation()
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !target) return null
return (
<group position={target.roof.position} rotation-y={target.roof.rotation}>
<group position={target.segment.position} rotation-y={target.segment.rotation}>
<group
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
rotation-y={target.snap.rotation}
>
<GutterPreview node={previewNode} />
</group>
</group>
</group>
)
}
export default GutterTool