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
@@ -0,0 +1,293 @@
import {
type AnyNode,
type AnyNodeId,
type DuctSegmentNode,
type ParametricDescriptor,
useScene,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import {
ductPortDiameterIn,
equivalentDiameterIn,
ovalEquivalentDiameterIn,
rollToContinueAcrossElbow,
} from '../duct-segment/geometry'
import { getDuctFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
/** Schema bounds for `diameter` / `diameter2`. */
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
/** A duct endpoint sitting this close to a collar counts as mated. */
const MATE_TOL_M = 0.03
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
/**
* Ducts whose endpoint sits ON one of the fitting's collars, keyed by
* port id. Auto-minted joints place duct ends exactly on the collar, so
* a tight distance check is enough — no connectivity graph yet.
*/
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
const mates = new Map<string, DuctMate>()
const ports = getDuctFittingPorts(fitting)
for (const node of Object.values(useScene.getState().nodes)) {
if (node.type !== 'duct-segment') continue
const duct = node as DuctSegmentNode
for (const endIndex of [0, duct.path.length - 1]) {
const p = duct.path[endIndex]
if (!p) continue
for (const port of ports) {
if (mates.has(port.id)) continue
const dx = p[0] - port.position[0]
const dy = p[1] - port.position[1]
const dz = p[2] - port.position[2]
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
mates.set(port.id, { duct, endIndex })
}
}
}
}
return mates
}
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
// Switching the run legs round↔rect flips the whole fitting and sizes
// the new profile off the ducts actually mated to its collars, so the
// fitting lands flush instead of at schema defaults. The tee branch
// follows its own mated duct (or the run shape when nothing is mated);
// `shape2` stays editable afterwards for mixed taps. Rect profiles
// also write their area-equivalent round size back into `diameter` /
// `diameter2`, which drive leg lengths + advertised ports — without
// this the legs keep the stale round size.
derive: (next, patch) => {
const out: Partial<DuctFittingNode> = {}
if ('shape' in patch && next.fittingType !== 'reducer') {
// `next` still carries the pre-edit diameters, so its ports sit
// where the mated ducts end — size off the actual neighbours.
const mates = matedDucts(next)
const run = (mates.get('inlet') ?? mates.get('outlet'))?.duct
if (next.shape !== 'round' && run?.shape === next.shape) {
out.width = run.width
out.height = run.height
} else if (next.shape === 'round' && run && run.shape !== 'rect') {
// Oval runs present their area-equivalent round size.
out.diameter = clampDiameter(ductPortDiameterIn(run))
}
if (next.fittingType === 'tee' || next.fittingType === 'cross') {
// A cross's two branches share one profile — size off whichever
// branch leg has a duct mated (both halves are the same run).
const branchDuct = (mates.get('branch') ?? mates.get('branch2'))?.duct
out.shape2 = branchDuct?.shape ?? next.shape
if (branchDuct && branchDuct.shape !== 'round') {
out.width2 = branchDuct.width
out.height2 = branchDuct.height
} else if (branchDuct) {
out.diameter2 = clampDiameter(ductPortDiameterIn(branchDuct))
}
}
}
// Non-round legs write their area-equivalent round size back into the
// diameters (leg lengths + advertised ports). A transition's inlet is
// always the rect end regardless of `shape`.
const runShape = next.fittingType === 'transition' ? 'rect' : next.shape
if (runShape !== 'round' && next.fittingType !== 'reducer') {
const equivalent = runShape === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
out.diameter = clampDiameter(equivalent(out.width ?? next.width, out.height ?? next.height))
}
const shape2 = out.shape2 ?? next.shape2
if ((next.fittingType === 'tee' || next.fittingType === 'cross') && shape2 !== 'round') {
const equivalent2 = shape2 === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
out.diameter2 = clampDiameter(
equivalent2(out.width2 ?? next.width2, out.height2 ?? next.height2),
)
}
return out
},
// Resizing a fitting moves its collars (leg lengths follow the
// diameters) — re-trim each mated duct's endpoint onto the collar's
// new position so metal keeps meeting metal instead of overlapping
// one neighbour and gapping off another.
reconcile: (prev, next) => {
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
const newPorts = new Map(getDuctFittingPorts(next).map((p) => [p.id, p]))
const mates = matedDucts(prev)
for (const [portId, mate] of mates) {
const target = newPorts.get(portId)
if (!target) continue
const end = mate.duct.path[mate.endIndex]
if (!end) continue
const data: Partial<DuctSegmentNode> = {}
const dx = end[0] - target.position[0]
const dy = end[1] - target.position[1]
const dz = end[2] - target.position[2]
if (dx * dx + dy * dy + dz * dz >= 1e-12) {
const path = mate.duct.path.map((p) => [...p] as [number, number, number])
path[mate.endIndex] = [...target.position]
data.path = path
}
// Steep rect / oval runs also re-derive their cross-section roll
// so a riser's profile stays continuous through the fitting (same
// continuity the draw tool computes; runs flipped to rect after
// drawing never got it). Horizontal runs are left alone — their
// roll-0 orientation is canonical and re-deriving it from a
// possibly-stale riser roll would corrupt it.
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
const source = getDuctFittingPorts(next).find(
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
)
if (away && source) {
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
if (newDir.lengthSq() >= 1e-10) {
newDir.normalize()
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
const srcMate = mates.get(source.id)
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
const srcDir = new Vector3(...source.direction)
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
}
}
}
}
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
}
return updates
},
groups: [
{
label: 'Fitting',
fields: [
{
key: 'fittingType',
kind: 'enum',
options: ['elbow', 'tee', 'cross', 'reducer', 'transition'],
display: 'segmented',
},
{
key: 'angle',
kind: 'number',
unit: '°',
min: 15,
max: 90,
step: 15,
visibleIf: (n) => n.fittingType === 'elbow',
},
{
key: 'branchAngle',
kind: 'number',
unit: '°',
min: 45,
max: 135,
step: 15,
visibleIf: (n) => n.fittingType === 'tee',
},
{
key: 'system',
kind: 'enum',
options: ['supply', 'return'],
display: 'segmented',
},
],
},
{
label: 'Connections',
fields: [
{
key: 'shape',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
// Reducers are always round; a transition's ends are fixed
// (rect inlet, round outlet) so there's nothing to pick.
visibleIf: (n) => n.fittingType !== 'reducer' && n.fittingType !== 'transition',
},
{
key: 'diameter',
kind: 'number',
unit: 'in',
min: 4,
max: 24,
step: 1,
// Hidden when the run legs are rect / oval (transition's inlet
// always is) — `diameter` is then derived as the area equivalent.
visibleIf: (n) =>
n.fittingType === 'reducer' || (n.fittingType !== 'transition' && n.shape === 'round'),
},
{
key: 'width',
kind: 'number',
unit: 'in',
min: 4,
max: 60,
step: 1,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'height',
kind: 'number',
unit: 'in',
min: 3,
max: 40,
step: 1,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'shape2',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
visibleIf: (n) => n.fittingType === 'tee' || n.fittingType === 'cross',
},
{
key: 'diameter2',
kind: 'number',
unit: 'in',
min: 4,
max: 24,
step: 1,
visibleIf: (n) =>
n.fittingType !== 'elbow' &&
(n.fittingType !== 'tee' || n.shape2 === 'round') &&
(n.fittingType !== 'cross' || n.shape2 === 'round'),
},
{
key: 'width2',
kind: 'number',
unit: 'in',
min: 4,
max: 60,
step: 1,
visibleIf: (n) =>
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
},
{
key: 'height2',
kind: 'number',
unit: 'in',
min: 3,
max: 40,
step: 1,
visibleIf: (n) =>
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
},
{
key: 'ductMaterial',
kind: 'enum',
options: ['sheet-metal', 'flex', 'duct-board'],
},
],
},
{
label: 'Placement',
fields: [
{ key: 'position', kind: 'vec3' },
{ key: 'rotation', kind: 'vec3' },
],
},
],
}