Files
editor/packages/nodes/src/duct-segment/geometry.ts
T
Sudhir YadavandGitHub 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

467 lines
16 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 {
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
}