'use client' import { type AlignmentAnchor, type AnyNode, type AnyNodeId, emitter, type GridEvent, PipeFittingNode, sceneRegistry, useScene, } from '@pascal-app/core' import { DragBoundingBox, EDITOR_LAYER, isAlignmentGuideActive, isMagneticSnapActive, markToolCancelConsumed, stripPlacementMetadataFlags, triggerSFX, useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three' import { type Aabb2D, collectGhostAlignmentCandidates, resolveGhostAlignment, } from '../shared/ghost-alignment' import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' import { buildPipeFittingGeometry } from './geometry' type Vec3 = [number, number, number] const GHOST_COLOR = '#818cf8' const GHOST_OPACITY = 0.5 /** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the * pipe draw tool's Alt-vertical feel. 100 px ≈ 1 m. */ const VERTICAL_PIXELS_PER_METER = 100 const VERTICAL_Y_MIN_M = -3 const VERTICAL_Y_MAX_M = 10 /** Snap a coordinate to the editor's live grid step. */ function snapToGridStep(value: number): number { const step = useEditor.getState().gridSnapStep if (step <= 0) return value return Math.round(value / step) * step } /** World-space size + centre offset of `box` after the fitting's euler * rotation — the footprint box that wraps the oriented geometry. */ function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } { const euler = new Euler(rotation[0], rotation[1], rotation[2]) const min = box.min const max = box.max const corners: Vec3[] = [ [min.x, min.y, min.z], [max.x, min.y, min.z], [min.x, max.y, min.z], [min.x, min.y, max.z], [max.x, max.y, min.z], [max.x, min.y, max.z], [min.x, max.y, max.z], [max.x, max.y, max.z], ] const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] const v = new Vector3() for (const c of corners) { v.set(c[0], c[1], c[2]).applyEuler(euler) lo[0] = Math.min(lo[0], v.x) lo[1] = Math.min(lo[1], v.y) lo[2] = Math.min(lo[2], v.z) hi[0] = Math.max(hi[0], v.x) hi[1] = Math.max(hi[1], v.y) hi[2] = Math.max(hi[2], v.z) } return { size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]], offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2], } } /** * Ghost-preview duplicate / move tool for DWV pipe fittings (elbow / wye / * sanitary tee) — the plumbing sibling of the duct-fitting move tool. * * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is * inserted into the scene until the commit click. A translucent copy of the * fitting (built from its real geometry, at its own `rotation`, so an elbow * / riser stays properly aligned) rides the cursor inside a footprint * bounding box — the same affordance other items get — and Figma-style * alignment guides snap the box edges to nearby geometry. The commit click * calls `createNode`; Esc discards. * * **Move** (existing fitting): the real node is hidden while the ghost + box * track the cursor; commit writes the new `position` and reveals it. * * Modifiers (mirroring the duct-fitting move): * - **Alt** detaches: the connected-pipe follow drops so the fitting moves * on its own, leaving every mated run where it sits. * - **Ctrl / Cmd** switches to vertical movement (stack / riser editing): XZ * holds and the cursor's screen-Y drives the riser height. * - **Shift** bypasses grid snapping / alignment. * * Wired via `def.affordanceTools.move`. */ export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { const fitting = node as PipeFittingNode const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3 const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3 const isNew = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) && (node.metadata as Record).isNew === true const [cursorPos, setCursorPos] = useState(originalPosition) // Translucent stand-in built from the fitting's real geometry. Rotation is // a geometry input (it decides the elbow's profile roles), so the ghost // matches what lands. Rebuilt only if the source changes. const ghost = useMemo(() => { const group = buildPipeFittingGeometry(fitting) group.traverse((obj) => { const mesh = obj as Mesh if ((mesh as { isMesh?: boolean }).isMesh) { mesh.material = new MeshBasicMaterial({ color: GHOST_COLOR, transparent: true, opacity: GHOST_OPACITY, depthTest: false, }) mesh.renderOrder = 999 } obj.layers.set(EDITOR_LAYER) }) return group }, [fitting]) // Footprint box that wraps the oriented geometry (size + centre offset), // measured once from the ghost. const bounds = useMemo(() => { const box = new Box3().setFromObject(ghost) if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 } return rotatedBounds(box, rotation) }, [ghost, rotation]) useEffect(() => { return () => { ghost.traverse((obj) => { const mesh = obj as Mesh if ((mesh as { isMesh?: boolean }).isMesh) { mesh.geometry?.dispose?.() const mat = mesh.material as Material | Material[] if (Array.isArray(mat)) for (const m of mat) m.dispose?.() else mat?.dispose?.() } }) } }, [ghost]) useEffect(() => { const nodeId = node.id as AnyNodeId const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2] const [ox, , oz] = bounds.offset useScene.temporal.getState().pause() let committed = false let hasMoved = false const activatedAt = Date.now() const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( useScene.getState().nodes, nodeId, useViewer.getState().selection.levelId ?? node.parentId, ) // Moving an existing fitting: hide its 3D MESH imperatively (NOT the // store `visible` flag — the 2D floor plan skips `visible:false` nodes, // so a store hide makes it vanish in 2D / split view). The ghost stands // in until commit; the real mesh is restored on cancel / unmount. const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] const setMeshHidden = (hidden: boolean) => { const obj = sceneRegistry.nodes.get(nodeId) if (obj) obj.visible = !hidden } if (existedAtStart) setMeshHidden(true) // Carry connected pipes as the fitting slides: the part of the move along // a run's axis stretches it, the part across translates the whole run (and // propagates to its far joint). Snapshot once at drag start; only existing // fittings are mated to anything. const connectivity: RunMoveConnectivity | null = existedAtStart ? startRunMoveConnectivity(node) : null let lastPos: Vec3 = originalPosition // Tracks whether the last frame held Alt: the fitting is detached from its // connected pipes for the drag, so they stay put (no follow) and the // commit omits their updates. Mirrors the pipe endpoint's Alt-detach. let lastDetached = false // Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the // frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared // when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor. let verticalAnchor: { clientY: number; baseY: number } | null = null const onMove = (event: GridEvent) => { const bypass = event.nativeEvent?.shiftKey === true // Alt = detach: drop the connected-pipe follow so the fitting moves on // its own, leaving every mated run where it sits. const detached = event.nativeEvent?.altKey === true // Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's // screen-Y drives the riser height (connected pipes still follow). const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY const snap = bypass ? (v: number) => v : snapToGridStep let next: Vec3 if (vertical && typeof clientY === 'number') { if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] } // Screen +Y points down, so subtract to map "drag up = raise". const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER const y = Math.min( VERTICAL_Y_MAX_M, Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)), ) next = [lastPos[0], y, lastPos[2]] useAlignmentGuides.getState().clear() } else { verticalAnchor = null let x = snap(event.localPosition[0]) let z = snap(event.localPosition[2]) // Alignment: snap the footprint box edges onto nearby geometry and // publish guides (Alt / Shift bypass). Guides are DISPLAYED in every // snapping mode (isAlignmentGuideActive); the magnetic pull toward them // applies only in 'lines' mode (isMagneticSnapActive). if (!bypass && isAlignmentGuideActive()) { const proposed: Aabb2D = { minX: x + ox - hx, maxX: x + ox + hx, minZ: z + oz - hz, maxZ: z + oz + hz, } const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) if (isMagneticSnapActive()) { x += dx z += dz } useAlignmentGuides.getState().set(guides) } else { useAlignmentGuides.getState().clear() } next = [x, lastPos[1], z] } if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) { triggerSFX('sfx:grid-snap') } lastPos = next lastDetached = detached hasMoved = true setCursorPos(next) // Detached: keep the followers at their origin (drop any live overrides // from a prior non-detached frame). Otherwise preview the follow. if (detached) connectivity?.clear() else connectivity?.preview({ position: next }) } const commit = (event: GridEvent) => { if (committed) return if (Date.now() - activatedAt < 150) { event.nativeEvent?.stopPropagation?.() return } if (!hasMoved) { event.nativeEvent?.stopPropagation?.() return } committed = true useScene.temporal.getState().resume() let selectId = nodeId if (isNew && !useScene.getState().nodes[nodeId]) { const created = PipeFittingNode.parse({ ...(node as Record), position: lastPos, metadata: stripPlacementMetadataFlags(node.metadata), visible: true, }) useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) selectId = created.id as AnyNodeId } else { // Fold connected-pipe / sibling-run follow-updates into the SAME batch // as the moved fitting so the whole joint is one undo step. Detached // (Alt on the final frame): the joint is broken, so nothing follows. const followUpdates = lastDetached ? [] : (connectivity?.commitUpdates({ position: lastPos }) ?? []) useScene .getState() .updateNodes([ { id: nodeId, data: { position: lastPos } as Partial }, ...followUpdates, ]) useScene.getState().markDirty(nodeId) } useScene.temporal.getState().pause() // Followers are committed to the store — drop their live overrides so // renderers read the canonical path/position. connectivity?.clear() setMeshHidden(false) useAlignmentGuides.getState().clear() triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [selectId] }) useEditor.getState().setMovingNodeOrigin('3d') useEditor.getState().setMovingNode(null) event.nativeEvent?.stopPropagation?.() } const onCancel = () => { connectivity?.clear() if (existedAtStart) { setMeshHidden(false) useViewer.getState().setSelection({ selectedIds: [nodeId] }) } useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() markToolCancelConsumed() useEditor.getState().setMovingNodeOrigin('3d') useEditor.getState().setMovingNode(null) } emitter.on('grid:move', onMove) emitter.on('grid:click', commit) emitter.on('tool:cancel', onCancel) return () => { emitter.off('grid:move', onMove) emitter.off('grid:click', commit) emitter.off('tool:cancel', onCancel) connectivity?.clear() useAlignmentGuides.getState().clear() if (existedAtStart) setMeshHidden(false) useScene.temporal.getState().resume() } }, [bounds, isNew, node, originalPosition]) return ( ) } export default MovePipeFittingTool