@pascal-app/core 0.8.0 → 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 (471) hide show
  1. package/dist/events/bus.d.ts +67 -13
  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 +14 -23
  7. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  8. package/dist/hooks/scene-registry/scene-registry.js +59 -26
  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.d.ts +14 -0
  13. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -0
  14. package/dist/hooks/spatial-grid/floor-placed-elevation.js +51 -0
  15. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +2 -0
  16. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +1 -0
  17. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +366 -0
  18. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +79 -11
  19. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  20. package/dist/hooks/spatial-grid/spatial-grid-manager.js +508 -141
  21. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +28 -1
  22. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  23. package/dist/hooks/spatial-grid/spatial-grid-sync.js +109 -18
  24. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
  25. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
  26. package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
  27. package/dist/index.d.ts +36 -8
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +33 -6
  30. package/dist/lib/level-name.d.ts +4 -0
  31. package/dist/lib/level-name.d.ts.map +1 -0
  32. package/dist/lib/level-name.js +10 -0
  33. package/dist/lib/measurement-geometry.d.ts +19 -0
  34. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  35. package/dist/lib/measurement-geometry.js +232 -0
  36. package/dist/lib/measurement-geometry.test.d.ts +2 -0
  37. package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
  38. package/dist/lib/measurement-geometry.test.js +102 -0
  39. package/dist/lib/polygon-geometry.d.ts +0 -1
  40. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  41. package/dist/lib/polygon-geometry.js +0 -18
  42. package/dist/lib/polygon-relations.d.ts +10 -0
  43. package/dist/lib/polygon-relations.d.ts.map +1 -0
  44. package/dist/lib/polygon-relations.js +73 -0
  45. package/dist/lib/selection-proxy.d.ts +4 -0
  46. package/dist/lib/selection-proxy.d.ts.map +1 -0
  47. package/dist/lib/selection-proxy.js +12 -0
  48. package/dist/lib/slab-polygon.d.ts +31 -2
  49. package/dist/lib/slab-polygon.d.ts.map +1 -1
  50. package/dist/lib/slab-polygon.js +525 -31
  51. package/dist/lib/slab-polygon.test.d.ts +2 -0
  52. package/dist/lib/slab-polygon.test.d.ts.map +1 -0
  53. package/dist/lib/slab-polygon.test.js +635 -0
  54. package/dist/lib/slots.d.ts +14 -0
  55. package/dist/lib/slots.d.ts.map +1 -0
  56. package/dist/lib/slots.js +26 -0
  57. package/dist/lib/space-detection-pause.test.d.ts +2 -0
  58. package/dist/lib/space-detection-pause.test.d.ts.map +1 -0
  59. package/dist/lib/space-detection-pause.test.js +42 -0
  60. package/dist/lib/space-detection.d.ts +54 -2
  61. package/dist/lib/space-detection.d.ts.map +1 -1
  62. package/dist/lib/space-detection.js +599 -124
  63. package/dist/lib/space-detection.test.d.ts +2 -0
  64. package/dist/lib/space-detection.test.d.ts.map +1 -0
  65. package/dist/lib/space-detection.test.js +376 -0
  66. package/dist/lib/wall-distance.d.ts +60 -0
  67. package/dist/lib/wall-distance.d.ts.map +1 -0
  68. package/dist/lib/wall-distance.js +85 -0
  69. package/dist/lib/zone-quantities.d.ts +21 -0
  70. package/dist/lib/zone-quantities.d.ts.map +1 -0
  71. package/dist/lib/zone-quantities.js +183 -0
  72. package/dist/lib/zone-quantities.test.d.ts +2 -0
  73. package/dist/lib/zone-quantities.test.d.ts.map +1 -0
  74. package/dist/lib/zone-quantities.test.js +82 -0
  75. package/dist/material-library.d.ts +20 -1
  76. package/dist/material-library.d.ts.map +1 -1
  77. package/dist/material-library.js +3737 -200
  78. package/dist/registry/__bench__/relations-resolver.bench.d.ts +15 -0
  79. package/dist/registry/__bench__/relations-resolver.bench.d.ts.map +1 -0
  80. package/dist/registry/__bench__/relations-resolver.bench.js +170 -0
  81. package/dist/registry/handles.d.ts +394 -0
  82. package/dist/registry/handles.d.ts.map +1 -0
  83. package/dist/registry/handles.js +24 -0
  84. package/dist/registry/index.d.ts +7 -0
  85. package/dist/registry/index.d.ts.map +1 -0
  86. package/dist/registry/index.js +4 -0
  87. package/dist/registry/registry.d.ts +136 -0
  88. package/dist/registry/registry.d.ts.map +1 -0
  89. package/dist/registry/registry.js +282 -0
  90. package/dist/registry/registry.test.d.ts +2 -0
  91. package/dist/registry/registry.test.d.ts.map +1 -0
  92. package/dist/registry/registry.test.js +196 -0
  93. package/dist/registry/relations-resolver.d.ts +42 -0
  94. package/dist/registry/relations-resolver.d.ts.map +1 -0
  95. package/dist/registry/relations-resolver.js +77 -0
  96. package/dist/registry/relations-resolver.test.d.ts +2 -0
  97. package/dist/registry/relations-resolver.test.d.ts.map +1 -0
  98. package/dist/registry/relations-resolver.test.js +183 -0
  99. package/dist/registry/scene-api.d.ts +44 -0
  100. package/dist/registry/scene-api.d.ts.map +1 -0
  101. package/dist/registry/scene-api.js +111 -0
  102. package/dist/registry/scene-api.test.d.ts +2 -0
  103. package/dist/registry/scene-api.test.d.ts.map +1 -0
  104. package/dist/registry/scene-api.test.js +183 -0
  105. package/dist/registry/subtree.d.ts +67 -0
  106. package/dist/registry/subtree.d.ts.map +1 -0
  107. package/dist/registry/subtree.js +123 -0
  108. package/dist/registry/subtree.test.d.ts +2 -0
  109. package/dist/registry/subtree.test.d.ts.map +1 -0
  110. package/dist/registry/subtree.test.js +163 -0
  111. package/dist/registry/types.d.ts +2099 -0
  112. package/dist/registry/types.d.ts.map +1 -0
  113. package/dist/registry/types.js +1 -0
  114. package/dist/schema/index.d.ts +35 -4
  115. package/dist/schema/index.d.ts.map +1 -1
  116. package/dist/schema/index.js +32 -4
  117. package/dist/schema/material.d.ts +13 -2
  118. package/dist/schema/material.d.ts.map +1 -1
  119. package/dist/schema/material.js +17 -5
  120. package/dist/schema/material.test.d.ts +2 -0
  121. package/dist/schema/material.test.d.ts.map +1 -0
  122. package/dist/schema/material.test.js +43 -0
  123. package/dist/schema/nodes/box-vent.d.ts +74 -0
  124. package/dist/schema/nodes/box-vent.d.ts.map +1 -0
  125. package/dist/schema/nodes/box-vent.js +63 -0
  126. package/dist/schema/nodes/building.d.ts +1 -1
  127. package/dist/schema/nodes/building.d.ts.map +1 -1
  128. package/dist/schema/nodes/building.js +3 -2
  129. package/dist/schema/nodes/cabinet.d.ts +534 -0
  130. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  131. package/dist/schema/nodes/cabinet.js +137 -0
  132. package/dist/schema/nodes/ceiling.d.ts +5 -2
  133. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  134. package/dist/schema/nodes/ceiling.js +5 -1
  135. package/dist/schema/nodes/chimney.d.ts +158 -0
  136. package/dist/schema/nodes/chimney.d.ts.map +1 -0
  137. package/dist/schema/nodes/chimney.js +67 -0
  138. package/dist/schema/nodes/column.d.ts +600 -7
  139. package/dist/schema/nodes/column.d.ts.map +1 -1
  140. package/dist/schema/nodes/column.js +589 -6
  141. package/dist/schema/nodes/cupola.d.ts +66 -0
  142. package/dist/schema/nodes/cupola.d.ts.map +1 -0
  143. package/dist/schema/nodes/cupola.js +28 -0
  144. package/dist/schema/nodes/door.d.ts +13 -5
  145. package/dist/schema/nodes/door.d.ts.map +1 -1
  146. package/dist/schema/nodes/door.js +12 -0
  147. package/dist/schema/nodes/dormer.d.ts +228 -0
  148. package/dist/schema/nodes/dormer.d.ts.map +1 -0
  149. package/dist/schema/nodes/dormer.js +119 -0
  150. package/dist/schema/nodes/downspout.d.ts +75 -0
  151. package/dist/schema/nodes/downspout.d.ts.map +1 -0
  152. package/dist/schema/nodes/downspout.js +63 -0
  153. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  154. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  155. package/dist/schema/nodes/duct-fitting.js +92 -0
  156. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  157. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  158. package/dist/schema/nodes/duct-segment.js +73 -0
  159. package/dist/schema/nodes/duct-terminal.d.ts +57 -0
  160. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  161. package/dist/schema/nodes/duct-terminal.js +51 -0
  162. package/dist/schema/nodes/elevator.d.ts +106 -0
  163. package/dist/schema/nodes/elevator.d.ts.map +1 -0
  164. package/dist/schema/nodes/elevator.js +57 -0
  165. package/dist/schema/nodes/eyebrow-vent.d.ts +68 -0
  166. package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -0
  167. package/dist/schema/nodes/eyebrow-vent.js +40 -0
  168. package/dist/schema/nodes/fence.d.ts +19 -2
  169. package/dist/schema/nodes/fence.d.ts.map +1 -1
  170. package/dist/schema/nodes/fence.js +27 -2
  171. package/dist/schema/nodes/gutter.d.ts +84 -0
  172. package/dist/schema/nodes/gutter.d.ts.map +1 -0
  173. package/dist/schema/nodes/gutter.js +76 -0
  174. package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
  175. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  176. package/dist/schema/nodes/hvac-equipment.js +55 -0
  177. package/dist/schema/nodes/item.d.ts +12 -0
  178. package/dist/schema/nodes/item.d.ts.map +1 -1
  179. package/dist/schema/nodes/item.js +20 -0
  180. package/dist/schema/nodes/level.d.ts +1 -1
  181. package/dist/schema/nodes/level.d.ts.map +1 -1
  182. package/dist/schema/nodes/level.js +4 -0
  183. package/dist/schema/nodes/lineset.d.ts +42 -0
  184. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  185. package/dist/schema/nodes/lineset.js +38 -0
  186. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  187. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  188. package/dist/schema/nodes/liquid-line.js +24 -0
  189. package/dist/schema/nodes/measurement.d.ts +306 -0
  190. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  191. package/dist/schema/nodes/measurement.js +87 -0
  192. package/dist/schema/nodes/measurement.test.d.ts +2 -0
  193. package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
  194. package/dist/schema/nodes/measurement.test.js +139 -0
  195. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  196. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  197. package/dist/schema/nodes/pipe-fitting.js +45 -0
  198. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  199. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  200. package/dist/schema/nodes/pipe-segment.js +36 -0
  201. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  202. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  203. package/dist/schema/nodes/pipe-trap.js +36 -0
  204. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
  205. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
  206. package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
  207. package/dist/schema/nodes/ridge-vent.d.ts +82 -0
  208. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -0
  209. package/dist/schema/nodes/ridge-vent.js +248 -0
  210. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  211. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  212. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  213. package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
  214. package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
  215. package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
  216. package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
  217. package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
  218. package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
  219. package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
  220. package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
  221. package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
  222. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  223. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  224. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  225. package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
  226. package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
  227. package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
  228. package/dist/schema/nodes/roof-segment.d.ts +273 -3
  229. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  230. package/dist/schema/nodes/roof-segment.js +539 -3
  231. package/dist/schema/nodes/roof.d.ts +8 -8
  232. package/dist/schema/nodes/roof.d.ts.map +1 -1
  233. package/dist/schema/nodes/roof.js +4 -16
  234. package/dist/schema/nodes/shelf.d.ts +106 -0
  235. package/dist/schema/nodes/shelf.d.ts.map +1 -0
  236. package/dist/schema/nodes/shelf.js +90 -0
  237. package/dist/schema/nodes/site.d.ts +1 -122
  238. package/dist/schema/nodes/site.d.ts.map +1 -1
  239. package/dist/schema/nodes/site.js +2 -6
  240. package/dist/schema/nodes/skylight.d.ts +279 -0
  241. package/dist/schema/nodes/skylight.d.ts.map +1 -0
  242. package/dist/schema/nodes/skylight.js +152 -0
  243. package/dist/schema/nodes/slab.d.ts +5 -2
  244. package/dist/schema/nodes/slab.d.ts.map +1 -1
  245. package/dist/schema/nodes/slab.js +5 -1
  246. package/dist/schema/nodes/solar-panel.d.ts +117 -0
  247. package/dist/schema/nodes/solar-panel.d.ts.map +1 -0
  248. package/dist/schema/nodes/solar-panel.js +47 -0
  249. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  250. package/dist/schema/nodes/stair.d.ts +13 -12
  251. package/dist/schema/nodes/stair.d.ts.map +1 -1
  252. package/dist/schema/nodes/stair.js +3 -0
  253. package/dist/schema/nodes/surface-hole-metadata.d.ts +2 -0
  254. package/dist/schema/nodes/surface-hole-metadata.d.ts.map +1 -1
  255. package/dist/schema/nodes/surface-hole-metadata.js +3 -1
  256. package/dist/schema/nodes/turbine-vent.d.ts +68 -0
  257. package/dist/schema/nodes/turbine-vent.d.ts.map +1 -0
  258. package/dist/schema/nodes/turbine-vent.js +41 -0
  259. package/dist/schema/nodes/wall.d.ts +245 -6
  260. package/dist/schema/nodes/wall.d.ts.map +1 -1
  261. package/dist/schema/nodes/wall.js +257 -0
  262. package/dist/schema/nodes/wall.test.d.ts +2 -0
  263. package/dist/schema/nodes/wall.test.d.ts.map +1 -0
  264. package/dist/schema/nodes/wall.test.js +208 -0
  265. package/dist/schema/nodes/window.d.ts +11 -3
  266. package/dist/schema/nodes/window.d.ts.map +1 -1
  267. package/dist/schema/nodes/window.js +12 -0
  268. package/dist/schema/nodes/zone.d.ts +2 -0
  269. package/dist/schema/nodes/zone.d.ts.map +1 -1
  270. package/dist/schema/nodes/zone.js +6 -0
  271. package/dist/schema/scene-material.d.ts +41 -0
  272. package/dist/schema/scene-material.d.ts.map +1 -0
  273. package/dist/schema/scene-material.js +9 -0
  274. package/dist/schema/types.d.ts +2850 -655
  275. package/dist/schema/types.d.ts.map +1 -1
  276. package/dist/schema/types.js +49 -0
  277. package/dist/services/alignment-anchors.d.ts +100 -0
  278. package/dist/services/alignment-anchors.d.ts.map +1 -0
  279. package/dist/services/alignment-anchors.js +312 -0
  280. package/dist/services/alignment-anchors.test.d.ts +2 -0
  281. package/dist/services/alignment-anchors.test.d.ts.map +1 -0
  282. package/dist/services/alignment-anchors.test.js +383 -0
  283. package/dist/services/alignment.d.ts +131 -0
  284. package/dist/services/alignment.d.ts.map +1 -0
  285. package/dist/services/alignment.js +171 -0
  286. package/dist/services/alignment.test.d.ts +2 -0
  287. package/dist/services/alignment.test.d.ts.map +1 -0
  288. package/dist/services/alignment.test.js +92 -0
  289. package/dist/services/drag-session.d.ts +47 -0
  290. package/dist/services/drag-session.d.ts.map +1 -0
  291. package/dist/services/drag-session.js +100 -0
  292. package/dist/services/drag-session.test.d.ts +2 -0
  293. package/dist/services/drag-session.test.d.ts.map +1 -0
  294. package/dist/services/drag-session.test.js +219 -0
  295. package/dist/services/hosting.d.ts +88 -0
  296. package/dist/services/hosting.d.ts.map +1 -0
  297. package/dist/services/hosting.js +131 -0
  298. package/dist/services/hosting.test.d.ts +2 -0
  299. package/dist/services/hosting.test.d.ts.map +1 -0
  300. package/dist/services/hosting.test.js +230 -0
  301. package/dist/services/index.d.ts +13 -0
  302. package/dist/services/index.d.ts.map +1 -0
  303. package/dist/services/index.js +12 -0
  304. package/dist/services/level-height.d.ts +28 -0
  305. package/dist/services/level-height.d.ts.map +1 -0
  306. package/dist/services/level-height.js +62 -0
  307. package/dist/services/movement.d.ts +48 -0
  308. package/dist/services/movement.d.ts.map +1 -0
  309. package/dist/services/movement.js +65 -0
  310. package/dist/services/movement.test.d.ts +2 -0
  311. package/dist/services/movement.test.d.ts.map +1 -0
  312. package/dist/services/movement.test.js +133 -0
  313. package/dist/services/opening-guides.d.ts +141 -0
  314. package/dist/services/opening-guides.d.ts.map +1 -0
  315. package/dist/services/opening-guides.js +267 -0
  316. package/dist/services/opening-guides.test.d.ts +2 -0
  317. package/dist/services/opening-guides.test.d.ts.map +1 -0
  318. package/dist/services/opening-guides.test.js +208 -0
  319. package/dist/services/port-connectivity.d.ts +115 -0
  320. package/dist/services/port-connectivity.d.ts.map +1 -0
  321. package/dist/services/port-connectivity.js +300 -0
  322. package/dist/services/port-connectivity.test.d.ts +2 -0
  323. package/dist/services/port-connectivity.test.d.ts.map +1 -0
  324. package/dist/services/port-connectivity.test.js +297 -0
  325. package/dist/services/riser-diagram.d.ts +40 -0
  326. package/dist/services/riser-diagram.d.ts.map +1 -0
  327. package/dist/services/riser-diagram.js +110 -0
  328. package/dist/services/riser-diagram.test.d.ts +2 -0
  329. package/dist/services/riser-diagram.test.d.ts.map +1 -0
  330. package/dist/services/riser-diagram.test.js +66 -0
  331. package/dist/services/single-undo-dance.test.d.ts +2 -0
  332. package/dist/services/single-undo-dance.test.d.ts.map +1 -0
  333. package/dist/services/single-undo-dance.test.js +210 -0
  334. package/dist/services/snap.d.ts +80 -0
  335. package/dist/services/snap.d.ts.map +1 -0
  336. package/dist/services/snap.js +140 -0
  337. package/dist/services/snap.test.d.ts +2 -0
  338. package/dist/services/snap.test.d.ts.map +1 -0
  339. package/dist/services/snap.test.js +137 -0
  340. package/dist/services/system-graph.d.ts +30 -0
  341. package/dist/services/system-graph.d.ts.map +1 -0
  342. package/dist/services/system-graph.js +164 -0
  343. package/dist/services/system-graph.test.d.ts +2 -0
  344. package/dist/services/system-graph.test.d.ts.map +1 -0
  345. package/dist/services/system-graph.test.js +138 -0
  346. package/dist/services/validate-dwv.d.ts +32 -0
  347. package/dist/services/validate-dwv.d.ts.map +1 -0
  348. package/dist/services/validate-dwv.js +118 -0
  349. package/dist/services/validate-dwv.test.d.ts +2 -0
  350. package/dist/services/validate-dwv.test.d.ts.map +1 -0
  351. package/dist/services/validate-dwv.test.js +109 -0
  352. package/dist/solar-panel-presets.d.ts +17 -0
  353. package/dist/solar-panel-presets.d.ts.map +1 -0
  354. package/dist/solar-panel-presets.js +43 -0
  355. package/dist/store/actions/node-actions.d.ts +14 -2
  356. package/dist/store/actions/node-actions.d.ts.map +1 -1
  357. package/dist/store/actions/node-actions.js +585 -15
  358. package/dist/store/actions/node-mutation-sanitize.test.d.ts +2 -0
  359. package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +1 -0
  360. package/dist/store/actions/node-mutation-sanitize.test.js +147 -0
  361. package/dist/store/actions/reparent.test.d.ts +2 -0
  362. package/dist/store/actions/reparent.test.d.ts.map +1 -0
  363. package/dist/store/actions/reparent.test.js +212 -0
  364. package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
  365. package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
  366. package/dist/store/actions/ridge-vent-update.test.js +264 -0
  367. package/dist/store/history-control.d.ts +11 -0
  368. package/dist/store/history-control.d.ts.map +1 -1
  369. package/dist/store/history-control.js +33 -0
  370. package/dist/store/use-interactive.d.ts +39 -0
  371. package/dist/store/use-interactive.d.ts.map +1 -1
  372. package/dist/store/use-interactive.js +75 -0
  373. package/dist/store/use-live-node-overrides.d.ts +22 -0
  374. package/dist/store/use-live-node-overrides.d.ts.map +1 -0
  375. package/dist/store/use-live-node-overrides.js +59 -0
  376. package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
  377. package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
  378. package/dist/store/use-scene-dirty-tracking.test.js +68 -0
  379. package/dist/store/use-scene-elevator-migration.test.d.ts +2 -0
  380. package/dist/store/use-scene-elevator-migration.test.d.ts.map +1 -0
  381. package/dist/store/use-scene-elevator-migration.test.js +109 -0
  382. package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
  383. package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
  384. package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
  385. package/dist/store/use-scene-plugins.test.d.ts +2 -0
  386. package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
  387. package/dist/store/use-scene-plugins.test.js +51 -0
  388. package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
  389. package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
  390. package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
  391. package/dist/store/use-scene-window-migration.test.d.ts +2 -0
  392. package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
  393. package/dist/store/use-scene-window-migration.test.js +85 -0
  394. package/dist/store/use-scene.d.ts +28 -2
  395. package/dist/store/use-scene.d.ts.map +1 -1
  396. package/dist/store/use-scene.js +546 -49
  397. package/dist/systems/elevator/elevator-dispatch.d.ts +12 -0
  398. package/dist/systems/elevator/elevator-dispatch.d.ts.map +1 -0
  399. package/dist/systems/elevator/elevator-dispatch.js +57 -0
  400. package/dist/systems/elevator/elevator-geometry.d.ts +15 -0
  401. package/dist/systems/elevator/elevator-geometry.d.ts.map +1 -0
  402. package/dist/systems/elevator/elevator-geometry.js +50 -0
  403. package/dist/systems/elevator/elevator-opening-sync.d.ts +6 -0
  404. package/dist/systems/elevator/elevator-opening-sync.d.ts.map +1 -0
  405. package/dist/systems/elevator/elevator-opening-sync.js +218 -0
  406. package/dist/systems/elevator/elevator-opening-sync.test.d.ts +2 -0
  407. package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +1 -0
  408. package/dist/systems/elevator/elevator-opening-sync.test.js +146 -0
  409. package/dist/systems/elevator/elevator-opening-system.d.ts +2 -0
  410. package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -0
  411. package/dist/systems/elevator/elevator-opening-system.js +48 -0
  412. package/dist/systems/elevator/elevator-runtime-system.d.ts +2 -0
  413. package/dist/systems/elevator/elevator-runtime-system.d.ts.map +1 -0
  414. package/dist/systems/elevator/elevator-runtime-system.js +8 -0
  415. package/dist/systems/elevator/elevator-runtime.d.ts +18 -0
  416. package/dist/systems/elevator/elevator-runtime.d.ts.map +1 -0
  417. package/dist/systems/elevator/elevator-runtime.js +225 -0
  418. package/dist/systems/elevator/elevator-runtime.test.d.ts +2 -0
  419. package/dist/systems/elevator/elevator-runtime.test.d.ts.map +1 -0
  420. package/dist/systems/elevator/elevator-runtime.test.js +64 -0
  421. package/dist/systems/elevator/elevator-service.d.ts +19 -0
  422. package/dist/systems/elevator/elevator-service.d.ts.map +1 -0
  423. package/dist/systems/elevator/elevator-service.js +111 -0
  424. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  425. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  426. package/dist/systems/fence/fence-centerline.js +37 -0
  427. package/dist/systems/fence/fence-spline.d.ts +39 -0
  428. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  429. package/dist/systems/fence/fence-spline.js +196 -0
  430. package/dist/systems/fence/fence-spline.test.d.ts +2 -0
  431. package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
  432. package/dist/systems/fence/fence-spline.test.js +136 -0
  433. package/dist/systems/stair/stair-footprint.d.ts +44 -0
  434. package/dist/systems/stair/stair-footprint.d.ts.map +1 -0
  435. package/dist/systems/stair/stair-footprint.js +178 -0
  436. package/dist/systems/stair/stair-opening-preview.d.ts +2447 -0
  437. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -0
  438. package/dist/systems/stair/stair-opening-preview.js +93 -0
  439. package/dist/systems/stair/stair-opening-preview.test.d.ts +2 -0
  440. package/dist/systems/stair/stair-opening-preview.test.d.ts.map +1 -0
  441. package/dist/systems/stair/stair-opening-preview.test.js +80 -0
  442. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  443. package/dist/systems/stair/stair-opening-sync.js +155 -147
  444. package/dist/systems/stair/stair-opening-sync.test.js +424 -2
  445. package/dist/systems/stair/stair-opening-system.d.ts +2 -0
  446. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -0
  447. package/dist/systems/stair/stair-opening-system.js +104 -0
  448. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  449. package/dist/systems/wall/wall-mitering.js +38 -2
  450. package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
  451. package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
  452. package/dist/systems/wall/wall-mitering.test.js +63 -0
  453. package/dist/systems/wall/wall-move.d.ts +48 -0
  454. package/dist/systems/wall/wall-move.d.ts.map +1 -0
  455. package/dist/systems/wall/wall-move.js +193 -0
  456. package/dist/utils/clone-scene-graph.d.ts +7 -4
  457. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  458. package/dist/utils/clone-scene-graph.js +29 -7
  459. package/dist/utils/clone-scene-graph.test.d.ts +2 -0
  460. package/dist/utils/clone-scene-graph.test.d.ts.map +1 -0
  461. package/dist/utils/clone-scene-graph.test.js +62 -0
  462. package/dist/utils/heal-scene-graph.d.ts +18 -0
  463. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  464. package/dist/utils/heal-scene-graph.js +90 -0
  465. package/dist/utils/heal-scene-graph.test.d.ts +2 -0
  466. package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
  467. package/dist/utils/heal-scene-graph.test.js +88 -0
  468. package/dist/validation/validate-build-json.d.ts +44 -0
  469. package/dist/validation/validate-build-json.d.ts.map +1 -0
  470. package/dist/validation/validate-build-json.js +256 -0
  471. package/package.json +11 -4
@@ -1,12 +1,24 @@
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';
5
5
  const DEFAULT_AUTO_SLAB_ELEVATION = 0.05;
6
6
  const DEFAULT_AUTO_CEILING_HEIGHT = 2.5;
7
+ const CEILING_HEIGHT_EPSILON = 1e-6;
7
8
  const ROOM_CURVE_TOLERANCE = 0.04;
8
9
  const MAX_CURVE_SUBDIVISION_DEPTH = 6;
9
10
  const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08;
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;
10
22
  function pointFromTuple(point) {
11
23
  return { x: point[0], y: point[1] };
12
24
  }
@@ -114,6 +126,120 @@ function bboxOverlapArea(a, b) {
114
126
  const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY));
115
127
  return ix * iy;
116
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
+ }
169
+ function pointDistanceToPolygonBoundary(point, polygon) {
170
+ let minDistance = Number.POSITIVE_INFINITY;
171
+ for (let index = 0; index < polygon.length; index += 1) {
172
+ const start = polygon[index];
173
+ const end = polygon[(index + 1) % polygon.length];
174
+ if (!(start && end))
175
+ continue;
176
+ minDistance = Math.min(minDistance, distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)));
177
+ }
178
+ return minDistance;
179
+ }
180
+ function wallBoundsRoom(wall, roomPolygon) {
181
+ const sampled = sampleWallPointsForRoomDetection(wall);
182
+ if (sampled.length === 0)
183
+ return false;
184
+ const candidates = sampled.length === 2
185
+ ? [
186
+ sampled[0],
187
+ {
188
+ x: (sampled[0].x + sampled[1].x) / 2,
189
+ y: (sampled[0].y + sampled[1].y) / 2,
190
+ },
191
+ sampled[1],
192
+ ]
193
+ : sampled;
194
+ const matchingPoints = candidates.filter((point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE);
195
+ return matchingPoints.length >= 2;
196
+ }
197
+ function pointIsOnSlab(point, slab) {
198
+ if (slab.polygon.length < 3)
199
+ return false;
200
+ const slabPolygon = slab.polygon.map(pointFromTuple);
201
+ if (!pointInPolygon(point, slabPolygon))
202
+ return false;
203
+ for (const hole of slab.holes ?? []) {
204
+ if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) {
205
+ return false;
206
+ }
207
+ }
208
+ return true;
209
+ }
210
+ function slabSupportsRoom(roomPolygon, slab) {
211
+ if (slab.polygon.length < 3)
212
+ return false;
213
+ if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) {
214
+ return true;
215
+ }
216
+ return pointIsOnSlab(polygonCentroid(roomPolygon), slab);
217
+ }
218
+ function resolveRoomSlabElevation(roomPolygon, slabs = []) {
219
+ let maxElevation = 0;
220
+ for (const slab of slabs) {
221
+ if (!slabSupportsRoom(roomPolygon, slab))
222
+ continue;
223
+ maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION);
224
+ }
225
+ return maxElevation;
226
+ }
227
+ function resolveRoomWallHeight(roomPolygon, walls = []) {
228
+ let maxHeight = 0;
229
+ for (const wall of walls) {
230
+ if (!wallBoundsRoom(wall, roomPolygon))
231
+ continue;
232
+ const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT;
233
+ if (Number.isFinite(height)) {
234
+ maxHeight = Math.max(maxHeight, height);
235
+ }
236
+ }
237
+ return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT;
238
+ }
239
+ function resolveAutoCeilingHeight(roomPolygon, context = {}) {
240
+ return (resolveRoomSlabElevation(roomPolygon, context.slabs) +
241
+ resolveRoomWallHeight(roomPolygon, context.walls));
242
+ }
117
243
  function getWallDirection(wall) {
118
244
  const dx = wall.end[0] - wall.start[0];
119
245
  const dy = wall.end[1] - wall.start[1];
@@ -164,11 +290,52 @@ function sampleWallPointsForRoomDetection(wall, tolerance = ROOM_CURVE_TOLERANCE
164
290
  };
165
291
  return subdivide(0, start, 1, end, 0);
166
292
  }
167
- function getDirectedWallBoundaryPoints(wall, forward) {
168
- const points = sampleWallPointsForRoomDetection(wall);
169
- 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) };
170
305
  }
171
- 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) {
172
339
  if (walls.length < 3)
173
340
  return [];
174
341
  const graph = new Map();
@@ -180,35 +347,71 @@ function extractRoomPolygons(walls) {
180
347
  }
181
348
  return key;
182
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()];
183
365
  for (const wall of walls) {
184
366
  const start = pointFromTuple(wall.start);
185
367
  const end = pointFromTuple(wall.end);
186
- const startKey = upsertNode(start);
187
- const endKey = upsertNode(end);
188
- if (startKey === endKey)
368
+ if (samePointWithinTolerance(start, end))
189
369
  continue;
190
- const forwardDirection = getWallDirection(wall);
191
- const reverseDirection = getWallDirection({ start: wall.end, end: wall.start });
192
- const forwardId = `${wall.id}:f`;
193
- const reverseId = `${wall.id}:r`;
194
- halfEdges.set(forwardId, {
195
- id: forwardId,
196
- reverseId,
197
- fromKey: startKey,
198
- toKey: endKey,
199
- angle: Math.atan2(forwardDirection.tangent.y, forwardDirection.tangent.x),
200
- points: getDirectedWallBoundaryPoints(wall, true),
201
- });
202
- halfEdges.set(reverseId, {
203
- id: reverseId,
204
- reverseId: forwardId,
205
- fromKey: endKey,
206
- toKey: startKey,
207
- angle: Math.atan2(reverseDirection.tangent.y, reverseDirection.tangent.x),
208
- points: getDirectedWallBoundaryPoints(wall, false),
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);
209
414
  });
210
- graph.get(startKey)?.outgoing.push(forwardId);
211
- graph.get(endKey)?.outgoing.push(reverseId);
212
415
  }
213
416
  const sortedOutgoing = new Map();
214
417
  for (const [key, node] of graph.entries()) {
@@ -229,15 +432,49 @@ function extractRoomPolygons(walls) {
229
432
  const nextIdx = (idx - 1 + outgoing.length) % outgoing.length;
230
433
  return outgoing[nextIdx] ?? null;
231
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
+ };
232
465
  const visitedDirected = new Set();
233
- const faces = [];
234
- 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);
235
471
  for (const edgeId of halfEdges.keys()) {
236
472
  if (visitedDirected.has(edgeId))
237
473
  continue;
238
474
  const cycleEdgeIds = [];
239
475
  let currentEdgeId = edgeId;
240
476
  let valid = true;
477
+ let closed = false;
241
478
  for (let step = 0; step < maxSteps; step += 1) {
242
479
  const currentEdge = halfEdges.get(currentEdgeId);
243
480
  if (!currentEdge) {
@@ -252,29 +489,64 @@ function extractRoomPolygons(walls) {
252
489
  break;
253
490
  }
254
491
  currentEdgeId = next;
255
- if (currentEdgeId === edgeId)
492
+ if (currentEdgeId === edgeId) {
493
+ closed = true;
256
494
  break;
495
+ }
257
496
  }
258
- if (!valid || cycleEdgeIds.length < 3)
259
- continue;
260
- const polygon = dedupeSequentialPoints(cycleEdgeIds.flatMap((id, index) => {
261
- const points = halfEdges.get(id)?.points ?? [];
262
- return index === cycleEdgeIds.length - 1 ? points : points.slice(0, -1);
263
- }));
264
- if (polygon.length < 3)
497
+ if (!(valid && closed) || cycleEdgeIds.length < 3)
265
498
  continue;
266
- const signedArea = polygonArea(polygon);
267
- if (signedArea <= 0)
268
- continue;
269
- if (signedArea < 0.5 || signedArea > 10_000)
270
- continue;
271
- const signature = polygonSignature(polygon);
272
- if (faces.some((face) => polygonSignature(face) === signature))
273
- continue;
274
- 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
+ }
275
530
  }
276
- faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)));
277
- 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));
278
550
  }
279
551
  export function resolveWallSurfaceSides(wall, roomPolygons) {
280
552
  if (roomPolygons.length === 0) {
@@ -340,26 +612,111 @@ function wallGeometrySignature(wall) {
340
612
  wall.end[0].toFixed(4),
341
613
  wall.end[1].toFixed(4),
342
614
  (wall.thickness ?? 0.2).toFixed(4),
615
+ (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4),
343
616
  getClampedWallCurveOffset(wall).toFixed(4),
344
617
  ].join('|');
345
618
  }
346
619
  function levelWallSnapshot(walls) {
347
620
  return walls.map(wallGeometrySignature).sort().join('||');
348
621
  }
349
- function buildSpace(levelId, polygon) {
350
- const signature = polygonSignature(polygon);
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('|');
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.
634
+ function levelStructureSnapshots(nodes) {
635
+ const wallsByLevel = new Map();
636
+ const zonesByLevel = new Map();
637
+ for (const node of Object.values(nodes)) {
638
+ if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId))
639
+ continue;
640
+ const levelId = node.parentId;
641
+ if (node.type === 'wall') {
642
+ const walls = wallsByLevel.get(levelId) ?? [];
643
+ walls.push(node);
644
+ wallsByLevel.set(levelId, walls);
645
+ }
646
+ else if (node.type === 'zone') {
647
+ const zones = zonesByLevel.get(levelId) ?? [];
648
+ zones.push(ZoneNode.parse(node));
649
+ zonesByLevel.set(levelId, zones);
650
+ }
651
+ }
652
+ const snapshots = new Map();
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('||')}`);
658
+ }
659
+ return snapshots;
660
+ }
661
+ function buildSpace(levelId, room) {
662
+ const signature = polygonSignature(room.polygon);
351
663
  return {
352
664
  id: `space-${levelId}-${signature.slice(0, 12)}`,
353
665
  levelId,
354
- polygon: polygon.map(pointToTuple),
355
- wallIds: [],
666
+ polygon: room.polygon.map(pointToTuple),
667
+ wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
668
+ boundaryFaces: room.boundaryFaces,
356
669
  isExterior: false,
357
670
  };
358
671
  }
359
- function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore) {
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
+ }
715
+ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
360
716
  const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
361
717
  const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
362
- const detected = roomPolygons
718
+ const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple));
719
+ const detectedAll = roomPolygons
363
720
  .map((poly) => ({
364
721
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
365
722
  sig: '',
@@ -373,8 +730,8 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore)
373
730
  centroid: polygonCentroid(room.poly),
374
731
  area: Math.abs(polygonArea(room.poly)),
375
732
  bbox: bboxOf(room.poly),
376
- }))
377
- .filter(({ sig }) => !manualSignatures.has(sig));
733
+ }));
734
+ const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
378
735
  const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
379
736
  const existingAutoMeta = existingAuto.map((slab) => {
380
737
  const poly = slab.polygon.map(pointFromTuple);
@@ -391,10 +748,12 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore)
391
748
  const updatesById = new Map();
392
749
  const autoBySignature = new Map();
393
750
  for (const entry of existingAutoMeta) {
394
- autoBySignature.set(entry.sig, entry);
751
+ const bucket = autoBySignature.get(entry.sig) ?? [];
752
+ bucket.push(entry);
753
+ autoBySignature.set(entry.sig, bucket);
395
754
  }
396
755
  detected.forEach((room, index) => {
397
- const existing = autoBySignature.get(room.sig);
756
+ const existing = autoBySignature.get(room.sig)?.shift();
398
757
  if (!existing)
399
758
  return;
400
759
  matchedDetectedIdx.add(index);
@@ -430,17 +789,33 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore)
430
789
  matchedSlabIds.add(bestMatch.entry.slab.id);
431
790
  updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple));
432
791
  }
433
- const slabsToDelete = existingAuto
434
- .filter((slab) => !updatesById.has(slab.id))
435
- .map((slab) => slab.id);
436
- const slabsToUpdate = existingAuto
437
- .filter((slab) => updatesById.has(slab.id))
438
- .flatMap((slab) => {
439
- const polygon = updatesById.get(slab.id);
440
- if (!polygon)
441
- return [];
442
- return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }];
443
- });
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
+ ];
444
819
  const plannedSlabsForNaming = [...existingSlabs];
445
820
  const slabsToCreate = [];
446
821
  for (let index = 0; index < detected.length; index += 1) {
@@ -459,20 +834,46 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore)
459
834
  autoFromWalls: true,
460
835
  }));
461
836
  }
462
- if (slabsToDelete.length > 0) {
463
- sceneStore.getState().deleteNodes(slabsToDelete);
837
+ return {
838
+ create: slabsToCreate,
839
+ update: slabsToUpdate,
840
+ delete: slabsToDelete,
841
+ };
842
+ }
843
+ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore) {
844
+ const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs);
845
+ if (plan.delete.length > 0) {
846
+ sceneStore.getState().deleteNodes(plan.delete);
464
847
  }
465
- if (slabsToUpdate.length > 0) {
466
- sceneStore.getState().updateNodes(slabsToUpdate);
848
+ if (plan.update.length > 0) {
849
+ sceneStore.getState().updateNodes(plan.update);
467
850
  }
468
- if (slabsToCreate.length > 0) {
469
- sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })));
851
+ if (plan.create.length > 0) {
852
+ sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })));
470
853
  }
854
+ return plan;
471
855
  }
472
- function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore) {
856
+ export function projectAutoSlabsForPlan(existingSlabs, plan) {
857
+ const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab]));
858
+ for (const id of plan.delete) {
859
+ slabsById.delete(id);
860
+ }
861
+ for (const update of plan.update) {
862
+ const slab = slabsById.get(update.id);
863
+ if (!slab)
864
+ continue;
865
+ slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data }));
866
+ }
867
+ for (const slab of plan.create) {
868
+ slabsById.set(slab.id, slab);
869
+ }
870
+ return [...slabsById.values()];
871
+ }
872
+ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}) {
473
873
  const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
474
874
  const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
475
- const detected = roomPolygons
875
+ const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple));
876
+ const detectedAll = roomPolygons
476
877
  .map((poly) => ({
477
878
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
478
879
  sig: '',
@@ -486,8 +887,9 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
486
887
  centroid: polygonCentroid(room.poly),
487
888
  area: Math.abs(polygonArea(room.poly)),
488
889
  bbox: bboxOf(room.poly),
489
- }))
490
- .filter(({ sig }) => !manualSignatures.has(sig));
890
+ ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
891
+ }));
892
+ const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
491
893
  const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
492
894
  const existingAutoMeta = existingAuto.map((ceiling) => {
493
895
  const poly = ceiling.polygon.map(pointFromTuple);
@@ -504,15 +906,20 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
504
906
  const updatesById = new Map();
505
907
  const autoBySignature = new Map();
506
908
  for (const entry of existingAutoMeta) {
507
- autoBySignature.set(entry.sig, entry);
909
+ const bucket = autoBySignature.get(entry.sig) ?? [];
910
+ bucket.push(entry);
911
+ autoBySignature.set(entry.sig, bucket);
508
912
  }
509
913
  detected.forEach((room, index) => {
510
- const existing = autoBySignature.get(room.sig);
914
+ const existing = autoBySignature.get(room.sig)?.shift();
511
915
  if (!existing)
512
916
  return;
513
917
  matchedDetectedIdx.add(index);
514
918
  matchedCeilingIds.add(existing.ceiling.id);
515
- updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple));
919
+ updatesById.set(existing.ceiling.id, {
920
+ polygon: room.poly.map(pointToTuple),
921
+ height: room.ceilingHeight,
922
+ });
516
923
  });
517
924
  const remainingDetected = detected
518
925
  .map((room, index) => ({ room, index }))
@@ -541,21 +948,44 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
541
948
  continue;
542
949
  matchedDetectedIdx.add(index);
543
950
  matchedCeilingIds.add(bestMatch.entry.ceiling.id);
544
- updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple));
545
- }
546
- const ceilingsToDelete = existingAuto
547
- .filter((ceiling) => !updatesById.has(ceiling.id))
548
- .map((ceiling) => ceiling.id);
549
- const ceilingsToUpdate = existingAuto
550
- .filter((ceiling) => updatesById.has(ceiling.id))
551
- .flatMap((ceiling) => {
552
- const polygon = updatesById.get(ceiling.id);
553
- if (!polygon)
554
- return [];
555
- return sameTuplePolygon(ceiling.polygon, polygon)
556
- ? []
557
- : [{ id: ceiling.id, data: { polygon } }];
558
- });
951
+ updatesById.set(bestMatch.entry.ceiling.id, {
952
+ polygon: room.poly.map(pointToTuple),
953
+ height: room.ceilingHeight,
954
+ });
955
+ }
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);
965
+ }
966
+ else {
967
+ ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } });
968
+ }
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
+ ];
559
989
  const plannedCeilingsForNaming = [...existingCeilings];
560
990
  const ceilingsToCreate = [];
561
991
  for (let index = 0; index < detected.length; index += 1) {
@@ -570,29 +1000,38 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
570
1000
  name,
571
1001
  polygon: room.poly.map(pointToTuple),
572
1002
  holes: [],
573
- height: DEFAULT_AUTO_CEILING_HEIGHT,
1003
+ height: room.ceilingHeight,
574
1004
  autoFromWalls: true,
575
1005
  }));
576
1006
  }
577
- if (ceilingsToDelete.length > 0) {
578
- sceneStore.getState().deleteNodes(ceilingsToDelete);
1007
+ return {
1008
+ create: ceilingsToCreate,
1009
+ update: ceilingsToUpdate,
1010
+ delete: ceilingsToDelete,
1011
+ };
1012
+ }
1013
+ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore, context = {}) {
1014
+ const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context);
1015
+ if (plan.delete.length > 0) {
1016
+ sceneStore.getState().deleteNodes(plan.delete);
579
1017
  }
580
- if (ceilingsToUpdate.length > 0) {
581
- sceneStore.getState().updateNodes(ceilingsToUpdate);
1018
+ if (plan.update.length > 0) {
1019
+ sceneStore.getState().updateNodes(plan.update);
582
1020
  }
583
- if (ceilingsToCreate.length > 0) {
584
- sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })));
1021
+ if (plan.create.length > 0) {
1022
+ sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })));
585
1023
  }
586
1024
  }
587
1025
  function detectSpacesFromWalls(levelId, walls) {
588
- const roomPolygons = extractRoomPolygons(walls);
1026
+ const rooms = extractRooms(walls);
1027
+ const roomPolygons = rooms.map((room) => room.polygon);
589
1028
  const wallUpdates = walls.map((wall) => ({
590
1029
  wallId: wall.id,
591
1030
  ...resolveWallSurfaceSides(wall, roomPolygons),
592
1031
  }));
593
1032
  return {
594
1033
  roomPolygons,
595
- spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
1034
+ spaces: rooms.map((room) => buildSpace(levelId, room)),
596
1035
  wallUpdates,
597
1036
  };
598
1037
  }
@@ -612,6 +1051,7 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
612
1051
  const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
613
1052
  const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
614
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);
615
1055
  const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
616
1056
  const changedWallUpdates = wallUpdates.filter((update) => {
617
1057
  const wall = nodes[update.wallId];
@@ -626,16 +1066,45 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
626
1066
  },
627
1067
  })));
628
1068
  }
629
- syncAutoSlabsForLevel(levelId, roomPolygons, slabs.map((slab) => SlabNode.parse(slab)), sceneStore);
630
- syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore);
1069
+ const parsedSlabs = slabs.map((slab) => SlabNode.parse(slab));
1070
+ const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore);
1071
+ const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan);
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);
631
1076
  for (const space of spaces) {
632
1077
  nextSpaces[space.id] = space;
633
1078
  }
634
1079
  }
635
1080
  editorStore.getState().setSpaces(nextSpaces);
636
1081
  }
1082
+ // Refcount of outstanding pause requests, matching the pauseSceneHistory
1083
+ // pattern. The community editor flips this off while the AI is actively
1084
+ // mutating the scene so the wall-driven auto slab/ceiling sync doesn't race
1085
+ // `create_room`'s explicit slabs/ceilings (see plan
1086
+ // `ai-pause-space-detection`).
1087
+ let spaceDetectionPauseDepth = 0;
1088
+ /** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */
1089
+ export function pauseSpaceDetection() {
1090
+ spaceDetectionPauseDepth += 1;
1091
+ }
1092
+ /** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */
1093
+ export function resumeSpaceDetection() {
1094
+ if (spaceDetectionPauseDepth === 0)
1095
+ return;
1096
+ spaceDetectionPauseDepth -= 1;
1097
+ }
1098
+ /** True iff the wall-driven auto slab/ceiling sync is currently paused. */
1099
+ export function isSpaceDetectionPaused() {
1100
+ return spaceDetectionPauseDepth > 0;
1101
+ }
637
1102
  export function initSpaceDetectionSync(sceneStore, editorStore) {
638
- 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);
639
1108
  let isProcessing = false;
640
1109
  const unsubscribe = sceneStore.subscribe((state) => {
641
1110
  if (isProcessing)
@@ -643,22 +1112,27 @@ export function initSpaceDetectionSync(sceneStore, editorStore) {
643
1112
  if (getSceneHistoryPauseDepth() > 0)
644
1113
  return;
645
1114
  const nodes = state.nodes;
646
- const wallsByLevel = new Map();
647
- for (const node of Object.values(nodes)) {
648
- if (node && node.type === 'wall' && node.parentId) {
649
- const levelId = node.parentId;
650
- const levelWalls = wallsByLevel.get(levelId) ?? [];
651
- levelWalls.push(node);
652
- wallsByLevel.set(levelId, levelWalls);
1115
+ const currentSnapshots = levelStructureSnapshots(nodes);
1116
+ // Paused: roll the snapshot forward so we don't backfill (and re-duplicate)
1117
+ // every paused change once detection resumes. Whatever the AI built while
1118
+ // paused becomes the new baseline; only future changes will reconcile.
1119
+ if (spaceDetectionPauseDepth > 0) {
1120
+ previousSnapshots.clear();
1121
+ for (const [levelId, snapshot] of currentSnapshots.entries()) {
1122
+ previousSnapshots.set(levelId, snapshot);
653
1123
  }
654
- }
655
- const currentSnapshots = new Map();
656
- for (const [levelId, walls] of wallsByLevel.entries()) {
657
- currentSnapshots.set(levelId, levelWallSnapshot(walls));
1124
+ return;
658
1125
  }
659
1126
  const levelsToUpdate = new Set();
660
1127
  for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
661
- 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) ?? '')) {
662
1136
  levelsToUpdate.add(levelId);
663
1137
  }
664
1138
  }
@@ -677,7 +1151,8 @@ export function initSpaceDetectionSync(sceneStore, editorStore) {
677
1151
  finally {
678
1152
  resumeSceneHistory(sceneStore);
679
1153
  previousSnapshots.clear();
680
- for (const [levelId, snapshot] of currentSnapshots.entries()) {
1154
+ const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
1155
+ for (const [levelId, snapshot] of postRunSnapshots.entries()) {
681
1156
  previousSnapshots.set(levelId, snapshot);
682
1157
  }
683
1158
  isProcessing = false;