'use client' import { type AnyNodeId, emitter, type GridEvent, type LinesetNode, LiquidLineNode, useScene, } from '@pascal-app/core' import { CursorSphere, DimensionPill, EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' import { offsetPathHorizontal } from '../shared/path-offset' import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports' import { liquidLineDefinition } from './definition' import { useLiquidLineToolOptions } from './options' /** * Continuous placement tool for standalone liquid lines — the same draw model * as the lineset tool (the line it used to be a rail of): * - **First click** anchors the run start; within range of a refrigerant * service port it snaps onto it so a run mates flush. * - **Second click** commits a two-point line and keeps its far end anchored; * the in-flight end is angle-locked to 45° (Shift frees it), Alt drags it * vertical. * * **Follow mode** (toggled by the MEP panel's Follow button or the `F` key): * instead of free-drawing, hover an existing lineset and click — a liquid line * is laid beside it, tracing the lineset's whole path at a fixed offset on the * side the cursor is on. This is the "place it exactly next to this" affordance. */ const PREVIEW_OPACITY = 0.6 const PREVIEW_COLOR = '#b06b3f' /** Snap radius (meters) for joining onto a refrigerant port. */ const ENDPOINT_SNAP_RADIUS_M = 0.5 /** Angle step (radians) for the XZ angle lock — 45°. */ const ANGLE_STEP_RAD = Math.PI / 4 /** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */ const ALT_PIXELS_PER_METER = 100 const ALT_Y_MIN_M = -3 const ALT_Y_MAX_M = 10 const IN_TO_M = 0.0254 /** Default liquid OD (~3/8") — the ghost radius and trace-line size. */ const DEFAULT_DIAMETER_IN = 0.375 const GHOST_RADIUS_M = (DEFAULT_DIAMETER_IN * IN_TO_M) / 2 /** Matches the lineset's foam-jacket thickness so the traced line sits just * outside an insulated suction line, exactly where the old paired rail was. */ const INSULATION_THICKNESS_M = 0.01 /** How close (meters, XZ) the cursor must be to a lineset path to trace it. */ const FOLLOW_PICK_RADIUS_M = 0.6 /** Clear-air gap (meters) between the lineset's outer surface and the traced * liquid line, so the new run reads as its own line instead of fusing onto * the lineset (~2"). */ const FOLLOW_GAP_M = 0.05 type Vec3 = [number, number, number] function snap(value: number, step: number): number { if (step <= 0) return value return Math.round(value / step) * step } /** Nearest refrigerant port within snap range on the XZ plane, as a position * tuple. Y is ignored for the distance check; the snap adopts the port's full * 3D position. */ function findNearbyPort(point: Vec3): Vec3 | null { const port = findNearestPortXZ( point, collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }), ENDPOINT_SNAP_RADIUS_M, ) return port ? [port.position[0], port.position[1], port.position[2]] : null } function projectToAngleLock(from: Vec3, raw: Vec3): Vec3 { const dx = raw[0] - from[0] const dz = raw[2] - from[2] const len = Math.hypot(dx, dz) if (len < 1e-4) return [from[0], from[1], from[2]] const theta = Math.atan2(dz, dx) const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) const d = Math.max(0, proj) return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] } /** Distance (XZ) from point `p` to segment `a`→`b`. */ function distToSegmentXZ(p: Vec3, a: Vec3, b: Vec3): number { const dx = b[0] - a[0] const dz = b[2] - a[2] const len2 = dx * dx + dz * dz let t = len2 > 0 ? ((p[0] - a[0]) * dx + (p[2] - a[2]) * dz) / len2 : 0 t = Math.max(0, Math.min(1, t)) const cx = a[0] + t * dx const cz = a[2] + t * dz return Math.hypot(p[0] - cx, p[2] - cz) } /** Center-to-center offset (meters) that drops the liquid line a small gap * outside the lineset's outer surface, so the two read as separate lines. */ function traceOffsetMeters(lineset: LinesetNode): number { const suctionR = (lineset.suctionDiameter * IN_TO_M) / 2 const jacket = lineset.insulated ? INSULATION_THICKNESS_M : 0 return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M } /** Coincidence tolerance (meters) for treating two endpoints as the same joint * when chaining linesets — the draw tool snaps endpoints exactly, so this only * needs to absorb float drift. */ const JOINT_EPS_M = 1e-3 function samePt(a: Vec3, b: Vec3): boolean { return ( Math.abs(a[0] - b[0]) < JOINT_EPS_M && Math.abs(a[1] - b[1]) < JOINT_EPS_M && Math.abs(a[2] - b[2]) < JOINT_EPS_M ) } /** Quantized coordinate key so endpoints sharing a joint hash together. */ function jointKey(p: Vec3): string { return `${Math.round(p[0] / JOINT_EPS_M)},${Math.round(p[1] / JOINT_EPS_M)},${Math.round( p[2] / JOINT_EPS_M, )}` } /** * Whole-run trace target: the assembled centerline of every lineset chained to * the hovered one (each lineset is its own two-point node now), which side the * cursor is on (`sign`, matching `offsetPathHorizontal`'s convention), and a * representative lineset for the offset distance. */ type FollowTarget = { path: Vec3[]; sign: number; lineset: LinesetNode } /** * Walk the chain of linesets joined end-to-end at shared joint coordinates, * starting from `start`, into one continuous centerline. Follows a joint only * when it has a single unvisited continuation (degree-2) — a branch / junction * (degree ≥ 3) ends the run so the trace stays a simple path. */ function assembleRun(start: LinesetNode, linesets: LinesetNode[]): Vec3[] { const byJoint = new Map() for (const ls of linesets) { const a = ls.path[0] as Vec3 const b = ls.path[ls.path.length - 1] as Vec3 for (const key of [jointKey(a), jointKey(b)]) { const arr = byJoint.get(key) if (arr) arr.push(ls) else byJoint.set(key, [ls]) } } const visited = new Set([start.id]) let points: Vec3[] = (start.path as Vec3[]).map((p) => [...p] as Vec3) // Grow the run one lineset at a time off the chosen terminal, until a joint // has no unique continuation. `atEnd` extends after the last point; otherwise // before the first. const grow = (atEnd: boolean) => { for (;;) { const terminal = atEnd ? points[points.length - 1]! : points[0]! const next = (byJoint.get(jointKey(terminal)) ?? []).filter((ls) => !visited.has(ls.id)) if (next.length !== 1) break const node = next[0]! visited.add(node.id) const np = (node.path as Vec3[]).map((p) => [...p] as Vec3) if (atEnd) { if (samePt(np[np.length - 1]!, terminal)) np.reverse() // np must start at terminal points = [...points, ...np.slice(1)] } else { if (samePt(np[0]!, terminal)) np.reverse() // np must end at terminal points = [...np.slice(0, np.length - 1), ...points] } } } grow(true) grow(false) return points } /** Cursor side relative to the assembled run's nearest segment, as the offset * sign for `offsetPathHorizontal`. */ function sideSign(path: Vec3[], point: Vec3): number { let bestD = Number.POSITIVE_INFINITY let bi = 0 for (let i = 0; i < path.length - 1; i++) { const d = distToSegmentXZ(point, path[i]!, path[i + 1]!) if (d < bestD) { bestD = d bi = i } } const a = path[bi]! const b = path[bi + 1]! // Side vector = normalize(heading_xz) × UP = (-hz, 0, hx). const hx = b[0] - a[0] const hz = b[2] - a[2] const hlen = Math.hypot(hx, hz) const sx = hlen > 1e-9 ? -hz / hlen : 0 const sz = hlen > 1e-9 ? hx / hlen : 0 return (point[0] - a[0]) * sx + (point[2] - a[2]) * sz >= 0 ? 1 : -1 } /** * Nearest lineset within `FOLLOW_PICK_RADIUS_M` of the cursor, expanded into * the whole connected run it belongs to. Restricted to the active level. */ function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null { const scene = useScene.getState() const linesets: LinesetNode[] = [] for (const n of Object.values(scene.nodes)) { if (!n || n.type !== 'lineset') continue if ((n.parentId as AnyNodeId | null) !== levelId) continue const ls = n as LinesetNode if (ls.path.length >= 2) linesets.push(ls) } let hovered: LinesetNode | null = null let bestD = FOLLOW_PICK_RADIUS_M for (const ls of linesets) { for (let i = 0; i < ls.path.length - 1; i++) { const d = distToSegmentXZ(point, ls.path[i] as Vec3, ls.path[i + 1] as Vec3) if (d >= bestD) continue bestD = d hovered = ls } } if (!hovered) return null const path = assembleRun(hovered, linesets) if (path.length < 2) return null return { path, sign: sideSign(path, point), lineset: hovered } } /** The offset centerline a follow-target would trace, or null if degenerate. */ function tracePath(target: FollowTarget): Vec3[] | null { const offset = target.sign * traceOffsetMeters(target.lineset) const traced = offsetPathHorizontal(target.path, offset) return traced.length >= 2 ? traced : null } const LiquidLineTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const unit = useViewer((s) => s.unit) const follow = useLiquidLineToolOptions((s) => s.follow) const cursorRef = useRef(null) const [draftPoints, setDraftPoints] = useState([]) const [cursorPos, setCursorPos] = useState(null) const [snapTarget, setSnapTarget] = useState(null) const [traceGhost, setTraceGhost] = useState(null) const [altActive, setAltActive] = useState(false) const draftRef = useRef(draftPoints) draftRef.current = draftPoints const followTargetRef = useRef(null) const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) const lastClientYRef = useRef(null) // Clear in-flight draft / trace whenever Follow toggles (panel button or F). // biome-ignore lint/correctness/useExhaustiveDependencies: `follow` is an intentional re-run trigger; the body clears the in-flight draft when it toggles. useEffect(() => { setDraftPoints([]) setTraceGhost(null) followTargetRef.current = null altAnchorRef.current = null setAltActive(false) }, [follow]) // Leaving the tool clears Follow so re-arming it starts in free-draw. useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), []) useEffect(() => { if (!activeLevelId) return const commitSegment = (start: Vec3, end: Vec3) => { const sameSpot = Math.abs(start[0] - end[0]) < 1e-4 && Math.abs(start[1] - end[1]) < 1e-4 && Math.abs(start[2] - end[2]) < 1e-4 if (sameSpot) return // Each drawn segment is its own standalone two-point liquid-line node. // Independent nodes mean each segment selects and deletes on its own, // rather than folding into one mitered polyline run. const line = LiquidLineNode.parse({ ...liquidLineDefinition.defaults(), name: 'Liquid Line', path: [start, end], }) useScene.getState().createNode(line, activeLevelId) triggerSFX('sfx:item-place') setDraftPoints([end]) setSnapTarget(null) altAnchorRef.current = null setAltActive(false) } // Lay liquid lines beside the whole connected lineset run, tracing its // assembled centerline at the offset. One two-point node per segment so the // result stays per-segment selectable, matching free-drawn liquid lines. const commitTrace = (target: FollowTarget) => { const traced = tracePath(target) if (!traced) return const defaults = liquidLineDefinition.defaults() const create = [] for (let i = 0; i < traced.length - 1; i++) { const a = traced[i]! const b = traced[i + 1]! if (samePt(a, b)) continue const node = LiquidLineNode.parse({ ...defaults, name: 'Liquid Line', path: [a, b] }) create.push({ node, parentId: activeLevelId }) } if (create.length === 0) return useScene.getState().applyNodeChanges({ create }) triggerSFX('sfx:item-place') setTraceGhost(null) followTargetRef.current = null } const resolveSnappedPoint = (event: GridEvent): { point: Vec3; snapped: Vec3 | null } => { const last = draftRef.current.at(-1) if (!last) { const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]] if (event.nativeEvent?.altKey !== true) { const target = findNearbyPort(raw) if (target) return { point: target, snapped: target } } const step = useEditor.getState().gridSnapStep return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null } } const rawXZ: Vec3 = [event.localPosition[0], last[1], event.localPosition[2]] const shift = event.nativeEvent?.shiftKey === true const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) if (event.nativeEvent?.altKey !== true && !shift) { const target = findNearbyPort(rawXZ) if (target) return { point: target, snapped: target } } const step = useEditor.getState().gridSnapStep return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null } } const resolveAltVerticalPoint = (clientY: number): Vec3 | null => { const anchor = altAnchorRef.current const last = draftRef.current.at(-1) if (!anchor || !last) return null const step = useEditor.getState().gridSnapStep const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER const snappedDy = snap(dy, step) const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) return [last[0], y, last[2]] } const resolveAlignedPoint = (event: GridEvent) => { const r = resolveSnappedPoint(event) const hasStart = draftRef.current.length > 0 const shift = event.nativeEvent?.shiftKey === true const alt = event.nativeEvent?.altKey === true const point = alignDrawPoint(r.point, { applySnap: !hasStart || shift, bypass: alt || r.snapped !== null, }) return { ...r, point } } const onMove = (event: GridEvent) => { // Follow mode: track the lineset under the cursor and preview its trace. if (useLiquidLineToolOptions.getState().follow) { const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]] clearDrawAlignment() setCursorPos(raw) setSnapTarget(null) const target = findFollowTarget(raw, activeLevelId as AnyNodeId) followTargetRef.current = target setTraceGhost(target ? tracePath(target) : null) return } const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY if (typeof clientY === 'number') lastClientYRef.current = clientY if (altAnchorRef.current && typeof clientY === 'number') { const point = resolveAltVerticalPoint(clientY) if (point) { clearDrawAlignment() setCursorPos(point) setSnapTarget(null) return } } const { point, snapped } = resolveAlignedPoint(event) setCursorPos(point) setSnapTarget(snapped) } const onClick = (event: GridEvent) => { // Follow mode: a click commits the trace beside the hovered lineset. if (useLiquidLineToolOptions.getState().follow) { const target = followTargetRef.current if (target) commitTrace(target) return } const start = draftRef.current.at(-1) if (altAnchorRef.current && start) { const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current if (typeof clientY === 'number') { const point = resolveAltVerticalPoint(clientY) if (point && Math.abs(point[1] - start[1]) >= 1e-4) { commitSegment(start, point) } } return } const { point } = resolveAlignedPoint(event) if (!start) { triggerSFX('sfx:grid-snap') setDraftPoints([point]) return } commitSegment(start, point) } const enterAltMode = () => { if (useLiquidLineToolOptions.getState().follow) return const last = draftRef.current.at(-1) if (!last || lastClientYRef.current === null) return if (altAnchorRef.current) return altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] } setAltActive(true) } const exitAltMode = () => { if (!altAnchorRef.current) return altAnchorRef.current = null setAltActive(false) } const onKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement | null)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return if (e.key === 'f' || e.key === 'F') { e.preventDefault() useLiquidLineToolOptions.getState().toggleFollow() return } if (e.key === 'Alt') { e.preventDefault() enterAltMode() } } const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Alt') { e.preventDefault() exitAltMode() } } const onCancel = () => { clearDrawAlignment() if (draftRef.current.length === 0 && !followTargetRef.current) return markToolCancelConsumed() setDraftPoints([]) setCursorPos(null) setSnapTarget(null) setTraceGhost(null) followTargetRef.current = null } emitter.on('grid:move', onMove) emitter.on('grid:click', onClick) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) return () => { emitter.off('grid:move', onMove) emitter.off('grid:click', onClick) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) altAnchorRef.current = null clearDrawAlignment() } }, [activeLevelId]) if (!activeLevelId) return null const previewSegments: Array<{ a: Vec3; b: Vec3 }> = [] for (let i = 0; i < draftPoints.length - 1; i++) { previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! }) } const last = draftPoints.at(-1) if (last && cursorPos) { previewSegments.push({ a: last, b: cursorPos }) } const traceSegments: Array<{ a: Vec3; b: Vec3 }> = [] if (traceGhost) { for (let i = 0; i < traceGhost.length - 1; i++) { traceSegments.push({ a: traceGhost[i]!, b: traceGhost[i + 1]! }) } } const pillParts = cursorPos ? (['x', 'y', 'z'] as const).map((axis, i) => ({ key: axis, prefix: axis.toUpperCase(), value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!, signed: !!last, })) : null const pillPrimary = last && cursorPos ? altActive ? 'y' : Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2]) ? 'x' : 'z' : undefined return ( {cursorPos && ( <> {follow ? (
{followTargetRef.current ? 'Click to trace this lineset run' : 'Follow: hover a lineset'}
) : ( pillParts && ( ) )} )} {snapTarget && ( )} {draftPoints.map((p, i) => ( ))} {previewSegments.map((seg, i) => ( ))} {traceSegments.map((seg, i) => ( ))}
) } function PreviewSegment({ a, b }: { a: Vec3; b: Vec3 }) { 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 LiquidLineTool