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,134 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { rotateFittingNode } from '../shared/fitting-rotation'
|
||||
import { buildDuctFittingFloorplan } from './floorplan'
|
||||
import { buildDuctFittingGeometry } from './geometry'
|
||||
import { ductFittingParametrics } from './parametrics'
|
||||
import { getDuctFittingPorts } from './ports'
|
||||
import { DuctFittingNode } from './schema'
|
||||
|
||||
/**
|
||||
* Phase 2 of the HVAC node system — duct fittings (elbow / tee / reducer)
|
||||
* and the first kind to expose typed ports (`def.ports`).
|
||||
*
|
||||
* Composition: `def.geometry` only, same as duct-segment. Ports are the
|
||||
* architectural payload: placement tools snap onto them, and a later
|
||||
* slice walks them to build the supply/return system graph.
|
||||
*/
|
||||
export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
kind: 'duct-fitting',
|
||||
schemaVersion: 1,
|
||||
schema: DuctFittingNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'fitting',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
fittingType: 'elbow',
|
||||
shape: 'round',
|
||||
width: 14,
|
||||
height: 8,
|
||||
shape2: 'round',
|
||||
width2: 14,
|
||||
height2: 8,
|
||||
angle: 90,
|
||||
branchAngle: 90,
|
||||
diameter: 6,
|
||||
diameter2: 6,
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
// `cursorAttached`: a fitting is a small connector — an offset-
|
||||
// preserving drag reads as the mesh trailing the mouse, so pin its
|
||||
// origin to the cursor instead.
|
||||
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: ductFittingParametrics,
|
||||
|
||||
geometry: buildDuctFittingGeometry,
|
||||
geometryKey: (n) =>
|
||||
JSON.stringify([
|
||||
n.fittingType,
|
||||
// The mitered elbow + flange profiles swap width/height roles based
|
||||
// on where world-up sits in the local frame, so orientation is a
|
||||
// geometry input.
|
||||
n.rotation,
|
||||
n.shape,
|
||||
n.width,
|
||||
n.height,
|
||||
n.shape2,
|
||||
n.width2,
|
||||
n.height2,
|
||||
n.angle,
|
||||
n.branchAngle,
|
||||
n.diameter,
|
||||
n.diameter2,
|
||||
n.ductMaterial,
|
||||
n.system,
|
||||
]),
|
||||
|
||||
ports: getDuctFittingPorts,
|
||||
|
||||
floorplan: buildDuctFittingFloorplan,
|
||||
|
||||
// R/T rotate a selected fitting ±45° around the shared active axis.
|
||||
// The default editor rotate only knows Y; fittings need X/Z for
|
||||
// risers, so this overrides it. Alt-cycling of the axis + the axis
|
||||
// badge live in `./selection.tsx`.
|
||||
keyboardActions: {
|
||||
r: {
|
||||
appliesTo: (node) => node.type === 'duct-fitting',
|
||||
run: (node) => rotateFittingNode(node, 1),
|
||||
},
|
||||
t: {
|
||||
appliesTo: (node) => node.type === 'duct-fitting',
|
||||
run: (node) => rotateFittingNode(node, -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'),
|
||||
// Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a
|
||||
// translucent copy of the fitting (built from its real geometry, at its
|
||||
// own rotation, so an elbow / riser stays properly aligned) follows the
|
||||
// cursor and only lands on the commit click. Takes priority over
|
||||
// `capabilities.movable` in the MoveTool dispatcher.
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Click', label: 'Place fitting' },
|
||||
{ key: 'Hover a duct 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: 'Duct Fitting',
|
||||
description: 'Elbow, tee, reducer, or square-to-round transition connecting duct runs.',
|
||||
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 91,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A duct fitting (elbow, tee, reducer, or square-to-round transition) with typed connection ports. Position is level-local meters; rotation is an XYZ euler in radians.',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user