@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.
Files changed (321) hide show
  1. package/dist/events/bus.d.ts +30 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
  4. package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
  5. package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
  6. package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
  7. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  8. package/dist/hooks/scene-registry/scene-registry.js +27 -1
  9. package/dist/hooks/scene-registry/scene-registry.test.d.ts +2 -0
  10. package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +1 -0
  11. package/dist/hooks/scene-registry/scene-registry.test.js +26 -0
  12. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +93 -0
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +69 -12
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  15. package/dist/hooks/spatial-grid/spatial-grid-manager.js +476 -195
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.js +19 -1
  18. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
  19. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
  20. package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
  21. package/dist/index.d.ts +16 -6
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +15 -5
  24. package/dist/lib/level-name.d.ts +4 -0
  25. package/dist/lib/level-name.d.ts.map +1 -0
  26. package/dist/lib/level-name.js +10 -0
  27. package/dist/lib/measurement-geometry.d.ts +19 -0
  28. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  29. package/dist/lib/measurement-geometry.js +232 -0
  30. package/dist/lib/measurement-geometry.test.d.ts +2 -0
  31. package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
  32. package/dist/lib/measurement-geometry.test.js +102 -0
  33. package/dist/lib/polygon-geometry.d.ts +0 -1
  34. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  35. package/dist/lib/polygon-geometry.js +0 -18
  36. package/dist/lib/selection-proxy.d.ts +4 -0
  37. package/dist/lib/selection-proxy.d.ts.map +1 -0
  38. package/dist/lib/selection-proxy.js +12 -0
  39. package/dist/lib/slab-polygon.d.ts +31 -2
  40. package/dist/lib/slab-polygon.d.ts.map +1 -1
  41. package/dist/lib/slab-polygon.js +525 -31
  42. package/dist/lib/slab-polygon.test.d.ts +2 -0
  43. package/dist/lib/slab-polygon.test.d.ts.map +1 -0
  44. package/dist/lib/slab-polygon.test.js +635 -0
  45. package/dist/lib/slots.d.ts +14 -0
  46. package/dist/lib/slots.d.ts.map +1 -0
  47. package/dist/lib/slots.js +26 -0
  48. package/dist/lib/space-detection.d.ts +25 -2
  49. package/dist/lib/space-detection.d.ts.map +1 -1
  50. package/dist/lib/space-detection.js +414 -118
  51. package/dist/lib/space-detection.test.js +301 -2
  52. package/dist/lib/wall-distance.d.ts +60 -0
  53. package/dist/lib/wall-distance.d.ts.map +1 -0
  54. package/dist/lib/wall-distance.js +85 -0
  55. package/dist/lib/zone-quantities.d.ts +21 -0
  56. package/dist/lib/zone-quantities.d.ts.map +1 -0
  57. package/dist/lib/zone-quantities.js +183 -0
  58. package/dist/lib/zone-quantities.test.d.ts +2 -0
  59. package/dist/lib/zone-quantities.test.d.ts.map +1 -0
  60. package/dist/lib/zone-quantities.test.js +82 -0
  61. package/dist/material-library.d.ts +20 -1
  62. package/dist/material-library.d.ts.map +1 -1
  63. package/dist/material-library.js +2181 -441
  64. package/dist/registry/handles.d.ts +75 -2
  65. package/dist/registry/handles.d.ts.map +1 -1
  66. package/dist/registry/index.d.ts +3 -3
  67. package/dist/registry/index.d.ts.map +1 -1
  68. package/dist/registry/index.js +1 -1
  69. package/dist/registry/registry.d.ts +46 -3
  70. package/dist/registry/registry.d.ts.map +1 -1
  71. package/dist/registry/registry.js +82 -1
  72. package/dist/registry/registry.test.js +14 -1
  73. package/dist/registry/subtree.d.ts.map +1 -1
  74. package/dist/registry/subtree.js +4 -0
  75. package/dist/registry/subtree.test.js +30 -0
  76. package/dist/registry/types.d.ts +717 -38
  77. package/dist/registry/types.d.ts.map +1 -1
  78. package/dist/schema/index.d.ts +21 -5
  79. package/dist/schema/index.d.ts.map +1 -1
  80. package/dist/schema/index.js +17 -3
  81. package/dist/schema/material.d.ts +3 -2
  82. package/dist/schema/material.d.ts.map +1 -1
  83. package/dist/schema/material.js +7 -5
  84. package/dist/schema/material.test.d.ts +2 -0
  85. package/dist/schema/material.test.d.ts.map +1 -0
  86. package/dist/schema/material.test.js +43 -0
  87. package/dist/schema/nodes/box-vent.d.ts +2 -2
  88. package/dist/schema/nodes/cabinet.d.ts +534 -0
  89. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  90. package/dist/schema/nodes/cabinet.js +137 -0
  91. package/dist/schema/nodes/ceiling.d.ts +3 -2
  92. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  93. package/dist/schema/nodes/ceiling.js +4 -0
  94. package/dist/schema/nodes/chimney.d.ts +4 -4
  95. package/dist/schema/nodes/column.d.ts +3 -2
  96. package/dist/schema/nodes/column.d.ts.map +1 -1
  97. package/dist/schema/nodes/column.js +3 -0
  98. package/dist/schema/nodes/cupola.d.ts +2 -2
  99. package/dist/schema/nodes/door.d.ts +13 -5
  100. package/dist/schema/nodes/door.d.ts.map +1 -1
  101. package/dist/schema/nodes/door.js +12 -0
  102. package/dist/schema/nodes/dormer.d.ts +8 -8
  103. package/dist/schema/nodes/dormer.js +1 -1
  104. package/dist/schema/nodes/downspout.d.ts +2 -2
  105. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  106. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  107. package/dist/schema/nodes/duct-fitting.js +92 -0
  108. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  109. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  110. package/dist/schema/nodes/duct-segment.js +73 -0
  111. package/dist/schema/nodes/duct-terminal.d.ts +57 -0
  112. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  113. package/dist/schema/nodes/duct-terminal.js +51 -0
  114. package/dist/schema/nodes/elevator.d.ts +3 -2
  115. package/dist/schema/nodes/elevator.d.ts.map +1 -1
  116. package/dist/schema/nodes/elevator.js +3 -0
  117. package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
  118. package/dist/schema/nodes/fence.d.ts +18 -2
  119. package/dist/schema/nodes/fence.d.ts.map +1 -1
  120. package/dist/schema/nodes/fence.js +25 -2
  121. package/dist/schema/nodes/gutter.d.ts +2 -2
  122. package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
  123. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  124. package/dist/schema/nodes/hvac-equipment.js +55 -0
  125. package/dist/schema/nodes/item.d.ts +8 -0
  126. package/dist/schema/nodes/item.d.ts.map +1 -1
  127. package/dist/schema/nodes/item.js +11 -0
  128. package/dist/schema/nodes/level.d.ts +1 -1
  129. package/dist/schema/nodes/level.d.ts.map +1 -1
  130. package/dist/schema/nodes/level.js +2 -0
  131. package/dist/schema/nodes/lineset.d.ts +42 -0
  132. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  133. package/dist/schema/nodes/lineset.js +38 -0
  134. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  135. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  136. package/dist/schema/nodes/liquid-line.js +24 -0
  137. package/dist/schema/nodes/measurement.d.ts +306 -0
  138. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  139. package/dist/schema/nodes/measurement.js +87 -0
  140. package/dist/schema/nodes/measurement.test.d.ts +2 -0
  141. package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
  142. package/dist/schema/nodes/measurement.test.js +139 -0
  143. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  144. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  145. package/dist/schema/nodes/pipe-fitting.js +45 -0
  146. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  147. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  148. package/dist/schema/nodes/pipe-segment.js +36 -0
  149. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  150. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  151. package/dist/schema/nodes/pipe-trap.js +36 -0
  152. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
  153. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
  154. package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
  155. package/dist/schema/nodes/ridge-vent.d.ts +18 -3
  156. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  157. package/dist/schema/nodes/ridge-vent.js +221 -3
  158. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  159. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  160. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  161. package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
  162. package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
  163. package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
  164. package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
  165. package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
  166. package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
  167. package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
  168. package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
  169. package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
  170. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  171. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  172. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  173. package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
  174. package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
  175. package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
  176. package/dist/schema/nodes/roof-segment.d.ts +84 -8
  177. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  178. package/dist/schema/nodes/roof-segment.js +290 -5
  179. package/dist/schema/nodes/roof.d.ts +8 -8
  180. package/dist/schema/nodes/shelf.d.ts +3 -2
  181. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  182. package/dist/schema/nodes/shelf.js +6 -0
  183. package/dist/schema/nodes/skylight.d.ts +10 -10
  184. package/dist/schema/nodes/slab.d.ts +3 -2
  185. package/dist/schema/nodes/slab.d.ts.map +1 -1
  186. package/dist/schema/nodes/slab.js +4 -0
  187. package/dist/schema/nodes/solar-panel.d.ts +4 -4
  188. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  189. package/dist/schema/nodes/stair.d.ts +13 -12
  190. package/dist/schema/nodes/stair.d.ts.map +1 -1
  191. package/dist/schema/nodes/stair.js +3 -0
  192. package/dist/schema/nodes/turbine-vent.d.ts +2 -2
  193. package/dist/schema/nodes/wall.d.ts +245 -6
  194. package/dist/schema/nodes/wall.d.ts.map +1 -1
  195. package/dist/schema/nodes/wall.js +257 -0
  196. package/dist/schema/nodes/wall.test.d.ts +2 -0
  197. package/dist/schema/nodes/wall.test.d.ts.map +1 -0
  198. package/dist/schema/nodes/wall.test.js +208 -0
  199. package/dist/schema/nodes/window.d.ts +11 -3
  200. package/dist/schema/nodes/window.d.ts.map +1 -1
  201. package/dist/schema/nodes/window.js +12 -0
  202. package/dist/schema/nodes/zone.d.ts +2 -0
  203. package/dist/schema/nodes/zone.d.ts.map +1 -1
  204. package/dist/schema/nodes/zone.js +6 -0
  205. package/dist/schema/scene-material.d.ts +41 -0
  206. package/dist/schema/scene-material.d.ts.map +1 -0
  207. package/dist/schema/scene-material.js +9 -0
  208. package/dist/schema/types.d.ts +1089 -94
  209. package/dist/schema/types.d.ts.map +1 -1
  210. package/dist/schema/types.js +23 -0
  211. package/dist/services/alignment-anchors.d.ts.map +1 -1
  212. package/dist/services/alignment-anchors.js +30 -1
  213. package/dist/services/alignment.d.ts +5 -0
  214. package/dist/services/alignment.d.ts.map +1 -1
  215. package/dist/services/alignment.js +2 -0
  216. package/dist/services/hosting.d.ts +8 -0
  217. package/dist/services/hosting.d.ts.map +1 -1
  218. package/dist/services/hosting.js +13 -4
  219. package/dist/services/hosting.test.js +32 -1
  220. package/dist/services/index.d.ts +8 -2
  221. package/dist/services/index.d.ts.map +1 -1
  222. package/dist/services/index.js +8 -2
  223. package/dist/services/level-height.d.ts +28 -0
  224. package/dist/services/level-height.d.ts.map +1 -0
  225. package/dist/services/level-height.js +62 -0
  226. package/dist/services/opening-guides.d.ts +141 -0
  227. package/dist/services/opening-guides.d.ts.map +1 -0
  228. package/dist/services/opening-guides.js +267 -0
  229. package/dist/services/opening-guides.test.d.ts +2 -0
  230. package/dist/services/opening-guides.test.d.ts.map +1 -0
  231. package/dist/services/opening-guides.test.js +208 -0
  232. package/dist/services/port-connectivity.d.ts +115 -0
  233. package/dist/services/port-connectivity.d.ts.map +1 -0
  234. package/dist/services/port-connectivity.js +300 -0
  235. package/dist/services/port-connectivity.test.d.ts +2 -0
  236. package/dist/services/port-connectivity.test.d.ts.map +1 -0
  237. package/dist/services/port-connectivity.test.js +297 -0
  238. package/dist/services/riser-diagram.d.ts +40 -0
  239. package/dist/services/riser-diagram.d.ts.map +1 -0
  240. package/dist/services/riser-diagram.js +110 -0
  241. package/dist/services/riser-diagram.test.d.ts +2 -0
  242. package/dist/services/riser-diagram.test.d.ts.map +1 -0
  243. package/dist/services/riser-diagram.test.js +66 -0
  244. package/dist/services/snap.d.ts +9 -1
  245. package/dist/services/snap.d.ts.map +1 -1
  246. package/dist/services/snap.js +21 -1
  247. package/dist/services/snap.test.js +43 -1
  248. package/dist/services/system-graph.d.ts +30 -0
  249. package/dist/services/system-graph.d.ts.map +1 -0
  250. package/dist/services/system-graph.js +164 -0
  251. package/dist/services/system-graph.test.d.ts +2 -0
  252. package/dist/services/system-graph.test.d.ts.map +1 -0
  253. package/dist/services/system-graph.test.js +138 -0
  254. package/dist/services/validate-dwv.d.ts +32 -0
  255. package/dist/services/validate-dwv.d.ts.map +1 -0
  256. package/dist/services/validate-dwv.js +118 -0
  257. package/dist/services/validate-dwv.test.d.ts +2 -0
  258. package/dist/services/validate-dwv.test.d.ts.map +1 -0
  259. package/dist/services/validate-dwv.test.js +109 -0
  260. package/dist/store/actions/node-actions.d.ts.map +1 -1
  261. package/dist/store/actions/node-actions.js +94 -1
  262. package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
  263. package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
  264. package/dist/store/actions/ridge-vent-update.test.js +264 -0
  265. package/dist/store/history-control.d.ts +11 -0
  266. package/dist/store/history-control.d.ts.map +1 -1
  267. package/dist/store/history-control.js +33 -0
  268. package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
  269. package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
  270. package/dist/store/use-scene-dirty-tracking.test.js +68 -0
  271. package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
  272. package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
  273. package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
  274. package/dist/store/use-scene-plugins.test.d.ts +2 -0
  275. package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
  276. package/dist/store/use-scene-plugins.test.js +51 -0
  277. package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
  278. package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
  279. package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
  280. package/dist/store/use-scene-window-migration.test.d.ts +2 -0
  281. package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
  282. package/dist/store/use-scene-window-migration.test.js +85 -0
  283. package/dist/store/use-scene.d.ts +17 -2
  284. package/dist/store/use-scene.d.ts.map +1 -1
  285. package/dist/store/use-scene.js +358 -51
  286. package/dist/systems/elevator/elevator-service.js +1 -1
  287. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  288. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  289. package/dist/systems/fence/fence-centerline.js +37 -0
  290. package/dist/systems/fence/fence-spline.d.ts +39 -0
  291. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  292. package/dist/systems/fence/fence-spline.js +196 -0
  293. package/dist/systems/fence/fence-spline.test.d.ts +2 -0
  294. package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
  295. package/dist/systems/fence/fence-spline.test.js +136 -0
  296. package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
  297. package/dist/systems/stair/stair-footprint.js +21 -11
  298. package/dist/systems/stair/stair-opening-preview.d.ts +1139 -469
  299. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  300. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  301. package/dist/systems/stair/stair-opening-sync.js +1 -18
  302. package/dist/systems/stair/stair-opening-sync.test.js +39 -0
  303. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  304. package/dist/systems/wall/wall-mitering.js +38 -2
  305. package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
  306. package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
  307. package/dist/systems/wall/wall-mitering.test.js +63 -0
  308. package/dist/utils/clone-scene-graph.d.ts +1 -0
  309. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  310. package/dist/utils/clone-scene-graph.js +23 -3
  311. package/dist/utils/clone-scene-graph.test.js +3 -0
  312. package/dist/utils/heal-scene-graph.d.ts +18 -0
  313. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  314. package/dist/utils/heal-scene-graph.js +90 -0
  315. package/dist/utils/heal-scene-graph.test.d.ts +2 -0
  316. package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
  317. package/dist/utils/heal-scene-graph.test.js +88 -0
  318. package/dist/validation/validate-build-json.d.ts +1 -0
  319. package/dist/validation/validate-build-json.d.ts.map +1 -1
  320. package/dist/validation/validate-build-json.js +31 -2
  321. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=riser-diagram.test.d.ts.map
@@ -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
+ });
@@ -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°. Wall tools also use π/4 (45°). */
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,2EAA2E;AAC3E,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;;;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"}
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"}
@@ -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°. Wall tools also use π/4 (45°). */
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=system-graph.test.d.ts.map
@@ -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
+ });