'use client' import { type AlignmentAnchor, type AnyNode, type AnyNodeId, emitter, type GridEvent, LinesetNode, sceneRegistry, useScene, } from '@pascal-app/core' import { DragBoundingBox, EDITOR_LAYER, markToolCancelConsumed, stripPlacementMetadataFlags, triggerSFX, useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import { Vector3 } from 'three' import { type Aabb2D, collectGhostAlignmentCandidates, resolveGhostAlignment, } from '../shared/ghost-alignment' import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' type Vec3 = [number, number, number] const GHOST_COLOR = '#818cf8' const GHOST_OPACITY = 0.5 const IN_TO_M = 0.0254 /** 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 } function pathCenterXZ(path: readonly Vec3[]): [number, number] { let x = 0 let z = 0 for (const p of path) { x += p[0] z += p[2] } const n = path.length || 1 return [x / n, z / n] } /** The lineset's footprint radius (meters) — half the suction OD (the * bigger of the pair), used as box / footprint padding and ghost radius. */ function linesetRadiusM(lineset: LinesetNode): number { return (lineset.suctionDiameter * IN_TO_M) / 2 } /** XZ bounds of a path padded by the lineset's radius. */ function pathAabb(path: readonly Vec3[], r: number): Aabb2D { let minX = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY let minZ = Number.POSITIVE_INFINITY let maxZ = Number.NEGATIVE_INFINITY for (const p of path) { if (p[0] < minX) minX = p[0] if (p[0] > maxX) maxX = p[0] if (p[2] < minZ) minZ = p[2] if (p[2] > maxZ) maxZ = p[2] } return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r } } /** * Ghost-preview duplicate / move tool for refrigerant linesets — the * refrigerant-loop sibling of `MovePipeSegmentTool`. A lineset is a * suction + liquid copper pair; the ghost stands in with a single * translucent cylinder at the suction OD per section (mirrors the draw * tool's `PreviewSegment`). * * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is * inserted into the scene until the commit click. A translucent ghost of * the run rides the cursor inside a footprint bounding box — the same * affordance other items get — and Figma-style alignment guides snap the * box's edges to nearby geometry. The next grid click calls `createNode`; * Esc discards. The run's Y coords ride along untouched: the move only * shifts XZ. * * **Move** (existing run): the real node's mesh is hidden while the same * ghost + box tracks the cursor; the commit click writes the translated * `path` and reveals it, Esc reveals it unchanged. * * Wired via `def.affordanceTools.move`. */ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { const lineset = node as LinesetNode const originalPathRef = useRef(lineset.path.map((p) => [...p] as Vec3)) const isNew = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) && (node.metadata as Record).isNew === true const [previewPath, setPreviewPath] = useState(originalPathRef.current) const previewPathRef = useRef(originalPathRef.current) const hasMovedRef = useRef(false) const activatedAtRef = useRef(Date.now()) const prevSnapRef = useRef<[number, number] | null>(null) useEffect(() => { const nodeId = node.id as AnyNodeId const originalPath = originalPathRef.current const [centerX, centerZ] = pathCenterXZ(originalPath) const r = linesetRadiusM(lineset) const baseAabb = pathAabb(originalPath, r) useScene.temporal.getState().pause() let committed = false const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( useScene.getState().nodes, nodeId, useViewer.getState().selection.levelId ?? node.parentId, ) // Moving an existing run: hide its 3D MESH imperatively (NOT the store // `visible` flag — the 2D floor plan skips `visible:false` nodes, so a // store hide makes the run 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 fittings (+ their other runs) as the whole run slides. // Snapshot once at drag start; only existing runs are mated to anything. const connectivity: RunMoveConnectivity | null = existedAtStart ? startRunMoveConnectivity(node) : null const setPreview = (path: Vec3[]) => { previewPathRef.current = path setPreviewPath(path) } const onMove = (event: GridEvent) => { const bypass = event.nativeEvent?.shiftKey === true const snap = bypass ? (v: number) => v : snapToGridStep let dx = snap(event.localPosition[0] - centerX) let dz = snap(event.localPosition[2] - centerZ) // Figma-style alignment: snap the run's footprint box edges onto // nearby geometry and publish the guides (Shift bypass). if (!bypass) { const proposed: Aabb2D = { minX: baseAabb.minX + dx, maxX: baseAabb.maxX + dx, minZ: baseAabb.minZ + dz, maxZ: baseAabb.maxZ + dz, } const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) dx += sdx dz += sdz useAlignmentGuides.getState().set(guides) } else { useAlignmentGuides.getState().clear() } const cur: [number, number] = [centerX + dx, centerZ + dz] if ( !bypass && (!prevSnapRef.current || prevSnapRef.current[0] !== cur[0] || prevSnapRef.current[1] !== cur[1]) ) { triggerSFX('sfx:grid-snap') } prevSnapRef.current = cur hasMovedRef.current = true const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3) setPreview(nextPath) connectivity?.preview({ path: nextPath }) } const commit = (event: GridEvent) => { if (committed) return if (Date.now() - activatedAtRef.current < 150) { event.nativeEvent?.stopPropagation?.() return } if (!hasMovedRef.current) { event.nativeEvent?.stopPropagation?.() return } committed = true const finalPath = previewPathRef.current useScene.temporal.getState().resume() let selectId = nodeId if (isNew && !useScene.getState().nodes[nodeId]) { const created = LinesetNode.parse({ ...(node as Record), path: finalPath, metadata: stripPlacementMetadataFlags(node.metadata), visible: true, }) useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) selectId = created.id as AnyNodeId } else { // Fold connected-fitting / sibling-run follow-updates into the SAME // batch as the moved run so the whole joint is one undo step. const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? [] useScene .getState() .updateNodes([ { id: nodeId, data: { path: finalPath } 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() } }, [lineset, isNew, node]) const segments: Array<{ a: Vec3; b: Vec3 }> = [] for (let i = 0; i < previewPath.length - 1; i++) { segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! }) } // Footprint box spanning the whole run (axis-aligned), drawn around the // ghost the same way items get one. Recomputed from the live preview path. const r = linesetRadiusM(lineset) const box = pathAabb(previewPath, r) const boxY = previewPath[0]?.[1] ?? 0 return ( {segments.map((seg, i) => ( ))} ) } /** Translucent stand-in for one lineset section — mirrors the draw tool's * `PreviewSegment` so the ghost matches what actually lands. */ function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) { const start = new Vector3(...a) const end = new Vector3(...b) const dir = new Vector3().subVectors(end, start) const length = dir.length() if (length < 1e-4) return null dir.normalize() const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) return ( { if (!m) return m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) }} > ) } export default MoveLinesetTool