Files
editor/packages/nodes/src/shared/pipe-vertical-offset.ts
T
a71de82ccb 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>
2026-06-23 08:37:15 -04:00

1104 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type AnyNode,
type AnyNodeId,
PipeSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { getPipeFittingPorts, pipeFittingLegLength } from '../pipe-fitting/ports'
import type { PipeFittingNode } from '../pipe-fitting/schema'
import { type PipeElbowPlan, planPipeElbowAtPort, planPipeElbowRealign } from './auto-fitting'
import type { ScenePort } from './ports'
/**
* Center-cube vertical-move auto-routing for pipe runs.
*
* When a run is lifted / lowered with the run-center cube's ±Y arrows, a
* RUN-connected end should stay welded to its (stationary) partner by way of
* an offset: an elbow on the lifted run, a plumb riser down to the partner's
* height, and a second elbow that meets the partner — the classic pipe S/Z
* offset. Without it, plain connectivity-follow would translate the collinear
* partner run straight up too (it has no turn to absorb the lift), dragging
* the whole network along.
*
* RUN-connected end (partner stays at its old height):
* - top elbow at the lifted endpoint, turning the run axis → vertical;
* - a plumb riser straight down (same X/Z) to one leg above the partner;
* - bottom elbow at the partner joint, turning vertical → the partner's
* axis; the partner run is trimmed back one leg so the elbow replaces
* that stretch.
* The lifted run is trimmed back one leg at the offset end so it meets the
* top elbow's collar instead of overlapping it.
*
* ELBOW-connected end (a clean L): the existing elbow STAYS PUT and re-aims so
* its mated collar swings vertical (flattening toward a straight coupling); a
* plumb riser rises from that collar to a single new TOP elbow that turns back
* along the run axis onto the lifted endpoint. One new elbow + one riser — no
* horizontal jog. Non-elbow fittings (and elbows whose re-aim is out of the
* buildable 1590° range) ride up via plain connectivity-follow instead. Open
* ends likewise just ride up.
*/
type Point = [number, number, number]
type PipeProfile = {
diameter: number
pipeMaterial: PipeFittingNode['pipeMaterial']
}
/** Joint-coincidence epsilon (m), matching core's port connectivity. */
const COINCIDENT_EPS_M = 0.05
/** Shortest riser worth minting — below this there's no room to offset, so
* the caller keeps the plain vertical translate. */
const MIN_RISER_M = 0.05
const MAX_TRANSITION_STEPS = 8
/**
* Three-state outcome of a connected vertical lift:
* - `null` — the run has NO connected ends, so the caller plain-translates it
* (nothing to keep welded; everyone is free to ride along or there's no one).
* - `{ status: 'invalid' }` — at least one connected end CANNOT form a clean
* offset at this height (no room for the elbows + riser, a non-elbow fitting,
* or a re-aim out of the buildable 1590° range). The caller lifts ONLY the
* dragged run as a red preview, freezes every partner, and commits nothing on
* release. We never silently drag the network up to "absorb" the lift.
* - `{ status: 'valid', plan }` — every connected end welds back to its
* stationary partner via the planned offset; the caller lifts + trims, ghosts
* the new fittings green, and mints them on release.
*/
export type VerticalOffsetResult =
| { status: 'valid'; plan: VerticalOffsetPlan }
| { status: 'invalid' }
| null
export type VerticalOffsetPlan = {
/** Actual vertical offset used by the route. This can differ from the raw
* cursor delta when the route snaps through a topology transition. */
dy: number
/** The lifted run's new path: every point raised by `dy`, each RUN-offset
* end trimmed back one elbow-leg to meet its top elbow (fitting / open ends
* keep their lifted endpoint). */
pipePath: Point[]
/** The path to seed the caller's connectivity-follow from: identical to the
* lifted run except each RUN-offset end is reset to its ORIGINAL height, so
* its trimmed partner shows zero delta (we trim it via `updates` instead)
* while a FITTING / open end shows `+dy` — lifting its elbow rigidly and
* lengthening that elbow's riser into a clean L. */
followPath: Point[]
/** Two elbows per RUN-offset end (top + bottom). */
fittings: PipeFittingNode[]
/** One plumb riser per RUN-offset end. */
risers: PipeSegmentNode[]
/** Partner-run trims (the run mated at each RUN-offset end pulled back one
* leg). Fitting partners are rigid and never updated. */
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
/** Existing generated/absorbed parts to remove when an offset collapses into a direct L. */
delete?: AnyNodeId[]
}
function distSq(a: Point | readonly number[], b: Point | readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
function addPoint(a: Point, b: Point): Point {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
function subPoint(a: Point, b: Point): Point {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
function lenSq(v: Point): number {
return v[0] * v[0] + v[1] * v[1] + v[2] * v[2]
}
function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } {
const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]
const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot]
return { parallel, perp: subPoint(delta, parallel) }
}
function distToAxisSq(point: Point, origin: Point, axis: Point): number {
const dx = point[0] - origin[0]
const dy = point[1] - origin[1]
const dz = point[2] - origin[2]
const along = dx * axis[0] + dy * axis[1] + dz * axis[2]
const px = dx - axis[0] * along
const py = dy - axis[1] * along
const pz = dz - axis[2] * along
return px * px + py * py + pz * pz
}
function crossesVerticalTarget(startY: number, endY: number, targetY: number): boolean {
return Math.abs(endY - targetY) <= COINCIDENT_EPS_M || (startY - targetY) * (endY - targetY) < 0
}
/** Outward unit direction at the run endpoint `idx` (0 = start, last = end). */
function endpointOutwardDir(path: ReadonlyArray<readonly number[]>, idx: number): Point {
const last = path.length - 1
const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!]
const d: Point = [a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]
const len = Math.hypot(d[0], d[1], d[2])
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
}
function endpointAxis(path: Point[], portId: string): Point {
const n = path.length
const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!]
const dir = subPoint(b, a)
const l2 = lenSq(dir)
if (l2 < 1e-12) return [0, 0, 0]
const l = Math.sqrt(l2)
return [dir[0] / l, dir[1] / l, dir[2] / l]
}
function collectPipePortsFromNodes(
nodes: Record<string, AnyNode>,
excludeNodeId: AnyNodeId,
): ScenePort[] {
const ports: ScenePort[] = []
for (const node of Object.values(nodes)) {
if (!node || node.id === excludeNodeId) continue
if (node.type === 'pipe-fitting') {
ports.push(
...getPipeFittingPorts(node as PipeFittingNode).map((port) => ({
...port,
nodeId: node.id as AnyNodeId,
})),
)
} else if (node.type === 'pipe-segment') {
const pipe = node as PipeSegmentNode
if (pipe.path.length < 2) continue
ports.push(
{
id: 'start',
nodeId: pipe.id as AnyNodeId,
position: pipe.path[0]!,
direction: endpointOutwardDir(pipe.path, 0),
diameter: 0,
system: pipe.system,
},
{
id: 'end',
nodeId: pipe.id as AnyNodeId,
position: pipe.path[pipe.path.length - 1]!,
direction: endpointOutwardDir(pipe.path, pipe.path.length - 1),
diameter: 0,
system: pipe.system,
},
)
}
}
return ports
}
function collectPipeConnections(
pipe: PipeSegmentNode,
nodes: Record<string, AnyNode>,
scenePorts: ScenePort[],
): PortConnection[] {
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
const portsByNode = new Map<AnyNodeId, ScenePort[]>()
for (const port of scenePorts) {
const list = portsByNode.get(port.nodeId) ?? []
list.push(port)
portsByNode.set(port.nodeId, list)
}
const connections = new Map<AnyNodeId, PortConnection>()
const queue: ScenePort[] = []
for (const endIdx of pipe.path.length > 1 ? [0, pipe.path.length - 1] : [0]) {
const end = pipe.path[endIdx]!
queue.push({
id: endIdx === 0 ? 'start' : 'end',
nodeId: pipe.id as AnyNodeId,
position: end,
direction: endpointOutwardDir(pipe.path, endIdx),
diameter: 0,
system: pipe.system,
})
}
while (queue.length > 0) {
const source = queue.shift()!
for (const mate of scenePorts) {
if (mate.nodeId === source.nodeId) continue
if (source.system && mate.system && source.system !== mate.system) continue
if (distSq(mate.position, source.position) > eps2) continue
if (connections.has(mate.nodeId)) continue
const node = nodes[mate.nodeId]
if (!node) continue
if (node.type === 'pipe-segment') {
connections.set(mate.nodeId, {
kind: 'run',
nodeId: mate.nodeId,
startPath: (node as PipeSegmentNode).path.map((p) => [...p] as Point),
})
} else if (node.type === 'pipe-fitting') {
connections.set(mate.nodeId, {
kind: 'rigid-node',
nodeId: mate.nodeId,
startPosition: [...(node as PipeFittingNode).position] as Point,
})
} else {
continue
}
for (const port of portsByNode.get(mate.nodeId) ?? []) {
if (port.id !== mate.id) queue.push(port)
}
}
}
return [...connections.values()]
}
function mergeUpdates(
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const byId = new Map<AnyNodeId, { id: AnyNodeId; data: Partial<AnyNode> }>()
for (const update of updates) {
byId.set(update.id, {
id: update.id,
data: { ...(byId.get(update.id)?.data ?? {}), ...update.data } as Partial<AnyNode>,
})
}
return [...byId.values()]
}
function uniqueNodeIds(ids: AnyNodeId[]): AnyNodeId[] {
return [...new Set(ids)]
}
function resolveExplicitFollowUpdates(args: {
pipe: PipeSegmentNode
followPath: Point[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
}): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
const movedId = args.pipe.id as AnyNodeId
type LocalPort = { id: string; nodeId: AnyNodeId; position: Point; system?: string }
type LocalNode = {
id: AnyNodeId
role: 'run' | 'fitting'
ports: LocalPort[]
path?: Point[]
position?: Point
}
const portsByNode = new Map<AnyNodeId, LocalPort[]>()
const addPort = (port: LocalPort) => {
const list = portsByNode.get(port.nodeId) ?? []
list.push(port)
portsByNode.set(port.nodeId, list)
}
if (args.pipe.path.length >= 2) {
addPort({
id: 'start',
nodeId: movedId,
position: [...args.pipe.path[0]!] as Point,
system: args.pipe.system,
})
addPort({
id: 'end',
nodeId: movedId,
position: [...args.pipe.path[args.pipe.path.length - 1]!] as Point,
system: args.pipe.system,
})
}
for (const port of args.scenePorts) {
const node = args.nodesById[port.nodeId]
if (!node) continue
let id = port.id
if (node.type === 'pipe-segment') {
const path = (node as PipeSegmentNode).path
if (path.length < 2) continue
if (distSq(port.position, path[0]!) <= eps2) id = 'start'
else if (distSq(port.position, path[path.length - 1]!) <= eps2) id = 'end'
}
addPort({
id,
nodeId: port.nodeId,
position: [...port.position] as Point,
system: port.system,
})
}
const nodes = new Map<AnyNodeId, LocalNode>()
for (const [id, ports] of portsByNode) {
if (id === movedId) {
nodes.set(id, { id, role: 'run', ports, path: args.pipe.path.map((p) => [...p] as Point) })
continue
}
const node = args.nodesById[id]
if (!node) continue
if (node.type === 'pipe-segment') {
nodes.set(id, {
id,
role: 'run',
ports,
path: (node as PipeSegmentNode).path.map((p) => [...p] as Point),
})
} else if (node.type === 'pipe-fitting') {
nodes.set(id, {
id,
role: 'fitting',
ports,
position: [...(node as PipeFittingNode).position] as Point,
})
}
}
const adjacency = new Map<string, { nodeId: AnyNodeId; portId: string }[]>()
const key = (nodeId: AnyNodeId, portId: string) => `${nodeId}:${portId}`
const allPorts = [...nodes.values()].flatMap((node) => node.ports)
for (let i = 0; i < allPorts.length; i++) {
const a = allPorts[i]!
for (let j = i + 1; j < allPorts.length; j++) {
const b = allPorts[j]!
if (a.nodeId === b.nodeId) continue
if (a.system && b.system && a.system !== b.system) continue
if (distSq(a.position, b.position) > eps2) continue
const ak = key(a.nodeId, a.id)
const bk = key(b.nodeId, b.id)
adjacency.set(ak, [...(adjacency.get(ak) ?? []), { nodeId: b.nodeId, portId: b.id }])
adjacency.set(bk, [...(adjacency.get(bk) ?? []), { nodeId: a.nodeId, portId: a.id }])
}
}
const queue: { nodeId: AnyNodeId; portId: string; delta: Point }[] = []
const visited = new Set<AnyNodeId>([movedId])
const enqueueMates = (nodeId: AnyNodeId, portId: string, delta: Point) => {
for (const mate of adjacency.get(key(nodeId, portId)) ?? []) {
if (visited.has(mate.nodeId)) continue
queue.push({ ...mate, delta })
}
}
const start = args.pipe.path[0]
const nextStart = args.followPath[0]
if (start && nextStart) {
const delta = subPoint(nextStart as Point, start as Point)
if (lenSq(delta) > 1e-8) enqueueMates(movedId, 'start', delta)
}
const last = args.pipe.path.length - 1
const end = args.pipe.path[last]
const nextEnd = args.followPath[last]
if (end && nextEnd) {
const delta = subPoint(nextEnd as Point, end as Point)
if (lenSq(delta) > 1e-8) enqueueMates(movedId, 'end', delta)
}
const results: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
while (queue.length > 0) {
const { nodeId, portId, delta } = queue.shift()!
if (visited.has(nodeId)) continue
visited.add(nodeId)
const node = nodes.get(nodeId)
if (!node) continue
if (node.role === 'fitting') {
const position = addPoint(node.position!, delta)
results.push({ id: nodeId, data: { position } as Partial<AnyNode> })
for (const port of node.ports) {
if (port.id !== portId) enqueueMates(nodeId, port.id, delta)
}
continue
}
const path = node.path!.map((p) => [...p] as Point)
const nearIdx = portId === 'start' ? 0 : path.length - 1
const farPortId = portId === 'start' ? 'end' : 'start'
const axis = endpointAxis(path, portId)
const { parallel, perp } = decompose(delta, axis)
const nextPath = path.map((p) => addPoint(p, perp))
nextPath[nearIdx] = addPoint(nextPath[nearIdx]!, parallel)
results.push({ id: nodeId, data: { path: nextPath } as Partial<AnyNode> })
if (lenSq(perp) > 1e-8) enqueueMates(nodeId, farPortId, perp)
}
return results
}
/** Classifies whether the partner run's segment ADJACENT to its tip at
* `endPos` is a plumb existing riser that can safely stretch to `liftedEnd`.
* The lifted joint must stay on the same side of the fixed adjacent vertex;
* otherwise connectivity-follow would collapse the riser to zero or invert it
* through the upper/lower L, which makes connected pipework disappear. */
function alignedVerticalRiserStretch(
path: ReadonlyArray<readonly number[]> | undefined,
endPos: Point,
liftedEnd: Point,
eps2: number,
): 'stretch' | 'invalid' | null {
if (!path || path.length < 2) return null
const tip = path.findIndex((p) => distSq(p, endPos) <= eps2)
if (tip !== 0 && tip !== path.length - 1) return null // not an endpoint joint
const adj = tip === 0 ? path[1]! : path[path.length - 2]!
const dxz = Math.hypot(adj[0]! - endPos[0], adj[2]! - endPos[2])
if (dxz > COINCIDENT_EPS_M) return null
const originalSpan = adj[1]! - endPos[1]
if (Math.abs(originalSpan) <= MIN_RISER_M) return null
const nextSpan = adj[1]! - liftedEnd[1]
if (Math.sign(nextSpan) !== Math.sign(originalSpan) || Math.abs(nextSpan) <= MIN_RISER_M) {
return 'invalid'
}
return 'stretch'
}
/** Minimal ScenePort the elbow planner needs (position + direction + system). */
function portLike(position: Point, direction: Point, system: string): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNodeId,
position,
direction,
diameter: 0,
system,
} as unknown as ScenePort
}
/** A plumb riser pipe-segment between two points, carrying the run's profile. */
function makePipeRiser(from: Point, to: Point, pipe: PipeSegmentNode): PipeSegmentNode {
return PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: pipe.name ?? 'Pipe run',
path: [from, to],
diameter: pipe.diameter,
pipeMaterial: pipe.pipeMaterial,
system: pipe.system,
})
}
function planElbowFromCollar(args: {
collarPoint: Point
elbowPortOutDir: Point
awayDir: Point
pipe: PipeSegmentNode
profile: PipeProfile
}): PipeElbowPlan | null {
const { collarPoint, elbowPortOutDir, awayDir, pipe, profile } = args
const leg = pipeFittingLegLength(profile.diameter)
const junction: Point = [
collarPoint[0] - elbowPortOutDir[0] * leg,
collarPoint[1] - elbowPortOutDir[1] * leg,
collarPoint[2] - elbowPortOutDir[2] * leg,
]
const portDir: Point = [-elbowPortOutDir[0], -elbowPortOutDir[1], -elbowPortOutDir[2]]
return planPipeElbowAtPort(
portLike(junction, portDir, pipe.system),
awayDir,
profile.diameter,
profile.pipeMaterial,
)
}
function pipeElbowProfilePatch(profile: PipeProfile): Partial<PipeFittingNode> {
return {
diameter: profile.diameter,
diameter2: profile.diameter,
pipeMaterial: profile.pipeMaterial,
}
}
type FittingRiserAction =
| { status: 'stretch' }
| {
status: 'collapse'
delete: AnyNodeId[]
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
pipePoint: Point
}
| { status: 'snap'; dy: number }
| null
type RiserCollapseAction =
| {
status: 'collapse'
delete: AnyNodeId[]
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
pipePoint: Point
}
| { status: 'snap'; dy: number }
| null
type TransitionOptions = {
allowTransitionSnap: boolean
allowEarlySnap: boolean
depth: number
}
function directRiserCollapseAction(args: {
riserId: AnyNodeId
riserPath: ReadonlyArray<readonly number[]>
riserTopPoint: Point
liftedPipePoint: Point
pipeEndPoint: Point
pipePortDir: Point
profilePatch: Partial<PipeFittingNode>
connections: PortConnection[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
eps2: number
allowEarlySnap: boolean
}): RiserCollapseAction {
const {
riserId,
riserPath,
riserTopPoint,
liftedPipePoint,
pipeEndPoint,
pipePortDir,
profilePatch,
connections,
scenePorts,
nodesById,
eps2,
allowEarlySnap,
} = args
const tip = riserPath.findIndex((p) => distSq(p, riserTopPoint) <= eps2)
if (tip !== 0 && tip !== riserPath.length - 1) return null
const farPoint = tip === 0 ? riserPath[1]! : riserPath[riserPath.length - 2]!
const farPort = scenePorts.find(
(sp) =>
sp.nodeId !== riserId &&
distSq(sp.position, farPoint) <= eps2 &&
connections.some((c) => c.nodeId === sp.nodeId && c.kind !== 'run'),
)
if (!farPort) return null
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'pipe-fitting') return null
const lowerElbow = { ...(lower as PipeFittingNode), ...profilePatch } as PipeFittingNode
if (lowerElbow.fittingType !== 'elbow') return null
const directDir: Point = [-pipePortDir[0], -pipePortDir[1], -pipePortDir[2]]
const realign = planPipeElbowRealign(lowerElbow, farPort.id, directDir)
if (!realign) return null
const collarOnLiftedLevel: Point = [
realign.collarPoint[0],
liftedPipePoint[1],
realign.collarPoint[2],
]
if (distToAxisSq(collarOnLiftedLevel, liftedPipePoint, pipePortDir) > eps2) return null
if (Math.abs(realign.collarPoint[1] - liftedPipePoint[1]) > COINCIDENT_EPS_M) {
if (
allowEarlySnap ||
crossesVerticalTarget(pipeEndPoint[1], liftedPipePoint[1], realign.collarPoint[1])
) {
return { status: 'snap', dy: realign.collarPoint[1] - pipeEndPoint[1] }
}
return null
}
return {
status: 'collapse',
delete: [riserId],
updates: [
{
id: lowerElbow.id,
data: { ...profilePatch, ...realign.update.data } as Partial<AnyNode>,
},
],
pipePoint: realign.collarPoint,
}
}
function fittingRiserAction(args: {
fitting: PipeFittingNode
matedPortId: string
pipePortDir: Point
pipeEndPoint: Point
liftedPipePoint: Point
profilePatch: Partial<PipeFittingNode>
dy: number
connections: PortConnection[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
eps2: number
allowEarlySnap: boolean
}): FittingRiserAction {
const {
fitting,
matedPortId,
pipePortDir,
pipeEndPoint,
liftedPipePoint,
profilePatch,
dy,
connections,
scenePorts,
nodesById,
eps2,
allowEarlySnap,
} = args
const ports = getPipeFittingPorts(fitting)
for (const port of ports) {
if (port.id === matedPortId) continue
const otherPort = scenePorts.find(
(sp) =>
sp.nodeId !== fitting.id &&
distSq(sp.position, port.position) <= eps2 &&
connections.some((c) => c.nodeId === sp.nodeId && c.kind === 'run'),
)
if (!otherPort) continue
const partner = nodesById[otherPort.nodeId]
const partnerPath = (partner as unknown as { path?: Point[] } | undefined)?.path
const portPosition: Point = [port.position[0], port.position[1], port.position[2]]
const liftedPort: Point = [port.position[0], port.position[1] + dy, port.position[2]]
const stretch = alignedVerticalRiserStretch(partnerPath, portPosition, liftedPort, eps2)
if ((stretch === 'stretch' || stretch === 'invalid') && partnerPath) {
const tip = partnerPath.findIndex((p) => distSq(p, portPosition) <= eps2)
if (tip !== 0 && tip !== partnerPath.length - 1) {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
const farPoint = tip === 0 ? partnerPath[1]! : partnerPath[partnerPath.length - 2]!
const farPort = scenePorts.find(
(sp) =>
sp.nodeId !== fitting.id &&
sp.nodeId !== otherPort.nodeId &&
distSq(sp.position, farPoint) <= eps2 &&
connections.some((c) => c.nodeId === sp.nodeId && c.kind !== 'run'),
)
if (!farPort) {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'pipe-fitting') {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
const lowerElbow = { ...(lower as PipeFittingNode), ...profilePatch } as PipeFittingNode
if (lowerElbow.fittingType !== 'elbow') {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
const directDir: Point = [-pipePortDir[0], -pipePortDir[1], -pipePortDir[2]]
const realign = planPipeElbowRealign(lowerElbow, farPort.id, directDir)
if (!realign) {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
const collarOnLiftedLevel: Point = [
realign.collarPoint[0],
liftedPipePoint[1],
realign.collarPoint[2],
]
if (distToAxisSq(collarOnLiftedLevel, liftedPipePoint, pipePortDir) > eps2) {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
if (Math.abs(realign.collarPoint[1] - liftedPipePoint[1]) > COINCIDENT_EPS_M) {
if (
crossesVerticalTarget(pipeEndPoint[1], liftedPipePoint[1], realign.collarPoint[1]) ||
(allowEarlySnap && stretch === 'invalid')
) {
return { status: 'snap', dy: realign.collarPoint[1] - pipeEndPoint[1] }
}
if (stretch === 'stretch' || stretch === 'invalid') return { status: 'stretch' }
continue
}
return {
status: 'collapse',
delete: [fitting.id as AnyNodeId, otherPort.nodeId],
updates: [
{
id: lowerElbow.id,
data: { ...profilePatch, ...realign.update.data } as Partial<AnyNode>,
},
],
pipePoint: realign.collarPoint,
}
}
if (stretch === 'stretch') {
return { status: 'stretch' }
}
if (stretch !== 'invalid' || !partnerPath) continue
}
return null
}
export function planVerticalOffsets(args: {
pipe: PipeSegmentNode
/** Signed vertical move (meters); +up / -down. */
dy: number
profile: PipeProfile
/** The drag-start connectivity snapshot's connections. */
connections: PortConnection[]
/** Scene ports (excluding the lifted run) for partner direction lookup. */
scenePorts: ScenePort[]
/** Drag-start node snapshots keyed by id, so a connected elbow's ORIGINAL
* pose can be re-aimed each frame (the live store carries the last frame's
* re-aim). */
nodesById: Record<string, AnyNode>
}): VerticalOffsetResult {
return planVerticalOffsetsAtDy(args, args.dy, {
allowTransitionSnap: true,
allowEarlySnap: true,
depth: 0,
})
}
function planVerticalOffsetsAtDy(
args: {
pipe: PipeSegmentNode
dy: number
profile: PipeProfile
connections: PortConnection[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
},
routeDy: number,
options: TransitionOptions,
): VerticalOffsetResult {
const { pipe, profile, connections, scenePorts, nodesById } = args
const dy = routeDy
// No connected ends: nothing to weld, so the caller plain-translates the run.
if (connections.length === 0) return null
const leg = pipeFittingLegLength(profile.diameter)
// A connected end that must MINT an offset (two elbows + a real riser) needs
// room for both legs plus a non-degenerate riser. Checked per-end below, not
// globally — a partner that's already a vertical riser just STRETCHES and
// needs no such room, so a tiny lift on a riser-connected run is still valid.
const hasOffsetRoom = Math.abs(dy) - 2 * leg >= MIN_RISER_M
const startPath = pipe.path.map((p) => [...p] as Point)
const last = startPath.length - 1
const vSign = Math.sign(dy)
const up: Point = [0, vSign, 0]
const down: Point = [0, -vSign, 0]
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
// Lift the whole run; connected ends get adjusted below.
const pipePath = startPath.map((p) => [p[0], p[1] + dy, p[2]] as Point)
// Seed for the caller's connectivity-follow: starts as the lifted path, but
// each RUN-offset end is reset to its original point below so that end shows
// zero delta (its partner is trimmed via `updates`, not dragged), while any
// FITTING / open end stays lifted so its partner follows.
const followPath = startPath.map((p) => [p[0], p[1] + dy, p[2]] as Point)
const fittings: PipeFittingNode[] = []
const risers: PipeSegmentNode[] = []
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
const deletes: AnyNodeId[] = []
let offsetAny = false
for (const endIdx of last > 0 ? [0, last] : [0]) {
const endPos = startPath[endIdx]!
// Partner port sitting on this end, owned by a snapshotted connection.
const partnerPort = scenePorts.find(
(sp) =>
distSq(sp.position, endPos) <= eps2 && connections.some((c) => c.nodeId === sp.nodeId),
)
if (!partnerPort) continue // open end → just rides up
const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)!
const pipePortDir = endpointOutwardDir(startPath, endIdx)
const liftedEnd: Point = [endPos[0], endPos[1] + dy, endPos[2]]
// FITTING partner: a clean L. The existing ELBOW stays put and re-aims so
// its mated collar swings vertical (flattening toward a straight coupling);
// a plumb riser rises from that collar to a single new TOP elbow that turns
// back along the run axis onto the lifted endpoint. A non-elbow fitting, or
// an elbow whose re-aim falls out of the buildable range, can't form a clean
// offset here → the whole lift is invalid (the caller freezes everything and
// shows a red preview rather than dragging the network up to absorb it).
if (conn.kind !== 'run') {
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'pipe-fitting') return { status: 'invalid' }
const elbow = partner as PipeFittingNode
const profilePatch = pipeElbowProfilePatch(profile)
const profiledElbow = { ...elbow, ...profilePatch } as PipeFittingNode
if (profiledElbow.fittingType !== 'elbow') {
if (!hasOffsetRoom) return { status: 'invalid' }
const partnerDir: Point = [
partnerPort.direction[0],
partnerPort.direction[1],
partnerPort.direction[2],
]
const leg = pipeFittingLegLength(profile.diameter)
const pipeCollar: Point = [
liftedEnd[0] - pipePortDir[0] * leg * 2,
liftedEnd[1] - pipePortDir[1] * leg * 2,
liftedEnd[2] - pipePortDir[2] * leg * 2,
]
const bottom = planElbowFromCollar({
collarPoint: endPos,
elbowPortOutDir: [-partnerDir[0], -partnerDir[1], -partnerDir[2]],
awayDir: up,
pipe,
profile,
})
const top = planElbowFromCollar({
collarPoint: pipeCollar,
elbowPortOutDir: [-pipePortDir[0], -pipePortDir[1], -pipePortDir[2]],
awayDir: down,
pipe,
profile,
})
if (!bottom || !top) return { status: 'invalid' }
fittings.push(bottom.fitting, top.fitting)
risers.push(makePipeRiser(bottom.collarPoint, top.collarPoint, pipe))
pipePath[endIdx] = top.trimmedPortPoint
followPath[endIdx] = [...startPath[endIdx]!] as Point
offsetAny = true
continue
}
const riserAction = fittingRiserAction({
fitting: profiledElbow,
matedPortId: partnerPort.id,
pipePortDir,
pipeEndPoint: endPos,
liftedPipePoint: liftedEnd,
profilePatch,
dy,
connections,
scenePorts,
nodesById,
eps2,
allowEarlySnap: options.allowEarlySnap,
})
if (riserAction?.status === 'stretch') {
offsetAny = true
continue
}
if (riserAction?.status === 'collapse') {
pipePath[endIdx] = riserAction.pipePoint
followPath[endIdx] = [...startPath[endIdx]!] as Point
updates.push(...riserAction.updates)
deletes.push(...riserAction.delete)
offsetAny = true
continue
}
if (riserAction?.status === 'snap' && options.allowTransitionSnap) {
return continueAfterTransitionSnap(args, routeDy, riserAction.dy, options)
}
if (riserAction?.status === 'snap') return { status: 'invalid' }
// Minting the top elbow + riser needs elbow-leg + riser room.
if (!hasOffsetRoom) return { status: 'invalid' }
// Re-aim the existing elbow's mated collar to vertical; its other collar
// (mated to the rest of the run) stays fixed.
const realign = planPipeElbowRealign(profiledElbow, partnerPort.id, up)
if (!realign) return { status: 'invalid' }
// Top elbow: junction plumb above the elbow at the lifted height. Its
// "existing run" is the riser, whose top port faces UP; the new run
// leaves back along the run axis (awayBack) onto the lifted endpoint.
const topJunction: Point = [elbow.position[0], liftedEnd[1], elbow.position[2]]
const awayBack: Point = [-pipePortDir[0], -pipePortDir[1], -pipePortDir[2]]
const top = planPipeElbowAtPort(
portLike(topJunction, up, pipe.system),
awayBack,
profile.diameter,
profile.pipeMaterial,
)
if (!top) return { status: 'invalid' }
fittings.push(top.fitting)
// Plumb riser: the re-aimed elbow's vertical collar up to the top elbow's
// riser collar (its trimmedPortPoint) — both at the elbow's XZ.
risers.push(makePipeRiser(realign.collarPoint, top.trimmedPortPoint, pipe))
// Re-aim patch for the existing elbow.
updates.push({
id: elbow.id,
data: { ...profilePatch, ...realign.update.data } as Partial<AnyNode>,
})
// Lifted run ends on the top elbow's outlet collar (= the lifted
// endpoint); zero this end's connectivity-follow delta — the re-aim
// already reconnects it.
pipePath[endIdx] = top.collarPoint
followPath[endIdx] = [...startPath[endIdx]!] as Point
offsetAny = true
continue
}
const partnerDir: Point = [
partnerPort.direction[0],
partnerPort.direction[1],
partnerPort.direction[2],
]
const profilePatch = pipeElbowProfilePatch(profile)
// Aligned vertical riser: the partner run is already plumb and the lift runs
// along its axis, so there's nothing to turn — just STRETCH it. We leave this
// end of `followPath` lifted; connectivity-follow decomposes the Y-lift into
// the riser's (parallel) axis and slides the shared joint up while the far
// end stays put. No elbows, no new riser, no partner trim. Works at any lift
// height (a stretch needs no elbow-leg room).
const partner = nodesById[conn.nodeId]
const partnerPath = (partner as unknown as { path?: Point[] } | undefined)?.path
const riserStretch =
partner?.type === 'pipe-segment'
? alignedVerticalRiserStretch(partnerPath, endPos, liftedEnd, eps2)
: null
if (riserStretch === 'invalid' && partner?.type === 'pipe-segment' && partnerPath) {
const collapse = directRiserCollapseAction({
riserId: conn.nodeId,
riserPath: partnerPath,
riserTopPoint: endPos,
liftedPipePoint: liftedEnd,
pipeEndPoint: endPos,
pipePortDir,
profilePatch,
connections,
scenePorts,
nodesById,
eps2,
allowEarlySnap: options.allowEarlySnap,
})
if (collapse?.status === 'snap' && options.allowTransitionSnap) {
return continueAfterTransitionSnap(args, routeDy, collapse.dy, options)
}
if (collapse?.status === 'collapse') {
pipePath[endIdx] = collapse.pipePoint
followPath[endIdx] = [...startPath[endIdx]!] as Point
updates.push(...collapse.updates)
deletes.push(...collapse.delete)
offsetAny = true
continue
}
return { status: 'invalid' }
}
if (riserStretch === 'invalid') return { status: 'invalid' }
if (riserStretch === 'stretch') {
// followPath[endIdx] stays at the lifted point (set above); pipePath too.
offsetAny = true
continue
}
// Minting an offset here needs elbow-leg + riser room. Without it this end
// can't form a clean offset at this height → the whole lift is invalid.
if (!hasOffsetRoom) return { status: 'invalid' }
// Bottom elbow at the partner joint: turn from the partner's axis up the
// riser. Partner run trims to its inlet collar.
const bottom = planPipeElbowAtPort(
portLike(endPos, partnerDir, pipe.system),
up,
profile.diameter,
profile.pipeMaterial,
)
// Top elbow at the lifted endpoint: turn from the run axis down the
// riser. Lifted run trims to its inlet collar.
const top = planPipeElbowAtPort(
portLike(liftedEnd, pipePortDir, pipe.system),
down,
profile.diameter,
profile.pipeMaterial,
)
if (!bottom || !top) return { status: 'invalid' }
fittings.push(bottom.fitting, top.fitting)
risers.push(makePipeRiser(bottom.collarPoint, top.collarPoint, pipe))
pipePath[endIdx] = top.trimmedPortPoint
// This end's partner is trimmed (below), not dragged — keep its follow-seed
// at the original height so connectivity-follow sees zero delta here.
followPath[endIdx] = [...startPath[endIdx]!] as Point
// Trim the partner run's mated end back one leg.
const path = conn.startPath.map((p) => [...p] as Point)
const tip = path.findIndex((p) => distSq(p, endPos) <= eps2)
if (tip !== -1) {
path[tip] = bottom.trimmedPortPoint
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
}
offsetAny = true
}
// Every connected end either offset cleanly or there were none to offset.
// `offsetAny` false here means all ends were open — nothing to weld, plain
// translate. Otherwise a valid offset plan welds each connected end.
if (!offsetAny) return null
return {
status: 'valid',
plan: { dy, pipePath, followPath, fittings, risers, updates, delete: deletes },
}
}
function continueAfterTransitionSnap(
args: {
pipe: PipeSegmentNode
dy: number
profile: PipeProfile
connections: PortConnection[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
},
requestedDy: number,
snappedDy: number,
options: TransitionOptions,
): VerticalOffsetResult {
const snapped = planVerticalOffsetsAtDy(args, snappedDy, {
allowTransitionSnap: false,
allowEarlySnap: false,
depth: options.depth,
})
if (snapped?.status !== 'valid') return snapped
const remainingDy = requestedDy - snapped.plan.dy
if (Math.abs(remainingDy) <= MIN_RISER_M || Math.sign(remainingDy) !== Math.sign(requestedDy)) {
return snapped
}
if (options.depth >= MAX_TRANSITION_STEPS) return snapped
const nodesById: Record<string, AnyNode> = { ...args.nodesById }
for (const id of snapped.plan.delete ?? []) {
delete nodesById[id]
}
for (const update of snapped.plan.updates) {
const current = nodesById[update.id]
if (current) nodesById[update.id] = { ...current, ...update.data } as AnyNode
}
for (const update of resolveExplicitFollowUpdates({
pipe: args.pipe,
followPath: snapped.plan.followPath,
scenePorts: args.scenePorts,
nodesById: args.nodesById,
})) {
const current = nodesById[update.id]
if (current) nodesById[update.id] = { ...current, ...update.data } as AnyNode
}
for (const node of [...snapped.plan.fittings, ...snapped.plan.risers]) {
nodesById[node.id as AnyNodeId] = node as AnyNode
}
const collapsedPipe = PipeSegmentNode.parse({
...args.pipe,
path: snapped.plan.pipePath,
})
nodesById[collapsedPipe.id as AnyNodeId] = collapsedPipe as AnyNode
const scenePorts = collectPipePortsFromNodes(nodesById, collapsedPipe.id as AnyNodeId)
const connections = collectPipeConnections(collapsedPipe, nodesById, scenePorts)
const continued = planVerticalOffsetsAtDy(
{
...args,
pipe: collapsedPipe,
dy: remainingDy,
connections,
scenePorts,
nodesById,
},
remainingDy,
{
allowTransitionSnap: true,
allowEarlySnap: false,
depth: options.depth + 1,
},
)
if (continued?.status !== 'valid') return snapped
return {
status: 'valid',
plan: {
dy: snapped.plan.dy + continued.plan.dy,
pipePath: continued.plan.pipePath,
followPath: continued.plan.followPath,
fittings: [...snapped.plan.fittings, ...continued.plan.fittings],
risers: [...snapped.plan.risers, ...continued.plan.risers],
updates: mergeUpdates([...snapped.plan.updates, ...continued.plan.updates]),
delete: uniqueNodeIds([...(snapped.plan.delete ?? []), ...(continued.plan.delete ?? [])]),
},
}
}