nodes: add MEP movement controls and DWV parity (#438)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* feat(duct): ceiling-snap drawing + connected-joint endpoint move

Duct draw tool's ceiling mode now hangs each path point just below the
ceiling actually covering it (per-room heights tracked), with a
translucent surface highlight and a plumb line to the floor so the
in-flight point reads clearly from any angle.

Dragging a duct corner that sits on a fitting now carries the fitting's
other ducts along (port-connectivity second hop), so the joint moves
together instead of tearing apart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): detach + vertical modifiers for duct/pipe joint editing

Alt detaches a dragged duct/pipe endpoint or fitting from its connected
joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical
riser movement on the fitting move. Behavioral parity across 2D and 3D.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): full DWV pipe parity for joint editing

Bring pipe-segment endpoint drags and pipe-fitting moves to parity with
duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow
re-aim, and connectivity follow. Generalizes the shared elbow-reaim and
auto-fitting helpers to dispatch by run kind so 2D and 3D share one path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): wall-style arrow handles for duct fittings + segments

Add violet directional arrow affordances to duct-fitting selection (height,
move cross, rotate arc) mirroring the duct-segment rig: portaled into the
parent frame to stay out of the selection outline, rendered via the shared
HandleArrow, and carrying mated-run connectivity through the single-undo
dance. The move cross engages press-drag-release (placementDragMode) the same
way the floating drag does, so the markup hit-areas go inert and the fitting
move tool commits on pointer-up.

Also re-export the HandleArrow primitives from @pascal-app/editor and extend
the duct-segment side-move/floorplan affordances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): click-to-latch cube handles for duct + fitting editing

Replace the hover-reveal / multi-handle selection rigs with a single
click-to-latch cube that opens a directional cluster, shared between
duct segments and fittings via a new selection-handles module
(HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube).

- Duct segment: per-vertex + run-center cubes reveal axis-locked move
  chevrons (down arrow always shown), plus a roll arc at the run center.
- Duct fitting: center cube reveals six ±XYZ move arrows and three
  per-axis rotation arcs (oriented in place), replacing the old
  height/move/rotate trio with axis-cycling.
- Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth.
- thin chevron profile + press-drag-release commit retained.

* fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag

Build a fully-determined basis for the duct roll gizmo so the curved
arrow always seats at the top-outer 45° corner regardless of run
direction, instead of an arbitrary apex from a single setFromUnitVectors.
The selection-rig ±Y arrows now own vertical movement, so the redundant
Ctrl-modifier riser drag is removed from the fitting move tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): run-aligned duct handles, swing snapping, elbow flatten

Align the duct run-center cube + horizontal arrows to the run axis
(matching the per-vertex handles) while keeping whole-run translate.
Endpoint side / up-down swing arrows now follow grid snap points and
port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow
realign + fitting schemas to flatten to a straight 0° coupling. Surface
HVAC-specific hints in the select-mode helper panel for duct / fitting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): per-segment linesets/liquid-lines with joint-follow editing

Linesets and liquid lines now commit one independent two-point node per
drawn segment instead of folding into a single mitered polyline, so each
line selects and deletes on its own. Endpoint caps fill shared-coordinate
joints so connected segments still read as continuous pipe.

Dragging a shared endpoint carries mated segments along via port
connectivity (Alt detaches), so a run still edits as one welded piece.
Liquid-line follow mode traces the whole connected lineset run, laying a
per-segment parallel line down its full assembled length.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag

Lifting/lowering a connected run with the run-center cube now keeps each
connected end welded to its stationary partner instead of dragging the
whole network. Run-to-run ends get the classic S/Z offset (two elbows +
plumb riser, partner trimmed back one leg); elbow-connected ends form a
clean L — the existing elbow stays put and re-aims its collar vertical,
with one new top elbow + riser reconnecting to the lifted endpoint. The
offset is ghosted live and minted as a single undo step on release.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add roof accessory placement guides

Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters.

* Improve duct and placement routing

* Fix duct vertical movement routing

* Fix duct vertical offsets and roof accessory movement

* Add DWV movement parity and line endpoint controls

* Fix MEP handle review issues

* Fix chimney placement and duct offset cleanup

* Use snapped targets for roof accessory commits

* fix(nodes): repair MEP movement review issues

- auto-fitting: tee branch now follows the drawn lateral angle; update the
  stale square-tee test + doc comment that contradicted the rewrite
- duct-segment: re-enable the vertical auto-offset rewind (the disabled stub
  left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers
  and stacked duplicates); remove the dead stub
- duct-segment: strip the stale auto-offset tag on manual corner/roll commits
  so the horizontal-move path no longer trusts an out-of-date base
- chimney: resume history before mutating segment children arrays so a
  cross-segment move reparents in one tracked transaction (undo stays consistent)
- dormer: align schema test with the new windowSill=false default

* fix: address mep movement review issues

* fix: address follow-up mep review comments

* fix: address additional mep review comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-06-23 08:37:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 pascal-open-bot
parent ae90da6554
commit a71de82ccb
129 changed files with 15799 additions and 2049 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')
+144 -60
View File
@@ -24,17 +24,17 @@ 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
* is angle-locked to 45° (Shift frees it), Alt drags it vertical.
* - **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
@@ -117,46 +117,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
}
@@ -199,47 +291,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
@@ -471,7 +555,7 @@ const LiquidLineTool = () => {
}}
>
{followTargetRef.current
? 'Click to trace this lineset'
? 'Click to trace this lineset run'
: 'Follow: hover a lineset'}
</div>
</Html>