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:
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
BoxGeometry,
|
||||
type BufferGeometry,
|
||||
CylinderGeometry,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { createOvalSectionGeometry, INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import { COLLAR_LENGTH, mountQuaternion, terminalSystem } from './ports'
|
||||
import type { DuctTerminalNode } from './schema'
|
||||
|
||||
const RADIAL_SEGMENTS = 20
|
||||
|
||||
/** Radial clearance (meters) the collar sleeve carries over the duct's
|
||||
* nominal cross-section, so a run leaving at the advertised size nests
|
||||
* inside the sleeve instead of z-fighting its faces. ~5 mm ≈ a slip joint. */
|
||||
const COLLAR_CLEARANCE_M = 0.005
|
||||
|
||||
const FRAME_COLOR = '#e3e5e8'
|
||||
const SLAT_SUPPLY_COLOR = '#cdd1d6'
|
||||
const SLAT_RETURN_COLOR = '#aeb4bb'
|
||||
const COLLAR_COLOR = '#c2c2c2'
|
||||
|
||||
/**
|
||||
* Pure geometry builder for a duct terminal, in the node's LOCAL frame —
|
||||
* `<ParametricNodeRenderer>` applies `position` + yaw, and the builder
|
||||
* applies the mount orientation itself.
|
||||
*
|
||||
* Canonical (floor) frame before the mount rotation: face plate lying
|
||||
* in XZ at y=0 with its normal +Y, louver slats just above it, collar
|
||||
* cylinder going -Y toward the duct side. Ceiling mounts flip it; wall
|
||||
* mounts stand it up facing +Z.
|
||||
*/
|
||||
export function buildDuctTerminalGeometry(node: DuctTerminalNode): Group {
|
||||
const group = new Group()
|
||||
const oriented = new Group()
|
||||
oriented.quaternion.copy(mountQuaternion(node.mount))
|
||||
group.add(oriented)
|
||||
|
||||
const frameMaterial = new MeshStandardMaterial({
|
||||
color: FRAME_COLOR,
|
||||
metalness: 0.4,
|
||||
roughness: 0.5,
|
||||
})
|
||||
const slatMaterial = new MeshStandardMaterial({
|
||||
color: terminalSystem(node) === 'return' ? SLAT_RETURN_COLOR : SLAT_SUPPLY_COLOR,
|
||||
metalness: 0.45,
|
||||
roughness: 0.55,
|
||||
})
|
||||
|
||||
const frameThickness = 0.018
|
||||
const frame = new Mesh(new BoxGeometry(node.width, frameThickness, node.depth), frameMaterial)
|
||||
frame.name = 'terminal-frame'
|
||||
frame.position.set(0, frameThickness / 2, 0)
|
||||
oriented.add(frame)
|
||||
|
||||
// Louver slats across the face. Return grilles read denser; diffusers
|
||||
// get concentric-ish wide slats via the same simple pattern.
|
||||
const slatCount = node.terminalType === 'return-grille' ? 7 : 4
|
||||
const innerDepth = node.depth * 0.82
|
||||
const slatDepth = (innerDepth / slatCount) * 0.55
|
||||
for (let i = 0; i < slatCount; i++) {
|
||||
const slat = new Mesh(new BoxGeometry(node.width * 0.86, 0.006, slatDepth), slatMaterial)
|
||||
slat.name = `terminal-slat-${i}`
|
||||
const z = -innerDepth / 2 + (innerDepth / slatCount) * (i + 0.5)
|
||||
slat.position.set(0, frameThickness + 0.002, z)
|
||||
slat.rotation.x = node.terminalType === 'diffuser' ? 0 : -0.5
|
||||
oriented.add(slat)
|
||||
}
|
||||
|
||||
// Collar runs along -Y from the face toward the duct. Round is a
|
||||
// cylinder; rect a box; oval the flat-oval prism (its extrude basis
|
||||
// already puts the run length on Y, matching the collar axis). The
|
||||
// sleeve is grown one clearance on every side so a duct run leaving at
|
||||
// the advertised size nests inside it instead of z-fighting its faces.
|
||||
const grow = 2 * COLLAR_CLEARANCE_M
|
||||
let collarGeom: BufferGeometry
|
||||
if (node.collarShape === 'rect') {
|
||||
collarGeom = new BoxGeometry(
|
||||
node.collarWidth * INCHES_TO_METERS + grow,
|
||||
COLLAR_LENGTH,
|
||||
node.collarHeight * INCHES_TO_METERS + grow,
|
||||
)
|
||||
} else if (node.collarShape === 'oval') {
|
||||
collarGeom = createOvalSectionGeometry(
|
||||
node.collarWidth * INCHES_TO_METERS + grow,
|
||||
node.collarHeight * INCHES_TO_METERS + grow,
|
||||
COLLAR_LENGTH,
|
||||
)
|
||||
} else {
|
||||
const radius = (node.collarDiameter * INCHES_TO_METERS + grow) / 2
|
||||
collarGeom = new CylinderGeometry(radius, radius, COLLAR_LENGTH, RADIAL_SEGMENTS, 1, false)
|
||||
}
|
||||
const collar = new Mesh(
|
||||
collarGeom,
|
||||
new MeshStandardMaterial({ color: COLLAR_COLOR, metalness: 0.6, roughness: 0.4 }),
|
||||
)
|
||||
collar.name = 'terminal-collar'
|
||||
collar.position.copy(new Vector3(0, -COLLAR_LENGTH / 2, 0))
|
||||
oriented.add(collar)
|
||||
|
||||
return group
|
||||
}
|
||||
Reference in New Issue
Block a user