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,70 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildPipeTrapFloorplan } from './floorplan'
import { buildPipeTrapGeometry } from './geometry'
import { pipeTrapParametrics } from './parametrics'
import { getPipeTrapPorts } from './ports'
import { PipeTrapNode } from './schema'
/**
* DWV P-trap — the water-seal fitting on the waste line. Placed by its
* own click tool; the pipe tool then draws the trap arm off the outlet.
* Modeled explicitly so the IPC 909.1 trap-arm rule has a node to
* validate.
*/
export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
kind: 'pipe-trap',
schemaVersion: 1,
schema: PipeTrapNode,
category: 'utility',
distributionRole: 'fitting',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
diameter: 1.5,
pipeMaterial: 'pvc',
armLengthM: 0,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
movable: { axes: ['x', 'y', 'z'], gridSnap: true, portSnap: { systems: ['waste'] } },
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
duplicable: true,
deletable: true,
},
parametrics: pipeTrapParametrics,
geometry: buildPipeTrapGeometry,
geometryKey: (n) => JSON.stringify([n.diameter, n.pipeMaterial, n.armLengthM]),
ports: getPipeTrapPorts,
floorplan: buildPipeTrapFloorplan,
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place trap' },
{ key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Shift', label: 'Smooth (no grid snap)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'Trap',
description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.',
icon: { kind: 'iconify', name: 'lucide:spline' },
paletteSection: 'structure',
paletteOrder: 98,
},
mcp: {
description:
'A DWV P-trap with inlet (up) and outlet (trap arm) ports. Position is level-local meters; rotation is yaw radians. armLengthM is the trap-arm developed length checked against IPC 909.1.',
},
}
+53
View File
@@ -0,0 +1,53 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
import { getPipeTrapPorts } from './ports'
import type { PipeTrapNode } from './schema'
const PIPE_STROKE = '#57534e'
/**
* Floor-plan symbol — the conventional trap glyph: a short stub at the
* inlet (the fixture drop, drawn as a dot since it's vertical) and a
* solid line for the trap arm out to the outlet. Reads as the P-trap's
* arm in plan.
*/
export function buildPipeTrapFloorplan(
node: PipeTrapNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const ports = getPipeTrapPorts(node)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const stroke = showSelectedChrome && palette ? palette.selectedStroke : PIPE_STROKE
const inletXZ: FloorplanPoint = [inlet.position[0], inlet.position[2]]
const outletXZ: FloorplanPoint = [outlet.position[0], outlet.position[2]]
const children: FloorplanGeometry[] = [
{
kind: 'polyline',
points: [inletXZ, outletXZ],
stroke,
strokeWidth: showSelectedChrome ? 2.5 : 1.8,
vectorEffect: 'non-scaling-stroke',
opacity: 0.9,
},
{
kind: 'circle',
cx: inletXZ[0],
cy: inletXZ[1],
r: 0.04,
fill: stroke,
opacity: 0.9,
},
]
if (showSelectedChrome) {
children.push({ kind: 'move-handle', point: [node.position[0], node.position[2]] })
}
return { kind: 'group', children }
}
+69
View File
@@ -0,0 +1,69 @@
import { Group, Mesh, TorusGeometry, Vector3 } from 'three'
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
import { createPipeMaterial } from '../pipe-segment/geometry'
import type { PipeTrapNode } from './schema'
const BEND_SEGMENTS = 24
/** Inlet drop and arm reach in pipe radii — keeps the trap proportional
* to its size without per-size tuning. */
const INLET_DROP_RADII = 2.6
const ARM_REACH_RADII = 3.2
/**
* P-trap geometry in the LOCAL frame (origin at the trap weir, the low
* point of the U). Inlet stub rises +Y to the fixture tailpiece; a
* half-torus U-bend turns the flow; the trap arm runs +X toward the
* vented waste line. `<ParametricNodeRenderer>` applies position + yaw.
*/
export function buildPipeTrapGeometry(node: PipeTrapNode): Group {
const group = new Group()
const material = createPipeMaterial({ pipeMaterial: node.pipeMaterial, system: 'waste' })
const radius = (node.diameter * INCHES_TO_METERS) / 2
const bendR = radius * 1.6
// U-bend: half torus in the XY plane, opening upward. Sits so its two
// tops are at y = bendR (the inlet riser and the arm rise).
const bend = new Mesh(new TorusGeometry(bendR, radius, 12, BEND_SEGMENTS, Math.PI), material)
bend.rotation.z = Math.PI // open side up
bend.position.set(bendR, bendR, 0)
bend.name = 'pipe-trap-bend'
group.add(bend)
// Inlet riser: from the left top of the U straight up to the fixture.
const inletDrop = radius * INLET_DROP_RADII
const inletTop = new Vector3(0, bendR + inletDrop, 0)
const inletStub = buildSection(
new Vector3(0, bendR, 0),
inletTop,
radius,
material,
'pipe-trap-inlet',
)
if (inletStub) group.add(inletStub)
// Trap arm: from the right top of the U horizontally along +X.
const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM)
const armStart = new Vector3(bendR * 2, bendR, 0)
const armEnd = new Vector3(bendR * 2 + armReach, bendR, 0)
const arm = buildSection(armStart, armEnd, radius, material, 'pipe-trap-arm')
if (arm) group.add(arm)
return group
}
/** Local-frame port positions (before position/yaw): inlet at the top
* of the riser facing +Y, outlet at the end of the arm facing +X. */
export function localTrapPorts(node: PipeTrapNode): {
inlet: Vector3
outlet: Vector3
} {
const radius = (node.diameter * INCHES_TO_METERS) / 2
const bendR = radius * 1.6
const inletDrop = radius * INLET_DROP_RADII
const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM)
return {
inlet: new Vector3(0, bendR + inletDrop, 0),
outlet: new Vector3(bendR * 2 + armReach, bendR, 0),
}
}
+4
View File
@@ -0,0 +1,4 @@
export { pipeTrapDefinition } from './definition'
export { buildPipeTrapGeometry } from './geometry'
export { getPipeTrapPorts } from './ports'
export { PipeTrapNode } from './schema'
@@ -0,0 +1,19 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { PipeTrapNode } from './schema'
export const pipeTrapParametrics: ParametricDescriptor<PipeTrapNode> = {
groups: [
{
label: 'Trap',
fields: [
{ key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 4, step: 0.25 },
{ key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] },
{ key: 'armLengthM', kind: 'number', unit: 'm', min: 0, max: 4, step: 0.05 },
],
},
{
label: 'Placement',
fields: [{ key: 'position', kind: 'vec3' }],
},
],
}
+35
View File
@@ -0,0 +1,35 @@
import type { NodePort } from '@pascal-app/core'
import { Vector3 } from 'three'
import { localTrapPorts } from './geometry'
import type { PipeTrapNode } from './schema'
/**
* `def.ports` — the trap's inlet (up, to the fixture) and outlet (the
* trap arm, toward the vented waste line), transformed by position +
* yaw into level-local space. Both carry the trap diameter and the
* 'waste' system tag so the pipe tool and system graph treat them like
* any other DWV joint.
*/
export function getPipeTrapPorts(node: PipeTrapNode): NodePort[] {
const { inlet, outlet } = localTrapPorts(node)
const yaw = node.rotation
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
const place = (local: Vector3, dir: Vector3): NodePort => {
const position = local
.clone()
.applyAxisAngle(new Vector3(0, 1, 0), yaw)
.add(offset)
const direction = dir
.clone()
.applyAxisAngle(new Vector3(0, 1, 0), yaw)
.normalize()
return {
id: local === inlet ? 'inlet' : 'outlet',
position: [position.x, position.y, position.z] as const,
direction: [direction.x, direction.y, direction.z] as const,
diameter: node.diameter,
system: 'waste',
}
}
return [place(inlet, new Vector3(0, 1, 0)), place(outlet, new Vector3(1, 0, 0))]
}
+1
View File
@@ -0,0 +1 @@
export { PipeTrapNode } from '@pascal-app/core'
+135
View File
@@ -0,0 +1,135 @@
'use client'
import { emitter, type GridEvent, PipeTrapNode, useScene } from '@pascal-app/core'
import { triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { pipeTrapDefinition } from './definition'
import { buildPipeTrapGeometry } from './geometry'
const PREVIEW_OPACITY = 0.55
const ROTATE_STEP_RAD = Math.PI / 4
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/**
* Click-place tool for P-traps. The ghost follows the cursor on the
* floor. **R / T** rotate the arm ±45°, **Shift** disables grid snap.
* The pipe tool then draws the trap arm off the outlet toward the vent.
*/
const PipeTrapTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const [cursor, setCursor] = useState<[number, number, number] | null>(null)
const [yaw, setYaw] = useState(0)
const [diameter] = useState(1.5)
const yawRef = useRef(0)
const diameterRef = useRef(diameter)
diameterRef.current = diameter
const previewNode = useMemo(
() =>
PipeTrapNode.parse({
...pipeTrapDefinition.defaults(),
diameter,
}),
[diameter],
)
const ghost = useMemo(() => {
const group = buildPipeTrapGeometry(previewNode)
group.traverse((child) => {
const mesh = child as { material?: { transparent: boolean; opacity: number } }
if (mesh.material) {
mesh.material.transparent = true
mesh.material.opacity = PREVIEW_OPACITY
}
})
return group
}, [previewNode])
useEffect(() => {
if (!activeLevelId) return
const resolve = (event: GridEvent) => {
const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep
return {
position: [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [
number,
number,
number,
],
diameter: diameterRef.current,
}
}
const onMove = (event: GridEvent) => {
setCursor(resolve(event).position)
}
const onClick = (event: GridEvent) => {
const r = resolve(event)
const trap = PipeTrapNode.parse({
...pipeTrapDefinition.defaults(),
diameter: r.diameter,
position: r.position,
rotation: yawRef.current,
})
useScene.getState().createNode(trap, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [trap.id] })
triggerSFX('sfx:item-place')
}
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = e.key
if (key === 'r' || key === 'R' || key === 't' || key === 'T') {
e.preventDefault()
e.stopPropagation()
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
yawRef.current += steps * ROTATE_STEP_RAD
setYaw(yawRef.current)
triggerSFX('sfx:item-rotate')
}
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
window.addEventListener('keydown', onKeyDown, true)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
window.removeEventListener('keydown', onKeyDown, true)
}
}, [activeLevelId])
if (!activeLevelId || !cursor) return null
return (
<LevelOffsetGroup>
<group position={cursor} rotation={[0, yaw, 0]}>
<primitive object={ghost} />
</group>
<Html
center
position={[cursor[0], cursor[1] + 0.5, cursor[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
<span className="font-medium text-foreground">{diameter}" Trap</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">R/T rotate</span>
</div>
</Html>
</LevelOffsetGroup>
)
}
export default PipeTrapTool