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
@@ -295,8 +295,43 @@ export function nodeAlignmentAnchors(
const poly = (node as { polygon?: [number, number][] }).polygon
return poly ? polygonAnchors(node.id, poly) : []
}
const anchors: AlignmentAnchor[] = []
// Box footprint (items, columns, shelves, stairs, …).
const aabb = alignmentAABB(node, nodes)
return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : []
if (aabb) {
anchors.push(...bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ))
}
// Polyline kinds (duct / pipe / lineset): every path vertex is an anchor,
// so anything dragged snaps to a run's ends and bends.
const path = (node as { path?: unknown }).path
if (Array.isArray(path)) {
for (const p of path as Array<[number, number, number]>) {
anchors.push({ nodeId: node.id, kind: 'corner', x: p[0], z: p[2] })
}
}
// Typed ports (fittings, equipment, terminals, run ends): connection points
// are natural alignment targets — line a new run up with an existing collar.
const ports = nodeRegistry.get(node.type)?.ports?.(node)
if (ports) {
for (const port of ports) {
anchors.push({ nodeId: node.id, kind: 'corner', x: port.position[0], z: port.position[2] })
}
}
// Position-based kinds with no footprint (e.g. duct fittings): the origin
// itself is a useful centre anchor.
if (!aabb) {
const position = (node as { position?: [number, number, number] }).position
if (Array.isArray(position)) {
anchors.push({ nodeId: node.id, kind: 'center', x: position[0], z: position[2] })
}
}
return anchors
}
/**
+27
View File
@@ -41,6 +41,10 @@ export {
pickHost,
type Vec3,
} from './hosting'
export {
DEFAULT_LEVEL_HEIGHT,
getLevelHeight,
} from './level-height'
export {
type AxisLock,
applyAxisLock,
@@ -69,6 +73,19 @@ export {
type VerticalFeature,
type WallExtent,
} from './opening-guides'
export {
analyzePortConnectivity,
type PortConnection,
type PortConnectivity,
resolveConnectivityUpdates,
} from './port-connectivity'
export {
buildRiserDiagram,
projectIso,
type RiserDiagram,
type RiserLine,
type RiserMarker,
} from './riser-diagram'
export {
DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP,
@@ -82,3 +99,13 @@ export {
snapVec3ToGrid,
snapWorldXZToBuildingLocal,
} from './snap'
export {
buildPortComponents,
type SystemSummary,
summarizeSystemFor,
} from './system-graph'
export {
type DwvFinding,
type DwvSeverity,
validateDwv,
} from './validate-dwv'
@@ -0,0 +1,43 @@
import { sceneRegistry } from '../hooks/scene-registry/scene-registry'
import type { CeilingNode, LevelNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
export const DEFAULT_LEVEL_HEIGHT = 2.5
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
// Zustand produces a new `nodes` object on every mutation, so reference equality
// is a zero-cost way to detect stale data without any subscription overhead.
const heightCache = new Map<string, number>()
let lastNodesRef: object | null = null
export function getLevelHeight(levelId: string, nodes: Record<AnyNodeId, AnyNode>): number {
if (nodes !== lastNodesRef) {
heightCache.clear()
lastNodesRef = nodes
}
if (heightCache.has(levelId)) return heightCache.get(levelId)!
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (!child) continue
if (child.type === 'ceiling') {
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (ch > maxTop) maxTop = ch
} else if (child.type === 'wall') {
let meshY = sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0
if (meshY < 0) meshY = 0
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
if (top > maxTop) maxTop = top
}
}
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
heightCache.set(levelId, height)
return height
}
@@ -0,0 +1,189 @@
import { nodeRegistry } from '../registry'
import type { AnyNode, AnyNodeId } from '../schema'
/**
* Connectivity-aware editing for port-bearing kinds (HVAC ductwork).
*
* Two nodes are "connected" when a port of one coincides in space with a
* port of the other — exactly how the placement tools mate a fitting onto
* a duct end (they snap the fitting's collar onto the run's open port).
* This service reads that relationship back out so an edit to one node can
* carry its neighbours along.
*
* Pure logic: it asks each node for its ports via `def.ports` (level-local
* meters) and does arithmetic. No Three.js, no rendering — it lives in
* core and is consumed by the editor's move tool and the duct-segment
* system alike.
*
* Propagation is intentionally **one hop**: a moved fitting stretches the
* ducts touching it (their near endpoint follows) and rigidly drags any
* fitting mated collar-to-collar, but it does NOT chase the far end of
* those ducts or anything beyond. Bounded and predictable — no runaway
* network rearrangement.
*/
type Point = readonly [number, number, number]
/** Distance (meters) under which two ports count as the same joint. Joints
* formed by placement snapping coincide to sub-millimeter; 5 cm leaves
* generous slack for grid-snapped hand placement without false matches. */
const COINCIDENT_EPS_M = 0.05
/** A node attached to one of the moved node's ports, plus how it follows. */
export type PortConnection =
| {
/** Partner is a duct run: the endpoint touching the moved port slides
* to track it (one hop — the far endpoint stays put, stretching the
* run). */
kind: 'duct-endpoint'
nodeId: AnyNodeId
/** Index in the duct's `path` that tracks the moved port. */
pathIndex: number
/** The moved node's port id this endpoint follows. */
movedPortId: string
/** The duct's full path at edit-start (other points are preserved). */
startPath: Point[]
}
| {
/** Partner is another fitting mated collar-to-collar: it translates
* rigidly so its collar stays on the moved collar. */
kind: 'rigid-node'
nodeId: AnyNodeId
movedPortId: string
/** Partner node's `position` at edit-start. */
startPosition: Point
}
export type PortConnectivity = {
movedNodeId: AnyNodeId
/** The moved node's port world positions at edit-start, keyed by port id.
* Used as the reference each connection's delta is measured from. */
startMovedPorts: Record<string, Point>
connections: PortConnection[]
}
function portsOf(node: AnyNode): ReadonlyArray<{ id: string; position: Point }> | undefined {
return nodeRegistry.get(node.type)?.ports?.(node) as
| ReadonlyArray<{ id: string; position: Point }>
| undefined
}
function distSq(a: Point, b: Point): number {
const dx = a[0] - b[0]
const dy = a[1] - b[1]
const dz = a[2] - b[2]
return dx * dx + dy * dy + dz * dz
}
/**
* Snapshot which nodes are connected to `movedNode`'s ports, taken at the
* start of a move/resize. Call once before the drag; feed the result to
* `resolveConnectivityUpdates` on every frame.
*
* Only duct-segment (endpoint stretch) and duct-fitting (rigid follow)
* partners are tracked — terminals and equipment usually mount to a
* surface and shouldn't be yanked off it when an adjacent fitting nudges.
*/
export function analyzePortConnectivity(
movedNode: AnyNode,
nodes: Record<string, AnyNode>,
): PortConnectivity {
const movedPorts = portsOf(movedNode) ?? []
const startMovedPorts: Record<string, Point> = {}
for (const p of movedPorts) startMovedPorts[p.id] = p.position
const connections: PortConnection[] = []
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
for (const other of Object.values(nodes)) {
if (!other || other.id === movedNode.id) continue
if (other.type !== 'duct-segment' && other.type !== 'duct-fitting') continue
const otherPorts = portsOf(other)
if (!otherPorts) continue
for (const op of otherPorts) {
// Find which of the moved node's ports this partner port sits on.
let matchedId: string | null = null
for (const mp of movedPorts) {
if (distSq(op.position, mp.position) <= epsSq) {
matchedId = mp.id
break
}
}
if (!matchedId) continue
if (other.type === 'duct-segment') {
const path = (other as unknown as { path: Point[] }).path
if (!Array.isArray(path) || path.length < 2) continue
// Port id 'start' → first point, 'end' → last point.
const pathIndex = op.id === 'start' ? 0 : path.length - 1
connections.push({
kind: 'duct-endpoint',
nodeId: other.id,
pathIndex,
movedPortId: matchedId,
startPath: path.map((p) => [...p] as Point),
})
} else {
const position = (other as unknown as { position?: Point }).position
if (!position) continue
connections.push({
kind: 'rigid-node',
nodeId: other.id,
movedPortId: matchedId,
startPosition: [position[0], position[1], position[2]],
})
}
}
}
return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts }
}
/**
* Given the moved node in its live (in-drag) transform, produce the patches
* that keep every connected node attached. `previewNode` is the moved node
* with its current drag position/rotation applied so its ports recompute.
*
* - Duct endpoint: set the tracked path point to the moved port's new
* position (the joint stays welded; the run stretches).
* - Rigid fitting: translate by the moved port's delta so its mated collar
* rides along.
*/
export function resolveConnectivityUpdates(
connectivity: PortConnectivity,
previewNode: AnyNode,
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const newPorts = portsOf(previewNode) ?? []
const newById: Record<string, Point> = {}
for (const p of newPorts) newById[p.id] = p.position
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const conn of connectivity.connections) {
const start = connectivity.startMovedPorts[conn.movedPortId]
const now = newById[conn.movedPortId]
if (!start || !now) continue
if (conn.kind === 'duct-endpoint') {
const path = conn.startPath.map((p, i) =>
i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point),
)
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
} else {
const dx = now[0] - start[0]
const dy = now[1] - start[1]
const dz = now[2] - start[2]
updates.push({
id: conn.nodeId,
data: {
position: [
conn.startPosition[0] + dx,
conn.startPosition[1] + dy,
conn.startPosition[2] + dz,
],
} as Partial<AnyNode>,
})
}
}
return updates
}
@@ -0,0 +1,75 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../schema'
import { buildRiserDiagram, projectIso } from './riser-diagram'
type Point = [number, number, number]
let nextId = 0
function makeNode(type: string, fields: Record<string, unknown>): AnyNode {
nextId += 1
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode
}
function sceneOf(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record<AnyNodeId, AnyNode>
}
describe('projectIso', () => {
test('higher elevation maps to smaller screen Y', () => {
const [, lowY] = projectIso(0, 0, 0)
const [, highY] = projectIso(0, 2, 0)
expect(highY).toBeLessThan(lowY)
})
})
describe('buildRiserDiagram', () => {
test('null when no DWV nodes', () => {
const wall = makeNode('wall', {})
expect(buildRiserDiagram(sceneOf(wall))).toBeNull()
})
test('classifies a vertical stack vs a sloped horizontal drain', () => {
const stack = makeNode('pipe-segment', {
path: [
[0, 0, 0],
[0, 3, 0],
] as Point[],
diameter: 3,
system: 'vent',
})
const drain = makeNode('pipe-segment', {
path: [
[0, 0, 0],
[3, -0.06, 0],
] as Point[],
diameter: 2,
system: 'waste',
})
const diagram = buildRiserDiagram(sceneOf(stack, drain))!
const stackLine = diagram.lines.find((l) => l.nodeId === stack.id)!
const drainLine = diagram.lines.find((l) => l.nodeId === drain.id)!
expect(stackLine.vertical).toBe(true)
expect(drainLine.vertical).toBe(false)
})
test('emits a vent-termination marker for a vent run', () => {
const vent = makeNode('pipe-segment', {
path: [
[0, 0, 0],
[0, 3, 0],
] as Point[],
diameter: 2,
system: 'vent',
})
const diagram = buildRiserDiagram(sceneOf(vent))!
expect(diagram.markers.some((m) => m.kind === 'vent-termination')).toBe(true)
})
test('labels traps', () => {
const trap = makeNode('pipe-trap', {
position: [1, 0, 0] as Point,
diameter: 1.5,
})
const diagram = buildRiserDiagram(sceneOf(trap))!
expect(diagram.markers.some((m) => m.kind === 'trap')).toBe(true)
})
})
+143
View File
@@ -0,0 +1,143 @@
import type { AnyNode, AnyNodeId } from '../schema'
/**
* Riser diagram (plumbing isometric) — the conventional way DWV systems
* are drawn for permit: the drain/vent tree projected to a 30° iso so
* vertical stacks read as vertical and horizontal runs lean off at 30°,
* annotated with size + slope and vent terminations.
*
* This is a pure projector: it turns the scene's DWV nodes into 2D
* drawables (level-independent, no rendering). The editor draws the
* result as SVG. Air/refrigerant nodes are ignored — riser diagrams are
* a plumbing convention.
*/
const COS30 = Math.cos(Math.PI / 6)
const SIN30 = Math.sin(Math.PI / 6)
/** A 3D level-local point (meters) projected to 2D iso screen space.
* Screen Y grows DOWNWARD (SVG convention), so higher elevation → lower
* screen Y. */
export function projectIso(x: number, y: number, z: number): [number, number] {
const sx = (x - z) * COS30
const sy = (x + z) * SIN30 - y
return [sx, sy]
}
export type RiserLine = {
/** Projected endpoints in iso screen space. */
from: [number, number]
to: [number, number]
system: 'waste' | 'vent'
/** Nominal size in inches. */
diameter: number
/** True for a (near-)vertical run — drawn solid/bold as a stack. */
vertical: boolean
/** Source node, so the editor can link selection. */
nodeId: AnyNodeId
}
export type RiserMarker = {
point: [number, number]
kind: 'trap' | 'vent-termination' | 'fitting'
label: string
nodeId: AnyNodeId
}
export type RiserDiagram = {
lines: RiserLine[]
markers: RiserMarker[]
/** Bounding box of all projected geometry, screen space. */
bounds: { minX: number; minY: number; maxX: number; maxY: number }
}
/** Elevation gain per horizontal meter under which a leg is "vertical". */
const VERTICAL_EPS = 4 // dy/dxz ratio: steeper than this reads as a stack
type Vec3 = readonly [number, number, number]
function legIsVertical(a: Vec3, b: Vec3): boolean {
const horizontal = Math.hypot(b[0] - a[0], b[2] - a[2])
const vertical = Math.abs(b[1] - a[1])
if (horizontal < 1e-4) return true
return vertical / horizontal > VERTICAL_EPS
}
/**
* Build the riser diagram for the whole scene. Returns null when there's
* no DWV geometry to draw.
*/
export function buildRiserDiagram(
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
): RiserDiagram | null {
const lines: RiserLine[] = []
const markers: RiserMarker[] = []
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const grow = (p: [number, number]) => {
if (p[0] < minX) minX = p[0]
if (p[1] < minY) minY = p[1]
if (p[0] > maxX) maxX = p[0]
if (p[1] > maxY) maxY = p[1]
}
for (const node of Object.values(nodes)) {
if (!node) continue
if (node.type === 'pipe-segment') {
const path = node.path as Vec3[]
for (let i = 0; i < path.length - 1; i++) {
const a = path[i]!
const b = path[i + 1]!
const from = projectIso(a[0], a[1], a[2])
const to = projectIso(b[0], b[1], b[2])
grow(from)
grow(to)
lines.push({
from,
to,
system: node.system,
diameter: node.diameter,
vertical: legIsVertical(a, b),
nodeId: node.id,
})
}
// Vent runs that end above everything are vent terminations
// (through-roof). Tag the highest endpoint of a vent run.
if (node.system === 'vent') {
const top = path.reduce((hi, p) => (p[1] > hi[1] ? p : hi), path[0]!)
const pt = projectIso(top[0], top[1], top[2])
markers.push({
point: pt,
kind: 'vent-termination',
label: `${node.diameter}" VTR`,
nodeId: node.id,
})
}
} else if (node.type === 'pipe-trap') {
const pt = projectIso(node.position[0], node.position[1], node.position[2])
grow(pt)
markers.push({
point: pt,
kind: 'trap',
label: `${node.diameter}" P-trap`,
nodeId: node.id,
})
} else if (node.type === 'pipe-fitting') {
const pt = projectIso(node.position[0], node.position[1], node.position[2])
grow(pt)
markers.push({
point: pt,
kind: 'fitting',
label: node.fittingType,
nodeId: node.id,
})
}
}
if (lines.length === 0 && markers.length === 0) return null
return { lines, markers, bounds: { minX, minY, maxX, maxY } }
}
@@ -0,0 +1,158 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry'
import { registerNode } from '../registry'
import type { AnyNode, AnyNodeId } from '../schema'
import { buildPortComponents, summarizeSystemFor } from './system-graph'
type Point = [number, number, number]
// Stub registrations: the graph consults `def.ports` for the connectivity
// graph and `def.distributionRole` to classify each node. Mirrors the real
// kinds' port + role conventions (duct runs expose start/end, equipment a
// supply collar, terminals one collar) without importing the nodes package.
function stubDef(
kind: string,
distributionRole: DistributionRole,
ports: (node: AnyNode) => NodePort[],
): void {
registerNode({
kind,
schemaVersion: 1,
schema: {},
category: 'utility',
distributionRole,
defaults: () => ({}),
capabilities: {},
ports,
} as unknown as AnyNodeDefinition)
}
stubDef('duct-segment', 'run', (node) => {
const path = (node as unknown as { path: Point[] }).path
const system = (node as unknown as { system: string }).system
return [
{ id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system },
{
id: 'end',
position: path[path.length - 1]!,
direction: [1, 0, 0],
diameter: 6,
system,
},
]
})
stubDef('hvac-equipment', 'equipment', (node) => {
const position = (node as unknown as { position: Point }).position
return [{ id: 'supply', position, direction: [0, 1, 0], diameter: 12, system: 'supply' }]
})
stubDef('duct-terminal', 'terminal', (node) => {
const position = (node as unknown as { position: Point }).position
return [{ id: 'collar', position, direction: [0, -1, 0], diameter: 6, system: 'supply' }]
})
let nextId = 0
function makeNode(type: string, fields: Record<string, unknown>): AnyNode {
nextId += 1
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode
}
function sceneOf(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record<AnyNodeId, AnyNode>
}
function run(path: Point[], system = 'supply'): AnyNode {
return makeNode('duct-segment', { path, system, diameter: 6 })
}
describe('buildPortComponents', () => {
test('chained runs land in one component; a distant run is separate', () => {
const a = run([
[0, 0, 0],
[3, 0, 0],
])
const b = run([
[3, 0, 0],
[3, 0, 4],
]) // shares a's end
const c = run([
[20, 0, 0],
[24, 0, 0],
]) // far away
const components = buildPortComponents(sceneOf(a, b, c))
expect(components.length).toBe(2)
const joined = components.find((g) => g.length === 2)!
expect(new Set(joined)).toEqual(new Set([a.id, b.id]))
})
test('joints within tolerance still join; outside do not', () => {
const a = run([
[0, 0, 0],
[3, 0, 0],
])
const near = run([
[3.03, 0, 0],
[6, 0, 0],
]) // 3 cm — joined
const far = run([
[3.2, 0, 4],
[6, 0, 4],
]) // 20 cm in another row — separate
const components = buildPortComponents(sceneOf(a, near, far))
expect(components.length).toBe(2)
})
test('nodes without ports do not participate', () => {
const wall = makeNode('wall', {})
const a = run([
[0, 0, 0],
[3, 0, 0],
])
const components = buildPortComponents(sceneOf(wall, a))
expect(components.length).toBe(1)
expect(components[0]).toEqual([a.id])
})
})
describe('summarizeSystemFor', () => {
test('full tree: equipment → run → terminal, stats add up', () => {
const furnace = makeNode('hvac-equipment', { position: [0, 0, 0] as Point })
const trunk = run([
[0, 0, 0],
[4, 0, 0],
])
const branch = run([
[4, 0, 0],
[4, 0, 3],
])
const register = makeNode('duct-terminal', {
position: [4, 0, 3] as Point,
terminalType: 'supply-register',
})
const scene = sceneOf(furnace, trunk, branch, register)
const summary = summarizeSystemFor(register.id, scene)!
expect(summary.nodeIds.length).toBe(4)
expect(summary.connectedToEquipment).toBe(true)
expect(summary.runCount).toBe(2)
expect(summary.runLengthM).toBeCloseTo(7, 6)
expect(summary.terminalCount).toBe(1)
expect(summary.equipmentCount).toBe(1)
expect(summary.systems).toEqual(['supply'])
})
test('orphaned run reports no equipment', () => {
const lonely = run([
[10, 0, 10],
[14, 0, 10],
])
const summary = summarizeSystemFor(lonely.id, sceneOf(lonely))!
expect(summary.connectedToEquipment).toBe(false)
expect(summary.runCount).toBe(1)
expect(summary.runLengthM).toBeCloseTo(4, 6)
})
test('port-less node → null', () => {
const wall = makeNode('wall', {})
expect(summarizeSystemFor(wall.id, sceneOf(wall))).toBeNull()
})
})
+196
View File
@@ -0,0 +1,196 @@
import { nodeRegistry } from '../registry'
import type { AnyNode, AnyNodeId } from '../schema'
/**
* The "System" primitive: connected components over the port graph.
*
* Two nodes are joined when a port of one coincides in space with a port
* of the other — the same mated-joint relationship `port-connectivity`
* uses for drag propagation, read here at whole-scene scope. A component
* is one distribution system: a furnace, its trunk, the tees, branches,
* and registers hanging off it.
*
* Pure logic (def.ports + arithmetic), no rendering — lives in core so
* the editor (badges, schedules) and analyses (sizing, code checks) can
* share it.
*/
/** Distance (meters) under which two ports count as the same joint —
* matches port-connectivity's tolerance for hand-placed joints. */
const COINCIDENT_EPS_M = 0.05
export type SystemSummary = {
/** Every node in this connected component. */
nodeIds: AnyNodeId[]
/** Distribution loops present, e.g. ['supply'], ['supply','return']. */
systems: string[]
/** Duct / lineset run statistics. */
runCount: number
runLengthM: number
fittingCount: number
terminalCount: number
equipmentCount: number
/** False = orphaned subtree: air goes nowhere (no furnace / air
* handler / condenser anywhere in the component). */
connectedToEquipment: boolean
}
type PortRecord = {
nodeId: AnyNodeId
x: number
y: number
z: number
system: string | undefined
}
function collectPorts(nodes: Readonly<Record<AnyNodeId, AnyNode>>): PortRecord[] {
const result: PortRecord[] = []
for (const node of Object.values(nodes)) {
if (!node) continue
const ports = nodeRegistry.get(node.type)?.ports?.(node)
if (!ports) continue
for (const port of ports) {
result.push({
nodeId: node.id,
x: port.position[0],
y: port.position[1],
z: port.position[2],
system: port.system,
})
}
}
return result
}
/** Union-find over node ids. */
class Components {
private parent = new Map<AnyNodeId, AnyNodeId>()
find(id: AnyNodeId): AnyNodeId {
let root = this.parent.get(id) ?? id
if (root !== id) {
root = this.find(root)
this.parent.set(id, root)
}
return root
}
union(a: AnyNodeId, b: AnyNodeId): void {
const ra = this.find(a)
const rb = this.find(b)
if (ra !== rb) this.parent.set(rb, ra)
}
}
function pathLength(path: ReadonlyArray<readonly [number, number, number]>): number {
let total = 0
for (let i = 0; i < path.length - 1; i++) {
const a = path[i]!
const b = path[i + 1]!
total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])
}
return total
}
/**
* Group every port-bearing node into connected components via coinciding
* ports. Nodes with ports but no joints form singleton components; nodes
* without `def.ports` don't participate at all.
*/
export function buildPortComponents(nodes: Readonly<Record<AnyNodeId, AnyNode>>): AnyNodeId[][] {
const ports = collectPorts(nodes)
const components = new Components()
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
for (let i = 0; i < ports.length; i++) {
const a = ports[i]!
for (let j = i + 1; j < ports.length; j++) {
const b = ports[j]!
if (a.nodeId === b.nodeId) continue
const dx = a.x - b.x
const dy = a.y - b.y
const dz = a.z - b.z
if (dx * dx + dy * dy + dz * dz <= epsSq) components.union(a.nodeId, b.nodeId)
}
}
const grouped = new Map<AnyNodeId, AnyNodeId[]>()
const seen = new Set<AnyNodeId>()
for (const port of ports) {
if (seen.has(port.nodeId)) continue
seen.add(port.nodeId)
const root = components.find(port.nodeId)
const group = grouped.get(root)
if (group) group.push(port.nodeId)
else grouped.set(root, [port.nodeId])
}
return [...grouped.values()]
}
function summarize(
nodeIds: AnyNodeId[],
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
): SystemSummary {
const systems = new Set<string>()
let runCount = 0
let runLengthM = 0
let fittingCount = 0
let terminalCount = 0
let equipmentCount = 0
for (const id of nodeIds) {
const node = nodes[id]
if (!node) continue
const role = nodeRegistry.get(node.type)?.distributionRole
const fields = node as {
path?: ReadonlyArray<readonly [number, number, number]>
system?: string
terminalType?: string
}
if (role === 'run') {
runCount += 1
if (fields.path) runLengthM += pathLength(fields.path)
// Linesets carry refrigerant; duct / pipe runs name their own loop.
systems.add(fields.system ?? 'refrigerant')
} else if (role === 'fitting') {
fittingCount += 1
if (fields.system) systems.add(fields.system)
} else if (role === 'terminal') {
terminalCount += 1
systems.add(fields.terminalType === 'return-grille' ? 'return' : 'supply')
} else if (role === 'equipment') {
equipmentCount += 1
}
}
return {
nodeIds,
systems: [...systems].sort(),
runCount,
runLengthM,
fittingCount,
terminalCount,
equipmentCount,
connectedToEquipment: equipmentCount > 0,
}
}
/**
* Summary of the system the given node belongs to, or null when the node
* has no ports (not a distribution kind). A node with ports but no
* joints yet still gets a (singleton) summary — `connectedToEquipment:
* false` is the interesting signal there.
*/
export function summarizeSystemFor(
nodeId: AnyNodeId,
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
): SystemSummary | null {
const node = nodes[nodeId]
if (!node) return null
const ports = nodeRegistry.get(node.type)?.ports?.(node)
if (!ports || ports.length === 0) return null
for (const component of buildPortComponents(nodes)) {
if (component.includes(nodeId)) return summarize(component, nodes)
}
return summarize([nodeId], nodes)
}
@@ -0,0 +1,125 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNodeDefinition, NodePort } from '../registry'
import { registerNode } from '../registry'
import type { AnyNode, AnyNodeId } from '../schema'
import { validateDwv } from './validate-dwv'
type Point = [number, number, number]
// The validator reads node fields directly + buildPortComponents (which
// consults def.ports), so register stub port-providers for the DWV kinds
// it groups by. Mirrors the system-graph test's approach.
function stubDef(kind: string, ports: (node: AnyNode) => NodePort[]): void {
registerNode({
kind,
schemaVersion: 1,
schema: {},
category: 'utility',
defaults: () => ({}),
capabilities: {},
ports,
} as unknown as AnyNodeDefinition)
}
stubDef('pipe-segment', (node) => {
const path = (node as unknown as { path: Point[] }).path
const diameter = (node as unknown as { diameter: number }).diameter
const system = (node as unknown as { system: string }).system
return [
{ id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter, system },
{
id: 'end',
position: path[path.length - 1]!,
direction: [1, 0, 0],
diameter,
system,
},
]
})
stubDef('pipe-trap', (node) => {
const position = (node as unknown as { position: Point }).position
return [{ id: 'inlet', position, direction: [0, 1, 0], diameter: 1.5, system: 'waste' }]
})
let nextId = 0
function makeNode(type: string, fields: Record<string, unknown>): AnyNode {
nextId += 1
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode
}
function sceneOf(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record<AnyNodeId, AnyNode>
}
/** A waste run from a→b. Drop the end Y to slope it. */
function waste(path: Point[], diameter = 2): AnyNode {
return makeNode('pipe-segment', { path, diameter, system: 'waste' })
}
const QUARTER_PER_FOOT = 1 / 48
describe('validateDwv — slope', () => {
test('flags a flat waste run', () => {
const run = waste([
[0, 0, 0],
[3, 0, 0], // dead level
])
const findings = validateDwv(sceneOf(run))
expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(true)
})
test('passes a run sloped at quarter-inch per foot', () => {
const drop = 3 * QUARTER_PER_FOOT
const run = waste([
[0, 0, 0],
[3, -drop, 0],
])
const findings = validateDwv(sceneOf(run))
expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(false)
})
test('flags an over-steep run (siphoning risk)', () => {
// 2" pipe, max slope = 2/12 ≈ 0.167; drop 2m over 1m horizontal.
const run = waste([
[0, 0, 0],
[1, -2, 0],
])
const findings = validateDwv(sceneOf(run))
expect(findings.some((f) => f.code === 'slope-too-steep')).toBe(true)
})
test('ignores vents (level is fine)', () => {
const vent = makeNode('pipe-segment', {
path: [
[0, 0, 0],
[0, 3, 0],
] as Point[],
diameter: 2,
system: 'vent',
})
const findings = validateDwv(sceneOf(vent))
expect(findings.length).toBe(0)
})
})
describe('validateDwv — trap arm', () => {
test('flags an over-long trap arm', () => {
const trap = makeNode('pipe-trap', {
position: [0, 0, 0] as Point,
diameter: 1.5, // max arm 42in = 1.067m
armLengthM: 2, // way over
})
const findings = validateDwv(sceneOf(trap))
expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(true)
})
test('passes a trap arm within the limit', () => {
const trap = makeNode('pipe-trap', {
position: [0, 0, 0] as Point,
diameter: 2, // max arm 60in = 1.524m
armLengthM: 1,
})
const findings = validateDwv(sceneOf(trap))
expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(false)
})
})
+161
View File
@@ -0,0 +1,161 @@
import type { AnyNode, AnyNodeId } from '../schema'
import { buildPortComponents } from './system-graph'
/**
* IPC validators for the DWV (drain-waste-vent) system — the "CodeRule"
* primitive from the domain brief. The slope, minimum-size, and
* trap-arm rules are all geometric and read straight off the node
* fields, so they live here in core (pure logic) where the editor can
* surface them and analyses can reuse them.
*
* Scope is residential IPC, simplified:
* - 704.1 drainage slope by pipe size.
* - 909 trap-arm maximum developed length by trap size.
*
* These are intentionally conservative approximations, not a certified
* plan-check — enough to flag the mistakes a drawing tool invites.
*/
/** Drainage findings, worst-first per consumer's sort. */
export type DwvSeverity = 'error' | 'warning'
export type DwvFinding = {
severity: DwvSeverity
/** Stable rule id, e.g. 'slope-too-flat'. */
code: string
/** Human-readable, already-formatted message. */
message: string
/** Nodes the finding implicates (usually one). */
nodeIds: AnyNodeId[]
}
/** IPC 704.1 minimum drainage slope (rise/run, dimensionless) by
* nominal pipe size: ¼"/ft (1:48) under 3", ⅛"/ft (1:96) for 36",
* 1/16"/ft (1:192) at 8"+. */
function minSlopeFor(diameterIn: number): number {
if (diameterIn < 3) return 1 / 48
if (diameterIn < 8) return 1 / 96
return 1 / 192
}
/** IPC Table 909.1 maximum trap-arm developed length (meters) by trap
* size: 30" @ 1¼", 42" @ 1½", 60" @ 2", 72" @ 3", 120" @ 4". */
const TRAP_ARM_MAX_M: ReadonlyArray<readonly [number, number]> = [
[1.25, 30 * 0.0254],
[1.5, 42 * 0.0254],
[2, 60 * 0.0254],
[3, 72 * 0.0254],
[4, 120 * 0.0254],
]
function trapArmMaxFor(diameterIn: number): number {
let max = Infinity
for (const [size, lengthM] of TRAP_ARM_MAX_M) {
if (diameterIn <= size) return lengthM
max = lengthM
}
return max
}
/** Slopes shallower than this fraction of the minimum are flagged
* "too flat" — a small tolerance keeps round-off off the list. */
const SLOPE_TOLERANCE = 0.9
/** Horizontal legs shorter than this (meters) are treated as vertical
* stacks and skipped from the slope check. */
const VERTICAL_LEG_EPS_M = 0.02
type Vec3 = readonly [number, number, number]
function legSlope(a: Vec3, b: Vec3): { horizontalM: number; slope: number } {
const horizontalM = Math.hypot(b[0] - a[0], b[2] - a[2])
if (horizontalM < VERTICAL_LEG_EPS_M) return { horizontalM, slope: Infinity }
return { horizontalM, slope: Math.abs(a[1] - b[1]) / horizontalM }
}
function inchLabel(value: number): string {
return `${value}"`
}
/** Per-foot slope as a readable fraction, e.g. 0.0208 → '¼"/ft'. */
function slopePerFootLabel(slope: number): string {
const inchesPerFoot = slope * 12
return `${inchesPerFoot.toFixed(2)}"/ft`
}
/**
* Run every DWV rule over the scene and return the findings. Empty
* array = nothing to flag. Pure: no scene/store access, no rendering.
*/
export function validateDwv(nodes: Readonly<Record<AnyNodeId, AnyNode>>): DwvFinding[] {
const findings: DwvFinding[] = []
// ── Per-segment slope (waste only) ──────────────────────────────
for (const node of Object.values(nodes)) {
if (!node || node.type !== 'pipe-segment' || node.system !== 'waste') continue
const path = node.path as Vec3[]
const minSlope = minSlopeFor(node.diameter)
const maxSlope = node.diameter / 12 // 1 pipe-diameter per foot → siphoning
let flaggedFlat = false
let flaggedSteep = false
for (let i = 0; i < path.length - 1; i++) {
const { slope } = legSlope(path[i]!, path[i + 1]!)
if (slope === Infinity) continue // vertical stack leg
if (!flaggedFlat && slope < minSlope * SLOPE_TOLERANCE) {
findings.push({
severity: 'error',
code: 'slope-too-flat',
message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel(
slope,
)} — IPC 704.1 requires at least ${slopePerFootLabel(minSlope)}.`,
nodeIds: [node.id],
})
flaggedFlat = true
}
if (!flaggedSteep && slope > maxSlope) {
findings.push({
severity: 'warning',
code: 'slope-too-steep',
message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel(
slope,
)} — over one pipe-diameter per foot risks siphoning the traps.`,
nodeIds: [node.id],
})
flaggedSteep = true
}
}
}
// ── Component-scoped trap rules ──────────────────────────────────
for (const component of buildPortComponents(nodes)) {
const traps: AnyNode[] = []
for (const id of component) {
const node = nodes[id]
if (!node) continue
if (node.type === 'pipe-trap') {
traps.push(node)
}
}
// Trap-arm developed length: trap outlet → its vent, capped by size.
// Independent of waste segments — a trap on its own can already be
// over-armed.
for (const trap of traps) {
const t = trap as { id: AnyNodeId; diameter: number; armLengthM?: number }
const armLengthM = t.armLengthM ?? 0
const maxArm = trapArmMaxFor(t.diameter)
if (armLengthM > maxArm + 1e-6) {
findings.push({
severity: 'error',
code: 'trap-arm-too-long',
message: `${inchLabel(t.diameter)} trap arm runs ${(armLengthM / 0.0254).toFixed(
0,
)}" to its vent — IPC 909.1 caps it at ${(maxArm / 0.0254).toFixed(0)}".`,
nodeIds: [t.id],
})
}
}
}
return findings
}