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,106 @@
import type { NodeDefinition } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { getRotationAxis, rotateEulerWorld } from '../shared/fitting-rotation'
import { buildPipeFittingFloorplan } from './floorplan'
import { buildPipeFittingGeometry } from './geometry'
import { pipeFittingParametrics } from './parametrics'
import { getPipeFittingPorts } from './ports'
import { PipeFittingNode } from './schema'
/**
* DWV fittings — minted automatically by the pipe draw tool (corner
* joints → elbows, body taps → wyes on horizontal drains / sanitary
* tees on stacks), or click-placed via the tool (armed from the Build
* tab's DWV Pipe panel). Editable after the fact via the inspector.
*/
export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
kind: 'pipe-fitting',
schemaVersion: 1,
schema: PipeFittingNode,
category: 'utility',
distributionRole: 'fitting',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
fittingType: 'elbow',
angle: 90,
diameter: 2,
diameter2: 2,
pipeMaterial: 'pvc',
system: 'waste',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
duplicable: true,
deletable: true,
},
parametrics: pipeFittingParametrics,
geometry: buildPipeFittingGeometry,
geometryKey: (n) =>
JSON.stringify([n.fittingType, n.angle, n.diameter, n.diameter2, n.pipeMaterial, n.system]),
ports: getPipeFittingPorts,
floorplan: buildPipeFittingFloorplan,
// R/T rotate a selected fitting ±45° around the shared active axis —
// same scheme as duct fittings (the default editor rotate only knows
// Y; DWV stacks need X/Z). Alt-cycling lives in `./selection.tsx`.
keyboardActions: {
r: {
appliesTo: (node) => node.type === 'pipe-fitting',
run: (node) =>
useScene.getState().updateNode(node.id, {
rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), 1),
}),
},
t: {
appliesTo: (node) => node.type === 'pipe-fitting',
run: (node) =>
useScene.getState().updateNode(node.id, {
rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), -1),
}),
},
axisCycling: true,
},
// Alt-cycles the active rotation axis while a fitting is selected.
// Editor-only (drives `useEditor.rotationAxis`), so it mounts via the
// editor's SelectionAffordanceManager rather than `def.system`.
affordanceTools: {
selection: () => import('./selection'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place fitting' },
{ key: 'Hover a pipe end', label: 'Snap onto the run' },
{ key: 'R / T', label: 'Rotate ±45°' },
{ key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' },
{ key: 'Esc', label: 'Exit' },
],
presentation: {
label: 'Pipe Fitting',
description: 'DWV joint — elbow bend, 45° wye, or sanitary tee.',
// Reuses the duct-fitting artwork — DWV fittings read the same in the UI.
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
paletteSection: 'structure',
paletteOrder: 96,
hidden: true,
},
mcp: {
description:
'A DWV pipe fitting (elbow, wye, or sanitary tee) with typed ports. Minted automatically at drain joints; position is level-local meters, rotation an XYZ euler.',
},
}