@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
@@ -1,4 +1,4 @@
1
- import { CeilingNode, SlabNode, } from '../schema';
1
+ import { CeilingNode, SlabNode, ZoneNode, } from '../schema';
2
2
  import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control';
3
3
  import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve';
4
4
  import { simplifyClosedPolygon } from './polygon-geometry';
@@ -9,6 +9,16 @@ const ROOM_CURVE_TOLERANCE = 0.04;
9
9
  const MAX_CURVE_SUBDIVISION_DEPTH = 6;
10
10
  const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08;
11
11
  const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08;
12
+ // A wall endpoint within this distance of another wall's interior is treated as a
13
+ // T-junction and splits that wall (see `splitStraightWallAtVertices`).
14
+ const WALL_JUNCTION_TOLERANCE = 0.08;
15
+ // An unmatched auto slab/ceiling whose polygon is still substantially covered
16
+ // by a detected room was absorbed by a room merge — the surviving auto surface
17
+ // owns that area, so keeping it would z-fight and it is deleted. Below this
18
+ // coverage the room genuinely ceased to exist (e.g. an enclosing wall was
19
+ // deleted) and the node is demoted to manual so user data survives.
20
+ const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6;
21
+ const COVERAGE_SAMPLE_STEPS = 12;
12
22
  function pointFromTuple(point) {
13
23
  return { x: point[0], y: point[1] };
14
24
  }
@@ -116,6 +126,46 @@ function bboxOverlapArea(a, b) {
116
126
  const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY));
117
127
  return ix * iy;
118
128
  }
129
+ // Fraction of `subject`'s area lying inside any of `covers`, estimated by
130
+ // sampling a grid of cell centers over the subject's bbox. Cheap and robust
131
+ // enough for the merge-vs-demote decision; exact polygon clipping would be a
132
+ // heavy dependency for a 60% threshold.
133
+ function polygonCoverageRatio(subject, covers) {
134
+ if (subject.length < 3 || covers.length === 0)
135
+ return 0;
136
+ const bbox = bboxOf(subject);
137
+ const width = bbox.maxX - bbox.minX;
138
+ const height = bbox.maxY - bbox.minY;
139
+ let inside = 0;
140
+ let covered = 0;
141
+ for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) {
142
+ for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) {
143
+ const point = {
144
+ x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width,
145
+ y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height,
146
+ };
147
+ if (!pointInPolygon(point, subject))
148
+ continue;
149
+ inside += 1;
150
+ if (pointInAnyPolygon(point, covers))
151
+ covered += 1;
152
+ }
153
+ }
154
+ if (inside === 0) {
155
+ return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0;
156
+ }
157
+ return covered / inside;
158
+ }
159
+ // Demoted auto surfaces keep their polygon untouched, so a re-closed room
160
+ // usually hits the exact-signature manual check. Coverage handles the rest:
161
+ // a room split across multiple manual surfaces AND a single manual surface
162
+ // spanning multiple rooms both suppress a replacement auto surface — what
163
+ // matters is that the ROOM is already substantially covered, not that any
164
+ // one manual surface belongs to it (a per-surface "mostly inside the room"
165
+ // filter dropped multi-room slabs and resurrected deleted auto slabs).
166
+ function matchesManualFootprint(roomPolygon, manualPolygons) {
167
+ return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD;
168
+ }
119
169
  function pointDistanceToPolygonBoundary(point, polygon) {
120
170
  let minDistance = Number.POSITIVE_INFINITY;
121
171
  for (let index = 0; index < polygon.length; index += 1) {
@@ -240,11 +290,52 @@ function sampleWallPointsForRoomDetection(wall, tolerance = ROOM_CURVE_TOLERANCE
240
290
  };
241
291
  return subdivide(0, start, 1, end, 0);
242
292
  }
243
- function getDirectedWallBoundaryPoints(wall, forward) {
244
- const points = sampleWallPointsForRoomDetection(wall);
245
- return forward ? points : [...points].reverse();
293
+ function segmentProjection(point, start, end) {
294
+ const dx = end.x - start.x;
295
+ const dy = end.y - start.y;
296
+ const lengthSquared = dx * dx + dy * dy;
297
+ if (lengthSquared < 1e-12) {
298
+ return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) };
299
+ }
300
+ const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared;
301
+ const clampedT = Math.max(0, Math.min(1, t));
302
+ const projX = start.x + clampedT * dx;
303
+ const projY = start.y + clampedT * dy;
304
+ return { t, distance: Math.hypot(point.x - projX, point.y - projY) };
246
305
  }
247
- function extractRoomPolygons(walls) {
306
+ // Break a straight wall at any junction vertex (another wall's endpoint) that
307
+ // lands on its interior, returning the ordered polyline [start, …splits, end].
308
+ // Splitting at the *vertex* position (not the projection) keeps the split node's
309
+ // key identical to the touching wall's endpoint so the two share a graph node.
310
+ function splitStraightWallAtVertices(start, end, vertices) {
311
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
312
+ if (length < 1e-9)
313
+ return [start, end];
314
+ const interior = [];
315
+ for (const vertex of vertices) {
316
+ const { t, distance } = segmentProjection(vertex, start, end);
317
+ if (distance > WALL_JUNCTION_TOLERANCE)
318
+ continue;
319
+ const along = t * length;
320
+ if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE)
321
+ continue;
322
+ interior.push({ point: vertex, t });
323
+ }
324
+ interior.sort((a, b) => a.t - b.t);
325
+ const ordered = [start];
326
+ let lastKey = pointKey(start);
327
+ for (const { point } of interior) {
328
+ const key = pointKey(point);
329
+ if (key === lastKey)
330
+ continue;
331
+ ordered.push(point);
332
+ lastKey = key;
333
+ }
334
+ if (lastKey !== pointKey(end))
335
+ ordered.push(end);
336
+ return ordered;
337
+ }
338
+ function extractRooms(walls) {
248
339
  if (walls.length < 3)
249
340
  return [];
250
341
  const graph = new Map();
@@ -256,35 +347,71 @@ function extractRoomPolygons(walls) {
256
347
  }
257
348
  return key;
258
349
  };
350
+ // Planarize first: collect every wall endpoint as a candidate graph vertex so
351
+ // straight walls can be split at T-junctions where another wall ends mid-span.
352
+ // Without this the touching wall's endpoint is a dangling degree-1 node and the
353
+ // enclosed area (e.g. a room added against the middle of an existing wall)
354
+ // never forms a cycle.
355
+ const vertexByKey = new Map();
356
+ for (const wall of walls) {
357
+ for (const tuple of [wall.start, wall.end]) {
358
+ const point = pointFromTuple(tuple);
359
+ const key = pointKey(point);
360
+ if (!vertexByKey.has(key))
361
+ vertexByKey.set(key, point);
362
+ }
363
+ }
364
+ const vertices = [...vertexByKey.values()];
259
365
  for (const wall of walls) {
260
366
  const start = pointFromTuple(wall.start);
261
367
  const end = pointFromTuple(wall.end);
262
- const startKey = upsertNode(start);
263
- const endKey = upsertNode(end);
264
- if (startKey === endKey)
368
+ if (samePointWithinTolerance(start, end))
265
369
  continue;
266
- const forwardDirection = getWallDirection(wall);
267
- const reverseDirection = getWallDirection({ start: wall.end, end: wall.start });
268
- const forwardId = `${wall.id}:f`;
269
- const reverseId = `${wall.id}:r`;
270
- halfEdges.set(forwardId, {
271
- id: forwardId,
272
- reverseId,
273
- fromKey: startKey,
274
- toKey: endKey,
275
- angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x),
276
- points: getDirectedWallBoundaryPoints(wall, true),
370
+ // Curved walls keep their sampled polyline as one edge; straight walls split
371
+ // into consecutive sub-edges at their interior junction vertices.
372
+ const subPolylines = isCurvedWall(wall)
373
+ ? [sampleWallPointsForRoomDetection(wall)]
374
+ : (() => {
375
+ const ordered = splitStraightWallAtVertices(start, end, vertices);
376
+ const parts = [];
377
+ for (let index = 0; index < ordered.length - 1; index += 1) {
378
+ parts.push([ordered[index], ordered[index + 1]]);
379
+ }
380
+ return parts;
381
+ })();
382
+ subPolylines.forEach((points, subIndex) => {
383
+ const from = points[0];
384
+ const to = points[points.length - 1];
385
+ const fromKey = upsertNode(from);
386
+ const toKey = upsertNode(to);
387
+ if (fromKey === toKey)
388
+ return;
389
+ const reversePoints = [...points].reverse();
390
+ const forwardId = `${wall.id}#${subIndex}:f`;
391
+ const reverseId = `${wall.id}#${subIndex}:r`;
392
+ halfEdges.set(forwardId, {
393
+ id: forwardId,
394
+ reverseId,
395
+ fromKey,
396
+ toKey,
397
+ angle: Math.atan2(points[1].y - from.y, points[1].x - from.x),
398
+ points,
399
+ wallId: wall.id,
400
+ face: 'front',
401
+ });
402
+ halfEdges.set(reverseId, {
403
+ id: reverseId,
404
+ reverseId: forwardId,
405
+ fromKey: toKey,
406
+ toKey: fromKey,
407
+ angle: Math.atan2(reversePoints[1].y - to.y, reversePoints[1].x - to.x),
408
+ points: reversePoints,
409
+ wallId: wall.id,
410
+ face: 'back',
411
+ });
412
+ graph.get(fromKey)?.outgoing.push(forwardId);
413
+ graph.get(toKey)?.outgoing.push(reverseId);
277
414
  });
278
- halfEdges.set(reverseId, {
279
- id: reverseId,
280
- reverseId: forwardId,
281
- fromKey: endKey,
282
- toKey: startKey,
283
- angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x),
284
- points: getDirectedWallBoundaryPoints(wall, false),
285
- });
286
- graph.get(startKey)?.outgoing.push(forwardId);
287
- graph.get(endKey)?.outgoing.push(reverseId);
288
415
  }
289
416
  const sortedOutgoing = new Map();
290
417
  for (const [key, node] of graph.entries()) {
@@ -305,15 +432,49 @@ function extractRoomPolygons(walls) {
305
432
  const nextIdx = (idx - 1 + outgoing.length) % outgoing.length;
306
433
  return outgoing[nextIdx] ?? null;
307
434
  };
435
+ const splitIntoSimpleCycles = (walkEdgeIds) => {
436
+ const cycles = [];
437
+ const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '');
438
+ if (!firstEdge)
439
+ return cycles;
440
+ const pathEdges = [];
441
+ const pathVertices = [firstEdge.fromKey];
442
+ const vertexIndex = new Map([[firstEdge.fromKey, 0]]);
443
+ for (const edgeId of walkEdgeIds) {
444
+ const edge = halfEdges.get(edgeId);
445
+ if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1])
446
+ return [];
447
+ pathEdges.push(edgeId);
448
+ const repeatedIndex = vertexIndex.get(edge.toKey);
449
+ if (repeatedIndex === undefined) {
450
+ pathVertices.push(edge.toKey);
451
+ vertexIndex.set(edge.toKey, pathVertices.length - 1);
452
+ continue;
453
+ }
454
+ const cycle = pathEdges.slice(repeatedIndex);
455
+ if (cycle.length >= 3)
456
+ cycles.push(cycle);
457
+ for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) {
458
+ vertexIndex.delete(pathVertices[index]);
459
+ }
460
+ pathVertices.length = repeatedIndex + 1;
461
+ pathEdges.length = repeatedIndex;
462
+ }
463
+ return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [];
464
+ };
308
465
  const visitedDirected = new Set();
309
- const faces = [];
310
- const maxSteps = Math.min(500, walls.length * 8 + 20);
466
+ const rooms = [];
467
+ // A face walk cannot revisit a half-edge, so the half-edge count bounds its
468
+ // length. It can revisit a vertex when dangling walls or other graph bridges
469
+ // are traced out and back; those excursions are removed below.
470
+ const maxSteps = Math.min(2000, halfEdges.size + 10);
311
471
  for (const edgeId of halfEdges.keys()) {
312
472
  if (visitedDirected.has(edgeId))
313
473
  continue;
314
474
  const cycleEdgeIds = [];
315
475
  let currentEdgeId = edgeId;
316
476
  let valid = true;
477
+ let closed = false;
317
478
  for (let step = 0; step < maxSteps; step += 1) {
318
479
  const currentEdge = halfEdges.get(currentEdgeId);
319
480
  if (!currentEdge) {
@@ -328,29 +489,64 @@ function extractRoomPolygons(walls) {
328
489
  break;
329
490
  }
330
491
  currentEdgeId = next;
331
- if (currentEdgeId === edgeId)
492
+ if (currentEdgeId === edgeId) {
493
+ closed = true;
332
494
  break;
495
+ }
333
496
  }
334
- if (!valid || cycleEdgeIds.length < 3)
497
+ if (!(valid && closed) || cycleEdgeIds.length < 3)
335
498
  continue;
336
- const polygon = dedupeSequentialPoints(cycleEdgeIds.flatMap((id, index) => {
337
- const points = halfEdges.get(id)?.points ?? [];
338
- return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1);
339
- }));
340
- if (polygon.length < 3)
341
- continue;
342
- const signedArea = polygonArea(polygon);
343
- if (signedArea <= 0)
344
- continue;
345
- if (signedArea < 0.5 || signedArea > 10_000)
346
- continue;
347
- const signature = polygonSignature(polygon);
348
- if (faces.some((face) => polygonSignature(face) === signature))
349
- continue;
350
- faces.push(polygon);
499
+ for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) {
500
+ const polygon = dedupeSequentialPoints(simpleCycleEdgeIds.flatMap((id, index) => {
501
+ const points = halfEdges.get(id)?.points ?? [];
502
+ return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1);
503
+ }));
504
+ if (polygon.length < 3)
505
+ continue;
506
+ const signedArea = polygonArea(polygon);
507
+ if (signedArea <= 0)
508
+ continue;
509
+ if (signedArea < 0.5 || signedArea > 10_000)
510
+ continue;
511
+ const signature = polygonSignature(polygon);
512
+ if (rooms.some((room) => polygonSignature(room.polygon) === signature))
513
+ continue;
514
+ rooms.push({
515
+ polygon,
516
+ boundaryFaces: simpleCycleEdgeIds.flatMap((id) => {
517
+ const edge = halfEdges.get(id);
518
+ if (!edge)
519
+ return [];
520
+ return [
521
+ {
522
+ wallId: edge.wallId,
523
+ face: edge.face,
524
+ points: edge.points.map(pointToTuple),
525
+ },
526
+ ];
527
+ }),
528
+ });
529
+ }
351
530
  }
352
- faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)));
353
- return faces;
531
+ rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)));
532
+ return rooms;
533
+ }
534
+ function extractRoomPolygons(walls) {
535
+ return extractRooms(walls).map((room) => room.polygon);
536
+ }
537
+ /**
538
+ * True when `wall` lies on the boundary of a room enclosed by `walls`, using the
539
+ * same planar room graph the auto slab/ceiling sync uses. The wall builder's
540
+ * "Room (auto-close)" mode calls this so drafting stops the moment a segment
541
+ * closes a room — whether the chain loops back to its own start or seals a bay
542
+ * against the middle of an existing wall (a T-junction). Sharing one graph means
543
+ * auto-close and auto-slab detection can never disagree about what is "closed".
544
+ */
545
+ export function wallClosesRoom(walls, wall) {
546
+ const roomPolygons = extractRoomPolygons(walls);
547
+ if (roomPolygons.length === 0)
548
+ return false;
549
+ return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon));
354
550
  }
355
551
  export function resolveWallSurfaceSides(wall, roomPolygons) {
356
552
  if (roomPolygons.length === 0) {
@@ -423,55 +619,104 @@ function wallGeometrySignature(wall) {
423
619
  function levelWallSnapshot(walls) {
424
620
  return walls.map(wallGeometrySignature).sort().join('||');
425
621
  }
426
- function slabGeometrySignature(slab) {
427
- const polygon = slab.polygon
428
- .map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
429
- .join(';');
430
- const holes = (slab.holes ?? [])
431
- .map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
432
- .join('/');
433
- return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join('|');
434
- }
435
- function levelSlabSnapshot(slabs) {
436
- return slabs.map(slabGeometrySignature).sort().join('||');
622
+ function zoneGeometrySignature(zone) {
623
+ return [
624
+ zone.id,
625
+ zone.autoFromWalls ? 'auto' : 'manual',
626
+ zone.boundaryWallIds.slice().sort().join(','),
627
+ zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'),
628
+ ].join('|');
437
629
  }
630
+ // Slabs and ceilings stay out of the trigger signature: including generated
631
+ // surfaces caused delete/recreate feedback. Zones are included only so a newly
632
+ // traced room footprint can adopt its enclosing walls without waiting for the
633
+ // next remodel.
438
634
  function levelStructureSnapshots(nodes) {
439
- const byLevel = new Map();
440
- const getEntry = (levelId) => {
441
- const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] };
442
- byLevel.set(levelId, entry);
443
- return entry;
444
- };
635
+ const wallsByLevel = new Map();
636
+ const zonesByLevel = new Map();
445
637
  for (const node of Object.values(nodes)) {
446
638
  if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId))
447
639
  continue;
640
+ const levelId = node.parentId;
448
641
  if (node.type === 'wall') {
449
- getEntry(node.parentId).walls.push(node);
642
+ const walls = wallsByLevel.get(levelId) ?? [];
643
+ walls.push(node);
644
+ wallsByLevel.set(levelId, walls);
450
645
  }
451
- else if (node.type === 'slab') {
452
- getEntry(node.parentId).slabs.push(SlabNode.parse(node));
646
+ else if (node.type === 'zone') {
647
+ const zones = zonesByLevel.get(levelId) ?? [];
648
+ zones.push(ZoneNode.parse(node));
649
+ zonesByLevel.set(levelId, zones);
453
650
  }
454
651
  }
455
652
  const snapshots = new Map();
456
- for (const [levelId, entry] of byLevel.entries()) {
457
- snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`);
653
+ const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]);
654
+ for (const levelId of levelIds) {
655
+ const walls = wallsByLevel.get(levelId) ?? [];
656
+ const zones = zonesByLevel.get(levelId) ?? [];
657
+ snapshots.set(levelId, `${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`);
458
658
  }
459
659
  return snapshots;
460
660
  }
461
- function buildSpace(levelId, polygon) {
462
- const signature = polygonSignature(polygon);
661
+ function buildSpace(levelId, room) {
662
+ const signature = polygonSignature(room.polygon);
463
663
  return {
464
664
  id: `space-${levelId}-${signature.slice(0, 12)}`,
465
665
  levelId,
466
- polygon: polygon.map(pointToTuple),
467
- wallIds: [],
666
+ polygon: room.polygon.map(pointToTuple),
667
+ wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
668
+ boundaryFaces: room.boundaryFaces,
468
669
  isExterior: false,
469
670
  };
470
671
  }
672
+ function sameStringSet(a, b) {
673
+ if (a.length !== b.length)
674
+ return false;
675
+ const right = new Set(b);
676
+ return a.every((value) => right.has(value));
677
+ }
678
+ export function planAutoZonesForLevel(spaces, existingZones) {
679
+ const update = [];
680
+ for (const zone of existingZones) {
681
+ const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
682
+ const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
683
+ ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
684
+ : spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
685
+ if (!matchingSpace)
686
+ continue;
687
+ const data = {};
688
+ if (!zone.autoFromWalls)
689
+ data.autoFromWalls = true;
690
+ if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
691
+ data.boundaryWallIds = matchingSpace.wallIds;
692
+ }
693
+ if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
694
+ data.polygon = matchingSpace.polygon;
695
+ }
696
+ if (Object.keys(data).length > 0)
697
+ update.push({ id: zone.id, data });
698
+ }
699
+ return { update };
700
+ }
701
+ export function resolveAutoZonePolygon(zone, resolve) {
702
+ if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
703
+ return zone.polygon;
704
+ const walls = zone.boundaryWallIds.flatMap((id) => {
705
+ const node = resolve(id);
706
+ return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
707
+ ? [node]
708
+ : [];
709
+ });
710
+ if (walls.length !== zone.boundaryWallIds.length)
711
+ return zone.polygon;
712
+ const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
713
+ return room ? room.polygon.map(pointToTuple) : zone.polygon;
714
+ }
471
715
  export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
472
716
  const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
473
717
  const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
474
- const detected = roomPolygons
718
+ const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple));
719
+ const detectedAll = roomPolygons
475
720
  .map((poly) => ({
476
721
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
477
722
  sig: '',
@@ -485,8 +730,8 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
485
730
  centroid: polygonCentroid(room.poly),
486
731
  area: Math.abs(polygonArea(room.poly)),
487
732
  bbox: bboxOf(room.poly),
488
- }))
489
- .filter(({ sig }) => !manualSignatures.has(sig));
733
+ }));
734
+ const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
490
735
  const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
491
736
  const existingAutoMeta = existingAuto.map((slab) => {
492
737
  const poly = slab.polygon.map(pointFromTuple);
@@ -503,10 +748,12 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
503
748
  const updatesById = new Map();
504
749
  const autoBySignature = new Map();
505
750
  for (const entry of existingAutoMeta) {
506
- autoBySignature.set(entry.sig, entry);
751
+ const bucket = autoBySignature.get(entry.sig) ?? [];
752
+ bucket.push(entry);
753
+ autoBySignature.set(entry.sig, bucket);
507
754
  }
508
755
  detected.forEach((room, index) => {
509
- const existing = autoBySignature.get(room.sig);
756
+ const existing = autoBySignature.get(room.sig)?.shift();
510
757
  if (!existing)
511
758
  return;
512
759
  matchedDetectedIdx.add(index);
@@ -542,17 +789,33 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
542
789
  matchedSlabIds.add(bestMatch.entry.slab.id);
543
790
  updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple));
544
791
  }
545
- const slabsToDelete = existingAuto
546
- .filter((slab) => !updatesById.has(slab.id))
547
- .map((slab) => slab.id);
548
- const slabsToUpdate = existingAuto
549
- .filter((slab) => updatesById.has(slab.id))
550
- .flatMap((slab) => {
551
- const polygon = updatesById.get(slab.id);
552
- if (!polygon)
553
- return [];
554
- return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }];
555
- });
792
+ const detectedRoomPolygons = detectedAll.map((room) => room.poly);
793
+ const slabsToDelete = [];
794
+ const slabDemotions = [];
795
+ for (const slab of existingAuto) {
796
+ if (updatesById.has(slab.id))
797
+ continue;
798
+ const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons);
799
+ if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
800
+ slabsToDelete.push(slab.id);
801
+ }
802
+ else {
803
+ // Render offsets derive from level context at geometry build time, so
804
+ // demotion leaves the stored polygon untouched (same as ceilings).
805
+ slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } });
806
+ }
807
+ }
808
+ const slabsToUpdate = [
809
+ ...existingAuto
810
+ .filter((slab) => updatesById.has(slab.id))
811
+ .flatMap((slab) => {
812
+ const polygon = updatesById.get(slab.id);
813
+ if (!polygon)
814
+ return [];
815
+ return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }];
816
+ }),
817
+ ...slabDemotions,
818
+ ];
556
819
  const plannedSlabsForNaming = [...existingSlabs];
557
820
  const slabsToCreate = [];
558
821
  for (let index = 0; index < detected.length; index += 1) {
@@ -609,7 +872,8 @@ export function projectAutoSlabsForPlan(existingSlabs, plan) {
609
872
  export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}) {
610
873
  const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
611
874
  const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
612
- const detected = roomPolygons
875
+ const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple));
876
+ const detectedAll = roomPolygons
613
877
  .map((poly) => ({
614
878
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
615
879
  sig: '',
@@ -624,8 +888,8 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
624
888
  area: Math.abs(polygonArea(room.poly)),
625
889
  bbox: bboxOf(room.poly),
626
890
  ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
627
- }))
628
- .filter(({ sig }) => !manualSignatures.has(sig));
891
+ }));
892
+ const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
629
893
  const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
630
894
  const existingAutoMeta = existingAuto.map((ceiling) => {
631
895
  const poly = ceiling.polygon.map(pointFromTuple);
@@ -642,10 +906,12 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
642
906
  const updatesById = new Map();
643
907
  const autoBySignature = new Map();
644
908
  for (const entry of existingAutoMeta) {
645
- autoBySignature.set(entry.sig, entry);
909
+ const bucket = autoBySignature.get(entry.sig) ?? [];
910
+ bucket.push(entry);
911
+ autoBySignature.set(entry.sig, bucket);
646
912
  }
647
913
  detected.forEach((room, index) => {
648
- const existing = autoBySignature.get(room.sig);
914
+ const existing = autoBySignature.get(room.sig)?.shift();
649
915
  if (!existing)
650
916
  return;
651
917
  matchedDetectedIdx.add(index);
@@ -687,25 +953,39 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
687
953
  height: room.ceilingHeight,
688
954
  });
689
955
  }
690
- const ceilingsToDelete = existingAuto
691
- .filter((ceiling) => !updatesById.has(ceiling.id))
692
- .map((ceiling) => ceiling.id);
693
- const ceilingsToUpdate = existingAuto
694
- .filter((ceiling) => updatesById.has(ceiling.id))
695
- .flatMap((ceiling) => {
696
- const update = updatesById.get(ceiling.id);
697
- if (!update)
698
- return [];
699
- const data = {};
700
- if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
701
- data.polygon = update.polygon;
956
+ const detectedRoomPolygons = detectedAll.map((room) => room.poly);
957
+ const ceilingsToDelete = [];
958
+ const ceilingDemotions = [];
959
+ for (const ceiling of existingAuto) {
960
+ if (updatesById.has(ceiling.id))
961
+ continue;
962
+ const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons);
963
+ if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
964
+ ceilingsToDelete.push(ceiling.id);
702
965
  }
703
- if (Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
704
- CEILING_HEIGHT_EPSILON) {
705
- data.height = update.height;
966
+ else {
967
+ ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } });
706
968
  }
707
- return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }];
708
- });
969
+ }
970
+ const ceilingsToUpdate = [
971
+ ...existingAuto
972
+ .filter((ceiling) => updatesById.has(ceiling.id))
973
+ .flatMap((ceiling) => {
974
+ const update = updatesById.get(ceiling.id);
975
+ if (!update)
976
+ return [];
977
+ const data = {};
978
+ if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
979
+ data.polygon = update.polygon;
980
+ }
981
+ if (Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
982
+ CEILING_HEIGHT_EPSILON) {
983
+ data.height = update.height;
984
+ }
985
+ return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }];
986
+ }),
987
+ ...ceilingDemotions,
988
+ ];
709
989
  const plannedCeilingsForNaming = [...existingCeilings];
710
990
  const ceilingsToCreate = [];
711
991
  for (let index = 0; index < detected.length; index += 1) {
@@ -743,14 +1023,15 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
743
1023
  }
744
1024
  }
745
1025
  function detectSpacesFromWalls(levelId, walls) {
746
- const roomPolygons = extractRoomPolygons(walls);
1026
+ const rooms = extractRooms(walls);
1027
+ const roomPolygons = rooms.map((room) => room.polygon);
747
1028
  const wallUpdates = walls.map((wall) => ({
748
1029
  wallId: wall.id,
749
1030
  ...resolveWallSurfaceSides(wall, roomPolygons),
750
1031
  }));
751
1032
  return {
752
1033
  roomPolygons,
753
- spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
1034
+ spaces: rooms.map((room) => buildSpace(levelId, room)),
754
1035
  wallUpdates,
755
1036
  };
756
1037
  }
@@ -770,6 +1051,7 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
770
1051
  const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
771
1052
  const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
772
1053
  const ceilings = Object.values(nodes).filter((node) => node?.type === 'ceiling' && node.parentId === levelId);
1054
+ const zones = Object.values(nodes).filter((node) => node?.type === 'zone' && node.parentId === levelId);
773
1055
  const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
774
1056
  const changedWallUpdates = wallUpdates.filter((update) => {
775
1057
  const wall = nodes[update.wallId];
@@ -788,6 +1070,9 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
788
1070
  const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore);
789
1071
  const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan);
790
1072
  syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore, { walls, slabs: projectedSlabs });
1073
+ const zonePlan = planAutoZonesForLevel(spaces, zones.map((zone) => ZoneNode.parse(zone)));
1074
+ if (zonePlan.update.length > 0)
1075
+ updateNodes(zonePlan.update);
791
1076
  for (const space of spaces) {
792
1077
  nextSpaces[space.id] = space;
793
1078
  }
@@ -815,7 +1100,11 @@ export function isSpaceDetectionPaused() {
815
1100
  return spaceDetectionPauseDepth > 0;
816
1101
  }
817
1102
  export function initSpaceDetectionSync(sceneStore, editorStore) {
818
- const previousSnapshots = new Map();
1103
+ // Baseline from whatever is already in the store. Detection reacts to wall
1104
+ // edits made IN-SESSION (create / move / delete); it must not re-litigate a
1105
+ // scene that merely loaded — rerunning on hydration resurrected auto slabs
1106
+ // the user had deleted in an earlier session.
1107
+ const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
819
1108
  let isProcessing = false;
820
1109
  const unsubscribe = sceneStore.subscribe((state) => {
821
1110
  if (isProcessing)
@@ -836,7 +1125,14 @@ export function initSpaceDetectionSync(sceneStore, editorStore) {
836
1125
  }
837
1126
  const levelsToUpdate = new Set();
838
1127
  for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
839
- if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {
1128
+ // First sight of a level is a hydration baseline, not a wall edit —
1129
+ // `setScene` delivers a loaded scene as one atomic update, and a level's
1130
+ // first wall can't close a room anyway. Record it (below) and only
1131
+ // react to subsequent changes.
1132
+ const previous = previousSnapshots.get(levelId);
1133
+ if (previous === undefined)
1134
+ continue;
1135
+ if (previous !== (currentSnapshots.get(levelId) ?? '')) {
840
1136
  levelsToUpdate.add(levelId);
841
1137
  }
842
1138
  }