Phase 5 Stage D slab: port placement + move + boundary/hole editors

Replicates the fence Stage D recipe for slab — three drag affordances
+ one placement tool, all routed through the registry:

  affordanceTools:
    'boundary-edit' → thin <PolygonEditor> wrapper (vertex/edge drag)
    'hole-edit'     → same for a single hole polygon
    move            → DragAction with single-undo dance
  tool: () => placement (multi-click polygon with axis/45° snap)

`PolygonEditor` + `PolygonEditorProps` exported from
`@pascal-app/editor` as Stage D transitional surface (Stage F cleanup
moves them into `@pascal-app/nodes`).

ToolManager mount sites for SlabBoundaryEditor / SlabHoleEditor now
route through `getRegistryAffordanceTool` with legacy fallback.

Slab move action does not use the live-drag exception (polygon CSG
rebuild every tick) — matches legacy behavior; optimization is a
separate task once we measure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 18:27:31 -04:00
co-authored by Claude Opus 4.7
parent 7b5a1c607a
commit b2e5d84986
8 changed files with 627 additions and 4 deletions
+66
View File
@@ -0,0 +1,66 @@
'use client'
import { type SlabNode, useScene } from '@pascal-app/core'
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { moveSlabDragAction } from './actions/move'
/**
* Phase 5 Stage D — thin React wrapper around `moveSlabDragAction`.
*
* Replaces the legacy `MoveSlabTool` (182 LoC). All math + history
* dance lives in the action; this wrapper just renders the cursor
* sphere following the live polygon center.
*/
export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const slabId = node.id
const initialCenter: [number, number] =
node.polygon.length > 0
? [
node.polygon.reduce((s, [x]) => s + x, 0) / node.polygon.length,
node.polygon.reduce((s, [, z]) => s + z, 0) / node.polygon.length,
]
: [0, 0]
// Live polygon center — re-derived from the scene store every tick
// since the action writes the translated polygon onto the slab.
const liveCenter = useScene((s) => {
const live = s.nodes[slabId]
if (live?.type !== 'slab') return initialCenter
const poly = (live as SlabNode).polygon
if (poly.length === 0) return initialCenter
let sx = 0
let sz = 0
for (const [x, z] of poly) {
sx += x
sz += z
}
return [sx / poly.length, sz / poly.length] as [number, number]
})
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [slabId] })
useEditor.getState().setMovingNode(null)
}
useDragAction({
active: true,
action: moveSlabDragAction,
initial: {
node,
point: initialCenter,
},
onCommit: () => exitMoveMode(true),
onCancel: () => exitMoveMode(false),
})
return (
<group>
<CursorSphere position={[liveCenter[0], 0, liveCenter[1]]} showTooltip={false} />
</group>
)
}
export default SlabMoveTool