From 56e022a0931a799a7dc5e6bfec77f648de438cad Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 18 May 2026 10:52:30 -0400 Subject: [PATCH] Phase 5 Stage D wall: port endpoint move, whole-move, placement (1:1 legacy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three remaining wall affordances ported into nodes/src/wall/ as direct copies of the legacy implementations. Wall D is now complete. - move-endpoint-tool.tsx (426 LoC) — linked-wall corner cascade + Alt-detach + angle label. Mounted via `affordanceTools.move-endpoint`. - move-tool.tsx (804 LoC, the most complex tool in the editor) — center-drag with axis lock, linked-wall corner cascade via `planWallMoveJunctions`, bridge wall ghost previews, auto-slab live preview via `planAutoSlabsForLevel`, R/T rotation in 45° steps, Shift to bypass grid snap, isNew metadata strip on first commit. Mounted via `affordanceTools.move`. - tool.tsx (332 LoC) — two-click placement with length/angle HUD, Shift to bypass angle snap. Mounted via `def.tool`. Editor public surface gains: - createWallOnCurrentLevel, snapWallDraftPoint, WallPlanPoint - MovingWallEndpoint type ToolManager dispatch for `movingWallEndpoint` routes through the registry with the legacy fallback (same shape as the fence move-endpoint dispatch). Wall is now A ✅ C ✅ D ✅. Stage B still pending (geometry depends on level-batch miter data, blocked on `ctx.levelData` design decision). Stage E pending (drop WallPanel — has slider drags + actions). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/tools/tool-manager.tsx | 15 +- packages/editor/src/index.tsx | 4 + packages/nodes/src/wall/definition.ts | 14 +- .../nodes/src/wall/move-endpoint-tool.tsx | 431 +++++++++ packages/nodes/src/wall/move-tool.tsx | 833 ++++++++++++++++++ packages/nodes/src/wall/tool.tsx | 313 +++++++ 6 files changed, 1604 insertions(+), 6 deletions(-) create mode 100644 packages/nodes/src/wall/move-endpoint-tool.tsx create mode 100644 packages/nodes/src/wall/move-tool.tsx create mode 100644 packages/nodes/src/wall/tool.tsx diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index b8d69862..fc8b49ef 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -229,7 +229,20 @@ export const ToolManager: React.FC = () => { ) })()} - {movingWallEndpoint && } + {movingWallEndpoint && + (() => { + const RegistryAffordance = getRegistryAffordanceTool( + movingWallEndpoint.wall.type, + 'move-endpoint', + ) + return RegistryAffordance ? ( + + + + ) : ( + + ) + })()} {movingFenceEndpoint && (() => { const RegistryAffordance = getRegistryAffordanceTool( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 2d88a021..bf9a9e04 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -25,9 +25,12 @@ export { getSegmentAngleReferenceAtPoint, } from './components/tools/shared/segment-angle' export { + createWallOnCurrentLevel, getWallGridStep, isWallLongEnough, snapScalarToGrid, + snapWallDraftPoint, + type WallPlanPoint, } from './components/tools/wall/wall-drafting' export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' @@ -74,6 +77,7 @@ export { type CommandAction, useCommandRegistry } from './store/use-command-regi export type { FloorplanSelectionTool, MovingFenceEndpoint, + MovingWallEndpoint, SplitOrientation, ViewMode, } from './store/use-editor' diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index aa08e0fb..42c5b60b 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -57,13 +57,17 @@ export const wallDefinition: NodeDefinition = { parametrics: wallParametrics, - // Stage D — wall curve is a 1:1 port of the legacy CurveWallTool, - // relocated into this folder and dispatched via the registry. Endpoint - // move (linked-wall corner cascade + ALT-detach), whole-wall move, and - // placement are still legacy — they're substantially larger and queued - // for separate port passes. + // Stage D — all four wall drag affordances live in this folder. + // curve / move-endpoint / move are 1:1 ports of the legacy tools + // (same snap pipelines, linked-wall corner cascade with + // `planWallMoveJunctions`, ALT-detach, bridge wall previews, + // auto-slab live preview, history dances). Placement is wired via + // `def.tool`. + tool: () => import('./tool'), affordanceTools: { curve: () => import('./curve-tool'), + 'move-endpoint': () => import('./move-endpoint-tool'), + move: () => import('./move-tool'), }, renderer: { diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx new file mode 100644 index 00000000..bc5183cf --- /dev/null +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -0,0 +1,431 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + CursorSphere, + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, + isWallLongEnough, + type MovingWallEndpoint, + markToolCancelConsumed, + snapWallDraftPoint, + triggerSFX, + useEditor, + type WallPlanPoint, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * Phase 5 Stage D — wall endpoint move tool (kind-owned). + * + * 1:1 port of the legacy `MoveWallEndpointTool` (426 LoC, the largest + * single tool in wall/). Same drag pipeline (snap → preview → apply + * with linked-wall corner cascade), same Alt-detach modifier, same + * angle label between the dragged segment and any neighbour sharing + * the endpoint, same single-undo dance on commit, same activation + * grace window. + * + * Mounted via `def.affordanceTools['move-endpoint']` from + * `wall/definition.ts`. Editor state trigger is + * `useEditor.movingWallEndpoint`. + */ +function samePoint(a: WallPlanPoint, b: WallPlanPoint) { + return a[0] === b[0] && a[1] === b[1] +} + +type WallSegmentLike = { + id: WallNode['id'] + start: WallPlanPoint + end: WallPlanPoint + curveOffset?: number +} + +type AngleLabelState = { + label: string + position: [number, number, number] +} | null + +function getEndpointAngleLabel(args: { + preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number } + walls: WallSegmentLike[] + nodeId: WallNode['id'] +}): AngleLabelState { + const { preview, walls, nodeId } = args + const endpoints = [{ point: preview.start }, { point: preview.end }] + const targetSegment: WallSegmentLike = { + id: nodeId, + start: preview.start, + end: preview.end, + curveOffset: preview.curveOffset, + } + + for (const endpoint of endpoints) { + const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment) + if (!targetReference) continue + + const connectedWall = walls.find( + (wall) => + wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(targetReference.vector, connectedReference) + if (angle === null) continue + + return { + label: formatAngleRadians(angle), + position: [endpoint.point[0], 0.34, endpoint.point[1]], + } + } + + return null +} + +type LinkedWallSnapshot = { + id: WallNode['id'] + start: WallPlanPoint + end: WallPlanPoint + curveOffset?: number +} + +function getLinkedWallSnapshots(args: { + wallId: WallNode['id'] + wallParentId: string | null + originalStart: WallPlanPoint + originalEnd: WallPlanPoint +}) { + const { wallId, wallParentId, originalStart, originalEnd } = args + const { nodes } = useScene.getState() + const snapshots: LinkedWallSnapshot[] = [] + + for (const node of Object.values(nodes)) { + if (!(node?.type === 'wall' && node.id !== wallId)) continue + if ((node.parentId ?? null) !== wallParentId) continue + if ( + !( + samePoint(node.start, originalStart) || + samePoint(node.start, originalEnd) || + samePoint(node.end, originalStart) || + samePoint(node.end, originalEnd) + ) + ) + continue + + snapshots.push({ + id: node.id, + start: [...node.start] as WallPlanPoint, + end: [...node.end] as WallPlanPoint, + curveOffset: node.curveOffset, + }) + } + + return snapshots +} + +function getLinkedWallUpdates( + linkedWalls: LinkedWallSnapshot[], + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +) { + return linkedWalls.map((wall) => ({ + id: wall.id, + curveOffset: wall.curveOffset, + start: samePoint(wall.start, originalStart) + ? nextStart + : samePoint(wall.start, originalEnd) + ? nextEnd + : wall.start, + end: samePoint(wall.end, originalStart) + ? nextStart + : samePoint(wall.end, originalEnd) + ? nextEnd + : wall.end, + })) +} + +export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => { + const activatedAtRef = useRef(Date.now()) + const previousGridPosRef = useRef(null) + const shiftPressedRef = useRef(false) + const altPressedRef = useRef(false) + const nodeIdRef = useRef(target.wall.id) + const originalStartRef = useRef([...target.wall.start] as WallPlanPoint) + const originalEndRef = useRef([...target.wall.end] as WallPlanPoint) + const fixedPointRef = useRef( + target.endpoint === 'start' + ? ([...target.wall.end] as WallPlanPoint) + : ([...target.wall.start] as WallPlanPoint), + ) + const linkedOriginalsRef = useRef( + getLinkedWallSnapshots({ + wallId: target.wall.id, + wallParentId: target.wall.parentId ?? null, + originalStart: target.wall.start, + originalEnd: target.wall.end, + }), + ) + const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null) + const [angleLabel, setAngleLabel] = useState(null) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const point = target.endpoint === 'start' ? target.wall.start : target.wall.end + return [point[0], 0, point[1]] + }) + const [altPressed, setAltPressed] = useState(false) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingWallEndpoint(null) + }, []) + + useEffect(() => { + const nodeId = nodeIdRef.current + const originalStart = originalStartRef.current + const originalEnd = originalEndRef.current + const fixedPoint = fixedPointRef.current + const levelWalls = Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), + ) + + pauseSceneHistory(useScene) + let wasCommitted = false + + const applyNodePreview = ( + updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>, + ) => { + useScene.getState().updateNodes( + updates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ) + for (const entry of updates) { + useScene.getState().markDirty(entry.id as AnyNodeId) + } + } + + const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { + const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint + const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint + const linkedUpdates = detachLinkedWalls + ? [] + : getLinkedWallUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + previewRef.current = { start: nextStart, end: nextEnd } + setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) + setAngleLabel( + getEndpointAngleLabel({ + preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset }, + walls: [ + ...levelWalls.map((wall) => ({ + id: wall.id, + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + })), + ...linkedUpdates, + ], + nodeId, + }), + ) + applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates]) + } + + const restoreOriginal = (clearAngleLabel = true) => { + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + if (clearAngleLabel) { + setAngleLabel(null) + } + } + + const onGridMove = (event: GridEvent) => { + const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + const snappedPoint = snapWallDraftPoint({ + point: planPoint, + walls: levelWalls, + start: fixedPoint, + angleSnap: !shiftPressedRef.current, + ignoreWallIds: [nodeId], + }) + + if ( + previousGridPosRef.current && + (snappedPoint[0] !== previousGridPosRef.current[0] || + snappedPoint[1] !== previousGridPosRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = snappedPoint + + applyPreview(snappedPoint, event.nativeEvent.altKey) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const preview = previewRef.current ?? { start: originalStart, end: originalEnd } + const hasChanged = !( + samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd) + ) + + if (hasChanged && isWallLongEnough(preview.start, preview.end)) { + wasCommitted = true + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + + resumeSceneHistory(useScene) + applyNodePreview([ + { id: nodeId, start: preview.start, end: preview.end }, + ...(altPressedRef.current + ? [] + : getLinkedWallUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + preview.start, + preview.end, + )), + ]) + pauseSceneHistory(useScene) + triggerSFX('sfx:item-place') + } + + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + setAngleLabel(null) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + resumeSceneHistory(useScene) + setAngleLabel(null) + markToolCancelConsumed() + exitMoveMode() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + if (event.key === 'Shift') { + shiftPressedRef.current = true + } + if (event.key === 'Alt') { + altPressedRef.current = true + setAltPressed(true) + } + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressedRef.current = false + } + if (event.key === 'Alt') { + altPressedRef.current = false + setAltPressed(false) + } + } + + const onWindowBlur = () => { + shiftPressedRef.current = false + altPressedRef.current = false + setAltPressed(false) + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onWindowBlur) + + return () => { + if (!wasCommitted) { + restoreOriginal(false) + } + resumeSceneHistory(useScene) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onWindowBlur) + } + }, [exitMoveMode, target]) + + return ( + + + +
+
+ {altPressed ? 'Detaching corner' : 'Alt to detach'} +
+
+ + {angleLabel && } +
+ ) +} + +function EndpointAngleLabel({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} + +export default MoveWallEndpointTool diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx new file mode 100644 index 00000000..02624e9a --- /dev/null +++ b/packages/nodes/src/wall/move-tool.tsx @@ -0,0 +1,833 @@ +'use client' + +import { + type AnyNodeId, + constrainWallMoveDeltaToAxis, + DEFAULT_WALL_HEIGHT, + detectSpacesForLevel, + emitter, + type GridEvent, + getMaterialPresetByRef, + getPerpendicularWallMoveAxis, + pauseSceneHistory, + planAutoSlabsForLevel, + planWallMoveJunctions, + resolveMaterial, + resumeSceneHistory, + type SlabNode, + useScene, + type WallMoveAxis, + type WallMoveBridgePlan, + type WallMoveJunctionPlan, + type WallNode, + WallNode as WallSchema, +} from '@pascal-app/core' +import { + CursorSphere, + EDITOR_LAYER, + getWallGridStep, + isWallLongEnough, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three' + +/** + * Phase 5 Stage D — wall whole-move tool (kind-owned). + * + * 1:1 port of the legacy `MoveWallTool` (804 LoC, the most complex + * single tool in the editor). Preserves every behavior: + * + * - **Center-drag with axis lock** — wall stays perpendicular to its + * move axis unless rotated via R/T (45° steps). + * - **Linked-wall corner cascade** — neighbours sharing endpoints + * move with the dragged wall via `planWallMoveJunctions`. + * - **Bridge wall ghost previews** — when a corner separates, a + * translucent ghost shows the new wall that would be inserted. + * - **Auto-slab live preview** — `planAutoSlabsForLevel` runs every + * tick so room slabs adapt to the new wall layout in real time. + * - **Single-undo dance** — paused history during drag, restore + + * resume + reapply on commit so one Ctrl-Z rolls back the whole + * operation. + * - **`isNew` metadata strip** — first commit after a fresh wall + * placement clears the placement marker. + * - **Activation grace** (150ms) + Shift to bypass grid snap. + * + * Mounted via `def.affordanceTools.move` from `wall/definition.ts`. + */ +function rotateVector([x, z]: [number, number], angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos - z * sin, x * sin + z * cos] +} + +function samePoint(a: [number, number], b: [number, number]) { + return a[0] === b[0] && a[1] === b[1] +} + +function pointKey(point: [number, number]) { + return `${point[0]}:${point[1]}` +} + +function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return meta + } + + const nextMeta = { ...(meta as Record) } as Record + delete nextMeta.isNew + return nextMeta as WallNode['metadata'] +} + +type LinkedWallSnapshot = WallNode + +type GhostWallPreview = { + id: string + start: [number, number] + end: [number, number] + color: string + height: number +} + +function getLinkedWallSnapshots(args: { + wallId: WallNode['id'] + wallParentId: string | null + originalStart: [number, number] + originalEnd: [number, number] +}) { + const { wallId, wallParentId, originalStart, originalEnd } = args + const { nodes } = useScene.getState() + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId, + ) + const directlyLinkedWalls = walls.filter( + (wall) => + samePoint(wall.start, originalStart) || + samePoint(wall.start, originalEnd) || + samePoint(wall.end, originalStart) || + samePoint(wall.end, originalEnd), + ) + const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)]) + + for (const wall of directlyLinkedWalls) { + contextPoints.add(pointKey(wall.start)) + contextPoints.add(pointKey(wall.end)) + } + + const snapshots: LinkedWallSnapshot[] = [] + const seenWallIds = new Set() + + for (const node of walls) { + if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) { + continue + } + + if (seenWallIds.has(node.id)) { + continue + } + seenWallIds.add(node.id) + + snapshots.push({ + ...node, + start: [...node.start] as [number, number], + end: [...node.end] as [number, number], + children: [...(node.children ?? [])], + }) + } + + return snapshots +} + +function getLinkedWallUpdates( + linkedWalls: Array<{ + wall: LinkedWallSnapshot + matchPoint?: [number, number] + targetPoint?: [number, number] + }>, + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + return linkedWalls.map(({ wall, matchPoint, targetPoint }) => { + if (matchPoint && targetPoint) { + return { + id: wall.id, + start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start, + end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end, + } + } + + const targetStart = targetPoint ?? nextStart + const targetEnd = targetPoint ?? nextEnd + + return { + id: wall.id, + start: samePoint(wall.start, originalStart) + ? targetStart + : samePoint(wall.start, originalEnd) + ? targetEnd + : wall.start, + end: samePoint(wall.end, originalStart) + ? targetStart + : samePoint(wall.end, originalEnd) + ? targetEnd + : wall.end, + } + }) +} + +function getPlannedLinkedWallUpdates( + plan: WallMoveJunctionPlan, + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + const movePlans = new Map< + WallNode['id'], + { wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] } + >() + + for (const wall of plan.linkedWallsToMove) { + movePlans.set(wall.id, { wall }) + } + + for (const targetPlan of plan.linkedWallTargetPlans) { + movePlans.set(targetPlan.wall.id, { + wall: targetPlan.wall, + matchPoint: targetPlan.originalPoint, + targetPoint: targetPlan.targetPoint, + }) + } + + return getLinkedWallUpdates( + Array.from(movePlans.values()), + originalStart, + originalEnd, + nextStart, + nextEnd, + ) +} + +function wallSegmentExists( + walls: Array>, + start: [number, number], + end: [number, number], +) { + return walls.some( + (wall) => + (samePoint(wall.start, start) && samePoint(wall.end, end)) || + (samePoint(wall.start, end) && samePoint(wall.end, start)), + ) +} + +function getWallGhostColor(wall: WallNode) { + const presetColor = + getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ?? + getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ?? + getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color + + if (presetColor) { + return presetColor + } + + return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color +} + +function getWallsAfterUpdates( + nodes: ReturnType['nodes'], + updates: Array<{ id: AnyNodeId; data: Partial }>, +) { + const updateById = new Map(updates.map((update) => [update.id, update.data])) + + return Object.values(nodes) + .filter((node): node is WallNode => node?.type === 'wall') + .map((wall) => { + const update = updateById.get(wall.id as AnyNodeId) + return update ? ({ ...wall, ...update } as WallNode) : wall + }) +} + +function cloneSlabSnapshot(slab: SlabNode): SlabNode { + return { + ...slab, + polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]), + holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), + holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })), + } +} + +function getLevelSlabs(levelId: string, nodes: ReturnType['nodes']) { + return Object.values(nodes).filter( + (entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId, + ) +} + +function getLevelAutoSlabs(levelId: string, nodes: ReturnType['nodes']) { + return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls) +} + +function getLevelAutoSlabSnapshots(levelId: string) { + return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot) +} + +function buildBridgeWallCreates(args: { + bridgePlans: Array> + nextStart: [number, number] + nextEnd: [number, number] + existingWalls: WallNode[] + wallCount: number +}): Array<{ node: WallNode; parentId?: AnyNodeId }> { + const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args + const wallsForDuplicateCheck = [...existingWalls] + const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = [] + + for (const plan of bridgePlans) { + const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd + + if (!isWallLongEnough(plan.originalPoint, nextPoint)) { + continue + } + + if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { + continue + } + + const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall + const bridgeWall = WallSchema.parse({ + ...sourceWall, + name: `Wall ${wallCount + creates.length + 1}`, + start: plan.originalPoint, + end: nextPoint, + children: [], + metadata: stripWallIsNewMetadata(plan.wall.metadata), + }) + + creates.push({ + node: bridgeWall, + parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined, + }) + wallsForDuplicateCheck.push(bridgeWall) + } + + return creates +} + +function buildBridgeWallPreviews(args: { + bridgePlans: Array> + nextStart: [number, number] + nextEnd: [number, number] + existingWalls: WallNode[] +}): Array<{ ghost: GhostWallPreview; wall: WallNode }> { + const { bridgePlans, nextStart, nextEnd, existingWalls } = args + const wallsForDuplicateCheck: Array> = [...existingWalls] + const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = [] + + for (const plan of bridgePlans) { + const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd + + if (!isWallLongEnough(plan.originalPoint, nextPoint)) { + continue + } + + if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { + continue + } + + const { id: _id, children: _children, ...sourceWall } = plan.wall + const wall = WallSchema.parse({ + ...sourceWall, + name: 'Wall Preview', + start: plan.originalPoint, + end: nextPoint, + children: [], + metadata: stripWallIsNewMetadata(plan.wall.metadata), + }) + const ghost = { + id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`, + start: [...plan.originalPoint] as [number, number], + end: [...nextPoint] as [number, number], + color: getWallGhostColor(plan.wall), + height: plan.wall.height ?? DEFAULT_WALL_HEIGHT, + } + previews.push({ ghost, wall }) + wallsForDuplicateCheck.push(wall) + } + + return previews +} + +function setPreviewGeometryAttributes( + geometry: BufferGeometry, + positions: number[], + normals: number[], + uvs: number[], +) { + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2)) +} + +function createWallPreviewGeometry(length: number, height: number) { + const geometry = new BufferGeometry() + setPreviewGeometryAttributes( + geometry, + [0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0], + [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], + [0, 0, 1, 0, 1, 1, 0, 1], + ) + geometry.setIndex([0, 1, 2, 0, 2, 3]) + geometry.computeBoundingSphere() + return geometry +} + +function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { + const dx = preview.end[0] - preview.start[0] + const dz = preview.end[1] - preview.start[1] + const length = Math.hypot(dx, dz) + const angle = -Math.atan2(dz, dx) + const geometry = useMemo(() => { + return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height) + }, [length, preview.height]) + + useEffect(() => () => geometry?.dispose(), [geometry]) + + if (!geometry) { + return null + } + + return ( + + + + + + + ) +} + +export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { + const meta = + typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + const activatedAtRef = useRef(Date.now()) + const previousGridPosRef = useRef<[number, number] | null>(null) + const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) + const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) + const originalCenterRef = useRef<[number, number]>([ + (node.start[0] + node.end[0]) / 2, + (node.start[1] + node.end[1]) / 2, + ]) + const originalHalfVectorRef = useRef<[number, number]>([ + (node.end[0] - node.start[0]) / 2, + (node.end[1] - node.start[1]) / 2, + ]) + const moveAxisRef = useRef( + getPerpendicularWallMoveAxis(node.start, node.end), + ) + const linkedOriginalsRef = useRef( + isNew + ? [] + : getLinkedWallSnapshots({ + wallId: node.id, + wallParentId: node.parentId ?? null, + originalStart: node.start, + originalEnd: node.end, + }), + ) + const originalAutoSlabsRef = useRef( + node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [], + ) + const dragAnchorRef = useRef<[number, number] | null>(null) + const nodeIdRef = useRef(node.id) + const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + const pendingRotationRef = useRef(0) + const shiftPressedRef = useRef(false) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const centerX = (node.start[0] + node.end[0]) / 2 + const centerZ = (node.start[1] + node.end[1]) / 2 + return [centerX, 0, centerZ] + }) + const [ghostWallPreviews, setGhostWallPreviews] = useState([]) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + const nodeId = nodeIdRef.current + const originalStart = originalStartRef.current + const originalEnd = originalEndRef.current + const originalCenter = originalCenterRef.current + const originalHalfVector = originalHalfVectorRef.current + const levelId = node.parentId ?? null + const originalAutoSlabs = originalAutoSlabsRef.current + + pauseSceneHistory(useScene) + let shouldRestoreOnCleanup = true + + const applyNodePreview = ( + updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, + ) => { + useScene.getState().updateNodes( + updates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ) + for (const entry of updates) { + useScene.getState().markDirty(entry.id as AnyNodeId) + } + } + + const applyLiveAutoSlabPreview = (walls: WallNode[]) => { + if (!levelId) { + return + } + + const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) + const sceneState = useScene.getState() + const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) + const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes)) + + if ( + slabPlan.create.length === 0 && + slabPlan.update.length === 0 && + slabPlan.delete.length === 0 + ) { + return + } + + sceneState.applyNodeChanges({ + update: slabPlan.update.map((entry) => ({ + id: entry.id as AnyNodeId, + data: entry.data, + })), + create: slabPlan.create.map((slab) => ({ + node: slab, + parentId: levelId as AnyNodeId, + })), + delete: slabPlan.delete.map((id) => id as AnyNodeId), + }) + } + + const restoreAutoSlabPreview = () => { + if (!levelId) { + return + } + + const sceneState = useScene.getState() + const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id)) + const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes) + const update = originalAutoSlabs + .filter((slab) => sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + id: slab.id as AnyNodeId, + data: cloneSlabSnapshot(slab), + })) + const create = originalAutoSlabs + .filter((slab) => !sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + node: cloneSlabSnapshot(slab), + parentId: levelId as AnyNodeId, + })) + const deleteIds = currentAutoSlabs + .filter((slab) => !originalIds.has(slab.id)) + .map((slab) => slab.id as AnyNodeId) + + if (update.length === 0 && create.length === 0 && deleteIds.length === 0) { + return + } + + sceneState.applyNodeChanges({ + update, + create, + delete: deleteIds, + }) + } + + const buildWallFromCenter = (center: [number, number]) => { + const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) + const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] + const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]] + return { start: nextStart, end: nextEnd } + } + + const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) => + planWallMoveJunctions( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + + const getLinkedPreviewUpdates = ( + plan: WallMoveJunctionPlan, + nextStart: [number, number], + nextEnd: [number, number], + ) => { + const movedUpdates = getPlannedLinkedWallUpdates( + plan, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry])) + + return linkedOriginalsRef.current.map( + (wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end }, + ) + } + + const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { + previewRef.current = { start: nextStart, end: nextEnd } + const centerX = (nextStart[0] + nextEnd[0]) / 2 + const centerZ = (nextStart[1] + nextEnd[1]) / 2 + setCursorLocalPos([centerX, 0, centerZ]) + const previewPlan = getMovePlan(nextStart, nextEnd) + const previewUpdates = [ + { id: nodeId, start: nextStart, end: nextEnd }, + ...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd), + ] + const previewCollapsedWallIds = new Set([ + ...previewUpdates + .filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), + ]) + const previewSceneWalls = getWallsAfterUpdates( + useScene.getState().nodes, + previewUpdates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId)) + const bridgePreviews = buildBridgeWallPreviews({ + bridgePlans: previewPlan.bridgePlans, + nextStart, + nextEnd, + existingWalls: previewSceneWalls, + }) + const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost) + const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) + setGhostWallPreviews(nextGhostWalls) + applyNodePreview(previewUpdates) + applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls]) + } + + const restoreOriginal = () => { + setGhostWallPreviews([]) + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + restoreAutoSlabPreview() + } + + const onGridMove = (event: GridEvent) => { + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const snapStep = getWallGridStep() + const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep) + const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep) + + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( + localX - anchor[0], + localZ - anchor[1], + moveAxisRef.current, + ) + const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] + + if ( + previousGridPosRef.current && + (constrainedGridPos[0] !== previousGridPosRef.current[0] || + constrainedGridPos[1] !== previousGridPosRef.current[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = constrainedGridPos + + const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] + const nextWall = buildWallFromCenter(nextCenter) + applyPreview(nextWall.start, nextWall.end) + } + + const onGridClick = (event: GridEvent) => { + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + + const preview = previewRef.current ?? { start: originalStart, end: originalEnd } + + shouldRestoreOnCleanup = false + + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + setGhostWallPreviews([]) + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + restoreAutoSlabPreview() + + resumeSceneHistory(useScene) + const commitPlan = getMovePlan(preview.start, preview.end) + const linkedWallUpdates = getPlannedLinkedWallUpdates( + commitPlan, + originalStart, + originalEnd, + preview.start, + preview.end, + ) + const collapsedLinkedWallIds = new Set([ + ...linkedWallUpdates + .filter((entry) => !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), + ]) + + const commitUpdates = [ + { + id: nodeId as AnyNodeId, + data: isNew + ? { + start: preview.start, + end: preview.end, + metadata: stripWallIsNewMetadata(node.metadata), + } + : { start: preview.start, end: preview.end }, + }, + ...linkedWallUpdates + .filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId)) + .map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ] + const sceneState = useScene.getState() + const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter( + (wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId), + ) + const bridgeCreates = buildBridgeWallCreates({ + bridgePlans: commitPlan.bridgePlans, + nextStart: preview.start, + nextEnd: preview.end, + existingWalls, + wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length, + }) + sceneState.applyNodeChanges({ + update: commitUpdates, + create: bridgeCreates, + delete: Array.from(collapsedLinkedWallIds), + }) + + pauseSceneHistory(useScene) + + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + if (event.key === 'Shift') { + shiftPressedRef.current = true + return + } + + const ROTATION_STEP = Math.PI / 4 + let rotationDelta = 0 + if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP + else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP + + if (rotationDelta === 0) { + return + } + + event.preventDefault() + pendingRotationRef.current += rotationDelta + triggerSFX('sfx:item-rotate') + + const preview = previewRef.current ?? { start: originalStart, end: originalEnd } + const currentCenter: [number, number] = [ + (preview.start[0] + preview.end[0]) / 2, + (preview.start[1] + preview.end[1]) / 2, + ] + const nextWall = buildWallFromCenter(currentCenter) + moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end) + applyPreview(nextWall.start, nextWall.end) + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressedRef.current = false + } + } + + const onCancel = () => { + shouldRestoreOnCleanup = false + restoreOriginal() + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + resumeSceneHistory(useScene) + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + if (shouldRestoreOnCleanup) { + restoreOriginal() + } + shiftPressedRef.current = false + resumeSceneHistory(useScene) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [exitMoveMode, isNew, node.metadata, node.parentId]) + + return ( + + + {ghostWallPreviews.map((preview) => ( + + ))} + + ) +} + +export default MoveWallTool diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx new file mode 100644 index 00000000..c63c512a --- /dev/null +++ b/packages/nodes/src/wall/tool.tsx @@ -0,0 +1,313 @@ +'use client' + +import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' +import { + CursorSphere, + createWallOnCurrentLevel, + EDITOR_LAYER, + formatAngleRadians, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, + markToolCancelConsumed, + snapWallDraftPoint, + triggerSFX, + type WallPlanPoint, +} 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 — wall placement tool (kind-owned). + * + * 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the + * start, click 2 creates the wall. Between clicks a vertical preview + * rectangle + length/angle measurement HUD follow the pointer. Shift + * bypasses the angle snap; Esc cancels. + * + * Not a `DragAction` — same reasoning as fence/slab/ceiling placement: + * stateful sequence of grid:click events, not a single drag-up. + * + * Mounted via `def.tool` from `wall/definition.ts`. + */ +const WALL_HEIGHT = 2.5 +const DRAFT_LABEL_Y = WALL_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 + +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: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): DraftAngleLabel[] { + const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: WallPlanPoint = [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 connectedWall = walls.find((wall) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + 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: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], + 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, walls), + } +} + +function updateWallPreview(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, WALL_HEIGHT) + shape.lineTo(0, WALL_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 getCurrentLevelWalls(): WallNode[] { + const currentLevelId = useViewer.getState().selection.levelId + const { nodes } = useScene.getState() + if (!currentLevelId) return [] + const levelNode = nodes[currentLevelId] + if (!levelNode || levelNode.type !== 'level') return [] + return (levelNode as LevelNode).children + .map((childId) => nodes[childId]) + .filter((node): node is WallNode => node?.type === 'wall') +} + +export const WallTool: React.FC = () => { + const unit = useViewer((state) => state.unit) + const cursorRef = useRef(null) + const wallPreviewRef = 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 gridPosition: WallPlanPoint = [0, 0] + let previousWallEnd: [number, number] | null = null + + const onGridMove = (event: GridEvent) => { + if (!(cursorRef.current && wallPreviewRef.current)) return + + const walls = getCurrentLevelWalls() + const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + gridPosition = snapWallDraftPoint({ point: localPoint, walls }) + + if (buildingState.current === 1) { + const snappedLocal = snapWallDraftPoint({ + point: localPoint, + walls, + 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 currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] + if ( + previousWallEnd && + (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) + ) { + triggerSFX('sfx:grid-snap') + } + previousWallEnd = currentWallEnd + + updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + setDraftMeasurement( + getDraftMeasurementState( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + walls, + unit, + ), + ) + } else { + cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) + setDraftMeasurement(null) + } + } + + const onGridClick = (event: GridEvent) => { + const walls = getCurrentLevelWalls() + const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + + if (buildingState.current === 0) { + const snappedStart = snapWallDraftPoint({ point: localClick, walls }) + gridPosition = snappedStart + startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + endingPoint.current.copy(startingPoint.current) + buildingState.current = 1 + wallPreviewRef.current.visible = true + setDraftMeasurement(null) + } else if (buildingState.current === 1) { + const snappedEnd = snapWallDraftPoint({ + point: localClick, + walls, + 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 + createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) + wallPreviewRef.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 + wallPreviewRef.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 WallTool