From b2e5d84986abf9f0b8f6e5d3093ca9973c6b0ead Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 18:27:31 -0400 Subject: [PATCH] Phase 5 Stage D slab: port placement + move + boundary/hole editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates the fence Stage D recipe for slab — three drag affordances + one placement tool, all routed through the registry: affordanceTools: 'boundary-edit' → thin 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) --- .../src/components/tools/tool-manager.tsx | 29 +- packages/editor/src/index.tsx | 5 + packages/nodes/src/slab/actions/move.ts | 151 ++++++++++ packages/nodes/src/slab/boundary-editor.tsx | 48 ++++ packages/nodes/src/slab/definition.ts | 13 + packages/nodes/src/slab/hole-editor.tsx | 53 ++++ packages/nodes/src/slab/move-tool.tsx | 66 +++++ packages/nodes/src/slab/tool.tsx | 266 ++++++++++++++++++ 8 files changed, 627 insertions(+), 4 deletions(-) create mode 100644 packages/nodes/src/slab/actions/move.ts create mode 100644 packages/nodes/src/slab/boundary-editor.tsx create mode 100644 packages/nodes/src/slab/hole-editor.tsx create mode 100644 packages/nodes/src/slab/move-tool.tsx create mode 100644 packages/nodes/src/slab/tool.tsx 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