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
+466
View File
@@ -0,0 +1,466 @@
import {
BoxGeometry,
CatmullRomCurve3,
CylinderGeometry,
ExtrudeGeometry,
Group,
Matrix4,
Mesh,
MeshStandardMaterial,
Quaternion,
Shape,
SphereGeometry,
TubeGeometry,
Vector3,
} from 'three'
import type { DuctSegmentNode } from './schema'
export const INCHES_TO_METERS = 0.0254
// Insulation wraps the duct in a roughly uniform shell. A strictly physical
// mapping (fiberglass ≈ R-3.2 per inch) makes low R-values nearly invisible
// at screen scale — R-1 would add only ~8 mm over a 15 cm duct. So the shell
// uses a perceptual mapping: a visible base jacket as soon as insulation is
// non-zero, plus a clear per-R increment. Anchored so R-8 still lands near
// the real-world ~3" jacket.
const INSULATION_BASE_IN = 0.5
const INSULATION_INCHES_PER_R = 0.3125
function pickInsulationThickness(r: number): number {
if (r <= 0) return 0
return (INSULATION_BASE_IN + r * INSULATION_INCHES_PER_R) * INCHES_TO_METERS
}
// Supply/return tint — kept only for the spiral seam ridge accent; the duct
// body itself is plain white (see createDuctMaterial).
const SUPPLY_COLOR = '#d4825a'
const RETURN_COLOR = '#5a8ad4'
const RADIAL_SEGMENTS = 24
const UP = new Vector3(0, 1, 0)
/**
* Area-equivalent round diameter (inches) for a rect cross-section —
* what a rect trunk advertises on its ports so round fittings / branches
* mate at a sensible size.
*/
export function equivalentDiameterIn(widthIn: number, heightIn: number): number {
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
}
/**
* Area-equivalent round diameter (inches) for a flat-oval cross-section:
* a rectangle of (width height) × height plus the two semicircular caps.
*/
export function ovalEquivalentDiameterIn(widthIn: number, heightIn: number): number {
const minor = Math.min(widthIn, heightIn)
const major = Math.max(widthIn, heightIn)
const area = (major - minor) * minor + Math.PI * (minor / 2) ** 2
return 2 * Math.sqrt(area / Math.PI)
}
/** The diameter (inches) a duct segment presents at its ports. */
export function ductPortDiameterIn(node: {
shape?: 'round' | 'rect' | 'oval'
diameter: number
width?: number
height?: number
}): number {
if (node.shape === 'rect' && node.width && node.height) {
return equivalentDiameterIn(node.width, node.height)
}
if (node.shape === 'oval' && node.width && node.height) {
return ovalEquivalentDiameterIn(node.width, node.height)
}
return node.diameter
}
/**
* Cross-section axes for a rect run along `dir`, rolled `roll` radians
* about the run direction. At roll 0: width is the horizontal axis
* (UP × dir) and height the vertical one — vertical runs, where that
* cross product degenerates, fall back to world X/Z. `roll` rotates the
* pair in the plane perpendicular to `dir`, letting a riser carry the
* orientation of the run it turned off instead of the bare fallback.
*/
export function rectSectionAxes(dir: Vector3, roll = 0): { width: Vector3; height: Vector3 } {
const d = dir.clone().normalize()
const xBase = new Vector3().crossVectors(UP, d)
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
xBase.normalize()
const zBase = new Vector3().crossVectors(xBase, d)
const c = Math.cos(roll)
const s = Math.sin(roll)
const width = xBase.clone().multiplyScalar(c).addScaledVector(zBase, s)
const height = xBase.clone().multiplyScalar(-s).addScaledVector(zBase, c)
return { width, height }
}
/**
* Roll (radians) that keeps a rect cross-section continuous across an
* elbow: the dimension lying along the joint's hinge — the bend-plane
* normal `portDir × newDir`, perpendicular to both legs — must stay on
* the same physical face on the new run as on the source run. Returns 0
* for an in-plane (degenerate-normal) joint, so horizontal turns keep
* the natural width-horizontal orientation.
*/
export function rollToContinueAcrossElbow(
sourceDir: Vector3,
sourceRoll: number,
portDir: Vector3,
newDir: Vector3,
): number {
const n = new Vector3().crossVectors(portDir, newDir)
if (n.lengthSq() < 1e-8) return 0
n.normalize()
const src = rectSectionAxes(sourceDir, sourceRoll)
const carriesWidth = Math.abs(src.width.dot(n)) >= Math.abs(src.height.dot(n))
const d = newDir.clone().normalize()
const xBase = new Vector3().crossVectors(UP, d)
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
xBase.normalize()
const zBase = new Vector3().crossVectors(xBase, d)
// Place the hinge-aligned face on the same axis the source carries it.
return carriesWidth
? Math.atan2(n.dot(zBase), n.dot(xBase))
: Math.atan2(-n.dot(xBase), n.dot(zBase))
}
/**
* Rect box spanning `start`→`end`. Orientation comes from `rectSectionAxes`
* (width horizontal, height vertical by default; `roll` reorients a riser
* to stay continuous through its elbow). Quaternion from an explicit basis
* — the minimal-rotation `setFromUnitVectors` used for cylinders would roll
* the cross-section on axis-aligned runs.
*/
export function buildRectSection(
start: Vector3,
end: Vector3,
widthM: number,
heightM: number,
material: MeshStandardMaterial,
name: string,
roll = 0,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
const { width: x, height: z } = rectSectionAxes(dir, roll)
const geom = new BoxGeometry(widthM, length, heightM)
const mesh = new Mesh(geom, material)
mesh.name = name
mesh.position.copy(start).addScaledVector(dir, length / 2)
mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)))
return mesh
}
/**
* Flat-oval (stadium) profile in the XY plane: width along X, height
* along Y, flat top/bottom joined by semicircular end caps of the height.
* Degenerates to a circle when width ≤ height.
*/
function stadiumShape(widthM: number, heightM: number): Shape {
const r = Math.min(widthM, heightM) / 2
const straight = Math.max(0, widthM - heightM) / 2
const shape = new Shape()
shape.absarc(straight, 0, r, -Math.PI / 2, Math.PI / 2, false)
shape.absarc(-straight, 0, r, Math.PI / 2, (3 * Math.PI) / 2, false)
shape.closePath()
return shape
}
/**
* Centered flat-oval prism with the same local axes as the rect box
* (X = width, Y = run length, Z = height), so sections and previews
* orient it with the `rectSectionAxes` basis.
*/
export function createOvalSectionGeometry(
widthM: number,
heightM: number,
lengthM: number,
): ExtrudeGeometry {
const geom = new ExtrudeGeometry(stadiumShape(widthM, heightM), {
depth: lengthM,
bevelEnabled: false,
curveSegments: RADIAL_SEGMENTS / 2,
})
geom.translate(0, 0, -lengthM / 2)
geom.rotateX(-Math.PI / 2)
return geom
}
/**
* Flat-oval section spanning `start`→`end` — the oval counterpart of
* `buildRectSection`, sharing its orientation basis and roll semantics.
*/
export function buildOvalSection(
start: Vector3,
end: Vector3,
widthM: number,
heightM: number,
material: MeshStandardMaterial,
name: string,
roll = 0,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
const { width: x, height: z } = rectSectionAxes(dir, roll)
const mesh = new Mesh(createOvalSectionGeometry(widthM, heightM, length), material)
mesh.name = name
mesh.position.copy(start).addScaledVector(dir, length / 2)
mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)))
return mesh
}
/**
* Cylinder spanning `start`→`end` at `radius`. Shared by the segment and
* fitting builders — fittings are just short sections + a junction.
*/
export function buildSection(
start: Vector3,
end: Vector3,
radius: number,
material: MeshStandardMaterial,
name: string,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
// Capped, front-side-only — ducts should read as solid metal tubes,
// not hollow open-ended shells.
const geom = new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false)
const mesh = new Mesh(geom, material)
mesh.name = name
mesh.position.copy(start).addScaledVector(dir, length / 2)
mesh.quaternion.setFromUnitVectors(UP, dir)
return mesh
}
/**
* Helical ridge wound around the cylinder spanning `start`→`end` at the
* given `pitch` (meters of run per turn) and `ridge` tube radius. The
* ridge sits centered on the body surface, so half its thickness reads
* as raised. Two construction details share this: the spiral duct's
* lock seam (long pitch, thin ridge) and the flex duct's wire helix
* (tight pitch, fat ridge → corrugated look).
*/
function buildHelixRidge(
start: Vector3,
end: Vector3,
radius: number,
pitch: number,
ridge: number,
material: MeshStandardMaterial,
name: string,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
const length = dir.length()
if (length < 1e-6) return null
dir.normalize()
const turns = length / pitch
const { width: u, height: v } = rectSectionAxes(dir)
const samples = Math.min(4096, Math.max(8, Math.ceil(turns * 12)))
const pts: Vector3[] = []
for (let i = 0; i <= samples; i++) {
const t = i / samples
const theta = 2 * Math.PI * turns * t
pts.push(
start
.clone()
.addScaledVector(dir, t * length)
.addScaledVector(u, radius * Math.cos(theta))
.addScaledVector(v, radius * Math.sin(theta)),
)
}
const geom = new TubeGeometry(new CatmullRomCurve3(pts), samples, ridge, 6, false)
const mesh = new Mesh(geom, material)
mesh.name = name
return mesh
}
/**
* Helix parameters for a construction material's body detail, or null
* for materials with a smooth body. Spiral: the machine seam keeps a
* roughly constant helix angle, so pitch scales with the diameter.
* Flex: the wire helix is tight and reads as corrugation; its pitch
* also follows the diameter but is clamped much lower.
*/
function helixRidgeFor(
ductMaterial: DuctAppearance['ductMaterial'],
radius: number,
): { pitch: number; ridge: number; color: string } | null {
if (ductMaterial === 'spiral') {
return {
pitch: Math.min(0.3, Math.max(0.08, radius * 1.2)),
ridge: Math.min(0.006, Math.max(0.002, radius * 0.06)),
color: '#9b9b9b',
}
}
if (ductMaterial === 'flex') {
return {
pitch: Math.min(0.06, Math.max(0.025, radius * 0.5)),
ridge: Math.min(0.009, Math.max(0.004, radius * 0.12)),
color: '#737373',
}
}
return null
}
type DuctAppearance = {
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
system: 'supply' | 'return'
}
function getSystemTint(node: DuctAppearance): string {
return node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
}
/**
* Standard duct body material — a plain white matte finish so runs and
* fittings read like walls / other building elements rather than tinted
* metal. Shared with the fitting builder so connected runs and junctions
* look like one piece.
*/
export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial {
return new MeshStandardMaterial({
color: '#ffffff',
metalness: 0,
roughness: 0.7,
})
}
/**
* Pure geometry builder for a round duct segment polyline.
*
* Strategy:
* - For every consecutive pair of path points, build a cylinder of the
* duct's inner diameter.
* - Drop a sphere of the same radius at every interior joint to cap the
* corner smoothly (no mitering yet — fittings come in a later slice).
* - When insulation is non-zero, repeat the same pattern at a larger
* radius using a translucent shell material.
*
* All children are returned in level-local meters; the framework's
* `<ParametricNodeRenderer>` handles the node-level transform (currently
* identity since the schema has no position field — the path itself is
* absolute within the level).
*/
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
const group = new Group()
if (node.path.length < 2) return group
const isRect = node.shape === 'rect'
const isOval = node.shape === 'oval'
const radius = (node.diameter * INCHES_TO_METERS) / 2
const widthM = node.width * INCHES_TO_METERS
const heightM = node.height * INCHES_TO_METERS
const ductMaterial = createDuctMaterial(node)
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
const addRun = (
half: number,
rectW: number,
rectH: number,
material: MeshStandardMaterial,
namePrefix: string,
endInsetM = 0,
) => {
for (let i = 0; i < points.length - 1; i++) {
// Loop bounds + min(2) on the schema guarantee both points exist.
let a = points[i] as Vector3
let b = points[i + 1] as Vector3
// Pull the run's open ends in so this shell's end faces never sit
// coplanar with the duct's own end caps (z-fighting). Clamped so
// a short section can't invert.
if (endInsetM > 0) {
const dir = new Vector3().subVectors(b, a)
const length = dir.length()
if (length < 1e-6) continue
dir.divideScalar(length)
const inset = Math.min(endInsetM, length * 0.25)
if (i === 0) a = a.clone().addScaledVector(dir, inset)
if (i === points.length - 2) b = b.clone().addScaledVector(dir, -inset)
}
const mesh = isRect
? buildRectSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
: isOval
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
if (mesh) group.add(mesh)
}
// Joint caps at interior points only (skip first and last — they're
// open ends; equipment / terminal / fitting collars cap them). Rect
// joints are cubes spanning the cross-section (oval joints the same
// prism in stadium profile); round joints spheres.
for (let i = 1; i < points.length - 1; i++) {
const joint = isRect
? new Mesh(new BoxGeometry(rectW, rectH, rectW), material)
: isOval
? new Mesh(createOvalSectionGeometry(rectW, rectH, rectW), material)
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
joint.name = `${namePrefix}-joint-${i}`
joint.position.copy(points[i] as Vector3)
group.add(joint)
}
}
addRun(radius, widthM, heightM, ductMaterial, 'duct')
// Construction body detail: spiral winds its lock seam, flex its wire
// helix (tight pitch — reads as corrugation) over each round section.
// These are round-body details, so rect / oval runs render smooth.
const helix =
node.shape === 'round' && node.seamDetail ? helixRidgeFor(node.ductMaterial, radius) : null
if (helix) {
const ridgeMaterial = new MeshStandardMaterial({
color: helix.color,
metalness: node.ductMaterial === 'flex' ? 0.1 : 0.7,
roughness: node.ductMaterial === 'flex' ? 0.85 : 0.35,
emissive: getSystemTint(node),
emissiveIntensity: 0.08,
})
for (let i = 0; i < points.length - 1; i++) {
const seam = buildHelixRidge(
points[i] as Vector3,
points[i + 1] as Vector3,
radius,
helix.pitch,
helix.ridge,
ridgeMaterial,
`duct-seam-${i}`,
)
if (seam) group.add(seam)
}
}
const insulationThickness = node.insulated ? pickInsulationThickness(node.insulationR) : 0
if (insulationThickness > 0) {
const insulationMaterial = new MeshStandardMaterial({
color: '#f0e4c8',
roughness: 1,
metalness: 0,
transparent: true,
opacity: 0.25,
})
addRun(
radius + insulationThickness,
widthM + insulationThickness * 2,
heightM + insulationThickness * 2,
insulationMaterial,
'duct-insulation',
0.01,
)
}
return group
}