@pascal-app/core 0.9.1 → 1.0.0-beta.1

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 (378) hide show
  1. package/README.md +17 -10
  2. package/dist/events/bus.d.ts +49 -2
  3. package/dist/events/bus.d.ts.map +1 -1
  4. package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
  5. package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
  6. package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
  7. package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
  8. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  9. package/dist/hooks/scene-registry/scene-registry.js +27 -1
  10. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts +17 -1
  11. package/dist/hooks/spatial-grid/floor-placed-elevation.d.ts.map +1 -1
  12. package/dist/hooks/spatial-grid/floor-placed-elevation.js +61 -8
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +135 -28
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  15. package/dist/hooks/spatial-grid/spatial-grid-manager.js +427 -271
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +46 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  18. package/dist/hooks/spatial-grid/spatial-grid-sync.js +208 -9
  19. package/dist/hooks/spatial-grid/support-host-id.d.ts +3 -0
  20. package/dist/hooks/spatial-grid/support-host-id.d.ts.map +1 -0
  21. package/dist/hooks/spatial-grid/support-host-id.js +2 -0
  22. package/dist/hooks/spatial-grid/support-host-patch.d.ts +51 -0
  23. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -0
  24. package/dist/hooks/spatial-grid/support-host-patch.js +164 -0
  25. package/dist/index.d.ts +33 -11
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +32 -10
  28. package/dist/lib/level-name.d.ts +4 -0
  29. package/dist/lib/level-name.d.ts.map +1 -0
  30. package/dist/lib/level-name.js +10 -0
  31. package/dist/lib/measurement-geometry.d.ts +23 -0
  32. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  33. package/dist/lib/measurement-geometry.js +249 -0
  34. package/dist/lib/polygon-geometry.d.ts +0 -1
  35. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  36. package/dist/lib/polygon-geometry.js +0 -18
  37. package/dist/lib/selection-proxy.d.ts +4 -0
  38. package/dist/lib/selection-proxy.d.ts.map +1 -0
  39. package/dist/lib/selection-proxy.js +12 -0
  40. package/dist/lib/slab-polygon.d.ts +31 -2
  41. package/dist/lib/slab-polygon.d.ts.map +1 -1
  42. package/dist/lib/slab-polygon.js +557 -31
  43. package/dist/lib/slots.d.ts +14 -0
  44. package/dist/lib/slots.d.ts.map +1 -0
  45. package/dist/lib/slots.js +26 -0
  46. package/dist/lib/space-detection.d.ts +40 -6
  47. package/dist/lib/space-detection.d.ts.map +1 -1
  48. package/dist/lib/space-detection.js +574 -192
  49. package/dist/lib/terrain-brush.d.ts +163 -0
  50. package/dist/lib/terrain-brush.d.ts.map +1 -0
  51. package/dist/lib/terrain-brush.js +359 -0
  52. package/dist/lib/terrain-codec.d.ts +51 -0
  53. package/dist/lib/terrain-codec.d.ts.map +1 -0
  54. package/dist/lib/terrain-codec.js +167 -0
  55. package/dist/lib/terrain-field.d.ts +150 -0
  56. package/dist/lib/terrain-field.d.ts.map +1 -0
  57. package/dist/lib/terrain-field.js +248 -0
  58. package/dist/lib/terrain-raycast.d.ts +44 -0
  59. package/dist/lib/terrain-raycast.d.ts.map +1 -0
  60. package/dist/lib/terrain-raycast.js +188 -0
  61. package/dist/lib/terrain-source.d.ts +60 -0
  62. package/dist/lib/terrain-source.d.ts.map +1 -0
  63. package/dist/lib/terrain-source.js +90 -0
  64. package/dist/lib/terrain-support.d.ts +60 -0
  65. package/dist/lib/terrain-support.d.ts.map +1 -0
  66. package/dist/lib/terrain-support.js +150 -0
  67. package/dist/lib/wall-distance.d.ts +60 -0
  68. package/dist/lib/wall-distance.d.ts.map +1 -0
  69. package/dist/lib/wall-distance.js +85 -0
  70. package/dist/lib/zone-quantities.d.ts +21 -0
  71. package/dist/lib/zone-quantities.d.ts.map +1 -0
  72. package/dist/lib/zone-quantities.js +428 -0
  73. package/dist/material-library.d.ts +28 -1
  74. package/dist/material-library.d.ts.map +1 -1
  75. package/dist/material-library.js +2233 -441
  76. package/dist/registry/handles.d.ts +91 -3
  77. package/dist/registry/handles.d.ts.map +1 -1
  78. package/dist/registry/index.d.ts +3 -3
  79. package/dist/registry/index.d.ts.map +1 -1
  80. package/dist/registry/index.js +1 -1
  81. package/dist/registry/registry.d.ts +46 -3
  82. package/dist/registry/registry.d.ts.map +1 -1
  83. package/dist/registry/registry.js +82 -1
  84. package/dist/registry/subtree.d.ts.map +1 -1
  85. package/dist/registry/subtree.js +8 -1
  86. package/dist/registry/types.d.ts +781 -39
  87. package/dist/registry/types.d.ts.map +1 -1
  88. package/dist/schema/index.d.ts +27 -8
  89. package/dist/schema/index.d.ts.map +1 -1
  90. package/dist/schema/index.js +23 -6
  91. package/dist/schema/material.d.ts +3 -2
  92. package/dist/schema/material.d.ts.map +1 -1
  93. package/dist/schema/material.js +7 -5
  94. package/dist/schema/nodes/box-vent.d.ts +2 -2
  95. package/dist/schema/nodes/cabinet.d.ts +536 -0
  96. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  97. package/dist/schema/nodes/cabinet.js +139 -0
  98. package/dist/schema/nodes/ceiling.d.ts +4 -3
  99. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  100. package/dist/schema/nodes/ceiling.js +11 -1
  101. package/dist/schema/nodes/chimney.d.ts +4 -4
  102. package/dist/schema/nodes/column.d.ts +4 -2
  103. package/dist/schema/nodes/column.d.ts.map +1 -1
  104. package/dist/schema/nodes/column.js +5 -0
  105. package/dist/schema/nodes/construction-dimension.d.ts +195 -0
  106. package/dist/schema/nodes/construction-dimension.d.ts.map +1 -0
  107. package/dist/schema/nodes/construction-dimension.js +151 -0
  108. package/dist/schema/nodes/cupola.d.ts +2 -2
  109. package/dist/schema/nodes/door.d.ts +42 -5
  110. package/dist/schema/nodes/door.d.ts.map +1 -1
  111. package/dist/schema/nodes/door.js +32 -0
  112. package/dist/schema/nodes/dormer.d.ts +8 -8
  113. package/dist/schema/nodes/dormer.js +1 -1
  114. package/dist/schema/nodes/downspout.d.ts +2 -2
  115. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  116. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  117. package/dist/schema/nodes/duct-fitting.js +92 -0
  118. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  119. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  120. package/dist/schema/nodes/duct-segment.js +73 -0
  121. package/dist/schema/nodes/duct-terminal.d.ts +58 -0
  122. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  123. package/dist/schema/nodes/duct-terminal.js +53 -0
  124. package/dist/schema/nodes/elevator.d.ts +3 -2
  125. package/dist/schema/nodes/elevator.d.ts.map +1 -1
  126. package/dist/schema/nodes/elevator.js +3 -0
  127. package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
  128. package/dist/schema/nodes/fence.d.ts +20 -2
  129. package/dist/schema/nodes/fence.d.ts.map +1 -1
  130. package/dist/schema/nodes/fence.js +33 -2
  131. package/dist/schema/nodes/gutter.d.ts +2 -2
  132. package/dist/schema/nodes/hvac-equipment.d.ts +64 -0
  133. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  134. package/dist/schema/nodes/hvac-equipment.js +57 -0
  135. package/dist/schema/nodes/item.d.ts +9 -0
  136. package/dist/schema/nodes/item.d.ts.map +1 -1
  137. package/dist/schema/nodes/item.js +24 -0
  138. package/dist/schema/nodes/level.d.ts +2 -1
  139. package/dist/schema/nodes/level.d.ts.map +1 -1
  140. package/dist/schema/nodes/level.js +12 -31
  141. package/dist/schema/nodes/lineset.d.ts +42 -0
  142. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  143. package/dist/schema/nodes/lineset.js +38 -0
  144. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  145. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  146. package/dist/schema/nodes/liquid-line.js +24 -0
  147. package/dist/schema/nodes/measurement.d.ts +306 -0
  148. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  149. package/dist/schema/nodes/measurement.js +87 -0
  150. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  151. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  152. package/dist/schema/nodes/pipe-fitting.js +45 -0
  153. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  154. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  155. package/dist/schema/nodes/pipe-segment.js +36 -0
  156. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  157. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  158. package/dist/schema/nodes/pipe-trap.js +36 -0
  159. package/dist/schema/nodes/ridge-vent.d.ts +18 -3
  160. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  161. package/dist/schema/nodes/ridge-vent.js +221 -3
  162. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  163. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  164. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  165. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  166. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  167. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  168. package/dist/schema/nodes/roof-segment.d.ts +84 -8
  169. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  170. package/dist/schema/nodes/roof-segment.js +290 -5
  171. package/dist/schema/nodes/roof.d.ts +8 -8
  172. package/dist/schema/nodes/shelf.d.ts +4 -2
  173. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  174. package/dist/schema/nodes/shelf.js +8 -0
  175. package/dist/schema/nodes/site.d.ts +9 -0
  176. package/dist/schema/nodes/site.d.ts.map +1 -1
  177. package/dist/schema/nodes/site.js +8 -6
  178. package/dist/schema/nodes/skylight.d.ts +10 -10
  179. package/dist/schema/nodes/slab.d.ts +8 -2
  180. package/dist/schema/nodes/slab.d.ts.map +1 -1
  181. package/dist/schema/nodes/slab.js +18 -2
  182. package/dist/schema/nodes/solar-panel.d.ts +4 -4
  183. package/dist/schema/nodes/spawn.d.ts +1 -0
  184. package/dist/schema/nodes/spawn.d.ts.map +1 -1
  185. package/dist/schema/nodes/spawn.js +2 -0
  186. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  187. package/dist/schema/nodes/stair.d.ts +16 -13
  188. package/dist/schema/nodes/stair.d.ts.map +1 -1
  189. package/dist/schema/nodes/stair.js +11 -1
  190. package/dist/schema/nodes/structural-grid.d.ts +27 -0
  191. package/dist/schema/nodes/structural-grid.d.ts.map +1 -0
  192. package/dist/schema/nodes/structural-grid.js +17 -0
  193. package/dist/schema/nodes/turbine-vent.d.ts +2 -2
  194. package/dist/schema/nodes/wall.d.ts +248 -6
  195. package/dist/schema/nodes/wall.d.ts.map +1 -1
  196. package/dist/schema/nodes/wall.js +266 -0
  197. package/dist/schema/nodes/window.d.ts +40 -3
  198. package/dist/schema/nodes/window.d.ts.map +1 -1
  199. package/dist/schema/nodes/window.js +30 -0
  200. package/dist/schema/nodes/zone.d.ts +22 -0
  201. package/dist/schema/nodes/zone.d.ts.map +1 -1
  202. package/dist/schema/nodes/zone.js +21 -0
  203. package/dist/schema/scene-material.d.ts +41 -0
  204. package/dist/schema/scene-material.d.ts.map +1 -0
  205. package/dist/schema/scene-material.js +9 -0
  206. package/dist/schema/terrain.d.ts +28 -0
  207. package/dist/schema/terrain.d.ts.map +1 -0
  208. package/dist/schema/terrain.js +30 -0
  209. package/dist/schema/types.d.ts +1300 -97
  210. package/dist/schema/types.d.ts.map +1 -1
  211. package/dist/schema/types.js +27 -0
  212. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts +80 -0
  213. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.d.ts.map +1 -0
  214. package/dist/services/__fixtures__/wall-plane-top-boundary-repro.js +89 -0
  215. package/dist/services/alignment-anchors.d.ts.map +1 -1
  216. package/dist/services/alignment-anchors.js +30 -1
  217. package/dist/services/alignment.d.ts +5 -0
  218. package/dist/services/alignment.d.ts.map +1 -1
  219. package/dist/services/alignment.js +2 -0
  220. package/dist/services/hosting.d.ts +8 -0
  221. package/dist/services/hosting.d.ts.map +1 -1
  222. package/dist/services/hosting.js +13 -4
  223. package/dist/services/index.d.ts +9 -2
  224. package/dist/services/index.d.ts.map +1 -1
  225. package/dist/services/index.js +9 -2
  226. package/dist/services/level-height.d.ts +27 -0
  227. package/dist/services/level-height.d.ts.map +1 -0
  228. package/dist/services/level-height.js +94 -0
  229. package/dist/services/opening-guides.d.ts +141 -0
  230. package/dist/services/opening-guides.d.ts.map +1 -0
  231. package/dist/services/opening-guides.js +267 -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/riser-diagram.d.ts +40 -0
  236. package/dist/services/riser-diagram.d.ts.map +1 -0
  237. package/dist/services/riser-diagram.js +110 -0
  238. package/dist/services/snap.d.ts +9 -1
  239. package/dist/services/snap.d.ts.map +1 -1
  240. package/dist/services/snap.js +21 -1
  241. package/dist/services/storey.d.ts +103 -0
  242. package/dist/services/storey.d.ts.map +1 -0
  243. package/dist/services/storey.js +296 -0
  244. package/dist/services/system-graph.d.ts +30 -0
  245. package/dist/services/system-graph.d.ts.map +1 -0
  246. package/dist/services/system-graph.js +164 -0
  247. package/dist/services/validate-dwv.d.ts +32 -0
  248. package/dist/services/validate-dwv.d.ts.map +1 -0
  249. package/dist/services/validate-dwv.js +118 -0
  250. package/dist/store/actions/node-actions.d.ts.map +1 -1
  251. package/dist/store/actions/node-actions.js +141 -4
  252. package/dist/store/history-control.d.ts +31 -0
  253. package/dist/store/history-control.d.ts.map +1 -1
  254. package/dist/store/history-control.js +130 -0
  255. package/dist/store/use-live-terrain.d.ts +65 -0
  256. package/dist/store/use-live-terrain.d.ts.map +1 -0
  257. package/dist/store/use-live-terrain.js +31 -0
  258. package/dist/store/use-live-transforms.d.ts +8 -0
  259. package/dist/store/use-live-transforms.d.ts.map +1 -1
  260. package/dist/store/use-scene.d.ts +47 -3
  261. package/dist/store/use-scene.d.ts.map +1 -1
  262. package/dist/store/use-scene.js +992 -55
  263. package/dist/systems/elevator/elevator-opening-system.d.ts +1 -0
  264. package/dist/systems/elevator/elevator-opening-system.d.ts.map +1 -1
  265. package/dist/systems/elevator/elevator-opening-system.js +27 -21
  266. package/dist/systems/elevator/elevator-service.d.ts +0 -2
  267. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  268. package/dist/systems/elevator/elevator-service.js +2 -24
  269. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  270. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  271. package/dist/systems/fence/fence-centerline.js +37 -0
  272. package/dist/systems/fence/fence-spline.d.ts +39 -0
  273. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  274. package/dist/systems/fence/fence-spline.js +196 -0
  275. package/dist/systems/slab/slab-placement.d.ts +11 -0
  276. package/dist/systems/slab/slab-placement.d.ts.map +1 -0
  277. package/dist/systems/slab/slab-placement.js +14 -0
  278. package/dist/systems/slab/slab-support.d.ts +137 -0
  279. package/dist/systems/slab/slab-support.d.ts.map +1 -0
  280. package/dist/systems/slab/slab-support.js +557 -0
  281. package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
  282. package/dist/systems/stair/stair-footprint.js +21 -11
  283. package/dist/systems/stair/stair-opening-preview.d.ts +1300 -505
  284. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  285. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  286. package/dist/systems/stair/stair-opening-sync.js +19 -33
  287. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  288. package/dist/systems/stair/stair-opening-system.js +32 -4
  289. package/dist/systems/stair/stair-rise.d.ts +22 -0
  290. package/dist/systems/stair/stair-rise.d.ts.map +1 -0
  291. package/dist/systems/stair/stair-rise.js +79 -0
  292. package/dist/systems/wall/wall-curve.d.ts +10 -0
  293. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  294. package/dist/systems/wall/wall-curve.js +1 -1
  295. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  296. package/dist/systems/wall/wall-mitering.js +44 -8
  297. package/dist/systems/wall/wall-top.d.ts +40 -0
  298. package/dist/systems/wall/wall-top.d.ts.map +1 -0
  299. package/dist/systems/wall/wall-top.js +46 -0
  300. package/dist/utils/clone-scene-graph.d.ts +1 -0
  301. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  302. package/dist/utils/clone-scene-graph.js +56 -5
  303. package/dist/utils/heal-scene-graph.d.ts +18 -0
  304. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  305. package/dist/utils/heal-scene-graph.js +106 -0
  306. package/dist/validation/validate-build-json.d.ts +3 -0
  307. package/dist/validation/validate-build-json.d.ts.map +1 -1
  308. package/dist/validation/validate-build-json.js +90 -5
  309. package/package.json +4 -4
  310. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts +0 -2
  311. package/dist/hooks/spatial-grid/floor-placed-elevation.test.d.ts.map +0 -1
  312. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +0 -273
  313. package/dist/lib/space-detection-pause.test.d.ts +0 -2
  314. package/dist/lib/space-detection-pause.test.d.ts.map +0 -1
  315. package/dist/lib/space-detection-pause.test.js +0 -42
  316. package/dist/lib/space-detection.test.d.ts +0 -2
  317. package/dist/lib/space-detection.test.d.ts.map +0 -1
  318. package/dist/lib/space-detection.test.js +0 -77
  319. package/dist/registry/registry.test.d.ts +0 -2
  320. package/dist/registry/registry.test.d.ts.map +0 -1
  321. package/dist/registry/registry.test.js +0 -183
  322. package/dist/registry/relations-resolver.test.d.ts +0 -2
  323. package/dist/registry/relations-resolver.test.d.ts.map +0 -1
  324. package/dist/registry/relations-resolver.test.js +0 -183
  325. package/dist/registry/scene-api.test.d.ts +0 -2
  326. package/dist/registry/scene-api.test.d.ts.map +0 -1
  327. package/dist/registry/scene-api.test.js +0 -183
  328. package/dist/registry/subtree.test.d.ts +0 -2
  329. package/dist/registry/subtree.test.d.ts.map +0 -1
  330. package/dist/registry/subtree.test.js +0 -133
  331. package/dist/schema/asset-url.test.d.ts +0 -2
  332. package/dist/schema/asset-url.test.d.ts.map +0 -1
  333. package/dist/schema/asset-url.test.js +0 -134
  334. package/dist/services/alignment-anchors.test.d.ts +0 -2
  335. package/dist/services/alignment-anchors.test.d.ts.map +0 -1
  336. package/dist/services/alignment-anchors.test.js +0 -383
  337. package/dist/services/alignment.test.d.ts +0 -2
  338. package/dist/services/alignment.test.d.ts.map +0 -1
  339. package/dist/services/alignment.test.js +0 -92
  340. package/dist/services/drag-session.test.d.ts +0 -2
  341. package/dist/services/drag-session.test.d.ts.map +0 -1
  342. package/dist/services/drag-session.test.js +0 -219
  343. package/dist/services/hosting.test.d.ts +0 -2
  344. package/dist/services/hosting.test.d.ts.map +0 -1
  345. package/dist/services/hosting.test.js +0 -199
  346. package/dist/services/movement.test.d.ts +0 -2
  347. package/dist/services/movement.test.d.ts.map +0 -1
  348. package/dist/services/movement.test.js +0 -133
  349. package/dist/services/single-undo-dance.test.d.ts +0 -2
  350. package/dist/services/single-undo-dance.test.d.ts.map +0 -1
  351. package/dist/services/single-undo-dance.test.js +0 -210
  352. package/dist/services/snap.test.d.ts +0 -2
  353. package/dist/services/snap.test.d.ts.map +0 -1
  354. package/dist/services/snap.test.js +0 -95
  355. package/dist/store/actions/node-mutation-sanitize.test.d.ts +0 -2
  356. package/dist/store/actions/node-mutation-sanitize.test.d.ts.map +0 -1
  357. package/dist/store/actions/node-mutation-sanitize.test.js +0 -147
  358. package/dist/store/actions/reparent.test.d.ts +0 -2
  359. package/dist/store/actions/reparent.test.d.ts.map +0 -1
  360. package/dist/store/actions/reparent.test.js +0 -212
  361. package/dist/store/use-scene-elevator-migration.test.d.ts +0 -2
  362. package/dist/store/use-scene-elevator-migration.test.d.ts.map +0 -1
  363. package/dist/store/use-scene-elevator-migration.test.js +0 -109
  364. package/dist/systems/elevator/elevator-opening-sync.test.d.ts +0 -2
  365. package/dist/systems/elevator/elevator-opening-sync.test.d.ts.map +0 -1
  366. package/dist/systems/elevator/elevator-opening-sync.test.js +0 -146
  367. package/dist/systems/elevator/elevator-runtime.test.d.ts +0 -2
  368. package/dist/systems/elevator/elevator-runtime.test.d.ts.map +0 -1
  369. package/dist/systems/elevator/elevator-runtime.test.js +0 -64
  370. package/dist/systems/stair/stair-opening-preview.test.d.ts +0 -2
  371. package/dist/systems/stair/stair-opening-preview.test.d.ts.map +0 -1
  372. package/dist/systems/stair/stair-opening-preview.test.js +0 -80
  373. package/dist/systems/stair/stair-opening-sync.test.d.ts +0 -2
  374. package/dist/systems/stair/stair-opening-sync.test.d.ts.map +0 -1
  375. package/dist/systems/stair/stair-opening-sync.test.js +0 -446
  376. package/dist/utils/clone-scene-graph.test.d.ts +0 -2
  377. package/dist/utils/clone-scene-graph.test.d.ts.map +0 -1
  378. package/dist/utils/clone-scene-graph.test.js +0 -59
@@ -1,14 +1,30 @@
1
- import { CeilingNode, SlabNode, } from '../schema';
1
+ import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id';
2
+ import { CeilingNode, SlabNode, ZoneNode, } from '../schema';
3
+ import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height';
4
+ import { CEILING_CLAMP_MARGIN, findLevelAboveId, getCeilingClampBound, getLevelElevations, getStoredLevelHeight, } from '../services/storey';
2
5
  import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control';
6
+ import { computeWallSlabSupport } from '../systems/slab/slab-support';
3
7
  import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve';
8
+ import { resolveWallTop } from '../systems/wall/wall-top';
4
9
  import { simplifyClosedPolygon } from './polygon-geometry';
10
+ import { terrainSupportLift } from './terrain-support';
5
11
  const DEFAULT_AUTO_SLAB_ELEVATION = 0.05;
6
- const DEFAULT_AUTO_CEILING_HEIGHT = 2.5;
7
12
  const CEILING_HEIGHT_EPSILON = 1e-6;
8
13
  const ROOM_CURVE_TOLERANCE = 0.04;
9
14
  const MAX_CURVE_SUBDIVISION_DEPTH = 6;
10
15
  const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08;
11
16
  const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08;
17
+ // A wall endpoint within this distance of another wall's interior is treated as a
18
+ // T-junction and splits that wall (see `splitStraightWallAtVertices`).
19
+ const WALL_JUNCTION_TOLERANCE = 0.08;
20
+ // An unmatched auto slab/ceiling whose polygon is still substantially covered
21
+ // by a detected room was absorbed by a room merge — the surviving auto surface
22
+ // owns that area, so keeping it would z-fight and it is deleted. Below this
23
+ // coverage the room genuinely ceased to exist (e.g. an enclosing wall was
24
+ // deleted) and the node is demoted to manual so user data survives.
25
+ const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6;
26
+ const COVERAGE_SAMPLE_STEPS = 12;
27
+ const ROOM_VERTICAL_PLANE_EPSILON = 1e-3;
12
28
  function pointFromTuple(point) {
13
29
  return { x: point[0], y: point[1] };
14
30
  }
@@ -116,6 +132,46 @@ function bboxOverlapArea(a, b) {
116
132
  const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY));
117
133
  return ix * iy;
118
134
  }
135
+ // Fraction of `subject`'s area lying inside any of `covers`, estimated by
136
+ // sampling a grid of cell centers over the subject's bbox. Cheap and robust
137
+ // enough for the merge-vs-demote decision; exact polygon clipping would be a
138
+ // heavy dependency for a 60% threshold.
139
+ function polygonCoverageRatio(subject, covers) {
140
+ if (subject.length < 3 || covers.length === 0)
141
+ return 0;
142
+ const bbox = bboxOf(subject);
143
+ const width = bbox.maxX - bbox.minX;
144
+ const height = bbox.maxY - bbox.minY;
145
+ let inside = 0;
146
+ let covered = 0;
147
+ for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) {
148
+ for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) {
149
+ const point = {
150
+ x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width,
151
+ y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height,
152
+ };
153
+ if (!pointInPolygon(point, subject))
154
+ continue;
155
+ inside += 1;
156
+ if (pointInAnyPolygon(point, covers))
157
+ covered += 1;
158
+ }
159
+ }
160
+ if (inside === 0) {
161
+ return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0;
162
+ }
163
+ return covered / inside;
164
+ }
165
+ // Demoted auto surfaces keep their polygon untouched, so a re-closed room
166
+ // usually hits the exact-signature manual check. Coverage handles the rest:
167
+ // a room split across multiple manual surfaces AND a single manual surface
168
+ // spanning multiple rooms both suppress a replacement auto surface — what
169
+ // matters is that the ROOM is already substantially covered, not that any
170
+ // one manual surface belongs to it (a per-surface "mostly inside the room"
171
+ // filter dropped multi-room slabs and resurrected deleted auto slabs).
172
+ function matchesManualFootprint(roomPolygon, manualPolygons) {
173
+ return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD;
174
+ }
119
175
  function pointDistanceToPolygonBoundary(point, polygon) {
120
176
  let minDistance = Number.POSITIVE_INFINITY;
121
177
  for (let index = 0; index < polygon.length; index += 1) {
@@ -144,51 +200,56 @@ function wallBoundsRoom(wall, roomPolygon) {
144
200
  const matchingPoints = candidates.filter((point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE);
145
201
  return matchingPoints.length >= 2;
146
202
  }
147
- function pointIsOnSlab(point, slab) {
148
- if (slab.polygon.length < 3)
149
- return false;
150
- const slabPolygon = slab.polygon.map(pointFromTuple);
151
- if (!pointInPolygon(point, slabPolygon))
152
- return false;
153
- for (const hole of slab.holes ?? []) {
154
- if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) {
155
- return false;
156
- }
157
- }
158
- return true;
203
+ /**
204
+ * The clamp bound for a ceiling polygon under this planning context —
205
+ * the context's cross-level resolver when provided, else the plane-only
206
+ * `storeyHeight - CEILING_CLAMP_MARGIN` degradation.
207
+ */
208
+ function resolveCeilingClampBound(polygon, context) {
209
+ if (context.ceilingClampBound)
210
+ return context.ceilingClampBound(polygon);
211
+ return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN;
159
212
  }
160
- function slabSupportsRoom(roomPolygon, slab) {
161
- if (slab.polygon.length < 3)
162
- return false;
163
- if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) {
164
- return true;
165
- }
166
- return pointIsOnSlab(polygonCentroid(roomPolygon), slab);
213
+ function consensusElevation(values) {
214
+ if (values.length === 0 || values.some((value) => !Number.isFinite(value)))
215
+ return undefined;
216
+ const min = Math.min(...values);
217
+ const max = Math.max(...values);
218
+ if (max - min > ROOM_VERTICAL_PLANE_EPSILON)
219
+ return undefined;
220
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
167
221
  }
168
- function resolveRoomSlabElevation(roomPolygon, slabs = []) {
169
- let maxElevation = 0;
170
- for (const slab of slabs) {
171
- if (!slabSupportsRoom(roomPolygon, slab))
222
+ function autoRoomVerticalPlacements(spaces, walls, supportSlabs, nodes, storeyHeight) {
223
+ const wallsById = new Map(walls.map((wall) => [wall.id, wall]));
224
+ const placements = new Map();
225
+ for (const space of spaces) {
226
+ const boundaryWalls = space.wallIds.flatMap((id) => {
227
+ const wall = wallsById.get(id);
228
+ return wall ? [wall] : [];
229
+ });
230
+ if (boundaryWalls.length !== space.wallIds.length)
172
231
  continue;
173
- maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION);
174
- }
175
- return maxElevation;
176
- }
177
- function resolveRoomWallHeight(roomPolygon, walls = []) {
178
- let maxHeight = 0;
179
- for (const wall of walls) {
180
- if (!wallBoundsRoom(wall, roomPolygon))
232
+ const wallBases = boundaryWalls.map((wall) => {
233
+ const offset = wall.supportOffset ?? 0;
234
+ if (wall.supportSlabId === GROUND_SUPPORT_ID) {
235
+ return ((terrainSupportLift(nodes, space.levelId, wall.start[0], wall.start[1]) ?? 0) + offset);
236
+ }
237
+ return (computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null).elevation +
238
+ offset);
239
+ });
240
+ const base = consensusElevation(wallBases);
241
+ if (base === undefined)
181
242
  continue;
182
- const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT;
183
- if (Number.isFinite(height)) {
184
- maxHeight = Math.max(maxHeight, height);
185
- }
243
+ const wallTops = boundaryWalls.map((wall, index) => resolveWallTop(wall, storeyHeight, wallBases[index] ?? base));
244
+ const top = consensusElevation(wallTops);
245
+ if (top === undefined)
246
+ continue;
247
+ placements.set(polygonSignature(space.polygon.map(pointFromTuple)), {
248
+ slabElevation: base + DEFAULT_AUTO_SLAB_ELEVATION,
249
+ ceilingHeight: top - CEILING_CLAMP_MARGIN,
250
+ });
186
251
  }
187
- return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT;
188
- }
189
- function resolveAutoCeilingHeight(roomPolygon, context = {}) {
190
- return (resolveRoomSlabElevation(roomPolygon, context.slabs) +
191
- resolveRoomWallHeight(roomPolygon, context.walls));
252
+ return placements;
192
253
  }
193
254
  function getWallDirection(wall) {
194
255
  const dx = wall.end[0] - wall.start[0];
@@ -240,11 +301,52 @@ function sampleWallPointsForRoomDetection(wall, tolerance = ROOM_CURVE_TOLERANCE
240
301
  };
241
302
  return subdivide(0, start, 1, end, 0);
242
303
  }
243
- function getDirectedWallBoundaryPoints(wall, forward) {
244
- const points = sampleWallPointsForRoomDetection(wall);
245
- return forward ? points : [...points].reverse();
304
+ function segmentProjection(point, start, end) {
305
+ const dx = end.x - start.x;
306
+ const dy = end.y - start.y;
307
+ const lengthSquared = dx * dx + dy * dy;
308
+ if (lengthSquared < 1e-12) {
309
+ return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) };
310
+ }
311
+ const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared;
312
+ const clampedT = Math.max(0, Math.min(1, t));
313
+ const projX = start.x + clampedT * dx;
314
+ const projY = start.y + clampedT * dy;
315
+ return { t, distance: Math.hypot(point.x - projX, point.y - projY) };
246
316
  }
247
- function extractRoomPolygons(walls) {
317
+ // Break a straight wall at any junction vertex (another wall's endpoint) that
318
+ // lands on its interior, returning the ordered polyline [start, …splits, end].
319
+ // Splitting at the *vertex* position (not the projection) keeps the split node's
320
+ // key identical to the touching wall's endpoint so the two share a graph node.
321
+ function splitStraightWallAtVertices(start, end, vertices) {
322
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
323
+ if (length < 1e-9)
324
+ return [start, end];
325
+ const interior = [];
326
+ for (const vertex of vertices) {
327
+ const { t, distance } = segmentProjection(vertex, start, end);
328
+ if (distance > WALL_JUNCTION_TOLERANCE)
329
+ continue;
330
+ const along = t * length;
331
+ if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE)
332
+ continue;
333
+ interior.push({ point: vertex, t });
334
+ }
335
+ interior.sort((a, b) => a.t - b.t);
336
+ const ordered = [start];
337
+ let lastKey = pointKey(start);
338
+ for (const { point } of interior) {
339
+ const key = pointKey(point);
340
+ if (key === lastKey)
341
+ continue;
342
+ ordered.push(point);
343
+ lastKey = key;
344
+ }
345
+ if (lastKey !== pointKey(end))
346
+ ordered.push(end);
347
+ return ordered;
348
+ }
349
+ function extractRooms(walls) {
248
350
  if (walls.length < 3)
249
351
  return [];
250
352
  const graph = new Map();
@@ -256,35 +358,71 @@ function extractRoomPolygons(walls) {
256
358
  }
257
359
  return key;
258
360
  };
361
+ // Planarize first: collect every wall endpoint as a candidate graph vertex so
362
+ // straight walls can be split at T-junctions where another wall ends mid-span.
363
+ // Without this the touching wall's endpoint is a dangling degree-1 node and the
364
+ // enclosed area (e.g. a room added against the middle of an existing wall)
365
+ // never forms a cycle.
366
+ const vertexByKey = new Map();
367
+ for (const wall of walls) {
368
+ for (const tuple of [wall.start, wall.end]) {
369
+ const point = pointFromTuple(tuple);
370
+ const key = pointKey(point);
371
+ if (!vertexByKey.has(key))
372
+ vertexByKey.set(key, point);
373
+ }
374
+ }
375
+ const vertices = [...vertexByKey.values()];
259
376
  for (const wall of walls) {
260
377
  const start = pointFromTuple(wall.start);
261
378
  const end = pointFromTuple(wall.end);
262
- const startKey = upsertNode(start);
263
- const endKey = upsertNode(end);
264
- if (startKey === endKey)
379
+ if (samePointWithinTolerance(start, end))
265
380
  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),
381
+ // Curved walls keep their sampled polyline as one edge; straight walls split
382
+ // into consecutive sub-edges at their interior junction vertices.
383
+ const subPolylines = isCurvedWall(wall)
384
+ ? [sampleWallPointsForRoomDetection(wall)]
385
+ : (() => {
386
+ const ordered = splitStraightWallAtVertices(start, end, vertices);
387
+ const parts = [];
388
+ for (let index = 0; index < ordered.length - 1; index += 1) {
389
+ parts.push([ordered[index], ordered[index + 1]]);
390
+ }
391
+ return parts;
392
+ })();
393
+ subPolylines.forEach((points, subIndex) => {
394
+ const from = points[0];
395
+ const to = points[points.length - 1];
396
+ const fromKey = upsertNode(from);
397
+ const toKey = upsertNode(to);
398
+ if (fromKey === toKey)
399
+ return;
400
+ const reversePoints = [...points].reverse();
401
+ const forwardId = `${wall.id}#${subIndex}:f`;
402
+ const reverseId = `${wall.id}#${subIndex}:r`;
403
+ halfEdges.set(forwardId, {
404
+ id: forwardId,
405
+ reverseId,
406
+ fromKey,
407
+ toKey,
408
+ angle: Math.atan2(points[1].y - from.y, points[1].x - from.x),
409
+ points,
410
+ wallId: wall.id,
411
+ face: 'front',
412
+ });
413
+ halfEdges.set(reverseId, {
414
+ id: reverseId,
415
+ reverseId: forwardId,
416
+ fromKey: toKey,
417
+ toKey: fromKey,
418
+ angle: Math.atan2(reversePoints[1].y - to.y, reversePoints[1].x - to.x),
419
+ points: reversePoints,
420
+ wallId: wall.id,
421
+ face: 'back',
422
+ });
423
+ graph.get(fromKey)?.outgoing.push(forwardId);
424
+ graph.get(toKey)?.outgoing.push(reverseId);
277
425
  });
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
426
  }
289
427
  const sortedOutgoing = new Map();
290
428
  for (const [key, node] of graph.entries()) {
@@ -305,15 +443,49 @@ function extractRoomPolygons(walls) {
305
443
  const nextIdx = (idx - 1 + outgoing.length) % outgoing.length;
306
444
  return outgoing[nextIdx] ?? null;
307
445
  };
446
+ const splitIntoSimpleCycles = (walkEdgeIds) => {
447
+ const cycles = [];
448
+ const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '');
449
+ if (!firstEdge)
450
+ return cycles;
451
+ const pathEdges = [];
452
+ const pathVertices = [firstEdge.fromKey];
453
+ const vertexIndex = new Map([[firstEdge.fromKey, 0]]);
454
+ for (const edgeId of walkEdgeIds) {
455
+ const edge = halfEdges.get(edgeId);
456
+ if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1])
457
+ return [];
458
+ pathEdges.push(edgeId);
459
+ const repeatedIndex = vertexIndex.get(edge.toKey);
460
+ if (repeatedIndex === undefined) {
461
+ pathVertices.push(edge.toKey);
462
+ vertexIndex.set(edge.toKey, pathVertices.length - 1);
463
+ continue;
464
+ }
465
+ const cycle = pathEdges.slice(repeatedIndex);
466
+ if (cycle.length >= 3)
467
+ cycles.push(cycle);
468
+ for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) {
469
+ vertexIndex.delete(pathVertices[index]);
470
+ }
471
+ pathVertices.length = repeatedIndex + 1;
472
+ pathEdges.length = repeatedIndex;
473
+ }
474
+ return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [];
475
+ };
308
476
  const visitedDirected = new Set();
309
- const faces = [];
310
- const maxSteps = Math.min(500, walls.length * 8 + 20);
477
+ const rooms = [];
478
+ // A face walk cannot revisit a half-edge, so the half-edge count bounds its
479
+ // length. It can revisit a vertex when dangling walls or other graph bridges
480
+ // are traced out and back; those excursions are removed below.
481
+ const maxSteps = Math.min(2000, halfEdges.size + 10);
311
482
  for (const edgeId of halfEdges.keys()) {
312
483
  if (visitedDirected.has(edgeId))
313
484
  continue;
314
485
  const cycleEdgeIds = [];
315
486
  let currentEdgeId = edgeId;
316
487
  let valid = true;
488
+ let closed = false;
317
489
  for (let step = 0; step < maxSteps; step += 1) {
318
490
  const currentEdge = halfEdges.get(currentEdgeId);
319
491
  if (!currentEdge) {
@@ -328,29 +500,64 @@ function extractRoomPolygons(walls) {
328
500
  break;
329
501
  }
330
502
  currentEdgeId = next;
331
- if (currentEdgeId === edgeId)
503
+ if (currentEdgeId === edgeId) {
504
+ closed = true;
332
505
  break;
506
+ }
333
507
  }
334
- if (!valid || cycleEdgeIds.length < 3)
335
- 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)
508
+ if (!(valid && closed) || cycleEdgeIds.length < 3)
344
509
  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);
510
+ for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) {
511
+ const polygon = dedupeSequentialPoints(simpleCycleEdgeIds.flatMap((id, index) => {
512
+ const points = halfEdges.get(id)?.points ?? [];
513
+ return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1);
514
+ }));
515
+ if (polygon.length < 3)
516
+ continue;
517
+ const signedArea = polygonArea(polygon);
518
+ if (signedArea <= 0)
519
+ continue;
520
+ if (signedArea < 0.5 || signedArea > 10_000)
521
+ continue;
522
+ const signature = polygonSignature(polygon);
523
+ if (rooms.some((room) => polygonSignature(room.polygon) === signature))
524
+ continue;
525
+ rooms.push({
526
+ polygon,
527
+ boundaryFaces: simpleCycleEdgeIds.flatMap((id) => {
528
+ const edge = halfEdges.get(id);
529
+ if (!edge)
530
+ return [];
531
+ return [
532
+ {
533
+ wallId: edge.wallId,
534
+ face: edge.face,
535
+ points: edge.points.map(pointToTuple),
536
+ },
537
+ ];
538
+ }),
539
+ });
540
+ }
351
541
  }
352
- faces.sort((a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)));
353
- return faces;
542
+ rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon)));
543
+ return rooms;
544
+ }
545
+ function extractRoomPolygons(walls) {
546
+ return extractRooms(walls).map((room) => room.polygon);
547
+ }
548
+ /**
549
+ * True when `wall` lies on the boundary of a room enclosed by `walls`, using the
550
+ * same planar room graph the auto slab/ceiling sync uses. The wall builder's
551
+ * "Room (auto-close)" mode calls this so drafting stops the moment a segment
552
+ * closes a room — whether the chain loops back to its own start or seals a bay
553
+ * against the middle of an existing wall (a T-junction). Sharing one graph means
554
+ * auto-close and auto-slab detection can never disagree about what is "closed".
555
+ */
556
+ export function wallClosesRoom(walls, wall) {
557
+ const roomPolygons = extractRoomPolygons(walls);
558
+ if (roomPolygons.length === 0)
559
+ return false;
560
+ return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon));
354
561
  }
355
562
  export function resolveWallSurfaceSides(wall, roomPolygons) {
356
563
  if (roomPolygons.length === 0) {
@@ -416,62 +623,147 @@ function wallGeometrySignature(wall) {
416
623
  wall.end[0].toFixed(4),
417
624
  wall.end[1].toFixed(4),
418
625
  (wall.thickness ?? 0.2).toFixed(4),
419
- (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4),
626
+ // Plane-bound (no stored height) is a distinct state, not a default
627
+ // value: it resolves to the storey plane, so it must not alias an
628
+ // explicit height of the same magnitude in the trigger signature.
629
+ wall.height == null ? 'plane' : wall.height.toFixed(4),
630
+ wall.supportSlabId ?? 'elected',
631
+ (wall.supportOffset ?? 0).toFixed(4),
420
632
  getClampedWallCurveOffset(wall).toFixed(4),
421
633
  ].join('|');
422
634
  }
423
635
  function levelWallSnapshot(walls) {
424
636
  return walls.map(wallGeometrySignature).sort().join('||');
425
637
  }
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('||');
638
+ function zoneGeometrySignature(zone) {
639
+ return [
640
+ zone.id,
641
+ zone.autoFromWalls ? 'auto' : 'manual',
642
+ zone.boundaryWallIds.slice().sort().join(','),
643
+ zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'),
644
+ ].join('|');
437
645
  }
646
+ // Slab/ceiling POLYGONS stay out of the trigger signature: including
647
+ // generated footprints caused delete/recreate feedback. Zones are included
648
+ // only so a newly traced room footprint can adopt its enclosing walls
649
+ // without waiting for the next remodel. Slab ELEVATIONS and the level's
650
+ // stored storey height ARE included — both feed the explicit-ceiling
651
+ // re-clamp bound (the storey plane), and neither is rewritten by
652
+ // the sync, so regeneration triggers when they change without feedback.
653
+ // Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation −
654
+ // thickness, recessed pools excluded): a deck created, lowered, or
655
+ // thickened above must re-run the sync below so ceilings re-clamp under
656
+ // it. Same polygon exclusion applies — the level-above's own auto sync
657
+ // rewrites its slab footprints, and hashing them here would re-trigger
658
+ // this level on every remodel above.
438
659
  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
- };
660
+ const wallsByLevel = new Map();
661
+ const zonesByLevel = new Map();
662
+ const slabElevationsByLevel = new Map();
663
+ const coveringUndersidesByLevel = new Map();
445
664
  for (const node of Object.values(nodes)) {
446
665
  if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId))
447
666
  continue;
667
+ const levelId = node.parentId;
448
668
  if (node.type === 'wall') {
449
- getEntry(node.parentId).walls.push(node);
669
+ const walls = wallsByLevel.get(levelId) ?? [];
670
+ walls.push(node);
671
+ wallsByLevel.set(levelId, walls);
672
+ }
673
+ else if (node.type === 'zone') {
674
+ const zones = zonesByLevel.get(levelId) ?? [];
675
+ zones.push(ZoneNode.parse(node));
676
+ zonesByLevel.set(levelId, zones);
450
677
  }
451
678
  else if (node.type === 'slab') {
452
- getEntry(node.parentId).slabs.push(SlabNode.parse(node));
679
+ const elevations = slabElevationsByLevel.get(levelId) ?? [];
680
+ elevations.push(`${node.id}:${(node.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`);
681
+ slabElevationsByLevel.set(levelId, elevations);
682
+ if (node.recessed !== true) {
683
+ const undersides = coveringUndersidesByLevel.get(levelId) ?? [];
684
+ const elevation = node.elevation ?? 0.05;
685
+ const thickness = node.thickness ?? 0.05;
686
+ undersides.push(`${node.id}:${(elevation - thickness).toFixed(4)}`);
687
+ coveringUndersidesByLevel.set(levelId, undersides);
688
+ }
453
689
  }
454
690
  }
691
+ const levelElevations = getLevelElevations(nodes);
455
692
  const snapshots = new Map();
456
- for (const [levelId, entry] of byLevel.entries()) {
457
- snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`);
693
+ const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]);
694
+ for (const levelId of levelIds) {
695
+ const walls = wallsByLevel.get(levelId) ?? [];
696
+ const zones = zonesByLevel.get(levelId) ?? [];
697
+ const level = nodes[levelId];
698
+ const storeyKey = level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '';
699
+ const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';');
700
+ const aboveId = findLevelAboveId(levelId, levelElevations);
701
+ const aboveSlabKey = aboveId
702
+ ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';')
703
+ : '';
704
+ snapshots.set(levelId, `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`);
458
705
  }
459
706
  return snapshots;
460
707
  }
461
- function buildSpace(levelId, polygon) {
462
- const signature = polygonSignature(polygon);
708
+ function buildSpace(levelId, room) {
709
+ const signature = polygonSignature(room.polygon);
463
710
  return {
464
711
  id: `space-${levelId}-${signature.slice(0, 12)}`,
465
712
  levelId,
466
- polygon: polygon.map(pointToTuple),
467
- wallIds: [],
713
+ polygon: room.polygon.map(pointToTuple),
714
+ wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))],
715
+ boundaryFaces: room.boundaryFaces,
468
716
  isExterior: false,
469
717
  };
470
718
  }
471
- export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
719
+ function sameStringSet(a, b) {
720
+ if (a.length !== b.length)
721
+ return false;
722
+ const right = new Set(b);
723
+ return a.every((value) => right.has(value));
724
+ }
725
+ export function planAutoZonesForLevel(spaces, existingZones) {
726
+ const update = [];
727
+ for (const zone of existingZones) {
728
+ const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
729
+ const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
730
+ ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
731
+ : spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
732
+ if (!matchingSpace)
733
+ continue;
734
+ const data = {};
735
+ if (!zone.autoFromWalls)
736
+ data.autoFromWalls = true;
737
+ if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
738
+ data.boundaryWallIds = matchingSpace.wallIds;
739
+ }
740
+ if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
741
+ data.polygon = matchingSpace.polygon;
742
+ }
743
+ if (Object.keys(data).length > 0)
744
+ update.push({ id: zone.id, data });
745
+ }
746
+ return { update };
747
+ }
748
+ export function resolveAutoZonePolygon(zone, resolve) {
749
+ if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
750
+ return zone.polygon;
751
+ const walls = zone.boundaryWallIds.flatMap((id) => {
752
+ const node = resolve(id);
753
+ return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
754
+ ? [node]
755
+ : [];
756
+ });
757
+ if (walls.length !== zone.boundaryWallIds.length)
758
+ return zone.polygon;
759
+ const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
760
+ return room ? room.polygon.map(pointToTuple) : zone.polygon;
761
+ }
762
+ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {}) {
472
763
  const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
473
764
  const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
474
- const detected = roomPolygons
765
+ const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple));
766
+ const detectedAll = roomPolygons
475
767
  .map((poly) => ({
476
768
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
477
769
  sig: '',
@@ -485,8 +777,8 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
485
777
  centroid: polygonCentroid(room.poly),
486
778
  area: Math.abs(polygonArea(room.poly)),
487
779
  bbox: bboxOf(room.poly),
488
- }))
489
- .filter(({ sig }) => !manualSignatures.has(sig));
780
+ }));
781
+ const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
490
782
  const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
491
783
  const existingAutoMeta = existingAuto.map((slab) => {
492
784
  const poly = slab.polygon.map(pointFromTuple);
@@ -503,15 +795,21 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
503
795
  const updatesById = new Map();
504
796
  const autoBySignature = new Map();
505
797
  for (const entry of existingAutoMeta) {
506
- autoBySignature.set(entry.sig, entry);
798
+ const bucket = autoBySignature.get(entry.sig) ?? [];
799
+ bucket.push(entry);
800
+ autoBySignature.set(entry.sig, bucket);
507
801
  }
508
802
  detected.forEach((room, index) => {
509
- const existing = autoBySignature.get(room.sig);
803
+ const existing = autoBySignature.get(room.sig)?.shift();
510
804
  if (!existing)
511
805
  return;
512
806
  matchedDetectedIdx.add(index);
513
807
  matchedSlabIds.add(existing.slab.id);
514
- updatesById.set(existing.slab.id, room.poly.map(pointToTuple));
808
+ const polygon = room.poly.map(pointToTuple);
809
+ updatesById.set(existing.slab.id, {
810
+ polygon,
811
+ elevation: context.elevationForRoom?.(polygon),
812
+ });
515
813
  });
516
814
  const remainingDetected = detected
517
815
  .map((room, index) => ({ room, index }))
@@ -540,19 +838,46 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
540
838
  continue;
541
839
  matchedDetectedIdx.add(index);
542
840
  matchedSlabIds.add(bestMatch.entry.slab.id);
543
- updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple));
544
- }
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
- });
841
+ const polygon = room.poly.map(pointToTuple);
842
+ updatesById.set(bestMatch.entry.slab.id, {
843
+ polygon,
844
+ elevation: context.elevationForRoom?.(polygon),
845
+ });
846
+ }
847
+ const detectedRoomPolygons = detectedAll.map((room) => room.poly);
848
+ const slabsToDelete = [];
849
+ const slabDemotions = [];
850
+ for (const slab of existingAuto) {
851
+ if (updatesById.has(slab.id))
852
+ continue;
853
+ const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons);
854
+ if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
855
+ slabsToDelete.push(slab.id);
856
+ }
857
+ else {
858
+ // Render offsets derive from level context at geometry build time, so
859
+ // demotion leaves the stored polygon untouched (same as ceilings).
860
+ slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } });
861
+ }
862
+ }
863
+ const slabsToUpdate = [
864
+ ...existingAuto
865
+ .filter((slab) => updatesById.has(slab.id))
866
+ .flatMap((slab) => {
867
+ const update = updatesById.get(slab.id);
868
+ if (!update)
869
+ return [];
870
+ const data = {};
871
+ if (!sameTuplePolygon(slab.polygon, update.polygon))
872
+ data.polygon = update.polygon;
873
+ if (update.elevation !== undefined &&
874
+ Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON) {
875
+ data.elevation = update.elevation;
876
+ }
877
+ return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : [];
878
+ }),
879
+ ...slabDemotions,
880
+ ];
556
881
  const plannedSlabsForNaming = [...existingSlabs];
557
882
  const slabsToCreate = [];
558
883
  for (let index = 0; index < detected.length; index += 1) {
@@ -563,11 +888,15 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
563
888
  continue;
564
889
  const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab');
565
890
  plannedSlabsForNaming.push({ name });
891
+ const polygon = room.poly.map(pointToTuple);
892
+ const elevation = context.elevationForRoom?.(polygon);
566
893
  slabsToCreate.push(SlabNode.parse({
567
894
  name,
568
- polygon: room.poly.map(pointToTuple),
895
+ polygon,
569
896
  holes: [],
570
- elevation: DEFAULT_AUTO_SLAB_ELEVATION,
897
+ elevation: elevation !== undefined && Number.isFinite(elevation)
898
+ ? elevation
899
+ : DEFAULT_AUTO_SLAB_ELEVATION,
571
900
  autoFromWalls: true,
572
901
  }));
573
902
  }
@@ -577,8 +906,8 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs) {
577
906
  delete: slabsToDelete,
578
907
  };
579
908
  }
580
- function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore) {
581
- const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs);
909
+ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore, context = {}) {
910
+ const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context);
582
911
  if (plan.delete.length > 0) {
583
912
  sceneStore.getState().deleteNodes(plan.delete);
584
913
  }
@@ -590,26 +919,11 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore)
590
919
  }
591
920
  return plan;
592
921
  }
593
- export function projectAutoSlabsForPlan(existingSlabs, plan) {
594
- const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab]));
595
- for (const id of plan.delete) {
596
- slabsById.delete(id);
597
- }
598
- for (const update of plan.update) {
599
- const slab = slabsById.get(update.id);
600
- if (!slab)
601
- continue;
602
- slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data }));
603
- }
604
- for (const slab of plan.create) {
605
- slabsById.set(slab.id, slab);
606
- }
607
- return [...slabsById.values()];
608
- }
609
922
  export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}) {
610
923
  const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
611
924
  const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
612
- const detected = roomPolygons
925
+ const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple));
926
+ const detectedAll = roomPolygons
613
927
  .map((poly) => ({
614
928
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
615
929
  sig: '',
@@ -623,9 +937,8 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
623
937
  centroid: polygonCentroid(room.poly),
624
938
  area: Math.abs(polygonArea(room.poly)),
625
939
  bbox: bboxOf(room.poly),
626
- ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
627
- }))
628
- .filter(({ sig }) => !manualSignatures.has(sig));
940
+ }));
941
+ const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
629
942
  const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
630
943
  const existingAutoMeta = existingAuto.map((ceiling) => {
631
944
  const poly = ceiling.polygon.map(pointFromTuple);
@@ -642,17 +955,20 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
642
955
  const updatesById = new Map();
643
956
  const autoBySignature = new Map();
644
957
  for (const entry of existingAutoMeta) {
645
- autoBySignature.set(entry.sig, entry);
958
+ const bucket = autoBySignature.get(entry.sig) ?? [];
959
+ bucket.push(entry);
960
+ autoBySignature.set(entry.sig, bucket);
646
961
  }
647
962
  detected.forEach((room, index) => {
648
- const existing = autoBySignature.get(room.sig);
963
+ const existing = autoBySignature.get(room.sig)?.shift();
649
964
  if (!existing)
650
965
  return;
651
966
  matchedDetectedIdx.add(index);
652
967
  matchedCeilingIds.add(existing.ceiling.id);
968
+ const polygon = room.poly.map(pointToTuple);
653
969
  updatesById.set(existing.ceiling.id, {
654
- polygon: room.poly.map(pointToTuple),
655
- height: room.ceilingHeight,
970
+ polygon,
971
+ height: context.heightForRoom?.(polygon),
656
972
  });
657
973
  });
658
974
  const remainingDetected = detected
@@ -682,30 +998,64 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
682
998
  continue;
683
999
  matchedDetectedIdx.add(index);
684
1000
  matchedCeilingIds.add(bestMatch.entry.ceiling.id);
1001
+ const polygon = room.poly.map(pointToTuple);
685
1002
  updatesById.set(bestMatch.entry.ceiling.id, {
686
- polygon: room.poly.map(pointToTuple),
687
- height: room.ceilingHeight,
1003
+ polygon,
1004
+ height: context.heightForRoom?.(polygon),
688
1005
  });
689
1006
  }
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;
1007
+ const detectedRoomPolygons = detectedAll.map((room) => room.poly);
1008
+ const ceilingsToDelete = [];
1009
+ const ceilingDemotions = [];
1010
+ for (const ceiling of existingAuto) {
1011
+ if (updatesById.has(ceiling.id))
1012
+ continue;
1013
+ const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons);
1014
+ if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
1015
+ ceilingsToDelete.push(ceiling.id);
702
1016
  }
703
- if (Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
704
- CEILING_HEIGHT_EPSILON) {
705
- data.height = update.height;
1017
+ else {
1018
+ ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } });
706
1019
  }
707
- return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }];
1020
+ }
1021
+ // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab
1022
+ // created, moved, or thickened on the level above can leave an EXISTING
1023
+ // manual explicit-height ceiling poking into its solid. Clamp explicit
1024
+ // heights down to the bound; never raise them — a user-lowered ceiling
1025
+ // is intent, only an over-bound one is a conflict. Follows-mode
1026
+ // ceilings (absent height) derive under the bound by construction and
1027
+ // are skipped, so the clamp can never convert one to an explicit
1028
+ // height.
1029
+ const manualClamps = manualCeilings.flatMap((ceiling) => {
1030
+ if (ceiling.height == null)
1031
+ return [];
1032
+ const bound = resolveCeilingClampBound(ceiling.polygon, context);
1033
+ if (!Number.isFinite(bound))
1034
+ return [];
1035
+ return ceiling.height > bound + CEILING_HEIGHT_EPSILON
1036
+ ? [{ id: ceiling.id, data: { height: bound } }]
1037
+ : [];
708
1038
  });
1039
+ const ceilingsToUpdate = [
1040
+ ...existingAuto
1041
+ .filter((ceiling) => updatesById.has(ceiling.id))
1042
+ .flatMap((ceiling) => {
1043
+ const update = updatesById.get(ceiling.id);
1044
+ if (!update)
1045
+ return [];
1046
+ const data = {};
1047
+ if (!sameTuplePolygon(ceiling.polygon, update.polygon))
1048
+ data.polygon = update.polygon;
1049
+ if (update.height !== undefined &&
1050
+ (ceiling.height === undefined ||
1051
+ Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON)) {
1052
+ data.height = update.height;
1053
+ }
1054
+ return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : [];
1055
+ }),
1056
+ ...ceilingDemotions,
1057
+ ...manualClamps,
1058
+ ];
709
1059
  const plannedCeilingsForNaming = [...existingCeilings];
710
1060
  const ceilingsToCreate = [];
711
1061
  for (let index = 0; index < detected.length; index += 1) {
@@ -716,11 +1066,13 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
716
1066
  continue;
717
1067
  const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling');
718
1068
  plannedCeilingsForNaming.push({ name });
1069
+ const polygon = room.poly.map(pointToTuple);
1070
+ const height = context.heightForRoom?.(polygon);
719
1071
  ceilingsToCreate.push(CeilingNode.parse({
720
1072
  name,
721
- polygon: room.poly.map(pointToTuple),
1073
+ polygon,
722
1074
  holes: [],
723
- height: room.ceilingHeight,
1075
+ ...(height !== undefined && Number.isFinite(height) ? { height } : {}),
724
1076
  autoFromWalls: true,
725
1077
  }));
726
1078
  }
@@ -743,14 +1095,15 @@ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, scene
743
1095
  }
744
1096
  }
745
1097
  function detectSpacesFromWalls(levelId, walls) {
746
- const roomPolygons = extractRoomPolygons(walls);
1098
+ const rooms = extractRooms(walls);
1099
+ const roomPolygons = rooms.map((room) => room.polygon);
747
1100
  const wallUpdates = walls.map((wall) => ({
748
1101
  wallId: wall.id,
749
1102
  ...resolveWallSurfaceSides(wall, roomPolygons),
750
1103
  }));
751
1104
  return {
752
1105
  roomPolygons,
753
- spaces: roomPolygons.map((polygon) => buildSpace(levelId, polygon)),
1106
+ spaces: rooms.map((room) => buildSpace(levelId, room)),
754
1107
  wallUpdates,
755
1108
  };
756
1109
  }
@@ -770,6 +1123,7 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
770
1123
  const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
771
1124
  const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
772
1125
  const ceilings = Object.values(nodes).filter((node) => node?.type === 'ceiling' && node.parentId === levelId);
1126
+ const zones = Object.values(nodes).filter((node) => node?.type === 'zone' && node.parentId === levelId);
773
1127
  const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
774
1128
  const changedWallUpdates = wallUpdates.filter((update) => {
775
1129
  const wall = nodes[update.wallId];
@@ -784,10 +1138,27 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
784
1138
  },
785
1139
  })));
786
1140
  }
1141
+ const levelNode = nodes[levelId];
1142
+ const storeyHeight = levelNode?.type === 'level'
1143
+ ? getStoredLevelHeight(levelNode)
1144
+ : DEFAULT_LEVEL_HEIGHT;
787
1145
  const parsedSlabs = slabs.map((slab) => SlabNode.parse(slab));
788
- const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore);
789
- const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan);
790
- syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore, { walls, slabs: projectedSlabs });
1146
+ const verticalPlacements = autoRoomVerticalPlacements(spaces, walls,
1147
+ // A derived floor cannot be evidence for its own next elevation: that
1148
+ // would lift unpinned walls, then lift the floor again on every pass.
1149
+ parsedSlabs.filter((slab) => !slab.autoFromWalls), nodes, storeyHeight);
1150
+ const placementFor = (polygon) => verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1151
+ syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore, {
1152
+ elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation,
1153
+ });
1154
+ syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore, {
1155
+ storeyHeight,
1156
+ ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
1157
+ heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight,
1158
+ });
1159
+ const zonePlan = planAutoZonesForLevel(spaces, zones.map((zone) => ZoneNode.parse(zone)));
1160
+ if (zonePlan.update.length > 0)
1161
+ updateNodes(zonePlan.update);
791
1162
  for (const space of spaces) {
792
1163
  nextSpaces[space.id] = space;
793
1164
  }
@@ -815,7 +1186,11 @@ export function isSpaceDetectionPaused() {
815
1186
  return spaceDetectionPauseDepth > 0;
816
1187
  }
817
1188
  export function initSpaceDetectionSync(sceneStore, editorStore) {
818
- const previousSnapshots = new Map();
1189
+ // Baseline from whatever is already in the store. Detection reacts to wall
1190
+ // edits made IN-SESSION (create / move / delete); it must not re-litigate a
1191
+ // scene that merely loaded — rerunning on hydration resurrected auto slabs
1192
+ // the user had deleted in an earlier session.
1193
+ const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
819
1194
  let isProcessing = false;
820
1195
  const unsubscribe = sceneStore.subscribe((state) => {
821
1196
  if (isProcessing)
@@ -836,7 +1211,14 @@ export function initSpaceDetectionSync(sceneStore, editorStore) {
836
1211
  }
837
1212
  const levelsToUpdate = new Set();
838
1213
  for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
839
- if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {
1214
+ // First sight of a level is a hydration baseline, not a wall edit —
1215
+ // `setScene` delivers a loaded scene as one atomic update, and a level's
1216
+ // first wall can't close a room anyway. Record it (below) and only
1217
+ // react to subsequent changes.
1218
+ const previous = previousSnapshots.get(levelId);
1219
+ if (previous === undefined)
1220
+ continue;
1221
+ if (previous !== (currentSnapshots.get(levelId) ?? '')) {
840
1222
  levelsToUpdate.add(levelId);
841
1223
  }
842
1224
  }