@pascal-app/core 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/events/bus.d.ts +30 -2
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
- package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
- package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
- package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +27 -1
- package/dist/hooks/scene-registry/scene-registry.test.d.ts +2 -0
- package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +1 -0
- package/dist/hooks/scene-registry/scene-registry.test.js +26 -0
- package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +93 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +69 -12
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +476 -195
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +19 -1
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -5
- package/dist/lib/level-name.d.ts +4 -0
- package/dist/lib/level-name.d.ts.map +1 -0
- package/dist/lib/level-name.js +10 -0
- package/dist/lib/measurement-geometry.d.ts +19 -0
- package/dist/lib/measurement-geometry.d.ts.map +1 -0
- package/dist/lib/measurement-geometry.js +232 -0
- package/dist/lib/measurement-geometry.test.d.ts +2 -0
- package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
- package/dist/lib/measurement-geometry.test.js +102 -0
- package/dist/lib/polygon-geometry.d.ts +0 -1
- package/dist/lib/polygon-geometry.d.ts.map +1 -1
- package/dist/lib/polygon-geometry.js +0 -18
- package/dist/lib/selection-proxy.d.ts +4 -0
- package/dist/lib/selection-proxy.d.ts.map +1 -0
- package/dist/lib/selection-proxy.js +12 -0
- package/dist/lib/slab-polygon.d.ts +31 -2
- package/dist/lib/slab-polygon.d.ts.map +1 -1
- package/dist/lib/slab-polygon.js +525 -31
- package/dist/lib/slab-polygon.test.d.ts +2 -0
- package/dist/lib/slab-polygon.test.d.ts.map +1 -0
- package/dist/lib/slab-polygon.test.js +635 -0
- package/dist/lib/slots.d.ts +14 -0
- package/dist/lib/slots.d.ts.map +1 -0
- package/dist/lib/slots.js +26 -0
- package/dist/lib/space-detection.d.ts +25 -2
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +414 -118
- package/dist/lib/space-detection.test.js +301 -2
- package/dist/lib/wall-distance.d.ts +60 -0
- package/dist/lib/wall-distance.d.ts.map +1 -0
- package/dist/lib/wall-distance.js +85 -0
- package/dist/lib/zone-quantities.d.ts +21 -0
- package/dist/lib/zone-quantities.d.ts.map +1 -0
- package/dist/lib/zone-quantities.js +183 -0
- package/dist/lib/zone-quantities.test.d.ts +2 -0
- package/dist/lib/zone-quantities.test.d.ts.map +1 -0
- package/dist/lib/zone-quantities.test.js +82 -0
- package/dist/material-library.d.ts +20 -1
- package/dist/material-library.d.ts.map +1 -1
- package/dist/material-library.js +2181 -441
- package/dist/registry/handles.d.ts +75 -2
- package/dist/registry/handles.d.ts.map +1 -1
- package/dist/registry/index.d.ts +3 -3
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/registry.d.ts +46 -3
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +82 -1
- package/dist/registry/registry.test.js +14 -1
- package/dist/registry/subtree.d.ts.map +1 -1
- package/dist/registry/subtree.js +4 -0
- package/dist/registry/subtree.test.js +30 -0
- package/dist/registry/types.d.ts +717 -38
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/schema/index.d.ts +21 -5
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +17 -3
- package/dist/schema/material.d.ts +3 -2
- package/dist/schema/material.d.ts.map +1 -1
- package/dist/schema/material.js +7 -5
- package/dist/schema/material.test.d.ts +2 -0
- package/dist/schema/material.test.d.ts.map +1 -0
- package/dist/schema/material.test.js +43 -0
- package/dist/schema/nodes/box-vent.d.ts +2 -2
- package/dist/schema/nodes/cabinet.d.ts +534 -0
- package/dist/schema/nodes/cabinet.d.ts.map +1 -0
- package/dist/schema/nodes/cabinet.js +137 -0
- package/dist/schema/nodes/ceiling.d.ts +3 -2
- package/dist/schema/nodes/ceiling.d.ts.map +1 -1
- package/dist/schema/nodes/ceiling.js +4 -0
- package/dist/schema/nodes/chimney.d.ts +4 -4
- package/dist/schema/nodes/column.d.ts +3 -2
- package/dist/schema/nodes/column.d.ts.map +1 -1
- package/dist/schema/nodes/column.js +3 -0
- package/dist/schema/nodes/cupola.d.ts +2 -2
- package/dist/schema/nodes/door.d.ts +13 -5
- package/dist/schema/nodes/door.d.ts.map +1 -1
- package/dist/schema/nodes/door.js +12 -0
- package/dist/schema/nodes/dormer.d.ts +8 -8
- package/dist/schema/nodes/dormer.js +1 -1
- package/dist/schema/nodes/downspout.d.ts +2 -2
- package/dist/schema/nodes/duct-fitting.d.ts +91 -0
- package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
- package/dist/schema/nodes/duct-fitting.js +92 -0
- package/dist/schema/nodes/duct-segment.d.ts +61 -0
- package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
- package/dist/schema/nodes/duct-segment.js +73 -0
- package/dist/schema/nodes/duct-terminal.d.ts +57 -0
- package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
- package/dist/schema/nodes/duct-terminal.js +51 -0
- package/dist/schema/nodes/elevator.d.ts +3 -2
- package/dist/schema/nodes/elevator.d.ts.map +1 -1
- package/dist/schema/nodes/elevator.js +3 -0
- package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
- package/dist/schema/nodes/fence.d.ts +18 -2
- package/dist/schema/nodes/fence.d.ts.map +1 -1
- package/dist/schema/nodes/fence.js +25 -2
- package/dist/schema/nodes/gutter.d.ts +2 -2
- package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
- package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
- package/dist/schema/nodes/hvac-equipment.js +55 -0
- package/dist/schema/nodes/item.d.ts +8 -0
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +11 -0
- package/dist/schema/nodes/level.d.ts +1 -1
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +2 -0
- package/dist/schema/nodes/lineset.d.ts +42 -0
- package/dist/schema/nodes/lineset.d.ts.map +1 -0
- package/dist/schema/nodes/lineset.js +38 -0
- package/dist/schema/nodes/liquid-line.d.ts +34 -0
- package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
- package/dist/schema/nodes/liquid-line.js +24 -0
- package/dist/schema/nodes/measurement.d.ts +306 -0
- package/dist/schema/nodes/measurement.d.ts.map +1 -0
- package/dist/schema/nodes/measurement.js +87 -0
- package/dist/schema/nodes/measurement.test.d.ts +2 -0
- package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
- package/dist/schema/nodes/measurement.test.js +139 -0
- package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
- package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
- package/dist/schema/nodes/pipe-fitting.js +45 -0
- package/dist/schema/nodes/pipe-segment.d.ts +48 -0
- package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
- package/dist/schema/nodes/pipe-segment.js +36 -0
- package/dist/schema/nodes/pipe-trap.d.ts +43 -0
- package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
- package/dist/schema/nodes/pipe-trap.js +36 -0
- package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
- package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
- package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
- package/dist/schema/nodes/ridge-vent.d.ts +18 -3
- package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
- package/dist/schema/nodes/ridge-vent.js +221 -3
- package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
- package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-shape.js +318 -0
- package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
- package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
- package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
- package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
- package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-walls.js +330 -0
- package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
- package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
- package/dist/schema/nodes/roof-segment.d.ts +84 -8
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
- package/dist/schema/nodes/roof-segment.js +290 -5
- package/dist/schema/nodes/roof.d.ts +8 -8
- package/dist/schema/nodes/shelf.d.ts +3 -2
- package/dist/schema/nodes/shelf.d.ts.map +1 -1
- package/dist/schema/nodes/shelf.js +6 -0
- package/dist/schema/nodes/skylight.d.ts +10 -10
- package/dist/schema/nodes/slab.d.ts +3 -2
- package/dist/schema/nodes/slab.d.ts.map +1 -1
- package/dist/schema/nodes/slab.js +4 -0
- package/dist/schema/nodes/solar-panel.d.ts +4 -4
- package/dist/schema/nodes/stair-segment.d.ts +4 -4
- package/dist/schema/nodes/stair.d.ts +13 -12
- package/dist/schema/nodes/stair.d.ts.map +1 -1
- package/dist/schema/nodes/stair.js +3 -0
- package/dist/schema/nodes/turbine-vent.d.ts +2 -2
- package/dist/schema/nodes/wall.d.ts +245 -6
- package/dist/schema/nodes/wall.d.ts.map +1 -1
- package/dist/schema/nodes/wall.js +257 -0
- package/dist/schema/nodes/wall.test.d.ts +2 -0
- package/dist/schema/nodes/wall.test.d.ts.map +1 -0
- package/dist/schema/nodes/wall.test.js +208 -0
- package/dist/schema/nodes/window.d.ts +11 -3
- package/dist/schema/nodes/window.d.ts.map +1 -1
- package/dist/schema/nodes/window.js +12 -0
- package/dist/schema/nodes/zone.d.ts +2 -0
- package/dist/schema/nodes/zone.d.ts.map +1 -1
- package/dist/schema/nodes/zone.js +6 -0
- package/dist/schema/scene-material.d.ts +41 -0
- package/dist/schema/scene-material.d.ts.map +1 -0
- package/dist/schema/scene-material.js +9 -0
- package/dist/schema/types.d.ts +1089 -94
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +23 -0
- package/dist/services/alignment-anchors.d.ts.map +1 -1
- package/dist/services/alignment-anchors.js +30 -1
- package/dist/services/alignment.d.ts +5 -0
- package/dist/services/alignment.d.ts.map +1 -1
- package/dist/services/alignment.js +2 -0
- package/dist/services/hosting.d.ts +8 -0
- package/dist/services/hosting.d.ts.map +1 -1
- package/dist/services/hosting.js +13 -4
- package/dist/services/hosting.test.js +32 -1
- package/dist/services/index.d.ts +8 -2
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +8 -2
- package/dist/services/level-height.d.ts +28 -0
- package/dist/services/level-height.d.ts.map +1 -0
- package/dist/services/level-height.js +62 -0
- package/dist/services/opening-guides.d.ts +141 -0
- package/dist/services/opening-guides.d.ts.map +1 -0
- package/dist/services/opening-guides.js +267 -0
- package/dist/services/opening-guides.test.d.ts +2 -0
- package/dist/services/opening-guides.test.d.ts.map +1 -0
- package/dist/services/opening-guides.test.js +208 -0
- package/dist/services/port-connectivity.d.ts +115 -0
- package/dist/services/port-connectivity.d.ts.map +1 -0
- package/dist/services/port-connectivity.js +300 -0
- package/dist/services/port-connectivity.test.d.ts +2 -0
- package/dist/services/port-connectivity.test.d.ts.map +1 -0
- package/dist/services/port-connectivity.test.js +297 -0
- package/dist/services/riser-diagram.d.ts +40 -0
- package/dist/services/riser-diagram.d.ts.map +1 -0
- package/dist/services/riser-diagram.js +110 -0
- package/dist/services/riser-diagram.test.d.ts +2 -0
- package/dist/services/riser-diagram.test.d.ts.map +1 -0
- package/dist/services/riser-diagram.test.js +66 -0
- package/dist/services/snap.d.ts +9 -1
- package/dist/services/snap.d.ts.map +1 -1
- package/dist/services/snap.js +21 -1
- package/dist/services/snap.test.js +43 -1
- package/dist/services/system-graph.d.ts +30 -0
- package/dist/services/system-graph.d.ts.map +1 -0
- package/dist/services/system-graph.js +164 -0
- package/dist/services/system-graph.test.d.ts +2 -0
- package/dist/services/system-graph.test.d.ts.map +1 -0
- package/dist/services/system-graph.test.js +138 -0
- package/dist/services/validate-dwv.d.ts +32 -0
- package/dist/services/validate-dwv.d.ts.map +1 -0
- package/dist/services/validate-dwv.js +118 -0
- package/dist/services/validate-dwv.test.d.ts +2 -0
- package/dist/services/validate-dwv.test.d.ts.map +1 -0
- package/dist/services/validate-dwv.test.js +109 -0
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +94 -1
- package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
- package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
- package/dist/store/actions/ridge-vent-update.test.js +264 -0
- package/dist/store/history-control.d.ts +11 -0
- package/dist/store/history-control.d.ts.map +1 -1
- package/dist/store/history-control.js +33 -0
- package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
- package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
- package/dist/store/use-scene-dirty-tracking.test.js +68 -0
- package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
- package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
- package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
- package/dist/store/use-scene-plugins.test.d.ts +2 -0
- package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
- package/dist/store/use-scene-plugins.test.js +51 -0
- package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
- package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
- package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
- package/dist/store/use-scene-window-migration.test.d.ts +2 -0
- package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
- package/dist/store/use-scene-window-migration.test.js +85 -0
- package/dist/store/use-scene.d.ts +17 -2
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +358 -51
- package/dist/systems/elevator/elevator-service.js +1 -1
- package/dist/systems/fence/fence-centerline.d.ts +12 -0
- package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
- package/dist/systems/fence/fence-centerline.js +37 -0
- package/dist/systems/fence/fence-spline.d.ts +39 -0
- package/dist/systems/fence/fence-spline.d.ts.map +1 -0
- package/dist/systems/fence/fence-spline.js +196 -0
- package/dist/systems/fence/fence-spline.test.d.ts +2 -0
- package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
- package/dist/systems/fence/fence-spline.test.js +136 -0
- package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
- package/dist/systems/stair/stair-footprint.js +21 -11
- package/dist/systems/stair/stair-opening-preview.d.ts +1139 -469
- package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
- package/dist/systems/stair/stair-opening-sync.js +1 -18
- package/dist/systems/stair/stair-opening-sync.test.js +39 -0
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
- package/dist/systems/wall/wall-mitering.js +38 -2
- package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
- package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-mitering.test.js +63 -0
- package/dist/utils/clone-scene-graph.d.ts +1 -0
- package/dist/utils/clone-scene-graph.d.ts.map +1 -1
- package/dist/utils/clone-scene-graph.js +23 -3
- package/dist/utils/clone-scene-graph.test.js +3 -0
- package/dist/utils/heal-scene-graph.d.ts +18 -0
- package/dist/utils/heal-scene-graph.d.ts.map +1 -0
- package/dist/utils/heal-scene-graph.js +90 -0
- package/dist/utils/heal-scene-graph.test.d.ts +2 -0
- package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
- package/dist/utils/heal-scene-graph.test.js +88 -0
- package/dist/validation/validate-build-json.d.ts +1 -0
- package/dist/validation/validate-build-json.d.ts.map +1 -1
- package/dist/validation/validate-build-json.js +31 -2
- package/package.json +3 -3
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Riser diagram (plumbing isometric) — the conventional way DWV systems
|
|
3
|
+
* are drawn for permit: the drain/vent tree projected to a 30° iso so
|
|
4
|
+
* vertical stacks read as vertical and horizontal runs lean off at 30°,
|
|
5
|
+
* annotated with size + slope and vent terminations.
|
|
6
|
+
*
|
|
7
|
+
* This is a pure projector: it turns the scene's DWV nodes into 2D
|
|
8
|
+
* drawables (level-independent, no rendering). The editor draws the
|
|
9
|
+
* result as SVG. Air/refrigerant nodes are ignored — riser diagrams are
|
|
10
|
+
* a plumbing convention.
|
|
11
|
+
*/
|
|
12
|
+
const COS30 = Math.cos(Math.PI / 6);
|
|
13
|
+
const SIN30 = Math.sin(Math.PI / 6);
|
|
14
|
+
/** A 3D level-local point (meters) projected to 2D iso screen space.
|
|
15
|
+
* Screen Y grows DOWNWARD (SVG convention), so higher elevation → lower
|
|
16
|
+
* screen Y. */
|
|
17
|
+
export function projectIso(x, y, z) {
|
|
18
|
+
const sx = (x - z) * COS30;
|
|
19
|
+
const sy = (x + z) * SIN30 - y;
|
|
20
|
+
return [sx, sy];
|
|
21
|
+
}
|
|
22
|
+
/** Elevation gain per horizontal meter under which a leg is "vertical". */
|
|
23
|
+
const VERTICAL_EPS = 4; // dy/dxz ratio: steeper than this reads as a stack
|
|
24
|
+
function legIsVertical(a, b) {
|
|
25
|
+
const horizontal = Math.hypot(b[0] - a[0], b[2] - a[2]);
|
|
26
|
+
const vertical = Math.abs(b[1] - a[1]);
|
|
27
|
+
if (horizontal < 1e-4)
|
|
28
|
+
return true;
|
|
29
|
+
return vertical / horizontal > VERTICAL_EPS;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the riser diagram for the whole scene. Returns null when there's
|
|
33
|
+
* no DWV geometry to draw.
|
|
34
|
+
*/
|
|
35
|
+
export function buildRiserDiagram(nodes) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
const markers = [];
|
|
38
|
+
let minX = Infinity;
|
|
39
|
+
let minY = Infinity;
|
|
40
|
+
let maxX = -Infinity;
|
|
41
|
+
let maxY = -Infinity;
|
|
42
|
+
const grow = (p) => {
|
|
43
|
+
if (p[0] < minX)
|
|
44
|
+
minX = p[0];
|
|
45
|
+
if (p[1] < minY)
|
|
46
|
+
minY = p[1];
|
|
47
|
+
if (p[0] > maxX)
|
|
48
|
+
maxX = p[0];
|
|
49
|
+
if (p[1] > maxY)
|
|
50
|
+
maxY = p[1];
|
|
51
|
+
};
|
|
52
|
+
for (const node of Object.values(nodes)) {
|
|
53
|
+
if (!node)
|
|
54
|
+
continue;
|
|
55
|
+
if (node.type === 'pipe-segment') {
|
|
56
|
+
const path = node.path;
|
|
57
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
58
|
+
const a = path[i];
|
|
59
|
+
const b = path[i + 1];
|
|
60
|
+
const from = projectIso(a[0], a[1], a[2]);
|
|
61
|
+
const to = projectIso(b[0], b[1], b[2]);
|
|
62
|
+
grow(from);
|
|
63
|
+
grow(to);
|
|
64
|
+
lines.push({
|
|
65
|
+
from,
|
|
66
|
+
to,
|
|
67
|
+
system: node.system,
|
|
68
|
+
diameter: node.diameter,
|
|
69
|
+
vertical: legIsVertical(a, b),
|
|
70
|
+
nodeId: node.id,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Vent runs that end above everything are vent terminations
|
|
74
|
+
// (through-roof). Tag the highest endpoint of a vent run.
|
|
75
|
+
if (node.system === 'vent') {
|
|
76
|
+
const top = path.reduce((hi, p) => (p[1] > hi[1] ? p : hi), path[0]);
|
|
77
|
+
const pt = projectIso(top[0], top[1], top[2]);
|
|
78
|
+
markers.push({
|
|
79
|
+
point: pt,
|
|
80
|
+
kind: 'vent-termination',
|
|
81
|
+
label: `${node.diameter}" VTR`,
|
|
82
|
+
nodeId: node.id,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (node.type === 'pipe-trap') {
|
|
87
|
+
const pt = projectIso(node.position[0], node.position[1], node.position[2]);
|
|
88
|
+
grow(pt);
|
|
89
|
+
markers.push({
|
|
90
|
+
point: pt,
|
|
91
|
+
kind: 'trap',
|
|
92
|
+
label: `${node.diameter}" P-trap`,
|
|
93
|
+
nodeId: node.id,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else if (node.type === 'pipe-fitting') {
|
|
97
|
+
const pt = projectIso(node.position[0], node.position[1], node.position[2]);
|
|
98
|
+
grow(pt);
|
|
99
|
+
markers.push({
|
|
100
|
+
point: pt,
|
|
101
|
+
kind: 'fitting',
|
|
102
|
+
label: node.fittingType,
|
|
103
|
+
nodeId: node.id,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (lines.length === 0 && markers.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
return { lines, markers, bounds: { minX, minY, maxX, maxY } };
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"riser-diagram.test.d.ts","sourceRoot":"","sources":["../../src/services/riser-diagram.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { buildRiserDiagram, projectIso } from './riser-diagram';
|
|
3
|
+
let nextId = 0;
|
|
4
|
+
function makeNode(type, fields) {
|
|
5
|
+
nextId += 1;
|
|
6
|
+
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields };
|
|
7
|
+
}
|
|
8
|
+
function sceneOf(...nodes) {
|
|
9
|
+
return Object.fromEntries(nodes.map((n) => [n.id, n]));
|
|
10
|
+
}
|
|
11
|
+
describe('projectIso', () => {
|
|
12
|
+
test('higher elevation maps to smaller screen Y', () => {
|
|
13
|
+
const [, lowY] = projectIso(0, 0, 0);
|
|
14
|
+
const [, highY] = projectIso(0, 2, 0);
|
|
15
|
+
expect(highY).toBeLessThan(lowY);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe('buildRiserDiagram', () => {
|
|
19
|
+
test('null when no DWV nodes', () => {
|
|
20
|
+
const wall = makeNode('wall', {});
|
|
21
|
+
expect(buildRiserDiagram(sceneOf(wall))).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
test('classifies a vertical stack vs a sloped horizontal drain', () => {
|
|
24
|
+
const stack = makeNode('pipe-segment', {
|
|
25
|
+
path: [
|
|
26
|
+
[0, 0, 0],
|
|
27
|
+
[0, 3, 0],
|
|
28
|
+
],
|
|
29
|
+
diameter: 3,
|
|
30
|
+
system: 'vent',
|
|
31
|
+
});
|
|
32
|
+
const drain = makeNode('pipe-segment', {
|
|
33
|
+
path: [
|
|
34
|
+
[0, 0, 0],
|
|
35
|
+
[3, -0.06, 0],
|
|
36
|
+
],
|
|
37
|
+
diameter: 2,
|
|
38
|
+
system: 'waste',
|
|
39
|
+
});
|
|
40
|
+
const diagram = buildRiserDiagram(sceneOf(stack, drain));
|
|
41
|
+
const stackLine = diagram.lines.find((l) => l.nodeId === stack.id);
|
|
42
|
+
const drainLine = diagram.lines.find((l) => l.nodeId === drain.id);
|
|
43
|
+
expect(stackLine.vertical).toBe(true);
|
|
44
|
+
expect(drainLine.vertical).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
test('emits a vent-termination marker for a vent run', () => {
|
|
47
|
+
const vent = makeNode('pipe-segment', {
|
|
48
|
+
path: [
|
|
49
|
+
[0, 0, 0],
|
|
50
|
+
[0, 3, 0],
|
|
51
|
+
],
|
|
52
|
+
diameter: 2,
|
|
53
|
+
system: 'vent',
|
|
54
|
+
});
|
|
55
|
+
const diagram = buildRiserDiagram(sceneOf(vent));
|
|
56
|
+
expect(diagram.markers.some((m) => m.kind === 'vent-termination')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
test('labels traps', () => {
|
|
59
|
+
const trap = makeNode('pipe-trap', {
|
|
60
|
+
position: [1, 0, 0],
|
|
61
|
+
diameter: 1.5,
|
|
62
|
+
});
|
|
63
|
+
const diagram = buildRiserDiagram(sceneOf(trap));
|
|
64
|
+
expect(diagram.markers.some((m) => m.kind === 'trap')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/dist/services/snap.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type Vec2 = readonly [number, number];
|
|
|
12
12
|
export type Vec3 = readonly [number, number, number];
|
|
13
13
|
/** Default planar grid spacing in meters. Matches the editor's wall tool. */
|
|
14
14
|
export declare const DEFAULT_GRID_STEP = 0.25;
|
|
15
|
-
/** Default angle-snap step — π/12 = 15°.
|
|
15
|
+
/** Default angle-snap step — π/12 = 15°. */
|
|
16
16
|
export declare const DEFAULT_ANGLE_STEP: number;
|
|
17
17
|
/** Snaps a single scalar to the nearest multiple of `step`. */
|
|
18
18
|
export declare function snapScalar(value: number, step?: number): number;
|
|
@@ -45,6 +45,14 @@ export declare function snapWorldXZToBuildingLocal(worldX: number, worldZ: numbe
|
|
|
45
45
|
* — keeps endpoints landing on grid intersections.
|
|
46
46
|
*/
|
|
47
47
|
export declare function snapPointToAngle(from: Vec2, cursor: Vec2, angleStep?: number, gridStep?: number): Vec2;
|
|
48
|
+
/**
|
|
49
|
+
* Snaps a cursor point onto the nearest angle ray from `from` (multiples of
|
|
50
|
+
* `angleStep`), projecting the cursor onto that ray, then snaps the distance
|
|
51
|
+
* ALONG the ray to `distanceStep`. Unlike `snapPointToAngle` with a
|
|
52
|
+
* `gridStep`, the result stays exactly on the snapped ray — grid-snapping
|
|
53
|
+
* after the angle projection pulls points off non-axis rays.
|
|
54
|
+
*/
|
|
55
|
+
export declare function snapPointAlongAngleRay(from: Vec2, cursor: Vec2, angleStep?: number, distanceStep?: number): Vec2;
|
|
48
56
|
/**
|
|
49
57
|
* Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in
|
|
50
58
|
* radians). Returns the original angle if no entry is within `toleranceRad`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"snap.d.ts","sourceRoot":"","sources":["../../src/services/snap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAC5C,MAAM,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC,
|
|
1
|
+
{"version":3,"file":"snap.d.ts","sourceRoot":"","sources":["../../src/services/snap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAC5C,MAAM,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,OAAO,CAAA;AAErC,4CAA4C;AAC5C,eAAO,MAAM,kBAAkB,QAAe,CAAA;AAI9C,+DAA+D;AAC/D,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,MAA0B,GAAG,MAAM,CAGlF;AAED,iDAAiD;AACjD,wBAAgB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,GAAE,MAA0B,GAAG,IAAI,CAEnF;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,GAAE,MAA0B,GAAG,IAAI,CAElF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,gBAAgB,EAAE,IAAI,EACtB,iBAAiB,EAAE,MAAM,EACzB,IAAI,GAAE,MAA0B,GAC/B;IAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CA2BtD;AAID;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,IAAI,EACZ,SAAS,GAAE,MAA2B,EACtC,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAWN;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,IAAI,EACZ,SAAS,GAAE,MAA2B,EACtC,YAAY,CAAC,EAAE,MAAM,GACpB,IAAI,CAYN;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,YAAY,GAAE,MAAqB,GAClC,MAAM,CAcR;AAID;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,EAAE;QACJ,IAAI,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;QAC1C,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;QAC9C,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;KACrD,CAAA;IACD,KAAK,EAAE;QACL,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;QACjF,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,MAAM,CAAA;KACtF,CAAA;CACF,CAAA;AAED,eAAO,MAAM,YAAY,EAAE,YAU1B,CAAA"}
|
package/dist/services/snap.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** Default planar grid spacing in meters. Matches the editor's wall tool. */
|
|
12
12
|
export const DEFAULT_GRID_STEP = 0.25;
|
|
13
|
-
/** Default angle-snap step — π/12 = 15°.
|
|
13
|
+
/** Default angle-snap step — π/12 = 15°. */
|
|
14
14
|
export const DEFAULT_ANGLE_STEP = Math.PI / 12;
|
|
15
15
|
// ─── Grid snap ────────────────────────────────────────────────────────
|
|
16
16
|
/** Snaps a single scalar to the nearest multiple of `step`. */
|
|
@@ -88,6 +88,26 @@ export function snapPointToAngle(from, cursor, angleStep = DEFAULT_ANGLE_STEP, g
|
|
|
88
88
|
];
|
|
89
89
|
return gridStep == null ? projected : snapPointToGrid(projected, gridStep);
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Snaps a cursor point onto the nearest angle ray from `from` (multiples of
|
|
93
|
+
* `angleStep`), projecting the cursor onto that ray, then snaps the distance
|
|
94
|
+
* ALONG the ray to `distanceStep`. Unlike `snapPointToAngle` with a
|
|
95
|
+
* `gridStep`, the result stays exactly on the snapped ray — grid-snapping
|
|
96
|
+
* after the angle projection pulls points off non-axis rays.
|
|
97
|
+
*/
|
|
98
|
+
export function snapPointAlongAngleRay(from, cursor, angleStep = DEFAULT_ANGLE_STEP, distanceStep) {
|
|
99
|
+
const dx = cursor[0] - from[0];
|
|
100
|
+
const dz = cursor[1] - from[1];
|
|
101
|
+
if (dx === 0 && dz === 0)
|
|
102
|
+
return [from[0], from[1]];
|
|
103
|
+
const angle = Math.atan2(dz, dx);
|
|
104
|
+
const snappedAngle = angleStep > 0 ? Math.round(angle / angleStep) * angleStep : angle;
|
|
105
|
+
const dirX = Math.cos(snappedAngle);
|
|
106
|
+
const dirZ = Math.sin(snappedAngle);
|
|
107
|
+
const projected = dx * dirX + dz * dirZ;
|
|
108
|
+
const distance = distanceStep != null && distanceStep > 0 ? snapScalar(projected, distanceStep) : projected;
|
|
109
|
+
return [from[0] + dirX * distance, from[1] + dirZ * distance];
|
|
110
|
+
}
|
|
91
111
|
/**
|
|
92
112
|
* Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in
|
|
93
113
|
* radians). Returns the original angle if no entry is within `toleranceRad`.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, snapAngleToList, snapPointToAngle, snapPointToGrid, snapScalar, snapServices, snapVec3ToGrid, } from './snap';
|
|
2
|
+
import { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, snapAngleToList, snapPointAlongAngleRay, snapPointToAngle, snapPointToGrid, snapScalar, snapServices, snapVec3ToGrid, } from './snap';
|
|
3
3
|
describe('snapScalar', () => {
|
|
4
4
|
test('rounds to multiples of step', () => {
|
|
5
5
|
expect(snapScalar(0.27, 0.25)).toBe(0.25);
|
|
@@ -65,6 +65,48 @@ describe('snapPointToAngle', () => {
|
|
|
65
65
|
expect(Math.hypot(snapped[0] - 2, snapped[1] - 3)).toBeCloseTo(distance);
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
+
describe('snapPointAlongAngleRay', () => {
|
|
69
|
+
test('stays exactly on the 15° ray while distance-snapping to the grid step', () => {
|
|
70
|
+
const from = [0, 0];
|
|
71
|
+
const cursor = [2, 0.5]; // ≈14° — snaps to 15°
|
|
72
|
+
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.25);
|
|
73
|
+
expect(Math.atan2(snapped[1], snapped[0])).toBeCloseTo(Math.PI / 12, 10);
|
|
74
|
+
const distance = Math.hypot(snapped[0], snapped[1]);
|
|
75
|
+
expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10);
|
|
76
|
+
});
|
|
77
|
+
test('grid-snapping after the angle projection would pull the point off the ray', () => {
|
|
78
|
+
const from = [0, 0];
|
|
79
|
+
const cursor = [2, 0.5];
|
|
80
|
+
const offRay = snapPointToAngle(from, cursor, Math.PI / 12, 0.25);
|
|
81
|
+
expect(Math.atan2(offRay[1], offRay[0])).not.toBeCloseTo(Math.PI / 12, 4);
|
|
82
|
+
});
|
|
83
|
+
test('45° back-compat: locks to the diagonal with grid-multiple distance', () => {
|
|
84
|
+
const from = [1, 1];
|
|
85
|
+
const cursor = [2.1, 1.9]; // near 45° from `from`
|
|
86
|
+
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 4, 0.25);
|
|
87
|
+
expect(Math.atan2(snapped[1] - 1, snapped[0] - 1)).toBeCloseTo(Math.PI / 4, 10);
|
|
88
|
+
const distance = Math.hypot(snapped[0] - 1, snapped[1] - 1);
|
|
89
|
+
expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10);
|
|
90
|
+
});
|
|
91
|
+
test('preserves the projected distance when no distanceStep is given', () => {
|
|
92
|
+
const from = [0, 0];
|
|
93
|
+
const cursor = [1, 0.05]; // near 0°
|
|
94
|
+
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12);
|
|
95
|
+
expect(snapped[0]).toBeCloseTo(1, 10); // projection of (1, 0.05) onto 0° ray
|
|
96
|
+
expect(snapped[1]).toBeCloseTo(0, 10);
|
|
97
|
+
});
|
|
98
|
+
test('returns `from` for a zero-length segment', () => {
|
|
99
|
+
expect(snapPointAlongAngleRay([2, 3], [2, 3], Math.PI / 12, 0.25)).toEqual([2, 3]);
|
|
100
|
+
});
|
|
101
|
+
test('is idempotent on its own output', () => {
|
|
102
|
+
const from = [0.5, -1];
|
|
103
|
+
const cursor = [3.2, 0.4];
|
|
104
|
+
const once = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.5);
|
|
105
|
+
const twice = snapPointAlongAngleRay(from, once, Math.PI / 12, 0.5);
|
|
106
|
+
expect(twice[0]).toBeCloseTo(once[0], 10);
|
|
107
|
+
expect(twice[1]).toBeCloseTo(once[1], 10);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
68
110
|
describe('snapAngleToList', () => {
|
|
69
111
|
test('snaps to the nearest entry within tolerance', () => {
|
|
70
112
|
const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeId } from '../schema';
|
|
2
|
+
export type SystemSummary = {
|
|
3
|
+
/** Every node in this connected component. */
|
|
4
|
+
nodeIds: AnyNodeId[];
|
|
5
|
+
/** Distribution loops present, e.g. ['supply'], ['supply','return']. */
|
|
6
|
+
systems: string[];
|
|
7
|
+
/** Duct / lineset run statistics. */
|
|
8
|
+
runCount: number;
|
|
9
|
+
runLengthM: number;
|
|
10
|
+
fittingCount: number;
|
|
11
|
+
terminalCount: number;
|
|
12
|
+
equipmentCount: number;
|
|
13
|
+
/** False = orphaned subtree: air goes nowhere (no furnace / air
|
|
14
|
+
* handler / condenser anywhere in the component). */
|
|
15
|
+
connectedToEquipment: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Group every port-bearing node into connected components via coinciding
|
|
19
|
+
* ports. Nodes with ports but no joints form singleton components; nodes
|
|
20
|
+
* without `def.ports` don't participate at all.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildPortComponents(nodes: Readonly<Record<AnyNodeId, AnyNode>>): AnyNodeId[][];
|
|
23
|
+
/**
|
|
24
|
+
* Summary of the system the given node belongs to, or null when the node
|
|
25
|
+
* has no ports (not a distribution kind). A node with ports but no
|
|
26
|
+
* joints yet still gets a (singleton) summary — `connectedToEquipment:
|
|
27
|
+
* false` is the interesting signal there.
|
|
28
|
+
*/
|
|
29
|
+
export declare function summarizeSystemFor(nodeId: AnyNodeId, nodes: Readonly<Record<AnyNodeId, AnyNode>>): SystemSummary | null;
|
|
30
|
+
//# sourceMappingURL=system-graph.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"system-graph.d.ts","sourceRoot":"","sources":["../../src/services/system-graph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAoBnD,MAAM,MAAM,aAAa,GAAG;IAC1B,8CAA8C;IAC9C,OAAO,EAAE,SAAS,EAAE,CAAA;IACpB,wEAAwE;IACxE,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,EAAE,MAAM,CAAA;IACrB,cAAc,EAAE,MAAM,CAAA;IACtB;0DACsD;IACtD,oBAAoB,EAAE,OAAO,CAAA;CAC9B,CAAA;AA2DD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,GAAG,SAAS,EAAE,EAAE,CA4B9F;AAkDD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,GAC1C,aAAa,GAAG,IAAI,CAStB"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { nodeRegistry } from '../registry';
|
|
2
|
+
/**
|
|
3
|
+
* The "System" primitive: connected components over the port graph.
|
|
4
|
+
*
|
|
5
|
+
* Two nodes are joined when a port of one coincides in space with a port
|
|
6
|
+
* of the other — the same mated-joint relationship `port-connectivity`
|
|
7
|
+
* uses for drag propagation, read here at whole-scene scope. A component
|
|
8
|
+
* is one distribution system: a furnace, its trunk, the tees, branches,
|
|
9
|
+
* and registers hanging off it.
|
|
10
|
+
*
|
|
11
|
+
* Pure logic (def.ports + arithmetic), no rendering — lives in core so
|
|
12
|
+
* the editor (badges, schedules) and analyses (sizing, code checks) can
|
|
13
|
+
* share it.
|
|
14
|
+
*/
|
|
15
|
+
/** Distance (meters) under which two ports count as the same joint —
|
|
16
|
+
* matches port-connectivity's tolerance for hand-placed joints. */
|
|
17
|
+
const COINCIDENT_EPS_M = 0.05;
|
|
18
|
+
function collectPorts(nodes) {
|
|
19
|
+
const result = [];
|
|
20
|
+
for (const node of Object.values(nodes)) {
|
|
21
|
+
if (!node)
|
|
22
|
+
continue;
|
|
23
|
+
const ports = nodeRegistry.get(node.type)?.ports?.(node);
|
|
24
|
+
if (!ports)
|
|
25
|
+
continue;
|
|
26
|
+
for (const port of ports) {
|
|
27
|
+
result.push({
|
|
28
|
+
nodeId: node.id,
|
|
29
|
+
x: port.position[0],
|
|
30
|
+
y: port.position[1],
|
|
31
|
+
z: port.position[2],
|
|
32
|
+
system: port.system,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/** Union-find over node ids. */
|
|
39
|
+
class Components {
|
|
40
|
+
parent = new Map();
|
|
41
|
+
find(id) {
|
|
42
|
+
let root = this.parent.get(id) ?? id;
|
|
43
|
+
if (root !== id) {
|
|
44
|
+
root = this.find(root);
|
|
45
|
+
this.parent.set(id, root);
|
|
46
|
+
}
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
union(a, b) {
|
|
50
|
+
const ra = this.find(a);
|
|
51
|
+
const rb = this.find(b);
|
|
52
|
+
if (ra !== rb)
|
|
53
|
+
this.parent.set(rb, ra);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function pathLength(path) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
59
|
+
const a = path[i];
|
|
60
|
+
const b = path[i + 1];
|
|
61
|
+
total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
|
62
|
+
}
|
|
63
|
+
return total;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Group every port-bearing node into connected components via coinciding
|
|
67
|
+
* ports. Nodes with ports but no joints form singleton components; nodes
|
|
68
|
+
* without `def.ports` don't participate at all.
|
|
69
|
+
*/
|
|
70
|
+
export function buildPortComponents(nodes) {
|
|
71
|
+
const ports = collectPorts(nodes);
|
|
72
|
+
const components = new Components();
|
|
73
|
+
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M;
|
|
74
|
+
for (let i = 0; i < ports.length; i++) {
|
|
75
|
+
const a = ports[i];
|
|
76
|
+
for (let j = i + 1; j < ports.length; j++) {
|
|
77
|
+
const b = ports[j];
|
|
78
|
+
if (a.nodeId === b.nodeId)
|
|
79
|
+
continue;
|
|
80
|
+
const dx = a.x - b.x;
|
|
81
|
+
const dy = a.y - b.y;
|
|
82
|
+
const dz = a.z - b.z;
|
|
83
|
+
if (dx * dx + dy * dy + dz * dz <= epsSq)
|
|
84
|
+
components.union(a.nodeId, b.nodeId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const grouped = new Map();
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (const port of ports) {
|
|
90
|
+
if (seen.has(port.nodeId))
|
|
91
|
+
continue;
|
|
92
|
+
seen.add(port.nodeId);
|
|
93
|
+
const root = components.find(port.nodeId);
|
|
94
|
+
const group = grouped.get(root);
|
|
95
|
+
if (group)
|
|
96
|
+
group.push(port.nodeId);
|
|
97
|
+
else
|
|
98
|
+
grouped.set(root, [port.nodeId]);
|
|
99
|
+
}
|
|
100
|
+
return [...grouped.values()];
|
|
101
|
+
}
|
|
102
|
+
function summarize(nodeIds, nodes) {
|
|
103
|
+
const systems = new Set();
|
|
104
|
+
let runCount = 0;
|
|
105
|
+
let runLengthM = 0;
|
|
106
|
+
let fittingCount = 0;
|
|
107
|
+
let terminalCount = 0;
|
|
108
|
+
let equipmentCount = 0;
|
|
109
|
+
for (const id of nodeIds) {
|
|
110
|
+
const node = nodes[id];
|
|
111
|
+
if (!node)
|
|
112
|
+
continue;
|
|
113
|
+
const role = nodeRegistry.get(node.type)?.distributionRole;
|
|
114
|
+
const fields = node;
|
|
115
|
+
if (role === 'run') {
|
|
116
|
+
runCount += 1;
|
|
117
|
+
if (fields.path)
|
|
118
|
+
runLengthM += pathLength(fields.path);
|
|
119
|
+
// Linesets carry refrigerant; duct / pipe runs name their own loop.
|
|
120
|
+
systems.add(fields.system ?? 'refrigerant');
|
|
121
|
+
}
|
|
122
|
+
else if (role === 'fitting') {
|
|
123
|
+
fittingCount += 1;
|
|
124
|
+
if (fields.system)
|
|
125
|
+
systems.add(fields.system);
|
|
126
|
+
}
|
|
127
|
+
else if (role === 'terminal') {
|
|
128
|
+
terminalCount += 1;
|
|
129
|
+
systems.add(fields.terminalType === 'return-grille' ? 'return' : 'supply');
|
|
130
|
+
}
|
|
131
|
+
else if (role === 'equipment') {
|
|
132
|
+
equipmentCount += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
nodeIds,
|
|
137
|
+
systems: [...systems].sort(),
|
|
138
|
+
runCount,
|
|
139
|
+
runLengthM,
|
|
140
|
+
fittingCount,
|
|
141
|
+
terminalCount,
|
|
142
|
+
equipmentCount,
|
|
143
|
+
connectedToEquipment: equipmentCount > 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Summary of the system the given node belongs to, or null when the node
|
|
148
|
+
* has no ports (not a distribution kind). A node with ports but no
|
|
149
|
+
* joints yet still gets a (singleton) summary — `connectedToEquipment:
|
|
150
|
+
* false` is the interesting signal there.
|
|
151
|
+
*/
|
|
152
|
+
export function summarizeSystemFor(nodeId, nodes) {
|
|
153
|
+
const node = nodes[nodeId];
|
|
154
|
+
if (!node)
|
|
155
|
+
return null;
|
|
156
|
+
const ports = nodeRegistry.get(node.type)?.ports?.(node);
|
|
157
|
+
if (!ports || ports.length === 0)
|
|
158
|
+
return null;
|
|
159
|
+
for (const component of buildPortComponents(nodes)) {
|
|
160
|
+
if (component.includes(nodeId))
|
|
161
|
+
return summarize(component, nodes);
|
|
162
|
+
}
|
|
163
|
+
return summarize([nodeId], nodes);
|
|
164
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"system-graph.test.d.ts","sourceRoot":"","sources":["../../src/services/system-graph.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { registerNode } from '../registry';
|
|
3
|
+
import { buildPortComponents, summarizeSystemFor } from './system-graph';
|
|
4
|
+
// Stub registrations: the graph consults `def.ports` for the connectivity
|
|
5
|
+
// graph and `def.distributionRole` to classify each node. Mirrors the real
|
|
6
|
+
// kinds' port + role conventions (duct runs expose start/end, equipment a
|
|
7
|
+
// supply collar, terminals one collar) without importing the nodes package.
|
|
8
|
+
function stubDef(kind, distributionRole, ports) {
|
|
9
|
+
registerNode({
|
|
10
|
+
kind,
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
schema: {},
|
|
13
|
+
category: 'utility',
|
|
14
|
+
distributionRole,
|
|
15
|
+
defaults: () => ({}),
|
|
16
|
+
capabilities: {},
|
|
17
|
+
ports,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
stubDef('duct-segment', 'run', (node) => {
|
|
21
|
+
const path = node.path;
|
|
22
|
+
const system = node.system;
|
|
23
|
+
return [
|
|
24
|
+
{ id: 'start', position: path[0], direction: [-1, 0, 0], diameter: 6, system },
|
|
25
|
+
{
|
|
26
|
+
id: 'end',
|
|
27
|
+
position: path[path.length - 1],
|
|
28
|
+
direction: [1, 0, 0],
|
|
29
|
+
diameter: 6,
|
|
30
|
+
system,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
stubDef('hvac-equipment', 'equipment', (node) => {
|
|
35
|
+
const position = node.position;
|
|
36
|
+
return [{ id: 'supply', position, direction: [0, 1, 0], diameter: 12, system: 'supply' }];
|
|
37
|
+
});
|
|
38
|
+
stubDef('duct-terminal', 'terminal', (node) => {
|
|
39
|
+
const position = node.position;
|
|
40
|
+
return [{ id: 'collar', position, direction: [0, -1, 0], diameter: 6, system: 'supply' }];
|
|
41
|
+
});
|
|
42
|
+
let nextId = 0;
|
|
43
|
+
function makeNode(type, fields) {
|
|
44
|
+
nextId += 1;
|
|
45
|
+
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields };
|
|
46
|
+
}
|
|
47
|
+
function sceneOf(...nodes) {
|
|
48
|
+
return Object.fromEntries(nodes.map((n) => [n.id, n]));
|
|
49
|
+
}
|
|
50
|
+
function run(path, system = 'supply') {
|
|
51
|
+
return makeNode('duct-segment', { path, system, diameter: 6 });
|
|
52
|
+
}
|
|
53
|
+
describe('buildPortComponents', () => {
|
|
54
|
+
test('chained runs land in one component; a distant run is separate', () => {
|
|
55
|
+
const a = run([
|
|
56
|
+
[0, 0, 0],
|
|
57
|
+
[3, 0, 0],
|
|
58
|
+
]);
|
|
59
|
+
const b = run([
|
|
60
|
+
[3, 0, 0],
|
|
61
|
+
[3, 0, 4],
|
|
62
|
+
]); // shares a's end
|
|
63
|
+
const c = run([
|
|
64
|
+
[20, 0, 0],
|
|
65
|
+
[24, 0, 0],
|
|
66
|
+
]); // far away
|
|
67
|
+
const components = buildPortComponents(sceneOf(a, b, c));
|
|
68
|
+
expect(components.length).toBe(2);
|
|
69
|
+
const joined = components.find((g) => g.length === 2);
|
|
70
|
+
expect(new Set(joined)).toEqual(new Set([a.id, b.id]));
|
|
71
|
+
});
|
|
72
|
+
test('joints within tolerance still join; outside do not', () => {
|
|
73
|
+
const a = run([
|
|
74
|
+
[0, 0, 0],
|
|
75
|
+
[3, 0, 0],
|
|
76
|
+
]);
|
|
77
|
+
const near = run([
|
|
78
|
+
[3.03, 0, 0],
|
|
79
|
+
[6, 0, 0],
|
|
80
|
+
]); // 3 cm — joined
|
|
81
|
+
const far = run([
|
|
82
|
+
[3.2, 0, 4],
|
|
83
|
+
[6, 0, 4],
|
|
84
|
+
]); // 20 cm in another row — separate
|
|
85
|
+
const components = buildPortComponents(sceneOf(a, near, far));
|
|
86
|
+
expect(components.length).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
test('nodes without ports do not participate', () => {
|
|
89
|
+
const wall = makeNode('wall', {});
|
|
90
|
+
const a = run([
|
|
91
|
+
[0, 0, 0],
|
|
92
|
+
[3, 0, 0],
|
|
93
|
+
]);
|
|
94
|
+
const components = buildPortComponents(sceneOf(wall, a));
|
|
95
|
+
expect(components.length).toBe(1);
|
|
96
|
+
expect(components[0]).toEqual([a.id]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('summarizeSystemFor', () => {
|
|
100
|
+
test('full tree: equipment → run → terminal, stats add up', () => {
|
|
101
|
+
const furnace = makeNode('hvac-equipment', { position: [0, 0, 0] });
|
|
102
|
+
const trunk = run([
|
|
103
|
+
[0, 0, 0],
|
|
104
|
+
[4, 0, 0],
|
|
105
|
+
]);
|
|
106
|
+
const branch = run([
|
|
107
|
+
[4, 0, 0],
|
|
108
|
+
[4, 0, 3],
|
|
109
|
+
]);
|
|
110
|
+
const register = makeNode('duct-terminal', {
|
|
111
|
+
position: [4, 0, 3],
|
|
112
|
+
terminalType: 'supply-register',
|
|
113
|
+
});
|
|
114
|
+
const scene = sceneOf(furnace, trunk, branch, register);
|
|
115
|
+
const summary = summarizeSystemFor(register.id, scene);
|
|
116
|
+
expect(summary.nodeIds.length).toBe(4);
|
|
117
|
+
expect(summary.connectedToEquipment).toBe(true);
|
|
118
|
+
expect(summary.runCount).toBe(2);
|
|
119
|
+
expect(summary.runLengthM).toBeCloseTo(7, 6);
|
|
120
|
+
expect(summary.terminalCount).toBe(1);
|
|
121
|
+
expect(summary.equipmentCount).toBe(1);
|
|
122
|
+
expect(summary.systems).toEqual(['supply']);
|
|
123
|
+
});
|
|
124
|
+
test('orphaned run reports no equipment', () => {
|
|
125
|
+
const lonely = run([
|
|
126
|
+
[10, 0, 10],
|
|
127
|
+
[14, 0, 10],
|
|
128
|
+
]);
|
|
129
|
+
const summary = summarizeSystemFor(lonely.id, sceneOf(lonely));
|
|
130
|
+
expect(summary.connectedToEquipment).toBe(false);
|
|
131
|
+
expect(summary.runCount).toBe(1);
|
|
132
|
+
expect(summary.runLengthM).toBeCloseTo(4, 6);
|
|
133
|
+
});
|
|
134
|
+
test('port-less node → null', () => {
|
|
135
|
+
const wall = makeNode('wall', {});
|
|
136
|
+
expect(summarizeSystemFor(wall.id, sceneOf(wall))).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|