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:
@@ -59,6 +59,9 @@ export {
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export { DownspoutNode } from './nodes/downspout'
|
||||
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
export {
|
||||
ElevatorDoorPanelStyle,
|
||||
ElevatorDoorStyle,
|
||||
@@ -69,6 +72,7 @@ export { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
||||
export { HvacEquipmentNode } from './nodes/hvac-equipment'
|
||||
export type {
|
||||
AnimationEffect,
|
||||
Asset,
|
||||
@@ -88,6 +92,11 @@ export {
|
||||
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
|
||||
} from './nodes/item'
|
||||
export { LevelNode } from './nodes/level'
|
||||
export { LinesetNode } from './nodes/lineset'
|
||||
export { LiquidLineNode } from './nodes/liquid-line'
|
||||
export { PipeFittingNode } from './nodes/pipe-fitting'
|
||||
export { PipeSegmentNode } from './nodes/pipe-segment'
|
||||
export { PipeTrapNode } from './nodes/pipe-trap'
|
||||
// Nodes
|
||||
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* Duct fitting — the junction pieces that connect round duct segments:
|
||||
* elbows (direction change), tees (branch takeoff), reducers (diameter
|
||||
* transition).
|
||||
*
|
||||
* Phase 2 of the HVAC node system. Fittings are the first kind to expose
|
||||
* typed ports (`def.ports`) — placement tools snap duct endpoints onto a
|
||||
* fitting's collars, and the future system graph walks ports to decide
|
||||
* connectivity.
|
||||
*
|
||||
* `position` is level-local meters; `rotation` is an XYZ euler in radians
|
||||
* so a fitting can turn a horizontal run vertical (riser elbows).
|
||||
*
|
||||
* Local-frame conventions (before `rotation` is applied):
|
||||
* - elbow: inlet faces -X, outlet turned by `angle` degrees in the
|
||||
* XZ plane (90° → +Z).
|
||||
* - tee: run along the X axis (ports face -X and +X), branch
|
||||
* collar at `branchAngle`° from the +X (outlet) axis in the
|
||||
* XZ plane — 90° a square straight tee, <90° a lateral
|
||||
* leaning downstream toward the outlet, >90° leaning upstream
|
||||
* toward the inlet — sized at `diameter2`.
|
||||
* - cross: four-way junction — run along the X axis (ports face -X
|
||||
* and +X) at the run profile, two opposed branches square to
|
||||
* the run along ±Z (branch faces +Z, branch2 faces -Z) at the
|
||||
* branch profile (`shape2` / `diameter2`).
|
||||
* - reducer: inlet at `diameter` faces -X, outlet at `diameter2`
|
||||
* faces +X.
|
||||
* - transition: square-to-round — rect end at `width` × `height` faces
|
||||
* -X, round end at `diameter2` faces +X. `diameter` carries
|
||||
* the rect end's area-equivalent round size.
|
||||
*/
|
||||
export const DuctFittingNode = BaseNode.extend({
|
||||
id: objectId('duct-fitting'),
|
||||
type: nodeType('duct-fitting'),
|
||||
// Level-local meters.
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// XYZ euler radians.
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
fittingType: z.enum(['elbow', 'tee', 'cross', 'reducer', 'transition']).default('elbow'),
|
||||
// Run-leg cross-section: round collars, or a rect / flat-oval profile
|
||||
// matching the trunk the fitting sits in. Reducers ignore the shape.
|
||||
// When non-round, `diameter` carries the area-equivalent round size
|
||||
// (drives leg lengths + advertised ports).
|
||||
shape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||
// Rect / oval run-leg profile in inches (used when shape ≠ 'round').
|
||||
width: z.number().min(4).max(60).default(14),
|
||||
height: z.number().min(3).max(40).default(8),
|
||||
// Tee / cross BRANCH cross-section: a round collar at `diameter2` or a
|
||||
// rect / oval profile matching the duct drawn off the tap. When
|
||||
// non-round, `diameter2` carries the branch's area-equivalent round
|
||||
// size. A cross's two opposed branches share this one profile.
|
||||
shape2: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||
// Rect / oval branch profile in inches (used when shape2 ≠ 'round').
|
||||
width2: z.number().min(4).max(60).default(14),
|
||||
height2: z.number().min(3).max(40).default(8),
|
||||
// Elbow turn angle in degrees. Residential sheet-metal elbows come in
|
||||
// 90° and 45°; adjustable elbows cover the range between.
|
||||
angle: z.number().min(15).max(90).default(90),
|
||||
// Tee branch angle in degrees, measured off the +X (outlet) axis: 90°
|
||||
// is a square straight tee, <90° a lateral whose branch sweeps
|
||||
// downstream toward the outlet (flow merges), >90° leans the branch
|
||||
// upstream toward the inlet. Ignored by every other fitting type.
|
||||
branchAngle: z.number().min(45).max(135).default(90),
|
||||
// Main (run/inlet) nominal diameter in inches.
|
||||
diameter: z.number().min(2).max(48).default(6),
|
||||
// Secondary diameter in inches — tee branch collar, reducer outlet.
|
||||
// Ignored by elbows.
|
||||
diameter2: z.number().min(2).max(48).default(6),
|
||||
ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'),
|
||||
system: z.enum(['supply', 'return']).default('supply'),
|
||||
}).describe(
|
||||
dedent`
|
||||
Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs.
|
||||
- position: [x, y, z] level-local meters
|
||||
- rotation: [x, y, z] euler radians
|
||||
- fittingType: elbow | tee | cross | reducer | transition (rect end -X, round end +X)
|
||||
- shape: round | rect | oval run legs (matches the trunk; ignored by reducer / transition)
|
||||
- width / height: rect / oval run-leg profile in inches (transition: the rect end)
|
||||
- shape2: round | rect | oval tee / cross branch (matches the duct drawn off the tap)
|
||||
- width2 / height2: rect / oval branch profile in inches
|
||||
- angle: elbow turn in degrees (45 or 90 typical)
|
||||
- branchAngle: tee branch angle off the outlet axis (90 straight tee, 45 downstream lateral, 135 upstream); cross branches are always square
|
||||
- diameter: main nominal diameter in inches
|
||||
- diameter2: tee / cross branch / reducer outlet / transition round-end diameter in inches
|
||||
- ductMaterial: sheet-metal | flex | duct-board
|
||||
- system: supply | return
|
||||
`,
|
||||
)
|
||||
export type DuctFittingNode = z.infer<typeof DuctFittingNode>
|
||||
export type DuctFittingNodeId = DuctFittingNode['id']
|
||||
@@ -0,0 +1,77 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* Round duct segment — a polyline of 3D points connected by cylindrical
|
||||
* duct sections. Forced-air HVAC supply/return runs in US residential.
|
||||
*
|
||||
* Phase 1 of the HVAC node system: just the geometry primitive. Fittings,
|
||||
* terminals, equipment, and typed ports come in later slices.
|
||||
*
|
||||
* Path coordinates are level-local meters: [x, y, z] tuples. y is height
|
||||
* above the level floor. A duct hung at ceiling height through three points
|
||||
* is e.g. `[[0, 2.6, 0], [3, 2.6, 0], [3, 2.6, 4]]`.
|
||||
*
|
||||
* Diameters are nominal US round-duct sizes in inches; the geometry
|
||||
* builder converts to meters for the cylinder radius.
|
||||
*/
|
||||
export const DuctSegmentNode = BaseNode.extend({
|
||||
id: objectId('duct-segment'),
|
||||
type: nodeType('duct-segment'),
|
||||
// Polyline path in level-local meters. Minimum two points (start, end).
|
||||
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||
// Cross-section. Round is the branch default; rect is the trunk /
|
||||
// plenum profile (real US systems: rect trunk, round branches); oval
|
||||
// is the flat-oval profile (two semicircles of the duct height joined
|
||||
// by flat sides) used where round won't fit a joist bay.
|
||||
shape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||
// Nominal inner diameter in inches (round shape). Common residential
|
||||
// sizes 4"–14"; we accept any positive number so the inspector slider
|
||||
// stays ergonomic and larger commercial sizes load without a schema bump.
|
||||
diameter: z.number().min(2).max(48).default(6),
|
||||
// Rect / oval cross-section in inches: width is the horizontal face,
|
||||
// height the vertical. Typical residential trunks 12×8 – 24×10. For
|
||||
// oval, height is also the end-cap semicircle diameter (width ≥ height).
|
||||
width: z.number().min(4).max(60).default(14),
|
||||
height: z.number().min(3).max(40).default(8),
|
||||
// Cross-section roll (radians) about the run direction. 0 = width
|
||||
// horizontal / height vertical (the natural orientation the geometry
|
||||
// derives from direction). Non-zero only on a rect riser turned out of
|
||||
// the horizontal plane, so its profile stays continuous through the
|
||||
// elbow it left instead of snapping to the world-axis fallback.
|
||||
roll: z.number().default(0),
|
||||
// Construction material. Spiral is round rigid sheet metal with the
|
||||
// helical lock seam drawn on the body (round shape only — rect / oval
|
||||
// runs render it as plain sheet metal).
|
||||
ductMaterial: z.enum(['sheet-metal', 'spiral', 'flex', 'duct-board']).default('flex'),
|
||||
// Whether to draw the construction body detail (spiral lock seam /
|
||||
// flex wire corrugation) on round runs. Off renders a smooth body —
|
||||
// lighter on the eyes and the GPU in dense scenes.
|
||||
seamDetail: z.boolean().default(false),
|
||||
// Whether the run wears its external insulation wrap (drawn as a
|
||||
// translucent shell). Off by default — bare duct.
|
||||
insulated: z.boolean().default(false),
|
||||
// External insulation R-value (used when insulated). Common flex-duct
|
||||
// values are R-4.2, R-6, R-8.
|
||||
insulationR: z.number().min(0).max(12).default(0.5),
|
||||
// Which side of the air loop this segment belongs to. Drives visual tint
|
||||
// and (in later slices) System graph membership.
|
||||
system: z.enum(['supply', 'return']).default('supply'),
|
||||
}).describe(
|
||||
dedent`
|
||||
Duct segment - polyline of 3D points connected by duct sections.
|
||||
- path: list of [x, y, z] points in level-local meters (min 2)
|
||||
- shape: round (branches) | rect (trunks / plenums) | oval (flat-oval, tight joist bays)
|
||||
- diameter: nominal inner diameter in inches for round (typ. 4-14 residential)
|
||||
- width / height: rect / oval cross-section in inches (typ. 12x8 - 24x10 trunks)
|
||||
- roll: cross-section roll in radians (0 = upright; set on risers to stay continuous through their elbow)
|
||||
- ductMaterial: sheet-metal | spiral (round rigid, helical seam) | flex | duct-board
|
||||
- seamDetail: draw the spiral seam / flex corrugation on round runs (default off)
|
||||
- insulated: whether the run wears its external insulation wrap (default off)
|
||||
- insulationR: external insulation R-value when insulated (4, 6, 8 typical)
|
||||
- system: supply | return (drives visual tint)
|
||||
`,
|
||||
)
|
||||
export type DuctSegmentNode = z.infer<typeof DuctSegmentNode>
|
||||
export type DuctSegmentNodeId = DuctSegmentNode['id']
|
||||
@@ -0,0 +1,56 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* Duct terminal — where the air loop meets the room: supply registers,
|
||||
* ceiling diffusers, return grilles.
|
||||
*
|
||||
* Phase 3 of the HVAC node system. Each terminal exposes a single typed
|
||||
* port at its collar (behind/above/below the face depending on mount),
|
||||
* so duct runs end onto it like any other port.
|
||||
*
|
||||
* `position` is the center of the visible face in level-local meters —
|
||||
* floor registers at y≈0, ceiling diffusers at ceiling height, wall
|
||||
* registers at their height on the wall. `rotation` is yaw radians.
|
||||
*/
|
||||
export const DuctTerminalNode = BaseNode.extend({
|
||||
id: objectId('duct-terminal'),
|
||||
type: nodeType('duct-terminal'),
|
||||
// Level-local meters — center of the face.
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Yaw in radians.
|
||||
rotation: z.number().default(0),
|
||||
terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'),
|
||||
// Which surface the terminal mounts on. Drives face orientation and
|
||||
// which way the collar (and its port) points.
|
||||
mount: z.enum(['floor', 'ceiling', 'wall']).default('floor'),
|
||||
// Face dimensions in meters. Typical floor register ~0.30 × 0.15;
|
||||
// ceiling diffusers are square (0.6 × 0.6); return grilles run large.
|
||||
width: z.number().min(0.1).max(1.5).default(0.3),
|
||||
depth: z.number().min(0.05).max(1.5).default(0.15),
|
||||
// Collar cross-section on the duct side. Round is the default; rect and
|
||||
// oval (flat-oval) match the duct shapes a run might end with.
|
||||
collarShape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||
// Round collar diameter in inches on the duct side.
|
||||
collarDiameter: z.number().min(4).max(20).default(6),
|
||||
// Rect / oval collar cross-section in inches: width is the horizontal
|
||||
// face, height the vertical. For oval, height is also the end-cap
|
||||
// semicircle diameter (width ≥ height).
|
||||
collarWidth: z.number().min(4).max(20).default(10),
|
||||
collarHeight: z.number().min(3).max(20).default(6),
|
||||
}).describe(
|
||||
dedent`
|
||||
Duct terminal - supply register, ceiling diffuser, or return grille.
|
||||
- position: [x, y, z] level-local meters, center of the face
|
||||
- rotation: yaw radians
|
||||
- terminalType: supply-register | diffuser | return-grille (grille = return side)
|
||||
- mount: floor | ceiling | wall - face orientation + collar direction
|
||||
- width / depth: face size in meters
|
||||
- collarShape: round | rect | oval - duct-side collar cross-section
|
||||
- collarDiameter: round collar diameter in inches
|
||||
- collarWidth / collarHeight: rect / oval collar cross-section in inches
|
||||
`,
|
||||
)
|
||||
export type DuctTerminalNode = z.infer<typeof DuctTerminalNode>
|
||||
export type DuctTerminalNodeId = DuctTerminalNode['id']
|
||||
@@ -0,0 +1,60 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* HVAC equipment — the boxes duct systems start and end at: furnace,
|
||||
* air handler, outdoor condenser.
|
||||
*
|
||||
* Phase 3 of the HVAC node system. Furnaces and air handlers expose
|
||||
* typed duct ports (supply plenum on top, return drop on the side) so
|
||||
* duct runs and fittings snap onto them. Every unit also exposes a
|
||||
* refrigerant service port on its valve face — a condenser, the outdoor
|
||||
* half of a split system, carries no duct ports but pipes to the indoor
|
||||
* coil through a `lineset` run mating onto that port.
|
||||
*
|
||||
* Floor-placed: `position` is level-local meters with y at the base,
|
||||
* `rotation` is yaw radians (the editor's default R-rotate applies).
|
||||
*/
|
||||
export const HvacEquipmentNode = BaseNode.extend({
|
||||
id: objectId('hvac-equipment'),
|
||||
type: nodeType('hvac-equipment'),
|
||||
// Level-local meters, y at the unit's base.
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Yaw in radians.
|
||||
rotation: z.number().default(0),
|
||||
equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'),
|
||||
// Cabinet dimensions in meters. Defaults match a typical upflow
|
||||
// furnace cabinet (~22" × 28" footprint, ~43" tall).
|
||||
width: z.number().min(0.3).max(2).default(0.56),
|
||||
depth: z.number().min(0.3).max(2).default(0.71),
|
||||
height: z.number().min(0.4).max(2.5).default(1.1),
|
||||
// Duct collar cross-section on the supply / return connections. Round is
|
||||
// the default; rect and oval (flat-oval) match the duct shapes a run
|
||||
// might mate with. Condensers carry no duct collars (ignored).
|
||||
supplyShape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||
returnShape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||
// Round collar diameters in inches.
|
||||
supplyDiameter: z.number().min(6).max(30).default(8),
|
||||
returnDiameter: z.number().min(6).max(30).default(8),
|
||||
// Rect / oval collar cross-section in inches: width is the horizontal
|
||||
// face, height the vertical. For oval, height is also the end-cap
|
||||
// semicircle diameter (width ≥ height).
|
||||
supplyWidth: z.number().min(6).max(30).default(12),
|
||||
supplyHeight: z.number().min(6).max(30).default(8),
|
||||
returnWidth: z.number().min(6).max(30).default(14),
|
||||
returnHeight: z.number().min(6).max(30).default(8),
|
||||
}).describe(
|
||||
dedent`
|
||||
HVAC equipment cabinet - furnace, air handler, or outdoor condenser.
|
||||
- position: [x, y, z] level-local meters (y = base)
|
||||
- rotation: yaw radians
|
||||
- equipmentType: furnace | air-handler | condenser
|
||||
- width / depth / height: cabinet size in meters
|
||||
- supplyShape / returnShape: round | rect | oval duct collar cross-section (ignored by condenser)
|
||||
- supplyDiameter / returnDiameter: round collar sizes in inches
|
||||
- supplyWidth / supplyHeight / returnWidth / returnHeight: rect / oval collar cross-section in inches
|
||||
`,
|
||||
)
|
||||
export type HvacEquipmentNode = z.infer<typeof HvacEquipmentNode>
|
||||
export type HvacEquipmentNodeId = HvacEquipmentNode['id']
|
||||
@@ -0,0 +1,43 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* Refrigerant lineset — the copper pipe pair that links the outdoor
|
||||
* condenser to the indoor coil (furnace / air handler) of a split system.
|
||||
* It is the refrigerant-side analogue of a duct run: a polyline of points,
|
||||
* but carrying two lines instead of one airway.
|
||||
*
|
||||
* Real linesets run a fat insulated SUCTION line (cool vapour back to the
|
||||
* compressor) beside a thin bare LIQUID line (warm liquid out to the coil).
|
||||
* The geometry builder draws a single copper line on the path centerline
|
||||
* (sized to `suctionDiameter`, wrapped in a foam jacket when `insulated`);
|
||||
* draw the liquid line as a second lineset rather than both off one path.
|
||||
*
|
||||
* Path coordinates are level-local meters: [x, y, z] tuples, same space as
|
||||
* duct paths and grid events. Diameters are nominal copper OD in inches.
|
||||
*/
|
||||
export const LinesetNode = BaseNode.extend({
|
||||
id: objectId('lineset'),
|
||||
type: nodeType('lineset'),
|
||||
// Polyline path in level-local meters. Minimum two points (start, end).
|
||||
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||
// Nominal suction-line copper OD in inches (the large insulated line).
|
||||
// Common residential sizes are 3/4"–1-1/8".
|
||||
suctionDiameter: z.number().min(0.25).max(2).default(0.875),
|
||||
// Nominal liquid-line copper OD in inches (the small bare line).
|
||||
// Common residential sizes are 1/4"–3/8".
|
||||
liquidDiameter: z.number().min(0.125).max(1).default(0.375),
|
||||
// Whether the suction line carries its foam insulation jacket. Bare = false.
|
||||
insulated: z.boolean().default(true),
|
||||
}).describe(
|
||||
dedent`
|
||||
Refrigerant lineset - copper suction + liquid pair linking a condenser to an indoor coil.
|
||||
- path: list of [x, y, z] points in level-local meters (min 2)
|
||||
- suctionDiameter: nominal copper OD in inches of the large insulated line (typ. 3/4"-1-1/8")
|
||||
- liquidDiameter: nominal copper OD in inches of the small bare line (typ. 1/4"-3/8")
|
||||
- insulated: whether the suction line wears its foam jacket
|
||||
`,
|
||||
)
|
||||
export type LinesetNode = z.infer<typeof LinesetNode>
|
||||
export type LinesetNodeId = LinesetNode['id']
|
||||
@@ -0,0 +1,29 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* Standalone refrigerant liquid line — the thin bare-copper line that carries
|
||||
* warm liquid out to the indoor coil. It is the line that used to be drawn as
|
||||
* the lineset's second rail; broken out here as its own polyline run so it can
|
||||
* be drawn on its own, including traced alongside an existing lineset.
|
||||
*
|
||||
* Path coordinates are level-local meters: [x, y, z] tuples, the same space as
|
||||
* lineset and duct paths. Diameter is nominal copper OD in inches.
|
||||
*/
|
||||
export const LiquidLineNode = BaseNode.extend({
|
||||
id: objectId('liquid-line'),
|
||||
type: nodeType('liquid-line'),
|
||||
// Polyline path in level-local meters. Minimum two points (start, end).
|
||||
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||
// Nominal copper OD in inches. Common residential sizes are 1/4"–3/8".
|
||||
diameter: z.number().min(0.125).max(1).default(0.375),
|
||||
}).describe(
|
||||
dedent`
|
||||
Standalone refrigerant liquid line - a thin bare-copper polyline run.
|
||||
- path: list of [x, y, z] points in level-local meters (min 2)
|
||||
- diameter: nominal copper OD in inches (typ. 1/4"-3/8")
|
||||
`,
|
||||
)
|
||||
export type LiquidLineNode = z.infer<typeof LiquidLineNode>
|
||||
export type LiquidLineNodeId = LiquidLineNode['id']
|
||||
@@ -0,0 +1,48 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* DWV pipe fitting — the joints drain systems are actually built from:
|
||||
* elbows (bends), wyes (45° branch entries, the code-preferred way to
|
||||
* join horizontal drains), sanitary tees (square branch entries), and
|
||||
* crosses (two opposed branches where a run passes straight through).
|
||||
*
|
||||
* Local-frame conventions (before `rotation`):
|
||||
* - elbow: inlet faces -X, outlet turned `angle`° in XZ.
|
||||
* - wye: run along X (inlet -X, outlet +X), branch collar at
|
||||
* 45° between +X and +Z.
|
||||
* - sanitary-tee: run along X, branch collar faces +Z.
|
||||
* - cross: run along X, two opposed branch collars on ±Z.
|
||||
*/
|
||||
export const PipeFittingNode = BaseNode.extend({
|
||||
id: objectId('pipe-fitting'),
|
||||
type: nodeType('pipe-fitting'),
|
||||
// Level-local meters.
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// XYZ euler radians.
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'),
|
||||
// Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long
|
||||
// sweep" for drains); adjustable range matches the duct elbow.
|
||||
angle: z.number().min(15).max(90).default(90),
|
||||
// Run nominal size in inches.
|
||||
diameter: z.number().min(1.25).max(8).default(2),
|
||||
// Branch collar size (wye / sanitary-tee).
|
||||
diameter2: z.number().min(1.25).max(8).default(2),
|
||||
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
|
||||
system: z.enum(['waste', 'vent']).default('waste'),
|
||||
}).describe(
|
||||
dedent`
|
||||
DWV pipe fitting - elbow (bend), wye (45° branch), sanitary tee (square branch), or cross (two opposed branches).
|
||||
- position: [x, y, z] level-local meters
|
||||
- rotation: [x, y, z] euler radians
|
||||
- fittingType: elbow | wye | sanitary-tee | cross
|
||||
- angle: elbow turn in degrees (22.5 / 45 / 90 typical)
|
||||
- diameter: run size in inches; diameter2: branch collar size (both branches for a cross)
|
||||
- pipeMaterial: pvc | abs | cast-iron
|
||||
- system: waste | vent
|
||||
`,
|
||||
)
|
||||
export type PipeFittingNode = z.infer<typeof PipeFittingNode>
|
||||
export type PipeFittingNodeId = PipeFittingNode['id']
|
||||
@@ -0,0 +1,41 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* DWV pipe segment — drain / waste / vent runs in US residential
|
||||
* plumbing. Phase 2 of the distribution-system effort: the plumbing
|
||||
* sibling of `duct-segment`, sharing the polyline model and the typed
|
||||
* port machinery.
|
||||
*
|
||||
* The defining difference from ducts is SLOPE: drains must fall
|
||||
* (IPC: ¼" per foot for pipes under 3", ⅛" allowed at 3"+). Slope is
|
||||
* stored implicitly in the path's Y coordinates — the draw tool drops
|
||||
* Y as you draw a waste run; vents run level or vertical.
|
||||
*
|
||||
* Path coordinates are level-local meters. Y may be negative (drains
|
||||
* drop below the floor into the joist / crawl space).
|
||||
*/
|
||||
export const PipeSegmentNode = BaseNode.extend({
|
||||
id: objectId('pipe-segment'),
|
||||
type: nodeType('pipe-segment'),
|
||||
// Polyline path in level-local meters. Minimum two points.
|
||||
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||
// Nominal pipe size in inches. Residential DWV: 1¼ (lav tailpiece) to
|
||||
// 4 (building drain); 6 covers oversized mains.
|
||||
diameter: z.number().min(1.25).max(8).default(2),
|
||||
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
|
||||
// Which DWV role the run plays. Waste carries water (sloped); vent
|
||||
// carries air (level or vertical, dashed in plan).
|
||||
system: z.enum(['waste', 'vent']).default('waste'),
|
||||
}).describe(
|
||||
dedent`
|
||||
DWV pipe segment - drain / waste / vent run as a polyline of 3D points.
|
||||
- path: list of [x, y, z] points in level-local meters (min 2; y may go below the floor)
|
||||
- diameter: nominal size in inches (1.5 / 2 / 3 / 4 typical residential)
|
||||
- pipeMaterial: pvc | abs | cast-iron
|
||||
- system: waste (sloped drains) | vent (level / vertical air pipes)
|
||||
`,
|
||||
)
|
||||
export type PipeSegmentNode = z.infer<typeof PipeSegmentNode>
|
||||
export type PipeSegmentNodeId = PipeSegmentNode['id']
|
||||
@@ -0,0 +1,41 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
/**
|
||||
* DWV trap — the P-trap between a fixture and the waste system. Holds a
|
||||
* water seal that blocks sewer gas; every drained fixture has exactly
|
||||
* one. Modeled as an explicit fitting (not folded into the fixture) so
|
||||
* the trap-arm rule (IPC 909.1 max developed length to the vent) has a
|
||||
* node to attach to and the inspector can edit size + arm length.
|
||||
*
|
||||
* Local-frame convention (before `rotation`): inlet faces +Y (up, to
|
||||
* the fixture tailpiece), outlet faces +X (the horizontal trap arm
|
||||
* toward the vented waste line).
|
||||
*/
|
||||
export const PipeTrapNode = BaseNode.extend({
|
||||
id: objectId('pipe-trap'),
|
||||
type: nodeType('pipe-trap'),
|
||||
// Level-local meters.
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Yaw in radians (the arm direction in plan).
|
||||
rotation: z.number().default(0),
|
||||
// Trap size in inches — matches the fixture drain it serves.
|
||||
diameter: z.number().min(1.25).max(4).default(1.5),
|
||||
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
|
||||
// Developed length of the trap arm (trap weir → vent) in meters. The
|
||||
// draw tool measures it when the arm is drawn; editable in the
|
||||
// inspector. Drives the IPC 909.1 max-trap-arm check.
|
||||
armLengthM: z.number().min(0).default(0),
|
||||
}).describe(
|
||||
dedent`
|
||||
DWV trap (P-trap) - the water-seal fitting between a fixture and the waste line.
|
||||
- position: [x, y, z] level-local meters
|
||||
- rotation: yaw radians (trap-arm direction in plan)
|
||||
- diameter: trap size in inches (matches the fixture drain)
|
||||
- pipeMaterial: pvc | abs | cast-iron
|
||||
- armLengthM: developed length from trap to vent in meters (IPC 909.1 limited by size)
|
||||
`,
|
||||
)
|
||||
export type PipeTrapNode = z.infer<typeof PipeTrapNode>
|
||||
export type PipeTrapNodeId = PipeTrapNode['id']
|
||||
@@ -8,13 +8,22 @@ import { CupolaNode } from './nodes/cupola'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { DownspoutNode } from './nodes/downspout'
|
||||
import { DuctFittingNode } from './nodes/duct-fitting'
|
||||
import { DuctSegmentNode } from './nodes/duct-segment'
|
||||
import { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
import { ElevatorNode } from './nodes/elevator'
|
||||
import { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||
import { FenceNode } from './nodes/fence'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
import { GutterNode } from './nodes/gutter'
|
||||
import { HvacEquipmentNode } from './nodes/hvac-equipment'
|
||||
import { ItemNode } from './nodes/item'
|
||||
import { LevelNode } from './nodes/level'
|
||||
import { LinesetNode } from './nodes/lineset'
|
||||
import { LiquidLineNode } from './nodes/liquid-line'
|
||||
import { PipeFittingNode } from './nodes/pipe-fitting'
|
||||
import { PipeSegmentNode } from './nodes/pipe-segment'
|
||||
import { PipeTrapNode } from './nodes/pipe-trap'
|
||||
import { RidgeVentNode } from './nodes/ridge-vent'
|
||||
import { RoofNode } from './nodes/roof'
|
||||
import { RoofSegmentNode } from './nodes/roof-segment'
|
||||
@@ -65,6 +74,15 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
SkylightNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DuctSegmentNode,
|
||||
DuctFittingNode,
|
||||
DuctTerminalNode,
|
||||
HvacEquipmentNode,
|
||||
LinesetNode,
|
||||
LiquidLineNode,
|
||||
PipeSegmentNode,
|
||||
PipeFittingNode,
|
||||
PipeTrapNode,
|
||||
])
|
||||
|
||||
export type AnyNode = z.infer<typeof AnyNode>
|
||||
|
||||
Reference in New Issue
Block a user