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.
This commit is contained in:
Sudhir Yadav
2026-06-16 15:30:39 -04:00
committed by GitHub
parent a0d3d9c701
commit 5551500d98
172 changed files with 17361 additions and 150 deletions
+463
View File
@@ -0,0 +1,463 @@
import {
BufferGeometry,
CylinderGeometry,
DoubleSide,
Euler,
Float32BufferAttribute,
Group,
Mesh,
type MeshStandardMaterial,
SphereGeometry,
TorusGeometry,
Vector3,
} from 'three'
import {
buildOvalSection,
buildRectSection,
buildSection,
createDuctMaterial,
INCHES_TO_METERS,
} from '../duct-segment/geometry'
import { localFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
const RADIAL_SEGMENTS = 24
const UP = new Vector3(0, 1, 0)
/**
* Mitered rectangular elbow as ONE closed solid — the way sheet-metal
* square elbows are actually folded. The rect profile sweeps from the
* inlet face to the outlet face through a single miter ring lying on
* the corner's bisector plane (the classic 2D miter-join offset:
* join(u) = (wA + wB) · u / (1 + wA·wB)), so the two legs meet in a
* crisp seam instead of interpenetrating boxes.
*
* Local frame: legs in the XZ plane (ports convention) so the fold hinge
* is always local Y. `sweepM` is the profile dimension carried through the
* bend (in the XZ bend plane); `cheekM` is the dimension that stays
* constant along the hinge. Which physical dimension (width vs height)
* plays each role depends on the elbow's world orientation and is decided
* by the caller — a floor turn folds about vertical (cheek = height),
* a wall riser folds about horizontal (cheek = width).
*
* Non-indexed triangles → flat face normals for the folded-metal look;
* the closed solid renders double-sided so winding never makes a face
* vanish.
*/
/**
* Stadium (flat-oval) outline in profile (u, v) coordinates: u-extent
* `uM`, v-extent `vM`, semicircular caps of the smaller dimension. The
* caps land on whichever axis is longer, so a riser-rotated profile
* (swapped roles) stays a valid stadium.
*/
function stadiumOutline(uM: number, vM: number, samplesPerCap = 10): Array<[number, number]> {
const pts: Array<[number, number]> = []
const r = Math.min(uM, vM) / 2
const s = (Math.max(uM, vM) - Math.min(uM, vM)) / 2
const cap = (cu: number, cv: number, startA: number) => {
for (let i = 0; i <= samplesPerCap; i++) {
const a = startA + (Math.PI * i) / samplesPerCap
pts.push([cu + r * Math.cos(a), cv + r * Math.sin(a)])
}
}
if (uM >= vM) {
cap(s, 0, -Math.PI / 2)
cap(-s, 0, Math.PI / 2)
} else {
cap(0, s, 0)
cap(0, -s, Math.PI)
}
return pts
}
function buildMiteredElbow(
inletPos: Vector3,
outletPos: Vector3,
sweepM: number,
cheekM: number,
profileShape: 'rect' | 'oval',
material: MeshStandardMaterial,
): Mesh {
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
const travelOut = outletPos.clone().normalize() // junction → outlet
const wA = new Vector3().crossVectors(UP, travelIn).normalize()
const wB = new Vector3().crossVectors(UP, travelOut).normalize()
// Elbow turns are ≤ 90°, so wA·wB ≥ 0 and the join never degenerates.
const miterScale = 1 / (1 + wA.dot(wB))
const wJoin = new Vector3().addVectors(wA, wB)
const hw = sweepM / 2
const hh = cheekM / 2
const corners: Array<[number, number]> =
profileShape === 'oval'
? stadiumOutline(sweepM, cheekM)
: [
[hw, hh],
[-hw, hh],
[-hw, -hh],
[hw, -hh],
]
const n = corners.length
const ring = (center: Vector3, uAxis: Vector3, scale = 1): Vector3[] =>
corners.map(([u, v]) =>
center
.clone()
.addScaledVector(uAxis, u * scale)
.addScaledVector(UP, v),
)
const inletRing = ring(inletPos, wA)
const miterRing = ring(new Vector3(0, 0, 0), wJoin, miterScale)
const outletRing = ring(outletPos, wB)
const positions: number[] = []
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
const quad = (a: Vector3, b: Vector3, c: Vector3, d: Vector3) => {
tri(a, b, c)
tri(a, c, d)
}
const skin = (from: Vector3[], to: Vector3[]) => {
for (let k = 0; k < n; k++) {
const k2 = (k + 1) % n
quad(from[k]!, to[k]!, to[k2]!, from[k2]!)
}
}
skin(inletRing, miterRing)
skin(miterRing, outletRing)
// End caps — triangle fans so any convex profile closes.
for (let k = 1; k < n - 1; k++) {
tri(inletRing[0]!, inletRing[k]!, inletRing[k + 1]!)
tri(outletRing[k + 1]!, outletRing[k]!, outletRing[0]!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
const solidMaterial = material.clone()
solidMaterial.side = DoubleSide
const mesh = new Mesh(geometry, solidMaterial)
mesh.name = `fitting-elbow-${profileShape}`
return mesh
}
/**
* Square-to-round loft between a rect ring at `xRect` and a round ring
* at `xRound`, both centered on the local X axis (the straight-through
* run). Profiles are sampled at matching polar angles — the rect point
* is the ray's intersection with the rectangle boundary — so the skin
* twists nowhere. Non-indexed triangles + computed normals give the
* faceted gore look of a real shop-made square-to-round.
*/
function buildRectToRoundLoft(
xRect: number,
xRound: number,
widthM: number,
heightM: number,
radius: number,
material: MeshStandardMaterial,
): Mesh {
const hw = widthM / 2
const hh = heightM / 2
const rectRing: Vector3[] = []
const roundRing: Vector3[] = []
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
const theta = (2 * Math.PI * i) / RADIAL_SEGMENTS
const cz = Math.cos(theta)
const sy = Math.sin(theta)
// Scale the unit ray until it hits the rectangle boundary. Width
// spans local Z and height local Y — the same axes buildRectSection
// gives a +X run.
const t = 1 / Math.max(Math.abs(cz) / hw, Math.abs(sy) / hh)
rectRing.push(new Vector3(xRect, t * sy, t * cz))
roundRing.push(new Vector3(xRound, radius * sy, radius * cz))
}
const positions: number[] = []
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
const j = (i + 1) % RADIAL_SEGMENTS
tri(rectRing[i]!, roundRing[i]!, roundRing[j]!)
tri(rectRing[i]!, roundRing[j]!, rectRing[j]!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
const solidMaterial = material.clone()
solidMaterial.side = DoubleSide
const mesh = new Mesh(geometry, solidMaterial)
mesh.name = 'fitting-transition-loft'
return mesh
}
/**
* Pure geometry builder for a duct fitting, in the fitting's LOCAL frame —
* `<ParametricNodeRenderer>` applies `node.position` / `node.rotation`.
*
* Strategy: one cylinder stub per port from the junction center outward
* (reusing the segment builder's `buildSection`), a sphere at the
* junction, and a slightly-oversized crimp collar ring at each port
* opening so fittings read as sheet-metal junctions rather than bare
* tube ends.
*
* The reducer is special-cased: instead of equal stubs + sphere it draws
* a short inlet stub, a tapered cone, and a short outlet stub inline.
*
* Non-round shapes (elbow / tee): run legs carry the fitting's
* width × height profile — rect prisms or flat-oval stadiums — matching
* the trunk they join; a tee's branch leg carries its own `shape2`
* profile (width2 × height2, or round at `diameter2`). The profile's
* height rides local +Y — for the horizontal-plane orientations trunks
* are drawn in, that's world-vertical.
*/
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
const group = new Group()
const material = createDuctMaterial(node)
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
const ports = localFittingPorts(node)
const widthM = node.width * INCHES_TO_METERS
const heightM = node.height * INCHES_TO_METERS
// The elbow folds about its local Y. Width spans the XZ bend plane and
// height rides the hinge ONLY when local Y is world-vertical (a floor
// turn). For a riser the node is rotated so local Y lands horizontal —
// then it's width that runs along the hinge, so the roles swap. Pick by
// where world-up sits in the fitting's local frame.
const hingeWorld = UP.clone().applyEuler(
new Euler(node.rotation[0], node.rotation[1], node.rotation[2]),
)
const hingeIsVertical = Math.abs(hingeWorld.y) >= Math.SQRT1_2
if (node.fittingType === 'reducer') {
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
const inlet = ports[0]!
const outlet = ports[1]!
const taperHalf = Math.abs(inlet.position.x) / 3
const stubA = buildSection(
inlet.position,
new Vector3(-taperHalf, 0, 0),
radiusMain,
material,
'fitting-stub-inlet',
)
if (stubA) group.add(stubA)
const cone = new Mesh(
new CylinderGeometry(radiusOut, radiusMain, taperHalf * 2, RADIAL_SEGMENTS, 1, false),
material,
)
cone.name = 'fitting-taper'
cone.quaternion.setFromUnitVectors(UP, new Vector3(1, 0, 0))
group.add(cone)
const stubB = buildSection(
new Vector3(taperHalf, 0, 0),
outlet.position,
radiusOut,
material,
'fitting-stub-outlet',
)
if (stubB) group.add(stubB)
} else if (node.fittingType === 'transition') {
// Square-to-round: rect stub on the inlet, lofted gore body through
// the junction, round stub on the outlet. Same inline layout as the
// reducer, with the taper replaced by the loft.
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
const inlet = ports[0]!
const outlet = ports[1]!
const taperHalf = Math.abs(inlet.position.x) / 3
const stubA = buildRectSection(
inlet.position,
new Vector3(-taperHalf, 0, 0),
widthM,
heightM,
material,
'fitting-stub-inlet',
)
if (stubA) group.add(stubA)
group.add(buildRectToRoundLoft(-taperHalf, taperHalf, widthM, heightM, radiusOut, material))
const stubB = buildSection(
new Vector3(taperHalf, 0, 0),
outlet.position,
radiusOut,
material,
'fitting-stub-outlet',
)
if (stubB) group.add(stubB)
} else if (node.shape !== 'round' && node.fittingType === 'elbow') {
// One mitered solid — no stubs, no junction blob. Oval profiles
// sweep the same way; the ring is a stadium instead of 4 corners.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
group.add(
buildMiteredElbow(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
node.shape,
material,
),
)
} else if (node.shape !== 'round' && node.fittingType === 'tee') {
// Straight rect / oval run inlet→outlet (one prism — nothing to
// miter) plus a branch leg tapping its side. The branch carries its
// own profile: rect or oval at width2 × height2, round at diameter2.
//
// Same orientation swap as the elbow: the run prism and branch stub
// are built on the `rectSectionAxes` basis, whose height rides local
// +Y. That's world-vertical only when the tee's local Y stays vertical
// (a flat tap off a horizontal trunk). When the tee is rotated so
// local Y lands horizontal, width and height roles swap so the
// physical height keeps reading as the vertical face — without this a
// tee drawn along the perpendicular axis looks squished.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
const run = buildRunSection(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
material,
'fitting-run',
)
if (run) group.add(run)
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
const stub =
node.shape2 !== 'round'
? buildBranchSection(
new Vector3(0, 0, 0),
branch.position,
hingeIsVertical ? width2M : height2M,
hingeIsVertical ? height2M : width2M,
material,
'fitting-stub-branch',
)
: buildSection(
new Vector3(0, 0, 0),
branch.position,
(branch.diameter * INCHES_TO_METERS) / 2,
material,
'fitting-stub-branch',
)
if (stub) group.add(stub)
} else if (node.shape !== 'round' && node.fittingType === 'cross') {
// Straight rect / oval run inlet→outlet plus two opposed branch legs
// (±Z) carrying the branch profile — both halves of the run that
// passed through, same size at `width2 × height2` / `diameter2`. Same
// orientation swap as the tee / elbow so the cross stays upright when
// rotated so its local Y lands horizontal.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
const run = buildRunSection(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
material,
'fitting-run',
)
if (run) group.add(run)
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
for (const id of ['branch', 'branch2'] as const) {
const branch = ports.find((p) => p.id === id)!
const stub =
node.shape2 !== 'round'
? buildBranchSection(
new Vector3(0, 0, 0),
branch.position,
hingeIsVertical ? width2M : height2M,
hingeIsVertical ? height2M : width2M,
material,
`fitting-stub-${id}`,
)
: buildSection(
new Vector3(0, 0, 0),
branch.position,
(branch.diameter * INCHES_TO_METERS) / 2,
material,
`fitting-stub-${id}`,
)
if (stub) group.add(stub)
}
} else {
for (const port of ports) {
const stub = buildSection(
new Vector3(0, 0, 0),
port.position,
(port.diameter * INCHES_TO_METERS) / 2,
material,
`fitting-stub-${port.id}`,
)
if (stub) group.add(stub)
}
const junction = new Mesh(new SphereGeometry(radiusMain * 1.02, RADIAL_SEGMENTS, 12), material)
junction.name = 'fitting-junction'
group.add(junction)
}
// Joint trim at each opening. Round legs get a crimp-collar torus just
// proud of the stub; rect legs get a drive-cleat flange — the thin
// raised rim (TDC/S-cleat) real sheet-metal trunk joints wear where a
// section meets a fitting. The plate is centered on the collar plane so
// the rim reads as the seam between fitting and duct. Run legs
// (inlet/outlet) are rect when `shape` is rect; a rect tee's branch is
// rect when `shape2` is rect. Reducers ignore shape.
// Which profile a leg's opening carries: a transition's inlet is its
// rect end regardless of `shape`; reducers are always round; otherwise
// the run legs follow `shape` and a tee's branch follows `shape2`
// (only meaningful when the run itself is non-round).
const legShape = (portId: string): 'round' | 'rect' | 'oval' => {
if (node.fittingType === 'transition') return portId === 'inlet' ? 'rect' : 'round'
if (node.fittingType === 'reducer' || node.shape === 'round') return 'round'
return portId === 'branch' || portId === 'branch2' ? node.shape2 : node.shape
}
// The flange's profile must match the leg it caps: the branch carries
// its own width2 × height2; elbow legs swap width/height roles when the
// fold hinge lies horizontal (riser elbows) — same choice as the
// mitered solid above.
const rectLegProfile = (portId: string): [number, number] => {
if (portId === 'branch' || portId === 'branch2') {
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
return hingeIsVertical ? [width2M, height2M] : [height2M, width2M]
}
if (!hingeIsVertical) return [heightM, widthM]
return [widthM, heightM]
}
const FLANGE_LIP_M = 0.02
const FLANGE_THICK_M = 0.012
for (const port of ports) {
const profile = legShape(port.id)
if (profile !== 'round') {
const [w, h] = rectLegProfile(port.id)
const start = port.position.clone().addScaledVector(port.direction, -FLANGE_THICK_M / 2)
const end = port.position.clone().addScaledVector(port.direction, FLANGE_THICK_M / 2)
const buildFlange = profile === 'oval' ? buildOvalSection : buildRectSection
const flange = buildFlange(
start,
end,
w + FLANGE_LIP_M * 2,
h + FLANGE_LIP_M * 2,
material,
`fitting-flange-${port.id}`,
)
if (flange) group.add(flange)
continue
}
const radius = (port.diameter * INCHES_TO_METERS) / 2
const collar = new Mesh(new TorusGeometry(radius, radius * 0.12, 8, RADIAL_SEGMENTS), material)
collar.name = `fitting-collar-${port.id}`
collar.position.copy(port.position)
collar.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), port.direction)
group.add(collar)
}
return group
}