diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index b4d51407..13c9d378 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -179,10 +179,31 @@ export const ToolManager: React.FC = () => {
rotation={buildingRotation as [number, number, number]}
>
{showZoneBoundaryEditor && selectedZoneId && }
- {showSlabBoundaryEditor && selectedSlabId && }
- {showSlabHoleEditor && selectedSlabId && editingHole && (
-
- )}
+ {showSlabBoundaryEditor &&
+ selectedSlabId &&
+ (() => {
+ const Registry = getRegistryAffordanceTool('slab', 'boundary-edit')
+ return Registry ? (
+
+
+
+ ) : (
+
+ )
+ })()}
+ {showSlabHoleEditor &&
+ selectedSlabId &&
+ editingHole &&
+ (() => {
+ const Registry = getRegistryAffordanceTool('slab', 'hole-edit')
+ return Registry ? (
+
+
+
+ ) : (
+
+ )
+ })()}
{showCeilingBoundaryEditor && selectedCeilingId && (
)}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 2adf1bd7..0fb7817c 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -14,6 +14,11 @@ export {
snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting'
export { CursorSphere } from './components/tools/shared/cursor-sphere'
+// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
+export {
+ PolygonEditor,
+ type PolygonEditorProps,
+} from './components/tools/shared/polygon-editor'
export {
formatAngleRadians,
getAngleToSegmentReference,
diff --git a/packages/nodes/src/slab/actions/move.ts b/packages/nodes/src/slab/actions/move.ts
new file mode 100644
index 00000000..c1203ea0
--- /dev/null
+++ b/packages/nodes/src/slab/actions/move.ts
@@ -0,0 +1,151 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ type DragAction,
+ type FenceNode,
+ type LevelNode,
+ type SlabNode,
+ useScene,
+ type WallNode,
+} from '@pascal-app/core'
+import { type FencePlanPoint, snapFenceDraftPoint } from '@pascal-app/editor'
+
+/**
+ * Phase 5 Stage D — whole-slab move drag affordance.
+ *
+ * Translates the slab's boundary polygon (and any holes) rigidly under
+ * the pointer. Snaps to walls / fences / grid at the level. Latches
+ * the drag anchor on the first preview tick so the slab doesn't jump
+ * to wherever the activation click landed.
+ *
+ * Unlike fence move, the slab port does **not** use the live-drag
+ * exception — polygon CSG geometry is expensive to rebuild per frame,
+ * but the legacy tool already writes the polygon to the scene every
+ * pointer tick and the user perceives that as smooth. Matching that
+ * for now; optimization is a separate task once we measure.
+ */
+
+function translatePolygon(
+ polygon: Array<[number, number]>,
+ deltaX: number,
+ deltaZ: number,
+): Array<[number, number]> {
+ return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
+}
+
+function polygonCenter(polygon: Array<[number, number]>): [number, number] {
+ if (polygon.length === 0) return [0, 0]
+ let sx = 0
+ let sz = 0
+ for (const [x, z] of polygon) {
+ sx += x
+ sz += z
+ }
+ return [sx / polygon.length, sz / polygon.length]
+}
+
+export type MoveSlabCtx = {
+ slabId: AnyNodeId
+ originalPolygon: Array<[number, number]>
+ originalHoles: Array>
+ parentId: string | null
+ levelWalls: WallNode[]
+ levelFences: FenceNode[]
+ dragAnchor: FencePlanPoint | null
+}
+
+export type MoveSlabDraft = {
+ polygon: Array<[number, number]>
+ holes: Array>
+ deltaX: number
+ deltaZ: number
+ center: [number, number]
+}
+
+export const moveSlabDragAction: DragAction = {
+ begin: (input) => {
+ const slab = input.node as SlabNode | undefined
+ if (!slab) throw new Error('[moveSlabDragAction] begin requires a slab node')
+ const parentId = slab.parentId ?? null
+ const originalPolygon: Array<[number, number]> = slab.polygon.map(
+ ([x, z]) => [x, z] as [number, number],
+ )
+ const originalHoles: Array> = (slab.holes ?? []).map((h) =>
+ h.map(([x, z]) => [x, z] as [number, number]),
+ )
+
+ const { nodes } = useScene.getState()
+ const levelNode =
+ parentId && nodes[parentId as AnyNodeId]?.type === 'level'
+ ? (nodes[parentId as AnyNodeId] as LevelNode)
+ : null
+ const levelWalls: WallNode[] = []
+ const levelFences: FenceNode[] = []
+ if (levelNode) {
+ for (const childId of levelNode.children ?? []) {
+ const child = nodes[childId as AnyNodeId]
+ if (!child) continue
+ if (child.type === 'wall') levelWalls.push(child)
+ else if (child.type === 'fence') levelFences.push(child)
+ }
+ }
+
+ return {
+ slabId: slab.id as AnyNodeId,
+ originalPolygon,
+ originalHoles,
+ parentId,
+ levelWalls,
+ levelFences,
+ dragAnchor: null,
+ }
+ },
+
+ preview: (ctx, point, _modifiers) => {
+ const snapped = snapFenceDraftPoint({
+ point: [point[0], point[1]],
+ walls: ctx.levelWalls,
+ fences: ctx.levelFences,
+ })
+ if (!ctx.dragAnchor) ctx.dragAnchor = snapped
+ const deltaX = snapped[0] - ctx.dragAnchor[0]
+ const deltaZ = snapped[1] - ctx.dragAnchor[1]
+ const polygon = translatePolygon(ctx.originalPolygon, deltaX, deltaZ)
+ const holes = ctx.originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ))
+ return {
+ polygon,
+ holes,
+ deltaX,
+ deltaZ,
+ center: polygonCenter(polygon),
+ }
+ },
+
+ apply: (draft, ctx, scene) => {
+ scene.update(ctx.slabId, {
+ polygon: draft.polygon,
+ holes: draft.holes,
+ } as Partial)
+ return [ctx.slabId]
+ },
+
+ commit: (draft, ctx, scene) => {
+ if (draft.deltaX === 0 && draft.deltaZ === 0) return false
+
+ // Single-undo dance — revert via snapshot, resume history, re-apply
+ // the final polygon/holes. Zundo captures the whole drag as one
+ // Ctrl-Z step.
+ scene.restoreAll()
+ scene.resumeHistory()
+ scene.update(ctx.slabId, {
+ polygon: draft.polygon,
+ holes: draft.holes,
+ } as Partial)
+ return true
+ },
+
+ cancel: (_ctx, _scene) => {
+ // No-op — orchestrator's scene.restoreAll() puts the original
+ // polygon/holes back via the snapshot.
+ },
+}
diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx
new file mode 100644
index 00000000..0e9412ed
--- /dev/null
+++ b/packages/nodes/src/slab/boundary-editor.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
+import { PolygonEditor } from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useCallback } 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']`.
+ */
+export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabId }) => {
+ const slabNode = useScene((s) => s.nodes[slabId])
+ const updateNode = useScene((s) => s.updateNode)
+ const setSelection = useViewer((s) => s.setSelection)
+
+ const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
+
+ const handlePolygonChange = useCallback(
+ (newPolygon: Array<[number, number]>) => {
+ updateNode(slabId, { polygon: newPolygon })
+ setSelection({ selectedIds: [slabId] })
+ },
+ [slabId, updateNode, setSelection],
+ )
+
+ if (!slab?.polygon || slab.polygon.length < 3) return null
+
+ return (
+
+ )
+}
+
+export default SlabBoundaryEditor
diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts
index 44a57b7b..c98849f3 100644
--- a/packages/nodes/src/slab/definition.ts
+++ b/packages/nodes/src/slab/definition.ts
@@ -54,6 +54,19 @@ export const slabDefinition: NodeDefinition = {
parametrics: slabParametrics,
+ // Stage D: kind-owned placement tool. Multi-click polygon drawing
+ // with axis/45° snap (Shift to defeat).
+ tool: () => import('./tool'),
+
+ // Stage D: drag/edit affordances. Boundary editor + hole editor
+ // delegate to the shared ``; move uses the single-
+ // undo dance for the polygon-translate commit.
+ affordanceTools: {
+ 'boundary-edit': () => import('./boundary-editor'),
+ 'hole-edit': () => import('./hole-editor'),
+ move: () => import('./move-tool'),
+ },
+
// Stage B: pure geometry function.
geometry: buildSlabGeometry,
// Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits
diff --git a/packages/nodes/src/slab/hole-editor.tsx b/packages/nodes/src/slab/hole-editor.tsx
new file mode 100644
index 00000000..43afdeb3
--- /dev/null
+++ b/packages/nodes/src/slab/hole-editor.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
+import { PolygonEditor } from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useCallback } from 'react'
+
+/**
+ * Phase 5 Stage D — slab hole editor (registry-driven).
+ *
+ * Edits a specific hole polygon inside a slab. Mounted by ToolManager
+ * via `def.affordanceTools['hole-edit']` when `useEditor.editingHole`
+ * is set on the selected slab.
+ */
+export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: number }> = ({
+ slabId,
+ holeIndex,
+}) => {
+ const slabNode = useScene((s) => s.nodes[slabId])
+ const updateNode = useScene((s) => s.updateNode)
+ const setSelection = useViewer((s) => s.setSelection)
+
+ const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
+ const holes = slab?.holes || []
+ const hole = holes[holeIndex]
+
+ const handlePolygonChange = useCallback(
+ (newPolygon: Array<[number, number]>) => {
+ const updatedHoles = [...holes]
+ updatedHoles[holeIndex] = newPolygon
+ updateNode(slabId, { holes: updatedHoles })
+ setSelection({ selectedIds: [slabId] })
+ },
+ [slabId, holeIndex, holes, updateNode, setSelection],
+ )
+
+ if (!(slab && hole) || hole.length < 3) return null
+
+ return (
+
+ )
+}
+
+export default SlabHoleEditor
diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx
new file mode 100644
index 00000000..086127da
--- /dev/null
+++ b/packages/nodes/src/slab/move-tool.tsx
@@ -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 (
+
+
+
+ )
+}
+
+export default SlabMoveTool
diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx
new file mode 100644
index 00000000..c6545d1c
--- /dev/null
+++ b/packages/nodes/src/slab/tool.tsx
@@ -0,0 +1,266 @@
+'use client'
+
+import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
+import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
+import { SlabNode } from './schema'
+
+/**
+ * Phase 5 Stage D — slab placement tool (kind-owned via `def.tool`).
+ *
+ * Multi-click polygon drawing: each click adds a vertex; clicking near
+ * the first vertex (or double-clicking) closes the polygon and creates
+ * the slab. Shift-modifier defeats the axis/45° snap during drag.
+ *
+ * Not a `DragAction` — same reasoning as `tool.tsx` for fence: this is
+ * a stateful sequence of grid:click events with preview state, not a
+ * single pointer-down → drag-up.
+ */
+
+const Y_OFFSET = 0.02
+
+function calculateSnapPoint(
+ lastPoint: [number, number],
+ currentPoint: [number, number],
+): [number, number] {
+ const [x1, y1] = lastPoint
+ const [x, y] = currentPoint
+ const dx = x - x1
+ const dy = y - y1
+ const absDx = Math.abs(dx)
+ const absDy = Math.abs(dy)
+ const horizontalDist = absDy
+ const verticalDist = absDx
+ const diagonalDist = Math.abs(absDx - absDy)
+ const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
+ if (minDist === diagonalDist) {
+ const diagonalLength = Math.min(absDx, absDy)
+ return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
+ }
+ if (minDist === horizontalDist) return [x, y1]
+ return [x1, y]
+}
+
+function commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string {
+ const { createNode, nodes } = useScene.getState()
+ const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
+ const name = `Slab ${slabCount + 1}`
+ const slab = SlabNode.parse({ name, polygon: points })
+ createNode(slab, levelId)
+ triggerSFX('sfx:structure-build')
+ return slab.id
+}
+
+export const SlabTool: React.FC = () => {
+ const cursorRef = useRef(null)
+ const mainLineRef = useRef(null!)
+ const closingLineRef = useRef(null!)
+ const currentLevelId = useViewer((s) => s.selection.levelId)
+ const setSelection = useViewer((s) => s.setSelection)
+
+ const [points, setPoints] = useState>([])
+ const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
+ const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
+ const [levelY, setLevelY] = useState(0)
+ const previousSnappedPointRef = useRef<[number, number] | null>(null)
+ const shiftPressed = useRef(false)
+
+ useEffect(() => {
+ if (!currentLevelId) return
+
+ const onGridMove = (event: GridEvent) => {
+ if (!cursorRef.current) return
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
+ const gridPosition: [number, number] = [gridX, gridZ]
+ setCursorPosition(gridPosition)
+ setLevelY(event.localPosition[1])
+ const lastPoint = points[points.length - 1]
+ const displayPoint =
+ shiftPressed.current || !lastPoint
+ ? gridPosition
+ : calculateSnapPoint(lastPoint, gridPosition)
+ setSnappedCursorPosition(displayPoint)
+ if (
+ points.length > 0 &&
+ previousSnappedPointRef.current &&
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
+ displayPoint[1] !== previousSnappedPointRef.current[1])
+ ) {
+ triggerSFX('sfx:grid-snap')
+ }
+ previousSnappedPointRef.current = displayPoint
+ cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
+ }
+
+ const onGridClick = (_event: GridEvent) => {
+ if (!currentLevelId) return
+ const clickPoint = previousSnappedPointRef.current ?? cursorPosition
+ const firstPoint = points[0]
+ if (
+ points.length >= 3 &&
+ firstPoint &&
+ Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
+ Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
+ ) {
+ const slabId = commitSlabDrawing(currentLevelId, points)
+ setSelection({ selectedIds: [slabId] })
+ setPoints([])
+ } else {
+ setPoints([...points, clickPoint])
+ }
+ }
+
+ const onGridDoubleClick = (_event: GridEvent) => {
+ if (!currentLevelId) return
+ if (points.length >= 3) {
+ const slabId = commitSlabDrawing(currentLevelId, points)
+ setSelection({ selectedIds: [slabId] })
+ setPoints([])
+ }
+ }
+
+ const onCancel = () => {
+ if (points.length > 0) markToolCancelConsumed()
+ setPoints([])
+ }
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') shiftPressed.current = true
+ }
+ const onKeyUp = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') shiftPressed.current = false
+ }
+ document.addEventListener('keydown', onKeyDown)
+ document.addEventListener('keyup', onKeyUp)
+
+ emitter.on('grid:move', onGridMove)
+ emitter.on('grid:click', onGridClick)
+ emitter.on('grid:double-click', onGridDoubleClick)
+ emitter.on('tool:cancel', onCancel)
+
+ return () => {
+ document.removeEventListener('keydown', onKeyDown)
+ document.removeEventListener('keyup', onKeyUp)
+ emitter.off('grid:move', onGridMove)
+ emitter.off('grid:click', onGridClick)
+ emitter.off('grid:double-click', onGridDoubleClick)
+ emitter.off('tool:cancel', onCancel)
+ }
+ }, [currentLevelId, points, cursorPosition, setSelection])
+
+ useEffect(() => {
+ if (!(mainLineRef.current && closingLineRef.current)) return
+ if (points.length === 0) {
+ mainLineRef.current.visible = false
+ closingLineRef.current.visible = false
+ return
+ }
+ const y = levelY + Y_OFFSET
+ const snappedCursor = snappedCursorPosition
+ const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
+ linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
+ if (linePoints.length >= 2) {
+ mainLineRef.current.geometry.dispose()
+ mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
+ mainLineRef.current.visible = true
+ } else {
+ mainLineRef.current.visible = false
+ }
+ const firstPoint = points[0]
+ if (points.length >= 2 && firstPoint) {
+ const closingPoints = [
+ new Vector3(snappedCursor[0], y, snappedCursor[1]),
+ new Vector3(firstPoint[0], y, firstPoint[1]),
+ ]
+ closingLineRef.current.geometry.dispose()
+ closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
+ closingLineRef.current.visible = true
+ } else {
+ closingLineRef.current.visible = false
+ }
+ }, [points, snappedCursorPosition, levelY])
+
+ const previewShape = useMemo(() => {
+ if (points.length < 3) return null
+ const snappedCursor = snappedCursorPosition
+ const allPoints = [...points, snappedCursor]
+ const firstPt = allPoints[0]
+ if (!firstPt) return null
+ const shape = new Shape()
+ shape.moveTo(firstPt[0], -firstPt[1])
+ for (let i = 1; i < allPoints.length; i++) {
+ const pt = allPoints[i]
+ if (pt) shape.lineTo(pt[0], -pt[1])
+ }
+ shape.closePath()
+ return shape
+ }, [points, snappedCursorPosition])
+
+ return (
+
+
+ {previewShape && (
+
+
+
+
+ )}
+ {/* @ts-ignore */}
+
+
+
+
+ {/* @ts-ignore */}
+
+
+
+
+ {points.map(([x, z], index) => (
+
+ ))}
+
+ )
+}
+
+export default SlabTool