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:
@@ -11,13 +11,22 @@ import type {
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
DuctTerminalNode,
|
||||
ElevatorNode,
|
||||
EyebrowVentNode,
|
||||
FenceNode,
|
||||
GuideNode,
|
||||
GutterNode,
|
||||
HvacEquipmentNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
LinesetNode,
|
||||
LiquidLineNode,
|
||||
PipeFittingNode,
|
||||
PipeSegmentNode,
|
||||
PipeTrapNode,
|
||||
RidgeVentNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
@@ -107,6 +116,15 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||
export type DormerEvent = NodeEvent<DormerNode>
|
||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
||||
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
||||
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
||||
export type HvacEquipmentEvent = NodeEvent<HvacEquipmentNode>
|
||||
export type PipeSegmentEvent = NodeEvent<PipeSegmentNode>
|
||||
export type PipeFittingEvent = NodeEvent<PipeFittingNode>
|
||||
export type PipeTrapEvent = NodeEvent<PipeTrapNode>
|
||||
export type LinesetEvent = NodeEvent<LinesetNode>
|
||||
export type LiquidLineEvent = NodeEvent<LiquidLineNode>
|
||||
|
||||
// Event suffixes - exported for use in hooks
|
||||
export const eventSuffixes = [
|
||||
@@ -261,6 +279,15 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'skylight', SkylightEvent> &
|
||||
NodeEvents<'dormer', DormerEvent> &
|
||||
NodeEvents<'downspout', DownspoutEvent> &
|
||||
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
||||
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
||||
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
||||
NodeEvents<'hvac-equipment', HvacEquipmentEvent> &
|
||||
NodeEvents<'pipe-segment', PipeSegmentEvent> &
|
||||
NodeEvents<'pipe-fitting', PipeFittingEvent> &
|
||||
NodeEvents<'pipe-trap', PipeTrapEvent> &
|
||||
NodeEvents<'lineset', LinesetEvent> &
|
||||
NodeEvents<'liquid-line', LiquidLineEvent> &
|
||||
CameraControlEvents &
|
||||
ToolEvents &
|
||||
GuideEvents &
|
||||
|
||||
@@ -56,6 +56,7 @@ export type {
|
||||
Capabilities,
|
||||
CapabilityCtx,
|
||||
CuttableConfig,
|
||||
DistributionRole,
|
||||
DragAction,
|
||||
EditorCtx,
|
||||
FloorPlacedConfig,
|
||||
@@ -85,6 +86,7 @@ export type {
|
||||
MovableConfig,
|
||||
NodeCategory,
|
||||
NodeDefinition,
|
||||
NodePort,
|
||||
NodeRegistry,
|
||||
PaintCapability,
|
||||
PaintEffectiveMaterialArgs,
|
||||
|
||||
@@ -168,6 +168,40 @@ export type FloorplanStyle = {
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
// ─── NodePort ────────────────────────────────────────────────────────
|
||||
//
|
||||
// A typed connection point exposed by a node — the open end of a duct
|
||||
// run, the collar of a fitting, the supply plenum of an air handler.
|
||||
// Ports are what placement tools snap to and what a future system graph
|
||||
// walks to decide connectivity.
|
||||
//
|
||||
// Coordinates are LEVEL-LOCAL meters — the same space duct paths and
|
||||
// grid events use. Kinds whose schema stores a node transform
|
||||
// (`position` / `rotation`) apply it themselves inside `def.ports` so
|
||||
// consumers never need to know how a kind stores its placement.
|
||||
|
||||
export type NodePort = {
|
||||
/** Stable identifier within the node, e.g. 'start', 'end', 'branch'. */
|
||||
id: string
|
||||
/** Level-local meters. */
|
||||
position: readonly [number, number, number]
|
||||
/** Unit vector pointing OUT of the port (away from the node body). */
|
||||
direction: readonly [number, number, number]
|
||||
/** Nominal connection diameter in inches. For a rect / oval port this is
|
||||
* the area-equivalent round size, so a round run still mates sensibly. */
|
||||
diameter: number
|
||||
/** Which distribution loop the port belongs to, e.g. 'supply' | 'return'. */
|
||||
system?: string
|
||||
/** Cross-section of the connection. Omitted = round at `diameter`. A duct
|
||||
* run joining a rect / oval port adopts this shape and rolls its
|
||||
* cross-section to line up with the collar. */
|
||||
shape?: 'round' | 'rect' | 'oval'
|
||||
/** Rect / oval cross-section in inches: width is the collar's horizontal
|
||||
* face at roll 0, height the vertical one. */
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
// ─── ToolHint ────────────────────────────────────────────────────────
|
||||
//
|
||||
// A single key + label entry in the contextual shortcut hint panel.
|
||||
@@ -666,12 +700,26 @@ export type SurfaceRole =
|
||||
| 'glazing'
|
||||
| 'furnishing'
|
||||
|
||||
/** Role a kind plays in a duct / pipe / lineset distribution system. */
|
||||
export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment'
|
||||
|
||||
export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
kind: string
|
||||
schemaVersion: number
|
||||
schema: S
|
||||
category: NodeCategory
|
||||
surfaceRole?: SurfaceRole
|
||||
/**
|
||||
* Role this kind plays in a distribution system (HVAC duct / DWV pipe /
|
||||
* refrigerant lineset). Lets the system-graph summary classify a
|
||||
* component without branching on `node.type`:
|
||||
* - `'run'` — a duct / pipe / lineset segment (carries `path`).
|
||||
* - `'fitting'` — an inline fitting (elbow / tee / reducer / trap).
|
||||
* - `'terminal'` — a grille / register / diffuser endpoint.
|
||||
* - `'equipment'` — a furnace / air handler / condenser source.
|
||||
* Kinds outside any distribution system leave this unset.
|
||||
*/
|
||||
distributionRole?: DistributionRole
|
||||
|
||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||
migrate?: Record<number, (old: unknown) => unknown>
|
||||
@@ -829,6 +877,15 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
liveOverrides: Map<string, Record<string, unknown>>
|
||||
}) => Record<AnyNodeId, AnyNode>
|
||||
/**
|
||||
* Typed connection points this kind exposes (duct/pipe open ends,
|
||||
* fitting collars, equipment plenums). Pure function of the node —
|
||||
* returns LEVEL-LOCAL positions/directions (the kind applies its own
|
||||
* transform). Consumed by placement tools for port-snapping and, in a
|
||||
* later slice, by the system graph for connectivity. Kinds with no
|
||||
* connectable geometry omit this.
|
||||
*/
|
||||
ports?: (node: z.infer<S>) => NodePort[]
|
||||
system?: SystemContribution
|
||||
tool?: LazyComponent
|
||||
/**
|
||||
@@ -915,6 +972,14 @@ export type KeyboardActions = {
|
||||
r?: KeyboardAction
|
||||
/** T / Shift+T secondary action. */
|
||||
t?: KeyboardAction
|
||||
/**
|
||||
* Set for kinds whose R/T rotation turns around a user-cyclable world
|
||||
* axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D
|
||||
* orientation. The floating action menu reads this to surface the
|
||||
* active-axis pill above the selected node; kinds with plain Y-only
|
||||
* rotation omit it.
|
||||
*/
|
||||
axisCycling?: boolean
|
||||
}
|
||||
|
||||
export type KeyboardAction = {
|
||||
@@ -1279,6 +1344,31 @@ export type CapabilityCtx = { node: AnyNode }
|
||||
export type MovableConfig = {
|
||||
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
||||
gridSnap?: boolean
|
||||
/**
|
||||
* Pin the dragged node to the cursor (absolute placement) instead of the
|
||||
* default offset-preserving drag, where the node moves by the cursor's
|
||||
* delta from where the drag started. Offset preservation suits large
|
||||
* furniture you grab by an edge; small connector-like kinds (duct
|
||||
* fittings) read as "lagging behind the mouse" — they want the cursor.
|
||||
*/
|
||||
cursorAttached?: boolean
|
||||
/**
|
||||
* Magnetically snap one of this kind's own ports onto a nearby scene
|
||||
* port while dragging — e.g. a register's collar onto a duct run end.
|
||||
* The dragged node shifts in XZ so its closest matching port lands on
|
||||
* the target port. Alt bypasses the snap. Kinds without `def.ports`
|
||||
* can't use this. Snap takes precedence over grid / alignment snap.
|
||||
*/
|
||||
portSnap?: {
|
||||
/**
|
||||
* Distribution loops a target port must belong to (e.g.
|
||||
* `['supply', 'return']`). A target port with no `system` always
|
||||
* matches. Omit to match every port.
|
||||
*/
|
||||
systems?: readonly string[]
|
||||
/** Snap radius in meters (XZ). Defaults to 0.5. */
|
||||
radius?: number
|
||||
}
|
||||
override?: (ctx: CapabilityCtx) => MovableConfig | null
|
||||
}
|
||||
|
||||
@@ -1411,7 +1501,24 @@ export type Relations = {
|
||||
export type ParametricDescriptor<N> = {
|
||||
groups: ParamGroup<N>[]
|
||||
invariants?: ReadonlyArray<(n: N) => Issue[]>
|
||||
derive?: (n: N) => Partial<N>
|
||||
/**
|
||||
* Co-update hook for fields that must stay consistent when edited
|
||||
* from the inspector. Called with the node AFTER `patch` is merged
|
||||
* plus the patch itself (so the hook can tell which field the user
|
||||
* touched); whatever it returns is folded into the same update.
|
||||
* Direct store/MCP writes bypass it — keep real invariants in
|
||||
* `invariants`.
|
||||
*/
|
||||
derive?: (next: N, patch: Partial<N>) => Partial<N>
|
||||
/**
|
||||
* Cross-node companion to `derive`: after an inspector edit lands on
|
||||
* this node, return patches for OTHER nodes that must follow to keep
|
||||
* the scene consistent — e.g. duct runs re-trimmed onto a resized
|
||||
* fitting's collars. `prev` is the node before the edit, `next` after
|
||||
* (with `derive` already folded in). Applied in the same gesture via
|
||||
* `updateNodes`.
|
||||
*/
|
||||
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
|
||||
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||
/**
|
||||
* Extra buttons rendered in the inspector's Actions section
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 3–6",
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user