Merge remote-tracking branch 'origin/main' into feat/paint-slots

# Conflicts:
#	packages/core/src/store/use-scene.ts
#	packages/editor/src/components/editor/index.tsx
This commit is contained in:
Wassim SAMAD
2026-06-18 12:22:26 -04:00
415 changed files with 22805 additions and 1414 deletions
+27
View File
@@ -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 &
+8
View File
@@ -93,6 +93,14 @@ export {
type Space,
wallTouchesOthers,
} from './lib/space-detection'
export {
closestOnSegment,
collectLevelWallSegments,
nearestWallSegment,
WALL_SNAP_DISTANCE_M,
type WallSegment,
type WallSegmentClosest,
} from './lib/wall-distance'
export {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
+121
View File
@@ -0,0 +1,121 @@
import type { WallNode } from '../schema/nodes/wall'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { isCurvedWall } from '../systems/wall/wall-curve'
/**
* Pure plan-space wall-distance math shared by the 2D opening snap
* (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D
* Voronoi debug overlay. One source of truth means the overlay is a
* faithful picture of what the snap actually decides.
*
* A wall segment's Voronoi cell is exactly "the points whose nearest wall
* is this segment", so nearest-segment classification == the segment
* Voronoi diagram. Curved walls are excluded (the opening snap rejects
* them — mitering + arc + opening tears in 3D).
*/
/**
* Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a
* wall. Tight, because plan walls are thin and often close together — a large
* radius would let a far wall's region reach across a nearer one. Shared so the
* snap and the Voronoi debug overlay clip to the exact same range.
*/
export const WALL_SNAP_DISTANCE_M = 0.4
export type WallSegment = {
wall: WallNode
/** [x, z] plan start. */
start: readonly [number, number]
/** [x, z] plan end. */
end: readonly [number, number]
/** Unit direction (start → end) in plan. */
dirX: number
dirY: number
/** Segment length in metres. */
length: number
}
export type WallSegmentClosest = {
segment: WallSegment
/** Distance from the query point to the closest point on the segment. */
distance: number
/** Distance along the wall from `start`, clamped to [0, length]. */
along: number
/** Signed perpendicular offset from the wall axis (+ on the front side). */
perp: number
}
/**
* Collect the straight (non-curved) wall segments that are direct children
* of a level — the candidates an opening can snap onto.
*/
export function collectLevelWallSegments(
nodes: Record<AnyNodeId, AnyNode>,
levelId: AnyNodeId | null,
): WallSegment[] {
if (!levelId) return []
const level = nodes[levelId]
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
if (!Array.isArray(childIds)) return []
const segments: WallSegment[] = []
for (const childId of childIds) {
const node = nodes[childId]
if (node?.type !== 'wall') continue
const wall = node as WallNode
if (isCurvedWall(wall)) continue
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) continue
segments.push({
wall,
start: wall.start,
end: wall.end,
dirX: dx / length,
dirY: dy / length,
length,
})
}
return segments
}
/** Closest point + signed offset of one query point against one segment. */
export function closestOnSegment(
segment: WallSegment,
pointX: number,
pointY: number,
): { distance: number; along: number; perp: number } {
const px = pointX - segment.start[0]
const py = pointY - segment.start[1]
const along = Math.max(0, Math.min(segment.length, px * segment.dirX + py * segment.dirY))
const perp = px * -segment.dirY + py * segment.dirX
const closestX = segment.start[0] + segment.dirX * along
const closestY = segment.start[1] + segment.dirY * along
const distance = Math.hypot(pointX - closestX, pointY - closestY)
return { distance, along, perp }
}
/**
* The single nearest wall segment to a plan point — its Voronoi cell. Returns
* null when `segments` is empty or (when `maxDistance` is given) nothing is
* within range. Ties resolve to the first segment scanned; callers pass an
* already-curved-filtered list from `collectLevelWallSegments`.
*/
export function nearestWallSegment(
segments: readonly WallSegment[],
pointX: number,
pointY: number,
maxDistance = Number.POSITIVE_INFINITY,
excludeWallId?: AnyNodeId,
): WallSegmentClosest | null {
let best: WallSegmentClosest | null = null
for (const segment of segments) {
if (excludeWallId && segment.wall.id === excludeWallId) continue
const { distance, along, perp } = closestOnSegment(segment, pointX, pointY)
if (distance > maxDistance) continue
if (best && distance >= best.distance) continue
best = { segment, distance, along, perp }
}
return best
}
+8
View File
@@ -116,6 +116,14 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
/**
* Optional per-tick hook fired while this handle is being dragged, with the
* live (in-progress, override-merged) node. A pure side-channel for transient
* feedback — doors/windows use it to publish proximity / sill guides for the
* edge being resized. The return value is ignored; the resize itself is driven
* by `apply`.
*/
onDrag?: (node: N, sceneApi: SceneApi) => void
/**
* Cross-node redirect. By default the drag's live override + the
* committed write both land on the SELECTED node. When this returns
+3
View File
@@ -18,6 +18,7 @@ export {
discoverPlugins,
getHostRefFields,
getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable,
@@ -55,6 +56,7 @@ export type {
Capabilities,
CapabilityCtx,
CuttableConfig,
DistributionRole,
DragAction,
EditorCtx,
FloorPlacedConfig,
@@ -84,6 +86,7 @@ export type {
MovableConfig,
NodeCategory,
NodeDefinition,
NodePort,
NodeRegistry,
PaintCapability,
PaintEffectiveMaterialArgs,
+14
View File
@@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean {
return false
}
/**
* Whether the kind has a move tool that MOUNTS in the 3D viewport — the
* generic `capabilities.movable` mover or a bespoke `affordanceTools.move`.
* Narrower than {@link isRegistryMovable}, which also accepts floorplan-only
* movers (e.g. zone) that have no 3D tool. Gates 3D direct move: Ctrl/Meta-drag
* and the move-cross grip. Kept beside `isRegistryMovable` so the 2D and 3D
* movability predicates can't drift apart.
*/
export function hasRegistry3DMoveTool(kind: string): boolean {
const def = nodeRegistry.get(kind)
if (!def) return false
return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined
}
/**
* Whether the kind can be saved as a reusable preset. Default: an
* explicit `capabilities.presettable` boolean wins; otherwise the kind
+144 -1
View File
@@ -178,6 +178,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.
@@ -450,6 +484,20 @@ export type FloorplanGeometry =
/** Rotation in radians. The renderer auto-flips to keep text upright. */
angle: number
}
/**
* Equal-spacing badge — a small accent pill marking one gap in a run of
* (near-)equally-spaced openings (the 2D counterpart of Figma's "=" distance
* chips). Emitted once per equal gap so the repeated value reads as a rhythm.
* `text` is the shared gap distance; `angle` orients the pill along the wall
* (the renderer auto-flips it upright).
*/
| {
kind: 'equal-spacing-badge'
point: FloorplanPoint
text: string
/** Rotation in radians. */
angle: number
}
/**
* Architect's dimension overlay — extension lines from the edge
* endpoints out past the dimension line, two dimension line halves
@@ -626,6 +674,14 @@ export type FloorplanMoveTargetSession = {
* returns.
*/
commit?(): void
/**
* Optional R-key flip toggle. Kinds with a directional facing
* (door / window: front ↔ back) implement this so the overlay can flip
* the orientation mid-placement before commit. Toggling just records the
* intent; the visible change lands when the overlay re-runs `apply()` with
* the last pointer position. Kinds with no facing leave it unset.
*/
flipSide?(): void
}
export type FloorplanMoveTarget<N> = (args: {
@@ -654,12 +710,40 @@ 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
/**
* When `distributionRole` is `'fitting'`, controls whether this fitting
* is dragged as a rigid follower when a connected run endpoint moves.
*
* - `true` (default for `distributionRole === 'fitting'`): the fitting
* translates rigidly so its mated collar stays on the moved port — the
* right behaviour for in-line fittings (elbows, tees, wyes, crosses).
* - `false`: the fitting is anchored in space; moving a connected run
* endpoint stretches the run arm, not the fitting. Use this for
* fixed-position fixtures like `pipe-trap`.
*
* Has no effect when `distributionRole` is not `'fitting'`.
*/
portConnectivityFollow?: boolean
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
@@ -817,6 +901,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
/**
@@ -903,6 +996,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 = {
@@ -1308,6 +1409,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
}
@@ -1440,7 +1566,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
+9
View File
@@ -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']
+43
View File
@@ -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']
+18
View File
@@ -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
}
/**
+47
View File
@@ -41,6 +41,10 @@ export {
pickHost,
type Vec3,
} from './hosting'
export {
DEFAULT_LEVEL_HEIGHT,
getLevelHeight,
} from './level-height'
export {
type AxisLock,
applyAxisLock,
@@ -49,6 +53,39 @@ export {
moveToward,
resolveMovable,
} from './movement'
export {
type AlongWallAlignment,
type AlongWallFeature,
computeEdgeGaps,
computeOpeningGuides,
DEFAULT_OPENING_GUIDE_TOLERANCES,
detectAlongWallAlignment,
detectEqualSpacing,
detectVerticalAlignment,
type EdgeGap,
type EqualSpacingRun,
type OpeningGuideInput,
type OpeningGuides,
type OpeningGuideTolerances,
type OpeningSpan,
type SillHeadGuide,
type VerticalAlignment,
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,
@@ -62,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,42 @@
import type { CeilingNode, LevelNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
export const DEFAULT_LEVEL_HEIGHT = 2.5
/**
* Optional resolver for a wall's rendered base Y (mesh elevation).
*
* `packages/core` is pure domain logic and must not read viewer/Three.js
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have
* registry access (viewer systems, node tools) may pass a resolver so the
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
* omit it and get a deterministic result from serialized node data alone.
*/
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined
export function getLevelHeight(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
resolveWallBaseY?: WallBaseYResolver,
): number {
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 baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
if (baseY < 0) baseY = 0
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
if (top > maxTop) maxTop = top
}
}
return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
}
@@ -0,0 +1,241 @@
import { describe, expect, test } from 'bun:test'
import {
computeEdgeGaps,
computeOpeningGuides,
detectAlongWallAlignment,
detectEqualSpacing,
detectVerticalAlignment,
type OpeningSpan,
type WallExtent,
} from './opening-guides'
function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan {
return { id, centerS, width, centerY, height }
}
const WALL: WallExtent = { length: 10, height: 2.5 }
describe('detectEqualSpacing', () => {
test('returns null for fewer than three openings', () => {
const a = span('a', 0.5, 1)
const b = span('b', 2.5, 1)
expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull()
})
test('detects a run of equal gaps across three openings', () => {
// width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m.
const a = span('a', 0.5, 1)
const b = span('b', 2.5, 1)
const c = span('c', 4.5, 1)
const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)
expect(run).not.toBeNull()
expect(run?.gap).toBeCloseTo(1)
expect(run?.segments).toHaveLength(2)
expect(run?.openingIds).toEqual(['a', 'b', 'c'])
expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 })
expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 })
})
test('extends a run across four openings (three gaps)', () => {
const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)]
const run = detectEqualSpacing(openings, 'c', 0.03, 0.02)
expect(run?.segments).toHaveLength(3)
expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd'])
})
test('returns null when gaps differ beyond tolerance', () => {
const a = span('a', 0.5, 1) // [0,1]
const b = span('b', 2.5, 1) // [2,3] → gap 1
const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull()
})
test('returns null when the moving opening is not part of the equal run', () => {
const a = span('a', 0.5, 1)
const b = span('b', 2.5, 1)
const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1
const d = span('d', 10, 1) // far right, breaks the run
expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull()
})
test('a near-zero (touching) gap breaks a run', () => {
const a = span('a', 0.5, 1) // [0,1]
const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap
const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull()
})
test('honours the equal-spacing tolerance', () => {
const a = span('a', 0.5, 1) // [0,1]
const b = span('b', 2.5, 1) // [2,3] → gap 1.0
const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2)
expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull()
})
})
describe('computeEdgeGaps', () => {
test('measures clearance to the nearest neighbour on each side', () => {
const moving = span('m', 5, 1) // [4.5,5.5]
const left = span('l', 2, 1) // [1.5,2.5]
const right = span('r', 8, 1) // [7.5,8.5]
const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02)
const byside = Object.fromEntries(gaps.map((g) => [g.side, g]))
expect(byside.left?.distance).toBeCloseTo(2)
expect(byside.left?.target).toBe('opening')
expect(byside.left?.targetId).toBe('l')
expect(byside.right?.distance).toBeCloseTo(2)
expect(byside.right?.targetId).toBe('r')
})
test('falls back to wall ends with no neighbour', () => {
const moving = span('m', 5, 1) // [4.5,5.5]
const gaps = computeEdgeGaps(moving, [], WALL, 0.02)
const byside = Object.fromEntries(gaps.map((g) => [g.side, g]))
expect(byside.left?.target).toBe('wall-start')
expect(byside.left?.distance).toBeCloseTo(4.5)
expect(byside.right?.target).toBe('wall-end')
expect(byside.right?.distance).toBeCloseTo(4.5)
})
test('omits a side that is flush / overlapping (below minGap)', () => {
const moving = span('m', 5, 1) // [4.5,5.5]
const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left
const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02)
expect(gaps.find((g) => g.side === 'left')).toBeUndefined()
expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end')
})
})
describe('detectAlongWallAlignment', () => {
test('detects edge-to-edge alignment within tolerance', () => {
const moving = span('m', 5, 2) // [4,6]
const sib = span('s', 7.05, 2) // left edge 6.05
const a = detectAlongWallAlignment(moving, [sib], 0.08)
expect(a?.movingFeature).toBe('right')
expect(a?.targetFeature).toBe('left')
expect(a?.snap).toBeCloseTo(0.05)
expect(a?.s).toBeCloseTo(6.05)
})
test('detects centre alignment', () => {
const moving = span('m', 5, 2)
const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges
const a = detectAlongWallAlignment(moving, [sib], 0.08)
expect(a?.movingFeature).toBe('center')
expect(a?.targetFeature).toBe('center')
expect(a?.snap).toBeCloseTo(0.03)
})
test('returns null when nothing is within tolerance', () => {
const moving = span('m', 5, 2)
const sib = span('s', 9, 2)
expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull()
})
})
describe('detectVerticalAlignment', () => {
test('detects a shared sill within tolerance', () => {
const moving = span('m', 5, 1, 1.5, 1) // sill 1.0
const sib = span('s', 8, 1, 2.04, 2) // sill 1.04
const a = detectVerticalAlignment(moving, [sib], 0.08)
expect(a?.movingFeature).toBe('sill')
expect(a?.targetFeature).toBe('sill')
expect(a?.snap).toBeCloseTo(0.04)
expect(a?.y).toBeCloseTo(1.04)
})
test('returns null when sills/tops differ beyond tolerance', () => {
const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5
const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4
expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull()
})
})
describe('computeOpeningGuides', () => {
test('includes sill/head for windows', () => {
const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2
const guides = computeOpeningGuides({
moving,
siblings: [],
wall: WALL,
includeVertical: true,
})
expect(guides.sillHead?.sill).toBeCloseTo(1)
expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2
expect(guides.sillHead?.bottomY).toBeCloseTo(1)
expect(guides.sillHead?.topY).toBeCloseTo(2)
})
test('omits vertical guides for doors (sit on the floor)', () => {
const moving = span('m', 5, 1, 1, 2)
const sib = span('s', 8, 1, 1, 2)
const guides = computeOpeningGuides({
moving,
siblings: [sib],
wall: WALL,
includeVertical: false,
})
expect(guides.sillHead).toBeNull()
expect(guides.vertical).toBeNull()
// along-wall + proximity still computed for doors
expect(guides.gaps.length).toBeGreaterThan(0)
})
test('combines proximity and equal-spacing in one pass', () => {
const moving = span('b', 2.5, 1)
const guides = computeOpeningGuides({
moving,
siblings: [span('a', 0.5, 1), span('c', 4.5, 1)],
wall: WALL,
includeVertical: true,
})
expect(guides.gaps).toHaveLength(2)
expect(guides.equalSpacing?.gap).toBeCloseTo(1)
expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c'])
})
})
describe('opening-guides — review regressions', () => {
test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => {
// gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the
// moving opening; a first-gap-anchored greedy scan used to drop it.
const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)]
const run = detectEqualSpacing(openings, 'd', 0.03, 0.02)
expect(run?.openingIds).toEqual(['b', 'c', 'd'])
expect(run?.gap).toBeCloseTo(1.03)
expect(run?.segments).toHaveLength(2)
})
test('detectEqualSpacing prefers the leftmost run on a length tie', () => {
// gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs.
const openings = [
span('a', 0.5, 1),
span('b', 2.5, 1),
span('c', 4.5, 1),
span('d', 7.5, 1),
span('e', 10.5, 1),
]
expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c'])
})
test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => {
const moving = span('m', 5, 1) // [4.5,5.5]
const containing = span('s', 5, 2) // [4,6] straddles both edges
expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([])
})
test('alignment detectors ignore the moving opening if present in siblings', () => {
const moving = span('m', 5, 2, 1.5, 1)
expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull()
expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull()
})
test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => {
const moving = span('m', 5, 2) // centre 5
const sib = span('s', 4.96, 0.5) // centre 4.96
const a = detectAlongWallAlignment(moving, [sib], 0.08)
expect(a?.movingFeature).toBe('center')
expect(a?.snap).toBeCloseTo(-0.04)
})
})
@@ -0,0 +1,404 @@
// Proximity / alignment guides for wall-hosted openings (doors, windows).
//
// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene
// store, no React — so it runs identically for the 3D viewport and the 2D
// floor plan and is unit-testable in isolation. Callers extract the spans from
// the scene graph (an opening's `position[0]` is its along-wall centre, its
// `position[1]` its vertical centre with the wall base at y=0) and feed them in;
// the renderers transform the returned wall-local coordinates back to world
// (3D) or plan (2D).
//
// What it produces, mirroring the affordances architects expect (and Figma's
// smart guides):
// - sill/head : a window's bottom edge → floor and top edge → wall top.
// - edge gaps : along-wall clearance to the nearest neighbour opening (or
// the wall end) on each side.
// - alongWall : the moving opening's edge/centre lining up with a
// neighbour's edge/centre along the wall.
// - vertical : two openings sharing a sill / head / vertical centre.
// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them.
//
// Detection is passive — it reports what currently coincides within tolerance
// and the snap delta that would make it exact, leaving the snap decision to the
// caller's manipulation policy (grid vs. alignment vs. Shift bypass).
/** An opening's footprint in its host wall's local frame. */
export type OpeningSpan = {
id: string
/** Centre along the wall, measured from `wall.start` (m). */
centerS: number
/** Along-wall extent (m). */
width: number
/** Vertical centre above the wall base (floor at y=0) (m). */
centerY: number
/** Vertical extent (m). */
height: number
}
export type WallExtent = {
/** Wall length (m). */
length: number
/** Wall height (m). */
height: number
}
export type OpeningGuideTolerances = {
/** Max distance for an edge/centre to count as aligned with a neighbour (m). */
align: number
/** Max difference between two gaps for them to count as equal (m). */
equalSpacing: number
/** Gaps below this are treated as touching/overlap noise and ignored (m). */
minGap: number
}
export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = {
// Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`).
align: 0.08,
equalSpacing: 0.03,
minGap: 0.02,
}
/** Which along-wall feature of an opening a guide references. */
export type AlongWallFeature = 'left' | 'center' | 'right'
/** Which vertical feature of an opening a guide references. */
export type VerticalFeature = 'sill' | 'center' | 'top'
export type SillHeadGuide = {
/** Floor (y=0) → the opening's bottom edge (m). */
sill: number
/** Wall-local y of the bottom edge. */
bottomY: number
/** The opening's top edge → the wall top (m). */
head: number
/** Wall-local y of the top edge. */
topY: number
}
export type EdgeGap = {
side: 'left' | 'right'
/** Clearance along the wall (m). */
distance: number
/** Wall-local s of the moving opening's edge. */
fromS: number
/** Wall-local s of the neighbour edge / wall end. */
toS: number
target: 'opening' | 'wall-start' | 'wall-end'
/** Set when `target === 'opening'`. */
targetId?: string
}
export type AlongWallAlignment = {
/** Wall-local s the two features share. */
s: number
movingFeature: AlongWallFeature
targetId: string
targetFeature: AlongWallFeature
/** Delta to add to the moving opening's `centerS` to make them coincide. */
snap: number
}
export type VerticalAlignment = {
/** Wall-local y the two features share. */
y: number
movingFeature: VerticalFeature
targetId: string
targetFeature: VerticalFeature
/** Delta to add to the moving opening's `centerY` to make them coincide. */
snap: number
}
export type EqualSpacingRun = {
/** The repeated gap value (average of the run's gaps) (m). */
gap: number
/** The equal-gap segments along the wall, in order (left → right). */
segments: { fromS: number; toS: number }[]
/** Participating opening ids, ordered along the wall, including the moving one. */
openingIds: string[]
}
export type OpeningGuides = {
sillHead: SillHeadGuide | null
gaps: EdgeGap[]
alongWall: AlongWallAlignment | null
vertical: VerticalAlignment | null
equalSpacing: EqualSpacingRun | null
}
export type OpeningGuideInput = {
moving: OpeningSpan
/** Other openings on the SAME wall (the moving opening excluded). */
siblings: readonly OpeningSpan[]
wall: WallExtent
/**
* Whether to compute vertical (sill/head/vertical-alignment) guides. True for
* windows; false for doors, which sit on the floor so their sill is always 0.
*/
includeVertical: boolean
tolerances?: Partial<OpeningGuideTolerances>
}
const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2
const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2
const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2
const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2
function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number {
if (feature === 'left') return leftEdge(s)
if (feature === 'right') return rightEdge(s)
return s.centerS
}
function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number {
if (feature === 'sill') return bottomEdge(s)
if (feature === 'top') return topEdge(s)
return s.centerY
}
const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right']
const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top']
/**
* Edge-to-edge clearance from the moving opening to the nearest neighbour on
* each side, falling back to the wall ends when there is no neighbour — the
* "how much wall is left here" reading. Returns 02 gaps (one per side); a side
* is omitted when its clearance is below `minGap` (the opening is flush against
* or overlapping that neighbour).
*/
export function computeEdgeGaps(
moving: OpeningSpan,
siblings: readonly OpeningSpan[],
wall: WallExtent,
minGap: number,
): EdgeGap[] {
const movingLeft = leftEdge(moving)
const movingRight = rightEdge(moving)
// A sibling that straddles one of the moving opening's edges is an OVERLAP,
// not a neighbour: there is no clearance on that side, and we must not fall
// back to the wall end (which would report a misleading distance measured
// "through" the overlapping opening).
const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft)
const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight)
let leftNeighbour: { s: number; id: string } | null = null
let rightNeighbour: { s: number; id: string } | null = null
for (const sib of siblings) {
const sibRight = rightEdge(sib)
const sibLeft = leftEdge(sib)
// Entirely to the left of the moving opening → candidate left neighbour.
if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) {
leftNeighbour = { s: sibRight, id: sib.id }
}
// Entirely to the right → candidate right neighbour.
if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) {
rightNeighbour = { s: sibLeft, id: sib.id }
}
}
const gaps: EdgeGap[] = []
if (!leftCrossed) {
const leftToS = leftNeighbour ? leftNeighbour.s : 0
const leftDistance = movingLeft - leftToS
if (leftDistance >= minGap) {
gaps.push({
side: 'left',
distance: leftDistance,
fromS: movingLeft,
toS: leftToS,
target: leftNeighbour ? 'opening' : 'wall-start',
targetId: leftNeighbour?.id,
})
}
}
if (!rightCrossed) {
const rightToS = rightNeighbour ? rightNeighbour.s : wall.length
const rightDistance = rightToS - movingRight
if (rightDistance >= minGap) {
gaps.push({
side: 'right',
distance: rightDistance,
fromS: movingRight,
toS: rightToS,
target: rightNeighbour ? 'opening' : 'wall-end',
targetId: rightNeighbour?.id,
})
}
}
return gaps
}
/**
* The closest coincidence between any of the moving opening's edges/centre and
* any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge
* and centre-to-centre are weighed equally; the single closest pair wins
* (matching the one-guide-per-axis behaviour of the floor-plane resolver).
*/
export function detectAlongWallAlignment(
moving: OpeningSpan,
siblings: readonly OpeningSpan[],
tolerance: number,
): AlongWallAlignment | null {
let best: AlongWallAlignment | null = null
let bestAbs = tolerance
for (const movingFeature of ALONG_WALL_FEATURES) {
const movingCoord = alongWallFeatureCoord(moving, movingFeature)
for (const sib of siblings) {
if (sib.id === moving.id) continue
for (const targetFeature of ALONG_WALL_FEATURES) {
const targetCoord = alongWallFeatureCoord(sib, targetFeature)
const diff = targetCoord - movingCoord
const abs = Math.abs(diff)
if (abs <= bestAbs && (best === null || abs < bestAbs)) {
bestAbs = abs
best = {
s: targetCoord,
movingFeature,
targetId: sib.id,
targetFeature,
snap: diff,
}
}
}
}
}
return best
}
/**
* The closest coincidence between the moving opening's sill/centre/top and any
* sibling's sill/centre/top, within `tolerance` — the "these two windows share
* a sill height" detector. Same single-best-match policy as the along-wall
* variant.
*/
export function detectVerticalAlignment(
moving: OpeningSpan,
siblings: readonly OpeningSpan[],
tolerance: number,
): VerticalAlignment | null {
let best: VerticalAlignment | null = null
let bestAbs = tolerance
for (const movingFeature of VERTICAL_FEATURES) {
const movingCoord = verticalFeatureCoord(moving, movingFeature)
for (const sib of siblings) {
if (sib.id === moving.id) continue
for (const targetFeature of VERTICAL_FEATURES) {
const targetCoord = verticalFeatureCoord(sib, targetFeature)
const diff = targetCoord - movingCoord
const abs = Math.abs(diff)
if (abs <= bestAbs && (best === null || abs < bestAbs)) {
bestAbs = abs
best = {
y: targetCoord,
movingFeature,
targetId: sib.id,
targetFeature,
snap: diff,
}
}
}
}
}
return best
}
/**
* Figma-style equal-spacing detection: order all openings along the wall, look
* at the clearances BETWEEN consecutive openings, and return the longest run of
* ≥2 consecutive gaps that are equal within `tolerance` and that the moving
* opening participates in (so the badges only appear while the drag is actually
* forming or extending a series). Returns null when no such run exists.
*
* Gaps below `minGap` (touching/overlapping openings) break a run — a row of
* flush openings is not "equally spaced".
*/
export function detectEqualSpacing(
allOpenings: readonly OpeningSpan[],
movingId: string,
tolerance: number,
minGap: number,
): EqualSpacingRun | null {
if (allOpenings.length < 3) return null
const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS)
const movingIndex = sorted.findIndex((s) => s.id === movingId)
if (movingIndex < 0) return null
// Clearance between opening i and i+1.
const gaps: { value: number; fromS: number; toS: number }[] = []
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (!a || !b) continue
const fromS = rightEdge(a)
const toS = leftEdge(b)
gaps.push({ value: toS - fromS, fromS, toS })
}
// Longest contiguous window of gaps that are (a) each ≥ minGap and (b)
// mutually equal within tolerance (window max min ≤ tolerance), spanning at
// least 2 gaps and including the moving opening. Brute force over windows
// (openings per wall are few). A first-gap-anchored greedy scan is NOT
// equivalent: it drops a valid run that begins partway through a drifting
// sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end,
// where [1.02, 1.04] is a real run. On a length tie the leftmost window wins,
// for determinism.
let best: EqualSpacingRun | null = null
for (let lo = 0; lo < gaps.length; lo++) {
let min = Number.POSITIVE_INFINITY
let max = Number.NEGATIVE_INFINITY
for (let hi = lo; hi < gaps.length; hi++) {
const gap = gaps[hi]
if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run
min = Math.min(min, gap.value)
max = Math.max(max, gap.value)
if (max - min > tolerance) break // extending only widens the spread
const gapCount = hi - lo + 1
if (gapCount < 2) continue
const firstOpening = lo // gap i sits between openings i and i+1
const lastOpening = hi + 1
if (movingIndex < firstOpening || movingIndex > lastOpening) continue
if (best !== null && gapCount <= best.segments.length) continue
const windowGaps = gaps.slice(lo, hi + 1)
best = {
gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length,
segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })),
openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id),
}
}
}
return best
}
/**
* Compute every proximity/alignment guide for the moving opening in one pass.
* Pure: feed it the moving opening's wall-local span, its same-wall siblings,
* and the wall extent; render the result in whichever view.
*/
export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides {
const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances }
const { moving, siblings, wall, includeVertical } = input
const sillHead: SillHeadGuide | null = includeVertical
? {
sill: bottomEdge(moving),
bottomY: bottomEdge(moving),
head: wall.height - topEdge(moving),
topY: topEdge(moving),
}
: null
return {
sillHead,
gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap),
alongWall: detectAlongWallAlignment(moving, siblings, tol.align),
vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null,
equalSpacing: detectEqualSpacing(
[moving, ...siblings],
moving.id,
tol.equalSpacing,
tol.minGap,
),
}
}
@@ -0,0 +1,213 @@
import { nodeRegistry } from '../registry'
import type { AnyNode, AnyNodeId } from '../schema'
/**
* Connectivity-aware editing for port-bearing distribution kinds
* (HVAC ductwork AND DWV plumbing).
*
* 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; system?: string }> | undefined {
return nodeRegistry.get(node.type)?.ports?.(node) as
| ReadonlyArray<{ id: string; position: Point; system?: string }>
| undefined
}
/** A node's distribution role from the registry (run / fitting / …). */
function roleOf(node: AnyNode): string | undefined {
return nodeRegistry.get(node.type)?.distributionRole
}
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 `run`-role partners (segments — endpoint stretch) and `fitting`-role
* partners (rigid follow) 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> = {}
const movedPortSystem: Record<string, string | undefined> = {}
for (const p of movedPorts) {
startMovedPorts[p.id] = p.position
movedPortSystem[p.id] = p.system
}
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
// Generalised across every distribution family (HVAC duct + DWV pipe):
// `run` partners stretch an endpoint, `fitting` partners follow rigidly.
// Terminals/equipment mount to surfaces and are intentionally NOT dragged.
// Fittings that declare `portConnectivityFollow: false` are anchored
// fixtures (e.g. pipe-trap) — moving a connected run stretches the arm.
const otherRole = roleOf(other)
if (otherRole !== 'run' && otherRole !== 'fitting') continue
const otherDef = nodeRegistry.get(other.type)
if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) 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) continue
// Don't fuse ports from incompatible systems (e.g. a supply duct
// and a waste pipe that happen to cross): only mate when both
// ports declare the same system, or at least one is unscoped.
const ms = movedPortSystem[mp.id]
if (ms && op.system && ms !== op.system) continue
matchedId = mp.id
break
}
if (!matchedId) continue
if (otherRole === 'run') {
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)
})
})
+143
View File
@@ -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()
})
})
+196
View File
@@ -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)
})
})
+161
View File
@@ -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 36",
* 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?.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
}
+5 -1
View File
@@ -31,6 +31,7 @@ import {
type SceneMaterialId,
} from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions'
import { resetSceneHistoryPauseDepth } from './history-control'
@@ -542,7 +543,10 @@ function migrateNodes(nodes: Record<string, any>): {
nodes: Record<string, AnyNode>
mintedMaterials: Record<SceneMaterialId, SceneMaterial>
} {
const patchedNodes = { ...nodes }
// Repair pre-existing corruption (null children, zero-length walls) before
// any per-type migration runs, so already-saved scenes load cleanly.
const { nodes: healed } = healSceneNodes(nodes)
const patchedNodes = { ...healed } as Record<string, any>
// Scene materials minted while moving legacy wall fields onto `node.slots`;
// merged into the scene material map by the caller (`setScene`).
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import type { WallNode } from '../../schema'
import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering'
function wall(id: string, start: [number, number], end: [number, number]): WallNode {
return {
id,
type: 'wall',
object: 'node',
visible: true,
parentId: 'level_test',
children: [],
start,
end,
thickness: 0.1,
height: 2.5,
frontSide: 'interior',
backSide: 'exterior',
metadata: {},
} as WallNode
}
function maxBoundaryCoord(walls: WallNode[]): number {
const miter = calculateLevelMiters(walls)
let max = 0
for (const w of walls) {
const bp = getWallMiterBoundaryPoints(w, miter)
expect(bp).not.toBeNull()
if (!bp) continue
for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) {
expect(Number.isFinite(p.x)).toBe(true)
expect(Number.isFinite(p.y)).toBe(true)
max = Math.max(max, Math.abs(p.x), Math.abs(p.y))
}
}
return max
}
describe('wall mitering miter limit', () => {
// Two 3 m walls sharing the origin, meeting at decreasing angles. Without a
// miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ),
// which is the "infinite wall" seen when a room-preset preview lands on top of
// an existing wall. The boundary must stay bounded near the wall length.
test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => {
const rad = (deg * Math.PI) / 180
const walls = [
wall('A', [0, 0], [3, 0]),
wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]),
]
// 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike.
expect(maxBoundaryCoord(walls)).toBeLessThan(4)
})
test('still miters a normal 90° corner', () => {
const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])]
const miter = calculateLevelMiters(walls)
const bpA = getWallMiterBoundaryPoints(walls[0]!, miter)
expect(bpA).not.toBeNull()
if (!bpA) throw new Error('expected miter boundary points')
// The shared corner is pulled to the mitred intersection, offset from the
// raw butt position (halfThickness 0.05) by the diagonal of the joint.
const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x)
expect(startSideX).toBeLessThan(-0.001)
expect(startSideX).toBeGreaterThan(-0.5)
})
})
@@ -35,6 +35,17 @@ type JunctionData = Map<string, WallIntersections>
const TOLERANCE = 0.001
// Miter joints are line-line intersections, so the joint point sits a distance
// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the
// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview
// dragged on top of an existing wall, or a freshly-drawn wall almost parallel
// to its neighbour) that distance runs away to infinity and the wall renders as
// an infinite spike. Cap the joint at this multiple of the wall half-thickness;
// beyond it we fall back to a square (butt) joint, exactly like the existing
// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps
// mitering down to ~11°) while bounding the pathological near-collinear case.
const MITER_LIMIT = 10
function pointToKey(p: Point2D, tolerance = TOLERANCE): string {
const snap = 1 / tolerance
return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}`
@@ -198,6 +209,7 @@ interface ProcessedWall {
edgeA: LineEquation // Left edge
edgeB: LineEquation // Right edge
isPassthrough: boolean // True if wall passes through junction (T-junction)
halfThickness: number // Used to bound the miter joint against runaway spikes
}
function calculateJunctionIntersections(
@@ -228,7 +240,14 @@ function calculateJunctionIntersections(
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true })
processedWalls.push({
wallId: wall.id,
angle,
edgeA,
edgeB,
isPassthrough: true,
halfThickness: halfT,
})
}
} else {
// Normal wall endpoint (start or end)
@@ -245,7 +264,14 @@ function calculateJunctionIntersections(
const edgeB = createLineFromPointAndVector(pB, v)
const angle = Math.atan2(v.y, v.x)
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false })
processedWalls.push({
wallId: wall.id,
angle,
edgeA,
edgeB,
isPassthrough: false,
halfThickness: halfT,
})
}
}
@@ -275,6 +301,21 @@ function calculateJunctionIntersections(
y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det,
}
// Miter limit: `det` only catches walls that are *exactly* parallel. Two
// walls meeting at a shallow angle have a small-but-nonzero `det`, so `p`
// lands far from the junction (∝ 1/sin θ) and the wall renders as an
// infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses
// from the meeting point — those walls fall back to a square joint.
const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness)
const dx = p.x - meetingPoint.x
const dy = p.y - meetingPoint.y
if (
!(Number.isFinite(p.x) && Number.isFinite(p.y)) ||
dx * dx + dy * dy > maxMiter * maxMiter
) {
continue
}
// Only assign intersection to non-passthrough walls
// Passthrough walls don't receive junction data (their geometry doesn't change)
if (!wall1.isPassthrough) {
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'
import { healSceneNodes } from './heal-scene-graph'
describe('healSceneNodes', () => {
test('strips non-string (null) children entries', () => {
const { nodes, strippedChildRefs } = healSceneNodes({
wall_a: {
id: 'wall_a',
type: 'wall',
start: [0, 0],
end: [1, 0],
children: [null, 'item_x'],
},
item_x: { id: 'item_x', type: 'item' },
})
expect(strippedChildRefs).toBe(1)
expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x'])
})
test('drops childless zero-length walls and removes their parent reference', () => {
const { nodes, droppedWallIds } = healSceneNodes({
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
wall_zero: { id: 'wall_zero', type: 'wall', start: [5, 5], end: [5, 5], children: [] },
wall_real: { id: 'wall_real', type: 'wall', start: [0, 0], end: [3, 0], children: [] },
})
expect(droppedWallIds).toEqual(['wall_zero'])
expect('wall_zero' in nodes).toBe(false)
expect((nodes.level_0 as { children: string[] }).children).toEqual(['wall_real'])
})
test('keeps a zero-length wall that still hosts a door/window', () => {
const { nodes, droppedWallIds } = healSceneNodes({
wall_z: { id: 'wall_z', type: 'wall', start: [1, 1], end: [1, 1], children: ['door_1'] },
door_1: { id: 'door_1', type: 'door' },
})
expect(droppedWallIds).toEqual([])
expect('wall_z' in nodes).toBe(true)
})
test('passes a clean scene through untouched', () => {
const input = {
wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['door_1'] },
door_1: { id: 'door_1', type: 'door' },
}
const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(input)
expect(droppedWallIds).toEqual([])
expect(strippedChildRefs).toBe(0)
expect(nodes.wall_a).toBe(input.wall_a)
})
})
@@ -0,0 +1,83 @@
// Repairs scene-graph corruption that pre-dates the source fixes, so existing
// saved scenes still load. Two known kinds of damage, both produced by the
// capture wall-merge before it was fixed:
//
// 1. A `children` array containing a non-string entry. The merge re-attached a
// wall-hosted item without minting an id, so `undefined` was pushed into the
// wall's children — which serializes to `[null]`. The wall schema rejects
// `null` children, so the whole scene fails to load.
// 2. A zero-length wall (start === end). It renders nothing, but lingers as a
// junk node and is a foot-gun for snapping/mitering.
//
// Both are also prevented at the source now (see merge-walls.ts and the wall
// miter limit); this is the load-time safety net for already-saved scenes.
const ZERO_LENGTH_EPS = 1e-6
export interface HealSceneResult {
nodes: Record<string, unknown>
/** Ids of zero-length walls that were dropped. */
droppedWallIds: string[]
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
strippedChildRefs: number
}
function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } {
if (!node || typeof node !== 'object') return false
const n = node as Record<string, unknown>
return (
n.type === 'wall' &&
Array.isArray(n.start) &&
Array.isArray(n.end) &&
typeof n.start[0] === 'number' &&
typeof n.start[1] === 'number' &&
typeof n.end[0] === 'number' &&
typeof n.end[1] === 'number'
)
}
/**
* Returns a healed copy of a `nodes` map. Pure — does not mutate `input`.
* Nodes that need no repair are passed through by reference.
*/
export function healSceneNodes(input: Record<string, unknown>): HealSceneResult {
const droppedWallIds: string[] = []
// Pass 1: drop childless zero-length walls. (Only childless ones — a wall
// carrying a door/window must keep its hosts, degenerate or not.)
const kept: Record<string, unknown> = {}
for (const [id, node] of Object.entries(input)) {
if (isWallLike(node)) {
const children = (node as { children?: unknown }).children
const childless = !Array.isArray(children) || children.length === 0
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) {
droppedWallIds.push(id)
continue
}
}
kept[id] = node
}
const dropped = new Set(droppedWallIds)
let strippedChildRefs = 0
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug)
// and references to walls we just removed.
const nodes: Record<string, unknown> = {}
for (const [id, node] of Object.entries(kept)) {
const children = (node as { children?: unknown })?.children
if (Array.isArray(children)) {
const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c))
if (cleaned.length !== children.length) {
strippedChildRefs += children.length - cleaned.length
nodes[id] = { ...(node as Record<string, unknown>), children: cleaned }
continue
}
}
nodes[id] = node
}
return { nodes, droppedWallIds, strippedChildRefs }
}
@@ -1,4 +1,5 @@
import { AnyNode, type AnyNodeType } from '../schema/types'
import { healSceneNodes } from '../utils/heal-scene-graph'
export type ValidationSeverity = 'error' | 'warning'
@@ -127,9 +128,22 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
}
}
const nodes = nodesRaw as Record<string, unknown>
// Heal known pre-existing corruption (null children, zero-length walls) up
// front, so a scene saved before the source fixes still imports instead of
// hard-failing schema validation. `parsed` below carries the repaired nodes.
const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(
nodesRaw as Record<string, unknown>,
)
const rootNodeIds = rootNodeIdsRaw as string[]
if (strippedChildRefs > 0 || droppedWallIds.length > 0) {
warnings.push({
severity: 'warning',
code: 'auto_repaired',
message: `Repaired on import: removed ${strippedChildRefs} invalid child reference${strippedChildRefs === 1 ? '' : 's'} and ${droppedWallIds.length} zero-length wall${droppedWallIds.length === 1 ? '' : 's'}.`,
})
}
if (rootNodeIds.length === 0) {
errors.push({
severity: 'error',