diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index 13c9d378..5e011722 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -204,12 +204,31 @@ export const ToolManager: React.FC = () => {
)
})()}
- {showCeilingBoundaryEditor && selectedCeilingId && (
-
- )}
- {showCeilingHoleEditor && selectedCeilingId && editingHole && (
-
- )}
+ {showCeilingBoundaryEditor &&
+ selectedCeilingId &&
+ (() => {
+ const Registry = getRegistryAffordanceTool('ceiling', 'boundary-edit')
+ return Registry ? (
+
+
+
+ ) : (
+
+ )
+ })()}
+ {showCeilingHoleEditor &&
+ selectedCeilingId &&
+ editingHole &&
+ (() => {
+ const Registry = getRegistryAffordanceTool('ceiling', 'hole-edit')
+ return Registry ? (
+
+
+
+ ) : (
+
+ )
+ })()}
{movingWallEndpoint && }
{movingFenceEndpoint &&
(() => {
diff --git a/packages/nodes/src/ceiling/actions/move.ts b/packages/nodes/src/ceiling/actions/move.ts
new file mode 100644
index 00000000..14baedfb
--- /dev/null
+++ b/packages/nodes/src/ceiling/actions/move.ts
@@ -0,0 +1,92 @@
+import type { AnyNode, AnyNodeId, CeilingNode, DragAction } from '@pascal-app/core'
+
+/**
+ * Phase 5 Stage D — whole-ceiling move drag affordance.
+ *
+ * Mirrors `slab/actions/move.ts` shape but ceiling snaps purely to a
+ * 0.5m grid (no wall/fence corner snap — ceilings are typically
+ * placed independent of the floor layout). Drag anchor is latched on
+ * the first preview tick so the ceiling doesn't jump.
+ *
+ * Single-undo dance on commit, same recipe as slab/fence.
+ */
+
+const GRID_STEP = 0.5
+
+function snap(value: number): number {
+ return Math.round(value / GRID_STEP) * GRID_STEP
+}
+
+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])
+}
+
+export type MoveCeilingCtx = {
+ ceilingId: AnyNodeId
+ originalPolygon: Array<[number, number]>
+ originalHoles: Array>
+ dragAnchor: [number, number] | null
+}
+
+export type MoveCeilingDraft = {
+ polygon: Array<[number, number]>
+ holes: Array>
+ deltaX: number
+ deltaZ: number
+}
+
+export const moveCeilingDragAction: DragAction = {
+ begin: (input) => {
+ const ceiling = input.node as CeilingNode | undefined
+ if (!ceiling) throw new Error('[moveCeilingDragAction] begin requires a ceiling node')
+ return {
+ ceilingId: ceiling.id as AnyNodeId,
+ originalPolygon: ceiling.polygon.map(([x, z]) => [x, z] as [number, number]),
+ originalHoles: (ceiling.holes ?? []).map((h) =>
+ h.map(([x, z]) => [x, z] as [number, number]),
+ ),
+ dragAnchor: null,
+ }
+ },
+
+ preview: (ctx, point, _modifiers) => {
+ const sx = snap(point[0])
+ const sz = snap(point[1])
+ if (!ctx.dragAnchor) ctx.dragAnchor = [sx, sz]
+ const deltaX = sx - ctx.dragAnchor[0]
+ const deltaZ = sz - ctx.dragAnchor[1]
+ return {
+ polygon: translatePolygon(ctx.originalPolygon, deltaX, deltaZ),
+ holes: ctx.originalHoles.map((h) => translatePolygon(h, deltaX, deltaZ)),
+ deltaX,
+ deltaZ,
+ }
+ },
+
+ apply: (draft, ctx, scene) => {
+ scene.update(ctx.ceilingId, {
+ polygon: draft.polygon,
+ holes: draft.holes,
+ } as Partial)
+ return [ctx.ceilingId]
+ },
+
+ commit: (draft, ctx, scene) => {
+ if (draft.deltaX === 0 && draft.deltaZ === 0) return false
+ scene.restoreAll()
+ scene.resumeHistory()
+ scene.update(ctx.ceilingId, {
+ polygon: draft.polygon,
+ holes: draft.holes,
+ } as Partial)
+ return true
+ },
+
+ cancel: (_ctx, _scene) => {
+ // No-op — orchestrator's scene.restoreAll() restores via snapshot.
+ },
+}
diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx
new file mode 100644
index 00000000..b807a6a8
--- /dev/null
+++ b/packages/nodes/src/ceiling/boundary-editor.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { type CeilingNode, resolveLevelId, 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 — ceiling boundary editor (registry-driven).
+ *
+ * Thin wrapper around the shared `` (same shape as
+ * slab's boundary-editor). Activates when a ceiling is selected in
+ * structure/select mode and no hole edit is in progress.
+ */
+export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ({
+ ceilingId,
+}) => {
+ const ceilingNode = useScene((s) => s.nodes[ceilingId])
+ const updateNode = useScene((s) => s.updateNode)
+ const setSelection = useViewer((s) => s.setSelection)
+
+ const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
+
+ const handlePolygonChange = useCallback(
+ (newPolygon: Array<[number, number]>) => {
+ updateNode(ceilingId, { polygon: newPolygon })
+ setSelection({ selectedIds: [ceilingId] })
+ },
+ [ceilingId, updateNode, setSelection],
+ )
+
+ if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
+
+ return (
+
+ )
+}
+
+export default CeilingBoundaryEditor
diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts
index ab3b8749..33318bfe 100644
--- a/packages/nodes/src/ceiling/definition.ts
+++ b/packages/nodes/src/ceiling/definition.ts
@@ -54,6 +54,19 @@ export const ceilingDefinition: NodeDefinition = {
parametrics: ceilingParametrics,
+ // Stage D: kind-owned placement tool. Multi-click polygon drawing
+ // with a vertical TSL-gradient connector + ground-shadow lines.
+ tool: () => import('./tool'),
+
+ // Stage D: drag/edit affordances. Boundary editor + hole editor
+ // delegate to the shared ``; move uses the single-
+ // undo dance.
+ affordanceTools: {
+ 'boundary-edit': () => import('./boundary-editor'),
+ 'hole-edit': () => import('./hole-editor'),
+ move: () => import('./move-tool'),
+ },
+
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
diff --git a/packages/nodes/src/ceiling/hole-editor.tsx b/packages/nodes/src/ceiling/hole-editor.tsx
new file mode 100644
index 00000000..5923a5ea
--- /dev/null
+++ b/packages/nodes/src/ceiling/hole-editor.tsx
@@ -0,0 +1,49 @@
+'use client'
+
+import { type CeilingNode, resolveLevelId, 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 — ceiling hole editor (registry-driven).
+ */
+export const CeilingHoleEditor: React.FC<{
+ ceilingId: CeilingNode['id']
+ holeIndex: number
+}> = ({ ceilingId, holeIndex }) => {
+ const ceilingNode = useScene((s) => s.nodes[ceilingId])
+ const updateNode = useScene((s) => s.updateNode)
+ const setSelection = useViewer((s) => s.setSelection)
+
+ const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
+ const holes = ceiling?.holes || []
+ const hole = holes[holeIndex]
+
+ const handlePolygonChange = useCallback(
+ (newPolygon: Array<[number, number]>) => {
+ const updatedHoles = [...holes]
+ updatedHoles[holeIndex] = newPolygon
+ updateNode(ceilingId, { holes: updatedHoles })
+ setSelection({ selectedIds: [ceilingId] })
+ },
+ [ceilingId, holeIndex, holes, updateNode, setSelection],
+ )
+
+ if (!(ceiling && hole) || hole.length < 3) return null
+
+ return (
+
+ )
+}
+
+export default CeilingHoleEditor
diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx
new file mode 100644
index 00000000..61a5b90a
--- /dev/null
+++ b/packages/nodes/src/ceiling/move-tool.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { type CeilingNode, useScene } from '@pascal-app/core'
+import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+import { useMemo } from 'react'
+import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
+import { moveCeilingDragAction } from './actions/move'
+
+/**
+ * Phase 5 Stage D — thin React wrapper around `moveCeilingDragAction`.
+ *
+ * Renders the cursor sphere at the ceiling polygon's live center plus
+ * a translucent preview fill + outline so the user sees where the
+ * ceiling lands before clicking. Polygon + holes are pulled from
+ * `useScene` so the wrapper mirrors the action's per-tick writes.
+ */
+export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
+ const ceilingId = node.id
+ const height = node.height ?? 2.5
+
+ const live = useScene((s) => s.nodes[ceilingId])
+ const liveCeiling = live?.type === 'ceiling' ? (live as CeilingNode) : node
+ const polygon = liveCeiling.polygon
+ const holes = liveCeiling.holes ?? []
+
+ const center: [number, number] = useMemo(() => {
+ 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]
+ }, [polygon])
+
+ const previewFillGeometry = useMemo(() => createPreviewFill(polygon, holes), [polygon, holes])
+ const previewOutlineGeometry = useMemo(() => createOutline(polygon), [polygon])
+
+ const exitMoveMode = (committed: boolean) => {
+ if (committed) triggerSFX('sfx:item-place')
+ useViewer.getState().setSelection({ selectedIds: [ceilingId] })
+ useEditor.getState().setMovingNode(null)
+ }
+
+ useDragAction({
+ active: true,
+ action: moveCeilingDragAction,
+ initial: {
+ node,
+ point: center,
+ },
+ onCommit: () => exitMoveMode(true),
+ onCancel: () => exitMoveMode(false),
+ })
+
+ return (
+
+
+
+
+ {/* @ts-ignore */}
+
+
+
+
+
+ )
+}
+
+function createPreviewFill(
+ polygon: Array<[number, number]>,
+ holes: Array>,
+): BufferGeometry {
+ if (polygon.length < 3) return new BufferGeometry()
+ const shape = new Shape()
+ const [firstX, firstZ] = polygon[0]!
+ shape.moveTo(firstX, -firstZ)
+ for (let i = 1; i < polygon.length; i++) {
+ const [x, z] = polygon[i]!
+ shape.lineTo(x, -z)
+ }
+ shape.closePath()
+ for (const holePolygon of holes) {
+ if (holePolygon.length < 3) continue
+ const hole = new Path()
+ const [hx, hz] = holePolygon[0]!
+ hole.moveTo(hx, -hz)
+ for (let i = 1; i < holePolygon.length; i++) {
+ const [x, z] = holePolygon[i]!
+ hole.lineTo(x, -z)
+ }
+ hole.closePath()
+ shape.holes.push(hole)
+ }
+ const geometry = new ShapeGeometry(shape)
+ geometry.rotateX(-Math.PI / 2)
+ geometry.computeVertexNormals()
+ return geometry
+}
+
+function createOutline(polygon: Array<[number, number]>): BufferGeometry {
+ const geometry = new BufferGeometry()
+ if (polygon.length < 2) return geometry
+ const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
+ const [firstX, firstZ] = polygon[0]!
+ points.push(new Vector3(firstX, 0, firstZ))
+ geometry.setFromPoints(points)
+ return geometry
+}
+
+export default CeilingMoveTool
diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx
new file mode 100644
index 00000000..84226f1f
--- /dev/null
+++ b/packages/nodes/src/ceiling/tool.tsx
@@ -0,0 +1,388 @@
+'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 { mix, positionLocal } from 'three/tsl'
+import { CeilingNode } from './schema'
+
+/**
+ * Phase 5 Stage D — ceiling placement tool (kind-owned via `def.tool`).
+ *
+ * Multi-click polygon drawing at the ceiling height (2.52m default)
+ * with a vertical TSL-gradient connector + ground-shadow lines so the
+ * draft is visible against both the ceiling plane and the floor.
+ * Shift defeats the axis/45° snap during drag.
+ */
+
+const CEILING_HEIGHT = 2.52
+const GRID_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 commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string {
+ const { createNode, nodes } = useScene.getState()
+ const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
+ const name = `Ceiling ${ceilingCount + 1}`
+ const ceiling = CeilingNode.parse({ name, polygon: points })
+ createNode(ceiling, levelId)
+ triggerSFX('sfx:structure-build')
+ return ceiling.id
+}
+
+export const CeilingTool: React.FC = () => {
+ const cursorRef = useRef(null)
+ const gridCursorRef = useRef(null)
+ const mainLineRef = useRef(null!)
+ const closingLineRef = useRef(null!)
+ const groundMainLineRef = useRef(null!)
+ const groundClosingLineRef = useRef(null!)
+ const verticalLineRef = 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)
+
+ const verticalGeo = useMemo(
+ () =>
+ new BufferGeometry().setFromPoints([
+ new Vector3(0, 0, 0),
+ new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
+ ]),
+ [],
+ )
+
+ const gradientOpacityNode = useMemo(
+ () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
+ [],
+ )
+
+ useEffect(() => {
+ if (!currentLevelId) return
+
+ const onGridMove = (event: GridEvent) => {
+ if (!(cursorRef.current && gridCursorRef.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 ceilingY = event.localPosition[1] + CEILING_HEIGHT
+ const gridY = event.localPosition[1] + GRID_OFFSET
+ 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], ceilingY, displayPoint[1])
+ gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
+ if (verticalLineRef.current) {
+ verticalLineRef.current.position.set(displayPoint[0], gridY, 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 ceilingId = commitCeilingDrawing(currentLevelId, points)
+ setSelection({ selectedIds: [ceilingId] })
+ setPoints([])
+ } else {
+ setPoints([...points, clickPoint])
+ }
+ }
+
+ const onGridDoubleClick = (_event: GridEvent) => {
+ if (!currentLevelId) return
+ if (points.length >= 3) {
+ const ceilingId = commitCeilingDrawing(currentLevelId, points)
+ setSelection({ selectedIds: [ceilingId] })
+ 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
+ groundMainLineRef.current && (groundMainLineRef.current.visible = false)
+ groundClosingLineRef.current && (groundClosingLineRef.current.visible = false)
+ return
+ }
+ const ceilingY = levelY + CEILING_HEIGHT
+ const snappedCursor = snappedCursorPosition
+ const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
+ linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
+ const gridY = levelY + GRID_OFFSET
+ const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
+ groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
+ if (linePoints.length >= 2) {
+ mainLineRef.current.geometry.dispose()
+ mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
+ mainLineRef.current.visible = true
+ groundMainLineRef.current.geometry.dispose()
+ groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
+ groundMainLineRef.current.visible = true
+ } else {
+ mainLineRef.current.visible = false
+ groundMainLineRef.current.visible = false
+ }
+ const firstPoint = points[0]
+ if (points.length >= 2 && firstPoint) {
+ const closingPoints = [
+ new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
+ new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
+ ]
+ closingLineRef.current.geometry.dispose()
+ closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
+ closingLineRef.current.visible = true
+ const groundClosingPoints = [
+ new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
+ new Vector3(firstPoint[0], gridY, firstPoint[1]),
+ ]
+ groundClosingLineRef.current.geometry.dispose()
+ groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
+ groundClosingPoints,
+ )
+ groundClosingLineRef.current.visible = true
+ } else {
+ closingLineRef.current.visible = false
+ groundClosingLineRef.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 (
+
+
+
+
+
+
+ {/* @ts-ignore */}
+
+
+
+ {previewShape && (
+
+
+
+
+ )}
+ {previewShape && (
+
+
+
+
+ )}
+ {/* @ts-ignore */}
+
+
+
+
+ {/* @ts-ignore */}
+
+
+
+
+ {/* @ts-ignore */}
+
+
+
+
+ {/* @ts-ignore */}
+
+
+
+
+ {points.map(([x, z], index) => (
+
+ ))}
+
+ )
+}
+
+export default CeilingTool