ai-chatbot/packages/nodes/src/slab/boundary-editor.tsx
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

111 lines
3.7 KiB
TypeScript

'use client'
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import {
clearSlabSnapFeedback,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveSlabPlanPointSnap,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react'
/**
* Phase 5 Stage D — slab boundary editor (registry-driven).
*
* Thin wrapper around the shared `PolygonEditor`. Activates when a
* slab is selected in structure/select mode (not currently editing a
* hole). The heavy lifting — vertex drag, edge slide, snap, history
* bracketing — lives in `PolygonEditor` itself.
*
* Mounted by ToolManager via `def.affordanceTools['boundary-edit']`.
*
* Drag flow: every pointer tick the editor hands back the in-flight
* polygon through `onPolygonPreview`; we mirror it onto
* `useLiveNodeOverrides` + `markDirty` so `GeometrySystem` rebuilds
* the slab mesh at pointer rate. On release the editor calls
* `onPolygonChange` once with the final polygon — that's the single
* `updateNode` tracked by undo. The follow-up `onPolygonPreview(null)`
* drops the override so subscribers read from the store again.
*/
export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabId }) => {
const slabNode = useScene((s) => s.nodes[slabId])
const updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const slabLevelId = slab ? resolveLevelId(slab, useScene.getState().nodes) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
clearSlabSnapFeedback()
updateNode(slabId, { polygon: newPolygon })
setSelection({ selectedIds: [slabId] })
},
[slabId, updateNode, setSelection],
)
const handlePolygonPreview = useCallback(
(preview: ReadonlyArray<readonly [number, number]> | null) => {
if (preview) {
useLiveNodeOverrides.getState().set(slabId, {
polygon: preview.map(([x, z]) => [x, z] as [number, number]),
})
} else {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId)
}
markDirty(slabId)
},
[slabId, markDirty],
)
const handleDragCommit = useCallback(() => {
clearSlabSnapFeedback()
}, [])
const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) =>
resolveSlabPlanPointSnap({
rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint,
levelId: slabLevelId,
excludeId: slabId,
altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point,
[slabId, slabLevelId],
)
// Guarantee the override clears if the editor unmounts mid-drag
// (selection change, mode switch) so the slab mesh doesn't get stuck
// on a stale polygon.
useEffect(() => {
return () => {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId)
useScene.getState().markDirty(slabId)
}
}, [slabId])
if (!slab?.polygon || slab.polygon.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
color="#a3a3a3"
levelId={slabLevelId ?? undefined}
minVertices={3}
onDragCommit={handleDragCommit}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={slab.polygon}
resolvePlanPoint={resolvePolygonEditorPlanPoint}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
export default SlabBoundaryEditor