From 7b5a1c607ae0ed49192e40eee7b3de98cb6e4dfb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 18:22:57 -0400 Subject: [PATCH] Phase 5 Stage D fence: port FenceTool placement to def.tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth and final Stage D fence affordance — the placement tool itself. Unlike the drag affordances (curve / move / endpoint), placement is a two-click flow with state across grid events, not a single drag-down → drag-up lifecycle. `DragAction` doesn't fit; the component owns its own emitter subscriptions directly. The kind exposes it via `def.tool: () => import('./tool')` and ToolManager's existing `getRegistryTool()` lookup mounts it (legacy `tools.structure.fence = FenceTool` falls through when the registry entry is missing). Adds transitional exports from `@pascal-app/editor` for the helpers the kind-owned tool needs at module scope: `createFenceOnCurrentLevel`, `markToolCancelConsumed`, `EDITOR_LAYER`. Stage F cleanup moves these into `@pascal-app/nodes` once every consumer is registry-driven. Fence Stage D is now complete. Per-kind progress: A ✅ B ✅ C ✅ D ✅ (four affordances ported — curve, move-endpoint, move, placement). E (drop legacy panel) and F (cleanup) pending across all kinds. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/editor/src/index.tsx | 9 +- packages/nodes/src/fence/definition.ts | 7 + packages/nodes/src/fence/tool.tsx | 328 +++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/fence/tool.tsx diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index afc95f9c..2adf1bd7 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -8,7 +8,11 @@ export { // consumed by kind-owned drag actions in @pascal-app/nodes. Stage F // cleanup moves these into @pascal-app/nodes (fence/drafting.ts + // shared/segment-angle.ts) once every Stage D port is in. -export { type FencePlanPoint, snapFenceDraftPoint } from './components/tools/fence/fence-drafting' +export { + createFenceOnCurrentLevel, + type FencePlanPoint, + snapFenceDraftPoint, +} from './components/tools/fence/fence-drafting' export { CursorSphere } from './components/tools/shared/cursor-sphere' export { formatAngleRadians, @@ -42,6 +46,9 @@ export type { SaveStatus } from './hooks/use-auto-save' // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) // can express their affordances declaratively in their own folder. export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' +// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). +export { markToolCancelConsumed } from './hooks/use-keyboard' +export { EDITOR_LAYER } from './lib/constants' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 869e0519..f29dc289 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -58,6 +58,13 @@ export const fenceDefinition: NodeDefinition = { parametrics: fenceParametrics, + // Stage D: kind-owned placement tool. Two-click flow (start → end) + // with live preview, length / angle HUD, snap to walls / fences / + // grid. Mounted by ToolManager when `useEditor.tool === 'fence'` + // via the registry's getRegistryTool() — falls back to the legacy + // tools[phase][tool] map when missing. + tool: () => import('./tool'), + // Stage B: pure geometry function. Generic rebuilds // on dirtyNodes; mounts the empty group. // `renderer` + `system` fields dropped along with their files. diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx new file mode 100644 index 00000000..032f46b4 --- /dev/null +++ b/packages/nodes/src/fence/tool.tsx @@ -0,0 +1,328 @@ +'use client' + +import { + emitter, + type FenceNode, + type GridEvent, + type LevelNode, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + CursorSphere, + createFenceOnCurrentLevel, + EDITOR_LAYER, + type FencePlanPoint, + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, + markToolCancelConsumed, + snapFenceDraftPoint, + triggerSFX, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' + +/** + * Phase 5 Stage D — fence placement tool (kind-owned via `def.tool`). + * + * Replaces the legacy `FenceTool` (346 LoC). Two-click placement flow: + * click 1 sets the start, click 2 creates the fence. Between clicks a + * preview rectangle and a length / angle measurement HUD follow the + * pointer (shift defeats angle snap). + * + * Not a `DragAction` — placement isn't a single pointer-drag, it's a + * sequence of discrete grid:click events with preview state across + * them. `useDragAction` doesn't fit; this component owns the lifecycle + * directly. The registry routes activation here via `def.tool`. + */ + +const FENCE_PREVIEW_HEIGHT = 1.8 +const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 +const DRAFT_ANGLE_LABEL_Y = 0.28 + +type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] +} + +type DraftMeasurementState = { + lengthLabel: string + lengthPosition: [number, number, number] + angleLabels: DraftAngleLabel[] +} | null + +type SegmentLike = { + id: string + start: FencePlanPoint + end: FencePlanPoint + curveOffset?: number +} + +function formatMeasurement(value: number, unit: 'metric' | 'imperial') { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + return `${Number.parseFloat(value.toFixed(2))}m` +} + +function getDraftAngleLabels( + start: FencePlanPoint, + end: FencePlanPoint, + segments: SegmentLike[], +): DraftAngleLabel[] { + const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] + const endpoints = [ + { id: 'start', point: start, draftVector: draftFromStart }, + { id: 'end', point: end, draftVector: draftFromEnd }, + ] + const labels: DraftAngleLabel[] = [] + for (const endpoint of endpoints) { + const connectedSegment = segments.find((segment) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), + ) + if (!connectedSegment) continue + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) + if (!connectedReference) continue + const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + if (angle === null) continue + labels.push({ + id: endpoint.id, + label: formatAngleRadians(angle), + position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + }) + } + return labels +} + +function getDraftMeasurementState( + start: FencePlanPoint, + end: FencePlanPoint, + segments: SegmentLike[], + unit: 'metric' | 'imperial', +): DraftMeasurementState { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 0.01) return null + return { + lengthLabel: formatMeasurement(length, unit), + lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, segments), + } +} + +function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { + return [ + ...walls.map((w) => ({ id: w.id, start: w.start, end: w.end, curveOffset: w.curveOffset })), + ...fences.map((f) => ({ id: f.id, start: f.start, end: f.end, curveOffset: f.curveOffset })), + ] +} + +function updateFencePreview(mesh: Mesh, start: Vector3, end: Vector3) { + const direction = new Vector3(end.x - start.x, 0, end.z - start.z) + const length = direction.length() + if (length < 0.01) { + mesh.visible = false + return + } + mesh.visible = true + direction.normalize() + const shape = new Shape() + shape.moveTo(0, 0) + shape.lineTo(length, 0) + shape.lineTo(length, FENCE_PREVIEW_HEIGHT) + shape.lineTo(0, FENCE_PREVIEW_HEIGHT) + shape.closePath() + const geometry = new ShapeGeometry(shape) + const angle = -Math.atan2(direction.z, direction.x) + mesh.position.set(start.x, start.y, start.z) + mesh.rotation.y = angle + if (mesh.geometry) mesh.geometry.dispose() + mesh.geometry = geometry +} + +function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { + const currentLevelId = useViewer.getState().selection.levelId + const { nodes } = useScene.getState() + if (!currentLevelId) return { walls: [], fences: [] } + const levelNode = nodes[currentLevelId] + if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] } + const children = (levelNode as LevelNode).children.map((childId) => nodes[childId]) + return { + walls: children.filter((n): n is WallNode => n?.type === 'wall'), + fences: children.filter((n): n is FenceNode => n?.type === 'fence'), + } +} + +export const FenceTool: React.FC = () => { + const unit = useViewer((s) => s.unit) + const cursorRef = useRef(null) + const previewRef = useRef(null!) + const startingPoint = useRef(new Vector3(0, 0, 0)) + const endingPoint = useRef(new Vector3(0, 0, 0)) + const buildingState = useRef(0) + const shiftPressed = useRef(false) + const [draftMeasurement, setDraftMeasurement] = useState(null) + + useEffect(() => { + let previousFenceEnd: FencePlanPoint | null = null + + const onGridMove = (event: GridEvent) => { + if (!(cursorRef.current && previewRef.current)) return + const { walls, fences } = getCurrentLevelElements() + const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] + + if (buildingState.current === 1) { + const snappedLocal = snapFenceDraftPoint({ + point: localPoint, + walls, + fences, + start: [startingPoint.current.x, startingPoint.current.z], + angleSnap: !shiftPressed.current, + }) + endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + cursorRef.current.position.copy(endingPoint.current) + const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] + if ( + previousFenceEnd && + (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousFenceEnd = currentFenceEnd + updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) + setDraftMeasurement( + getDraftMeasurementState( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + getReferenceSegments(walls, fences), + unit, + ), + ) + } else { + const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences }) + cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) + setDraftMeasurement(null) + } + } + + const onGridClick = (event: GridEvent) => { + const { walls, fences } = getCurrentLevelElements() + const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] + + if (buildingState.current === 0) { + const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences }) + startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + endingPoint.current.copy(startingPoint.current) + buildingState.current = 1 + previewRef.current.visible = true + setDraftMeasurement(null) + } else { + const snappedEnd = snapFenceDraftPoint({ + point: localClick, + walls, + fences, + start: [startingPoint.current.x, startingPoint.current.z], + angleSnap: !shiftPressed.current, + }) + const dx = snappedEnd[0] - startingPoint.current.x + const dz = snappedEnd[1] - startingPoint.current.z + if (dx * dx + dz * dz < 0.01 * 0.01) return + createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) + previewRef.current.visible = false + buildingState.current = 0 + setDraftMeasurement(null) + } + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = true + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = false + } + + const onCancel = () => { + if (buildingState.current === 1) { + markToolCancelConsumed() + buildingState.current = 0 + if (previewRef.current) previewRef.current.visible = false + setDraftMeasurement(null) + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [unit]) + + return ( + + + + + + + {draftMeasurement && ( + <> + + {draftMeasurement.angleLabels.map((angleLabel) => ( + + ))} + + )} + + ) +} + +function DraftMeasurementLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} + +export default FenceTool