Merge origin/main into feat/placement-interaction-overhaul

Resolve 7 conflicts keeping our snapping migration + floorplan perf work as
source of truth, combined with main's MEP run-continuation / Alt-detach /
latch handles. Rebuilt two import blocks the auto-merge silently truncated
(node-arrow-handles.tsx, duct-fitting/move-tool.tsx).

Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass,
biome clean. Floorplan view-transform re-render storm confirmed pre-existing
(not introduced by this merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 deletions
-98
View File
@@ -1,98 +0,0 @@
import type { LiquidLineNode } from './schema'
type Point = [number, number, number]
type LiquidLineId = LiquidLineNode['id']
/** Coincidence tolerance (meters) for folding endpoints into one run. The
* draw tool snaps onto an existing run's endpoint exactly, so this only
* needs to absorb float drift, not user aim. */
const COINCIDENT_EPS_M = 1e-3
function samePoint(a: Point, b: Point): boolean {
return (
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
)
}
/** Which terminal of `line` coincides with `p`, if either. */
function matchEnd(line: LiquidLineNode, p: Point): 'start' | 'end' | null {
const path = line.path as Point[]
if (samePoint(path[0]!, p)) return 'start'
if (samePoint(path[path.length - 1]!, p)) return 'end'
return null
}
/** First liquid line whose start or end coincides with `p`. */
function findConnection(
existing: LiquidLineNode[],
p: Point,
): { line: LiquidLineNode; side: 'start' | 'end' } | null {
for (const line of existing) {
if (line.path.length < 2) continue
const side = matchEnd(line, p)
if (side) return { line, side }
}
return null
}
/** Path re-ordered so the connecting terminal is its LAST point. */
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'end' ? path : [...path].reverse()
}
/** Path re-ordered so the connecting terminal is its FIRST point. */
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'start' ? path : [...path].reverse()
}
/**
* Outcome of committing a new `start`→`end` segment against the existing
* liquid-line runs on the same level:
* - `create` — no shared endpoint; place a fresh standalone run.
* - `extend` — one end lands on run `id`; grow that run's path so the old
* terminal becomes an interior point (the geometry miters it).
* - `bridge` — both ends land on two *different* runs; weld them plus the
* new segment into one path on `id` and delete the absorbed `deleteId`.
*/
export type LiquidLineConnectPlan =
| { kind: 'create'; path: Point[] }
| { kind: 'extend'; id: LiquidLineId; path: Point[] }
| { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
/**
* Decide how a freshly drawn `start`→`end` segment folds into existing
* liquid-line runs that share an endpoint coordinate. Pure: returns a plan,
* the caller mutates the scene. Coords are level-local, so `existing` must be
* pre-filtered to the segment's level.
*/
export function planLiquidLineConnect(
existing: LiquidLineNode[],
start: Point,
end: Point,
): LiquidLineConnectPlan {
const atStart = findConnection(existing, start)
const atEnd = findConnection(existing, end)
// Both ends meet distinct runs → weld the three into one path.
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return {
kind: 'bridge',
id: atStart.line.id,
path: [...left, ...right],
deleteId: atEnd.line.id,
}
}
if (atStart) {
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
}
if (atEnd) {
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
}
return { kind: 'create', path: [start, end] }
}
+10 -3
View File
@@ -31,8 +31,12 @@ function buildRun(
/**
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
* cylinder following the node path centerline, with joint spheres capping
* interior corners so turns read as continuous pipe.
* cylinder following the node path centerline.
*
* Each line is a standalone two-point node (no fitting system), so a sphere caps
* BOTH endpoints. On a free end it rounds the cap; where two segments share a
* coordinate the coincident spheres fill the miter gap, so the turn reads as
* continuous pipe.
*
* Children are level-local meters; `<ParametricNodeRenderer>` owns the node
* transform (identity today — the path is absolute within the level).
@@ -55,7 +59,10 @@ export function buildLiquidLineGeometry(node: LiquidLineNode): Group {
if (run) group.add(run)
}
for (let i = 1; i < points.length - 1; i++) {
// Spherical caps at every point: interior corners read as continuous pipe,
// and endpoint caps round the open ends so two separate segments sharing a
// coordinate fill the miter and look welded.
for (let i = 0; i < points.length; i++) {
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `liquid-line-joint-${i}`
joint.position.copy(points[i] as Vector3)
-1
View File
@@ -1,4 +1,3 @@
export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
export { liquidLineDefinition } from './definition'
export { buildLiquidLineGeometry } from './geometry'
export { useLiquidLineToolOptions } from './options'
+2 -279
View File
@@ -1,282 +1,5 @@
'use client'
import {
type AnyNodeId,
type LiquidLineNode,
pauseSceneHistory,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
const HANDLE_RADIUS = 0.07
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed liquid-line runs: one draggable handle
* per path point. Mirrors the lineset path-handle system; dragged run
* endpoints snap onto refrigerant ports only.
*
* Handles are PORTALED into the line's registered scene group so they share
* its exact frame. Drag raycasts run in world space and convert hits back into
* the group's local frame before writing the path.
*/
const LiquidLineSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const line = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
})
const lineId = line?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!lineId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [lineId])
if (!line || !target) return null
return createPortal(<LiquidLinePointHandles line={line} target={target} />, target, undefined)
}
const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = line.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene.getState().updateNode(line.id, { path })
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
useScene.getState().updateNode(line.id, { path: drag.initialPath })
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) useScene.getState().updateNode(line.id, { path: finalPath })
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{line.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`liquid-line-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#a5b4fc' : '#818cf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
line.path[draggingIndex] &&
(() => {
const point = line.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default LiquidLineSelectionAffordance
export default createRefrigerantLineSelectionAffordance('liquid-line')
+145 -61
View File
@@ -27,18 +27,18 @@ 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 { planLiquidLineConnect } from './connect'
import { liquidLineDefinition } from './definition'
import { useLiquidLineToolOptions } from './options'
/**
* One-segment-at-a-time placement tool for standalone liquid lines — the same
* draw model as the lineset tool (the line it used to be a rail of):
* 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 re-arms; the in-flight end
* follows the active snapping mode (`angles` locks it to 45°; Shift cycles
* the snapping mode), Alt drags it vertical.
* - **Second click** commits a two-point line and keeps its far end anchored,
* so the next click continues the run; the in-flight end follows the active
* snapping mode (`angles` locks it to 45°; Shift cycles the mode), 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
@@ -121,46 +121,138 @@ function traceOffsetMeters(lineset: LinesetNode): number {
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
}
type FollowTarget = { lineset: LinesetNode; sign: number }
/** 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,
)}`
}
/**
* Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the
* cursor, plus which side of it the cursor is on (`sign`, matching
* `offsetPathHorizontal`'s side convention). Restricted to the active level.
* 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<string, LinesetNode[]>()
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<string>([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()
let best: FollowTarget | null = null
let bestD = FOLLOW_PICK_RADIUS_M
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) continue
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 a = ls.path[i] as Vec3
const b = ls.path[i + 1] as Vec3
const d = distToSegmentXZ(point, a, b)
const d = distToSegmentXZ(point, ls.path[i] as Vec3, ls.path[i + 1] as Vec3)
if (d >= bestD) continue
bestD = d
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which
// side of the segment the cursor sits on.
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
const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz
best = { lineset: ls, sign: dot >= 0 ? 1 : -1 }
hovered = ls
}
}
return best
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 path a follow-target would trace, or null if degenerate. */
/** 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.lineset.path as Vec3[], offset)
const traced = offsetPathHorizontal(target.path, offset)
return traced.length >= 2 ? traced : null
}
@@ -203,47 +295,39 @@ const LiquidLineTool = () => {
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
// Fold into any existing run that shares this segment's endpoint, so two
// runs meeting at a coordinate become one mitered path instead of
// overlapping nodes. Only same-level runs are candidates.
const scene = useScene.getState()
const existing = Object.values(scene.nodes).filter(
(n): n is LiquidLineNode =>
n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId,
)
const plan = planLiquidLineConnect(existing, start, end)
if (plan.kind === 'create') {
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: plan.path,
})
scene.createNode(line, activeLevelId)
} else if (plan.kind === 'extend') {
scene.updateNode(plan.id, { path: plan.path })
} else {
scene.updateNode(plan.id, { path: plan.path })
scene.deleteNode(plan.deleteId)
}
// 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([])
setDraftPoints([end])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
}
// Lay a liquid line beside a lineset, tracing its whole path at the offset.
// 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 scene = useScene.getState()
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: traced,
})
scene.createNode(line, activeLevelId)
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
@@ -478,7 +562,7 @@ const LiquidLineTool = () => {
}}
>
{followTargetRef.current
? 'Click to trace this lineset'
? 'Click to trace this lineset run'
: 'Follow: hover a lineset'}
</div>
</Html>