Sudhir Yadav 5551500d98
feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
2026-06-16 15:30:39 -04:00

77 lines
3.4 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 { Vector3 } from 'three'
type Point = [number, number, number]
const UP = new Vector3(0, 1, 0)
const FALLBACK_PERP = new Vector3(1, 0, 0)
/** Cap on the miter-length multiplier so a sharp turn doesn't shoot the
* corner off to infinity — past this we'd want a bevel, but MEP runs bend
* gently enough that clamping is invisible. */
const MITER_LIMIT = 4
/**
* Horizontal side vector for each path segment — the axis a parallel line is
* pushed apart along, kept HORIZONTAL so the offset never tilts. A vertical
* (riser) segment has no horizontal heading of its own, so it inherits the
* side vector from the nearest segment that does; this keeps the offset line
* beside the source as the run climbs instead of rotating about the bend.
* Falls back to the X axis only if the whole path is vertical.
*/
function segmentSides(points: Vector3[]): Vector3[] {
const sides: (Vector3 | null)[] = []
for (let i = 0; i < points.length - 1; i++) {
const dir = new Vector3().subVectors(points[i + 1]!, points[i]!)
const horizontal = new Vector3(dir.x, 0, dir.z)
sides.push(horizontal.lengthSq() < 1e-9 ? null : horizontal.normalize().cross(UP).normalize())
}
// Forward then backward fill so vertical segments adopt a real heading.
for (let i = 1; i < sides.length; i++) if (!sides[i]) sides[i] = sides[i - 1] ?? null
for (let i = sides.length - 2; i >= 0; i--) if (!sides[i]) sides[i] = sides[i + 1] ?? null
return sides.map((s) => s ?? FALLBACK_PERP.clone())
}
/**
* Per-vertex offset vectors for shifting a path sideways into a parallel line.
* At an interior vertex the offset follows the angle bisector of the two
* adjacent segment side vectors, scaled by `1/cos(half-angle)` so the offset
* segments on either side of the bend meet exactly at one miter point (a plain
* per-segment side leaves them crossing/gapping). Endpoints use their single
* segment's side. Side vectors are horizontal, so the offset is too — a
* horizontal→vertical bend keeps the same side (cos 1, no expansion), leaving
* the parallel line side by side up the riser.
*/
function miterOffsets(points: Vector3[], offset: number): Vector3[] {
const sides = segmentSides(points)
return points.map((_p, i) => {
const sIn = i > 0 ? sides[i - 1]! : null
const sOut = i < sides.length ? sides[i]! : null
if (sIn && sOut) {
const bisector = sIn.clone().add(sOut)
// s_in == -s_out → a 180° switchback; the bisector vanishes, so just
// run straight out on one side.
if (bisector.lengthSq() < 1e-9) return sIn.clone().multiplyScalar(offset)
bisector.normalize()
const cos = bisector.dot(sIn)
const scale = Math.min(MITER_LIMIT, 1 / Math.max(cos, 1 / MITER_LIMIT))
return bisector.multiplyScalar(offset * scale)
}
return (sIn ?? sOut)!.clone().multiplyScalar(offset)
})
}
/**
* Offset a polyline horizontally by `offset` meters to one side, mitered at
* bends so the parallel line meets cleanly. Positive `offset` shifts along the
* `+UP × heading` side of each segment; negative flips to the other side. Used
* to lay a thin line beside an existing run (the liquid-line follow-trace).
*/
export function offsetPathHorizontal(path: readonly Point[], offset: number): Point[] {
const points = path.map(([x, y, z]) => new Vector3(x, y, z))
const offsets = miterOffsets(points, offset)
return points.map((p, i) => {
const o = p.clone().add(offsets[i]!)
return [o.x, o.y, o.z] as Point
})
}