@pascal-app/core 1.0.0-beta.4 → 1.0.0

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 (263) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/dist/capture/index.d.ts +3 -0
  4. package/dist/capture/index.d.ts.map +1 -0
  5. package/dist/capture/index.js +2 -0
  6. package/dist/capture/schema.d.ts +417 -0
  7. package/dist/capture/schema.d.ts.map +1 -0
  8. package/dist/capture/schema.js +348 -0
  9. package/dist/capture/source.d.ts +60 -0
  10. package/dist/capture/source.d.ts.map +1 -0
  11. package/dist/capture/source.js +254 -0
  12. package/dist/events/bus.d.ts +55 -7
  13. package/dist/events/bus.d.ts.map +1 -1
  14. package/dist/events/hidden-wall-pointer-hold.d.ts +28 -0
  15. package/dist/events/hidden-wall-pointer-hold.d.ts.map +1 -0
  16. package/dist/events/hidden-wall-pointer-hold.js +37 -0
  17. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
  18. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  19. package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
  20. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +2785 -1
  21. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  22. package/dist/hooks/spatial-grid/spatial-grid-sync.js +132 -19
  23. package/dist/hooks/spatial-grid/support-host-patch.d.ts +45 -1
  24. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -1
  25. package/dist/hooks/spatial-grid/support-host-patch.js +171 -6
  26. package/dist/index.d.ts +15 -6
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +17 -5
  29. package/dist/lib/asset-storage.d.ts.map +1 -1
  30. package/dist/lib/asset-storage.js +4 -1
  31. package/dist/lib/polygon-union.d.ts +4 -0
  32. package/dist/lib/polygon-union.d.ts.map +1 -0
  33. package/dist/lib/polygon-union.js +306 -0
  34. package/dist/lib/roof-overlap.d.ts +32 -0
  35. package/dist/lib/roof-overlap.d.ts.map +1 -0
  36. package/dist/lib/roof-overlap.js +72 -0
  37. package/dist/lib/room-topology-index.d.ts +50 -0
  38. package/dist/lib/room-topology-index.d.ts.map +1 -0
  39. package/dist/lib/room-topology-index.js +237 -0
  40. package/dist/lib/slab-polygon.d.ts +35 -2
  41. package/dist/lib/slab-polygon.d.ts.map +1 -1
  42. package/dist/lib/slab-polygon.js +133 -27
  43. package/dist/lib/space-detection.d.ts +40 -3
  44. package/dist/lib/space-detection.d.ts.map +1 -1
  45. package/dist/lib/space-detection.js +890 -246
  46. package/dist/registry/handles.d.ts +24 -3
  47. package/dist/registry/handles.d.ts.map +1 -1
  48. package/dist/registry/index.d.ts +3 -3
  49. package/dist/registry/index.d.ts.map +1 -1
  50. package/dist/registry/index.js +1 -1
  51. package/dist/registry/registry.d.ts +29 -6
  52. package/dist/registry/registry.d.ts.map +1 -1
  53. package/dist/registry/registry.js +111 -4
  54. package/dist/registry/scene-api.d.ts +16 -0
  55. package/dist/registry/scene-api.d.ts.map +1 -1
  56. package/dist/registry/scene-api.js +49 -1
  57. package/dist/registry/types.d.ts +261 -13
  58. package/dist/registry/types.d.ts.map +1 -1
  59. package/dist/registry/use-registry-version.d.ts +11 -0
  60. package/dist/registry/use-registry-version.d.ts.map +1 -0
  61. package/dist/registry/use-registry-version.js +15 -0
  62. package/dist/schema/__bench__/node-parsers.bench.d.ts +24 -0
  63. package/dist/schema/__bench__/node-parsers.bench.d.ts.map +1 -0
  64. package/dist/schema/__bench__/node-parsers.bench.js +314 -0
  65. package/dist/schema/__fixtures__/node-fixtures.d.ts +28 -0
  66. package/dist/schema/__fixtures__/node-fixtures.d.ts.map +1 -0
  67. package/dist/schema/__fixtures__/node-fixtures.js +125 -0
  68. package/dist/schema/base.d.ts +1 -1
  69. package/dist/schema/base.d.ts.map +1 -1
  70. package/dist/schema/base.js +8 -1
  71. package/dist/schema/compiled-node-parsers.d.ts +40 -0
  72. package/dist/schema/compiled-node-parsers.d.ts.map +1 -0
  73. package/dist/schema/compiled-node-parsers.js +95 -0
  74. package/dist/schema/index.d.ts +20 -15
  75. package/dist/schema/index.d.ts.map +1 -1
  76. package/dist/schema/index.js +19 -12
  77. package/dist/schema/material.d.ts +2 -1
  78. package/dist/schema/material.d.ts.map +1 -1
  79. package/dist/schema/material.js +1 -0
  80. package/dist/schema/nodes/automatic-downspout.d.ts +17 -0
  81. package/dist/schema/nodes/automatic-downspout.d.ts.map +1 -0
  82. package/dist/schema/nodes/automatic-downspout.js +237 -0
  83. package/dist/schema/nodes/block.d.ts +107 -0
  84. package/dist/schema/nodes/block.d.ts.map +1 -0
  85. package/dist/schema/nodes/block.js +207 -0
  86. package/dist/schema/nodes/box-vent.d.ts +8 -2
  87. package/dist/schema/nodes/box-vent.d.ts.map +1 -1
  88. package/dist/schema/nodes/box-vent.js +2 -0
  89. package/dist/schema/nodes/building.d.ts +1 -1
  90. package/dist/schema/nodes/cabinet.d.ts +25 -3
  91. package/dist/schema/nodes/cabinet.d.ts.map +1 -1
  92. package/dist/schema/nodes/cabinet.js +26 -5
  93. package/dist/schema/nodes/ceiling.d.ts +1 -1
  94. package/dist/schema/nodes/chimney.d.ts +2 -2
  95. package/dist/schema/nodes/column.d.ts +6 -6
  96. package/dist/schema/nodes/construction-dimension.d.ts +4 -4
  97. package/dist/schema/nodes/cupola.d.ts +9 -1
  98. package/dist/schema/nodes/cupola.d.ts.map +1 -1
  99. package/dist/schema/nodes/cupola.js +2 -0
  100. package/dist/schema/nodes/door.d.ts +1 -1
  101. package/dist/schema/nodes/dormer.d.ts +44 -1
  102. package/dist/schema/nodes/dormer.d.ts.map +1 -1
  103. package/dist/schema/nodes/dormer.js +131 -7
  104. package/dist/schema/nodes/downspout.d.ts +11 -1
  105. package/dist/schema/nodes/downspout.d.ts.map +1 -1
  106. package/dist/schema/nodes/downspout.js +24 -0
  107. package/dist/schema/nodes/duct-fitting.d.ts +18 -1
  108. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -1
  109. package/dist/schema/nodes/duct-fitting.js +25 -4
  110. package/dist/schema/nodes/duct-segment.d.ts +23 -1
  111. package/dist/schema/nodes/duct-segment.d.ts.map +1 -1
  112. package/dist/schema/nodes/duct-segment.js +20 -0
  113. package/dist/schema/nodes/duct-terminal.d.ts +1 -1
  114. package/dist/schema/nodes/elevator.d.ts +1 -1
  115. package/dist/schema/nodes/eyebrow-vent.d.ts +8 -2
  116. package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -1
  117. package/dist/schema/nodes/eyebrow-vent.js +2 -0
  118. package/dist/schema/nodes/fence.d.ts +1 -1
  119. package/dist/schema/nodes/guide.d.ts +1 -1
  120. package/dist/schema/nodes/gutter.d.ts +36 -2
  121. package/dist/schema/nodes/gutter.d.ts.map +1 -1
  122. package/dist/schema/nodes/gutter.js +308 -1
  123. package/dist/schema/nodes/hanger-overrides.d.ts +7 -0
  124. package/dist/schema/nodes/hanger-overrides.d.ts.map +1 -0
  125. package/dist/schema/nodes/hanger-overrides.js +6 -0
  126. package/dist/schema/nodes/hvac-equipment.d.ts +1 -1
  127. package/dist/schema/nodes/item.d.ts +4 -3
  128. package/dist/schema/nodes/item.d.ts.map +1 -1
  129. package/dist/schema/nodes/item.js +5 -0
  130. package/dist/schema/nodes/lean-to-extension.d.ts +212 -0
  131. package/dist/schema/nodes/lean-to-extension.d.ts.map +1 -0
  132. package/dist/schema/nodes/lean-to-extension.js +114 -0
  133. package/dist/schema/nodes/level.d.ts +5 -2
  134. package/dist/schema/nodes/level.d.ts.map +1 -1
  135. package/dist/schema/nodes/level.js +9 -0
  136. package/dist/schema/nodes/lineset.d.ts +1 -1
  137. package/dist/schema/nodes/liquid-line.d.ts +1 -1
  138. package/dist/schema/nodes/measurement.d.ts +1 -1
  139. package/dist/schema/nodes/pipe-fitting.d.ts +9 -1
  140. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -1
  141. package/dist/schema/nodes/pipe-fitting.js +6 -2
  142. package/dist/schema/nodes/pipe-segment.d.ts +23 -1
  143. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -1
  144. package/dist/schema/nodes/pipe-segment.js +20 -0
  145. package/dist/schema/nodes/pipe-trap.d.ts +1 -1
  146. package/dist/schema/nodes/ridge-vent.d.ts +1 -1
  147. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  148. package/dist/schema/nodes/ridge-vent.js +4 -1
  149. package/dist/schema/nodes/roof-segment-shape.d.ts +4 -0
  150. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -1
  151. package/dist/schema/nodes/roof-segment-shape.js +64 -1
  152. package/dist/schema/nodes/roof-segment-walls.d.ts +3 -1
  153. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -1
  154. package/dist/schema/nodes/roof-segment-walls.js +1 -1
  155. package/dist/schema/nodes/roof-segment.d.ts +41 -1
  156. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  157. package/dist/schema/nodes/roof-segment.js +79 -2
  158. package/dist/schema/nodes/roof.d.ts +22 -1
  159. package/dist/schema/nodes/roof.d.ts.map +1 -1
  160. package/dist/schema/nodes/roof.js +14 -0
  161. package/dist/schema/nodes/scan.d.ts +19 -2
  162. package/dist/schema/nodes/scan.d.ts.map +1 -1
  163. package/dist/schema/nodes/scan.js +11 -1
  164. package/dist/schema/nodes/shelf.d.ts +2 -2
  165. package/dist/schema/nodes/site.d.ts +1 -1
  166. package/dist/schema/nodes/skylight.d.ts +1 -1
  167. package/dist/schema/nodes/slab.d.ts +1 -1
  168. package/dist/schema/nodes/solar-panel.d.ts +1 -1
  169. package/dist/schema/nodes/spawn.d.ts +1 -1
  170. package/dist/schema/nodes/stair-segment.d.ts +1 -1
  171. package/dist/schema/nodes/stair.d.ts +1 -1
  172. package/dist/schema/nodes/structural-grid.d.ts +1 -1
  173. package/dist/schema/nodes/turbine-vent.d.ts +7 -1
  174. package/dist/schema/nodes/turbine-vent.d.ts.map +1 -1
  175. package/dist/schema/nodes/turbine-vent.js +2 -0
  176. package/dist/schema/nodes/wall.d.ts +4 -4
  177. package/dist/schema/nodes/wall.d.ts.map +1 -1
  178. package/dist/schema/nodes/wall.js +7 -1
  179. package/dist/schema/nodes/window.d.ts +10 -3
  180. package/dist/schema/nodes/window.d.ts.map +1 -1
  181. package/dist/schema/nodes/window.js +4 -0
  182. package/dist/schema/nodes/zone.d.ts +2 -2
  183. package/dist/schema/nodes/zone.js +1 -1
  184. package/dist/schema/types.d.ts +485 -109
  185. package/dist/schema/types.d.ts.map +1 -1
  186. package/dist/schema/types.js +27 -1
  187. package/dist/services/hosting.d.ts +2 -2
  188. package/dist/services/hosting.d.ts.map +1 -1
  189. package/dist/services/hosting.js +4 -4
  190. package/dist/services/index.d.ts +2 -2
  191. package/dist/services/index.d.ts.map +1 -1
  192. package/dist/services/index.js +2 -2
  193. package/dist/services/storey.d.ts +3 -10
  194. package/dist/services/storey.d.ts.map +1 -1
  195. package/dist/services/storey.js +45 -15
  196. package/dist/services/system-graph.d.ts +11 -0
  197. package/dist/services/system-graph.d.ts.map +1 -1
  198. package/dist/services/system-graph.js +35 -5
  199. package/dist/store/actions/node-actions.d.ts +15 -4
  200. package/dist/store/actions/node-actions.d.ts.map +1 -1
  201. package/dist/store/actions/node-actions.js +458 -19
  202. package/dist/store/history-control.d.ts +5 -0
  203. package/dist/store/history-control.d.ts.map +1 -1
  204. package/dist/store/history-control.js +68 -6
  205. package/dist/store/history-invalidation.d.ts +3 -0
  206. package/dist/store/history-invalidation.d.ts.map +1 -0
  207. package/dist/store/history-invalidation.js +72 -0
  208. package/dist/store/scene-hydration.d.ts +5 -0
  209. package/dist/store/scene-hydration.d.ts.map +1 -0
  210. package/dist/store/scene-hydration.js +57 -0
  211. package/dist/store/use-scene.d.ts +3 -0
  212. package/dist/store/use-scene.d.ts.map +1 -1
  213. package/dist/store/use-scene.js +407 -227
  214. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  215. package/dist/systems/elevator/elevator-service.js +16 -11
  216. package/dist/systems/roof/roof-elevation-system.d.ts +3 -0
  217. package/dist/systems/roof/roof-elevation-system.d.ts.map +1 -0
  218. package/dist/systems/roof/roof-elevation-system.js +94 -0
  219. package/dist/systems/roof/roof-elevation.d.ts +4 -0
  220. package/dist/systems/roof/roof-elevation.d.ts.map +1 -0
  221. package/dist/systems/roof/roof-elevation.js +95 -0
  222. package/dist/systems/roof/roof-footprint.d.ts +17 -0
  223. package/dist/systems/roof/roof-footprint.d.ts.map +1 -0
  224. package/dist/systems/roof/roof-footprint.js +83 -0
  225. package/dist/systems/slab/slab-support.d.ts +0 -31
  226. package/dist/systems/slab/slab-support.d.ts.map +1 -1
  227. package/dist/systems/slab/slab-support.js +26 -4
  228. package/dist/systems/stair/stair-flight.d.ts +17 -0
  229. package/dist/systems/stair/stair-flight.d.ts.map +1 -0
  230. package/dist/systems/stair/stair-flight.js +27 -0
  231. package/dist/systems/stair/stair-opening-preview.d.ts +1556 -1328
  232. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  233. package/dist/systems/stair/stair-opening-system.d.ts +1 -0
  234. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  235. package/dist/systems/stair/stair-opening-system.js +93 -96
  236. package/dist/systems/stair/stair-rise.js +21 -20
  237. package/dist/systems/wall/wall-curve.d.ts +1 -0
  238. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  239. package/dist/systems/wall/wall-curve.js +73 -0
  240. package/dist/systems/wall/wall-footprint.d.ts +1 -0
  241. package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
  242. package/dist/systems/wall/wall-footprint.js +1 -0
  243. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  244. package/dist/systems/wall/wall-mitering.js +88 -9
  245. package/dist/systems/wall/wall-topology.d.ts +53 -0
  246. package/dist/systems/wall/wall-topology.d.ts.map +1 -0
  247. package/dist/systems/wall/wall-topology.js +414 -0
  248. package/dist/utils/clone-scene-graph.d.ts +2 -0
  249. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  250. package/dist/utils/clone-scene-graph.js +39 -2
  251. package/dist/utils/scene-migrations.d.ts +1 -0
  252. package/dist/utils/scene-migrations.d.ts.map +1 -1
  253. package/dist/utils/scene-migrations.js +1 -0
  254. package/dist/utils/vertical-scene-migration.d.ts +13 -0
  255. package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
  256. package/dist/utils/vertical-scene-migration.js +179 -0
  257. package/dist/validation/validate-build-json.d.ts +3 -0
  258. package/dist/validation/validate-build-json.d.ts.map +1 -1
  259. package/dist/validation/validate-build-json.js +49 -2
  260. package/package.json +10 -3
  261. package/dist/systems/elevator/elevator-runtime-system.d.ts +0 -2
  262. package/dist/systems/elevator/elevator-runtime-system.d.ts.map +0 -1
  263. package/dist/systems/elevator/elevator-runtime-system.js +0 -8
@@ -1,12 +1,13 @@
1
1
  import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id';
2
2
  import { CeilingNode, SlabNode, ZoneNode, } from '../schema';
3
3
  import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height';
4
- import { CEILING_CLAMP_MARGIN, findLevelAboveId, getCeilingClampBound, getLevelElevations, getStoredLevelHeight, } from '../services/storey';
5
- import { getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, } from '../store/history-control';
4
+ import { CEILING_CLAMP_MARGIN, findLevelAboveId, getCeilingClampBound, getLevelBelow, getLevelElevations, getStoredLevelHeight, } from '../services/storey';
5
+ import { activeSceneCommitNodeIds, getSceneHistoryPauseDepth, pauseSceneHistory, resumeSceneHistory, subscribeSceneCommits, } from '../store/history-control';
6
6
  import { computeWallSlabSupport } from '../systems/slab/slab-support';
7
7
  import { getClampedWallCurveOffset, getWallCurveFrameAt, isCurvedWall, } from '../systems/wall/wall-curve';
8
8
  import { resolveWallTop } from '../systems/wall/wall-top';
9
9
  import { simplifyClosedPolygon } from './polygon-geometry';
10
+ import { distanceToSegment, RoomTopologyIndex, } from './room-topology-index';
10
11
  import { levelBaseElevationAt } from './terrain-support';
11
12
  const DEFAULT_AUTO_SLAB_ELEVATION = 0.05;
12
13
  const CEILING_HEIGHT_EPSILON = 1e-6;
@@ -138,10 +139,10 @@ function bboxOverlapArea(a, b) {
138
139
  // sampling a grid of cell centers over the subject's bbox. Cheap and robust
139
140
  // enough for the merge-vs-demote decision; exact polygon clipping would be a
140
141
  // heavy dependency for a 60% threshold.
141
- function polygonCoverageRatio(subject, covers) {
142
+ function polygonCoverageRatio(subject, covers, subjectBounds) {
142
143
  if (subject.length < 3 || covers.length === 0)
143
144
  return 0;
144
- const bbox = bboxOf(subject);
145
+ const bbox = subjectBounds ?? bboxOf(subject);
145
146
  const width = bbox.maxX - bbox.minX;
146
147
  const height = bbox.maxY - bbox.minY;
147
148
  let inside = 0;
@@ -645,6 +646,245 @@ function sameTuplePolygon(current, next) {
645
646
  return (current.length === next.length &&
646
647
  current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]));
647
648
  }
649
+ function sameTuplePolygonRotation(current, next) {
650
+ if (sameTuplePolygon(current, next))
651
+ return true;
652
+ if (current.length !== next.length)
653
+ return false;
654
+ // Preserve the stored ring when extraction only changes its starting vertex.
655
+ return next.some((_, offset) => current.every((point, index) => {
656
+ const candidate = next[(index + offset) % next.length];
657
+ return point[0] === candidate[0] && point[1] === candidate[1];
658
+ }));
659
+ }
660
+ function sameTuplePolygons(current, next) {
661
+ return (current.length === next.length &&
662
+ current.every((polygon, index) => {
663
+ const nextPolygon = next[index];
664
+ return nextPolygon ? sameTuplePolygon(polygon, nextPolygon) : false;
665
+ }));
666
+ }
667
+ function crossProduct(a, b, c) {
668
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
669
+ }
670
+ function lineIntersection(start, end, clipStart, clipEnd) {
671
+ const segment = { x: end.x - start.x, y: end.y - start.y };
672
+ const clip = { x: clipEnd.x - clipStart.x, y: clipEnd.y - clipStart.y };
673
+ const denominator = segment.x * clip.y - segment.y * clip.x;
674
+ if (Math.abs(denominator) < 1e-9)
675
+ return end;
676
+ const offset = { x: clipStart.x - start.x, y: clipStart.y - start.y };
677
+ const t = (offset.x * clip.y - offset.y * clip.x) / denominator;
678
+ return { x: start.x + segment.x * t, y: start.y + segment.y * t };
679
+ }
680
+ function clipPolygonToConvex(subject, clipPolygon) {
681
+ if (subject.length < 3 || clipPolygon.length < 3)
682
+ return [];
683
+ const orientation = polygonArea(clipPolygon) >= 0 ? 1 : -1;
684
+ let output = [...subject];
685
+ for (let index = 0; index < clipPolygon.length; index += 1) {
686
+ const clipStart = clipPolygon[index];
687
+ const clipEnd = clipPolygon[(index + 1) % clipPolygon.length];
688
+ const input = output;
689
+ output = [];
690
+ if (input.length === 0)
691
+ break;
692
+ let previous = input[input.length - 1];
693
+ let previousInside = orientation * crossProduct(clipStart, clipEnd, previous) >= -1e-8;
694
+ for (const current of input) {
695
+ const currentInside = orientation * crossProduct(clipStart, clipEnd, current) >= -1e-8;
696
+ if (currentInside !== previousInside) {
697
+ output.push(lineIntersection(previous, current, clipStart, clipEnd));
698
+ }
699
+ if (currentInside)
700
+ output.push(current);
701
+ previous = current;
702
+ previousInside = currentInside;
703
+ }
704
+ output = dedupeSequentialPoints(output, 1e-7);
705
+ }
706
+ return output.length >= 3 && Math.abs(polygonArea(output)) > 1e-8 ? output : [];
707
+ }
708
+ function isConvexPolygon(polygon) {
709
+ let direction = 0;
710
+ for (let index = 0; index < polygon.length; index += 1) {
711
+ const cross = crossProduct(polygon[index], polygon[(index + 1) % polygon.length], polygon[(index + 2) % polygon.length]);
712
+ if (Math.abs(cross) < 1e-8)
713
+ continue;
714
+ const nextDirection = Math.sign(cross);
715
+ if (direction !== 0 && nextDirection !== direction)
716
+ return false;
717
+ direction = nextDirection;
718
+ }
719
+ return true;
720
+ }
721
+ function pointInTriangle(point, a, b, c) {
722
+ return (crossProduct(a, b, point) >= -1e-8 &&
723
+ crossProduct(b, c, point) >= -1e-8 &&
724
+ crossProduct(c, a, point) >= -1e-8);
725
+ }
726
+ function triangulatePolygon(polygon) {
727
+ const points = polygonArea(polygon) >= 0 ? [...polygon] : [...polygon].reverse();
728
+ const indices = points.map((_, index) => index);
729
+ const triangles = [];
730
+ let attempts = 0;
731
+ while (indices.length > 3 && attempts < points.length * points.length) {
732
+ let clippedEar = false;
733
+ for (let index = 0; index < indices.length; index += 1) {
734
+ const previousIndex = indices[(index - 1 + indices.length) % indices.length];
735
+ const currentIndex = indices[index];
736
+ const nextIndex = indices[(index + 1) % indices.length];
737
+ const previous = points[previousIndex];
738
+ const current = points[currentIndex];
739
+ const next = points[nextIndex];
740
+ if (crossProduct(previous, current, next) <= 1e-8)
741
+ continue;
742
+ if (indices.some((candidateIndex) => candidateIndex !== previousIndex &&
743
+ candidateIndex !== currentIndex &&
744
+ candidateIndex !== nextIndex &&
745
+ pointInTriangle(points[candidateIndex], previous, current, next))) {
746
+ continue;
747
+ }
748
+ triangles.push([previous, current, next]);
749
+ indices.splice(index, 1);
750
+ clippedEar = true;
751
+ break;
752
+ }
753
+ if (!clippedEar)
754
+ break;
755
+ attempts += 1;
756
+ }
757
+ if (indices.length === 3)
758
+ triangles.push(indices.map((index) => points[index]));
759
+ return triangles;
760
+ }
761
+ function clipOpeningToRoom(opening, room) {
762
+ const openingIsInside = opening.every((point) => pointInPolygon(point, room) || pointDistanceToPolygonBoundary(point, room) <= 1e-7);
763
+ if (openingIsInside)
764
+ return [opening];
765
+ const clipRegions = isConvexPolygon(room) ? [room] : triangulatePolygon(room);
766
+ return clipRegions
767
+ .map((region) => clipPolygonToConvex(opening, region))
768
+ .filter((polygon) => polygon.length >= 3);
769
+ }
770
+ function partitionSurfaceOpenings(surface, roomIndices, detected) {
771
+ const assignments = new Map();
772
+ for (const roomIndex of roomIndices) {
773
+ assignments.set(roomIndex, { holes: [], holeMetadata: [] });
774
+ }
775
+ surface.holes.forEach((hole, holeIndex) => {
776
+ const holePolygon = hole.map(pointFromTuple);
777
+ for (const roomIndex of roomIndices) {
778
+ const room = detected[roomIndex];
779
+ const assignment = assignments.get(roomIndex);
780
+ if (!(room && assignment))
781
+ continue;
782
+ for (const clipped of clipOpeningToRoom(holePolygon, room.poly)) {
783
+ assignment.holes.push(clipped.map(pointToTuple));
784
+ assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' });
785
+ }
786
+ }
787
+ });
788
+ return assignments;
789
+ }
790
+ function partitionCeilingChildren(ceiling, roomIndices, detected, fallbackRoomIndex, childPosition) {
791
+ const assignments = new Map();
792
+ for (const roomIndex of roomIndices)
793
+ assignments.set(roomIndex, []);
794
+ for (const childId of ceiling.children) {
795
+ const tuple = childPosition?.(childId);
796
+ const point = tuple ? pointFromTuple(tuple) : undefined;
797
+ const boundaryRoomIndices = point
798
+ ? roomIndices.filter((roomIndex) => {
799
+ const room = detected[roomIndex];
800
+ return room ? pointDistanceToPolygonBoundary(point, room.poly) <= 1e-7 : false;
801
+ })
802
+ : [];
803
+ const interiorRoomIndex = point && boundaryRoomIndices.length === 0
804
+ ? roomIndices.find((roomIndex) => {
805
+ const room = detected[roomIndex];
806
+ return room ? pointInPolygon(point, room.poly) : false;
807
+ })
808
+ : undefined;
809
+ const roomIndex = interiorRoomIndex ??
810
+ (fallbackRoomIndex !== undefined && boundaryRoomIndices.includes(fallbackRoomIndex)
811
+ ? fallbackRoomIndex
812
+ : boundaryRoomIndices[0]) ??
813
+ fallbackRoomIndex ??
814
+ roomIndices[0];
815
+ if (roomIndex !== undefined)
816
+ assignments.get(roomIndex)?.push(childId);
817
+ }
818
+ return assignments;
819
+ }
820
+ function sameHoleMetadata(current, next) {
821
+ return (current.length === next.length &&
822
+ current.every((metadata, index) => {
823
+ const candidate = next[index];
824
+ return (candidate?.source === metadata.source &&
825
+ candidate.stairId === metadata.stairId &&
826
+ candidate.elevatorId === metadata.elevatorId);
827
+ }));
828
+ }
829
+ function mergedSurfaceOpenings(surfaces) {
830
+ const holes = [];
831
+ const holeMetadata = [];
832
+ const seen = new Set();
833
+ for (const surface of surfaces) {
834
+ surface.holes.forEach((hole, index) => {
835
+ const metadata = surface.holeMetadata[index] ?? { source: 'manual' };
836
+ const key = JSON.stringify([hole, metadata]);
837
+ if (seen.has(key))
838
+ return;
839
+ seen.add(key);
840
+ holes.push(hole);
841
+ holeMetadata.push(metadata);
842
+ });
843
+ }
844
+ return { holes, holeMetadata };
845
+ }
846
+ function ceilingMergeSettingsSignature(ceiling) {
847
+ return JSON.stringify([
848
+ ceiling.height ?? null,
849
+ ceiling.material ?? null,
850
+ ceiling.materialPreset ?? null,
851
+ ceiling.slots ?? null,
852
+ ceiling.visible,
853
+ ]);
854
+ }
855
+ function slabMergeSettingsSignature(slab) {
856
+ return JSON.stringify([
857
+ slab.elevation,
858
+ slab.thickness,
859
+ slab.recessed,
860
+ slab.recessedRimElevation ?? null,
861
+ slab.fillToTerrain ?? null,
862
+ slab.material ?? null,
863
+ slab.materialPreset ?? null,
864
+ slab.slots ?? null,
865
+ slab.visible,
866
+ ]);
867
+ }
868
+ function slabElevationForReconciledRoom(source, polygon, context) {
869
+ const currentDerived = context.elevationForRoom?.(polygon);
870
+ if (!context.previousElevationForRoom)
871
+ return currentDerived ?? source.elevation;
872
+ const previousDerived = context.previousElevationForRoom(source.polygon);
873
+ const sourceWasDerived = previousDerived !== undefined &&
874
+ Math.abs(source.elevation - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON;
875
+ return sourceWasDerived ? (currentDerived ?? source.elevation) : source.elevation;
876
+ }
877
+ function ceilingHeightForReconciledRoom(source, polygon, context) {
878
+ if (source.height === undefined)
879
+ return undefined;
880
+ const currentDerived = context.heightForRoom?.(polygon);
881
+ if (!context.previousHeightForRoom)
882
+ return currentDerived ?? source.height;
883
+ const previousDerived = context.previousHeightForRoom(source.polygon);
884
+ const sourceWasDerived = previousDerived !== undefined &&
885
+ Math.abs(source.height - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON;
886
+ return sourceWasDerived ? (currentDerived ?? source.height) : source.height;
887
+ }
648
888
  function wallGeometrySignature(wall, nodes, levelId) {
649
889
  return [
650
890
  wall.id,
@@ -771,53 +1011,166 @@ function buildSpace(levelId, room) {
771
1011
  isExterior: false,
772
1012
  };
773
1013
  }
774
- function sameStringSet(a, b) {
775
- if (a.length !== b.length)
1014
+ export function surfaceTouchesRooms(surface, rooms) {
1015
+ const { polygon, bbox } = surface;
1016
+ return rooms.some((room) => {
1017
+ if (bbox.maxX < room.bbox.minX ||
1018
+ room.bbox.maxX < bbox.minX ||
1019
+ bbox.maxY < room.bbox.minY ||
1020
+ room.bbox.maxY < bbox.minY) {
1021
+ return false;
1022
+ }
1023
+ return (polygonCoverageRatio(polygon, [room.polygon], bbox) > 0 ||
1024
+ polygonCoverageRatio(room.polygon, [polygon], room.bbox) > 0);
1025
+ });
1026
+ }
1027
+ function roomsAreRelated(beforeRoom, currentRoom) {
1028
+ const beforeIds = new Set(beforeRoom.boundaryFaces.map((boundary) => boundary.wallId));
1029
+ const currentIds = new Set(currentRoom.boundaryFaces.map((boundary) => boundary.wallId));
1030
+ const sharedWallCount = [...currentIds].filter((wallId) => beforeIds.has(wallId)).length;
1031
+ const smallerBoundarySize = Math.min(beforeIds.size, currentIds.size);
1032
+ if (sharedWallCount >= 2 && sharedWallCount >= Math.ceil(smallerBoundarySize / 2))
1033
+ return true;
1034
+ if (bboxOverlapArea(bboxOf(beforeRoom.polygon), bboxOf(currentRoom.polygon)) <= 1e-6)
776
1035
  return false;
777
- const right = new Set(b);
778
- return a.every((value) => right.has(value));
1036
+ return (polygonCoverageRatio(beforeRoom.polygon, [currentRoom.polygon]) > 0 ||
1037
+ polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]) > 0);
779
1038
  }
780
- export function planAutoZonesForLevel(spaces, existingZones) {
781
- const update = [];
782
- for (const zone of existingZones) {
783
- const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
784
- const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
785
- ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
786
- : spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
787
- if (!matchingSpace)
1039
+ function roomHasAutoSurface(room, surfaces) {
1040
+ return matchesManualFootprint(room.polygon, surfaces
1041
+ .filter((surface) => surface.autoFromWalls)
1042
+ .map((surface) => surface.polygon.map(pointFromTuple)));
1043
+ }
1044
+ function roomsEligibleForAutoSurface(beforeRooms, currentRooms, currentSurfaces) {
1045
+ return currentRooms.filter((currentRoom) => {
1046
+ const related = beforeRooms.flatMap((beforeRoom) => {
1047
+ if (!roomsAreRelated(beforeRoom, currentRoom))
1048
+ return [];
1049
+ return [
1050
+ {
1051
+ room: beforeRoom,
1052
+ coverage: polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]),
1053
+ },
1054
+ ];
1055
+ });
1056
+ const maxCoverage = Math.max(0, ...related.map(({ coverage }) => coverage));
1057
+ const predecessors = currentRooms.length >= beforeRooms.length && maxCoverage > 0
1058
+ ? related.filter(({ coverage }) => coverage >= maxCoverage - 1e-6).map(({ room }) => room)
1059
+ : related.map(({ room }) => room);
1060
+ if (predecessors.length === 0)
1061
+ return true;
1062
+ return predecessors.every((beforeRoom) => roomHasAutoSurface(beforeRoom, currentSurfaces));
1063
+ });
1064
+ }
1065
+ function detectedRoomsByLevel(nodes) {
1066
+ const wallsByLevel = new Map();
1067
+ for (const node of Object.values(nodes)) {
1068
+ if (node?.type !== 'wall' || !node.parentId)
788
1069
  continue;
789
- const data = {};
790
- if (!zone.autoFromWalls)
791
- data.autoFromWalls = true;
792
- if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
793
- data.boundaryWallIds = matchingSpace.wallIds;
1070
+ const walls = wallsByLevel.get(node.parentId) ?? [];
1071
+ walls.push(node);
1072
+ wallsByLevel.set(node.parentId, walls);
1073
+ }
1074
+ return new Map([...wallsByLevel].map(([levelId, walls]) => [levelId, extractRooms(walls)]));
1075
+ }
1076
+ function levelChildren(nodes, levelId) {
1077
+ const level = nodes[levelId];
1078
+ if (level?.type !== 'level')
1079
+ return [];
1080
+ return level.children.flatMap((id) => {
1081
+ const node = nodes[id];
1082
+ return node ? [node] : [];
1083
+ });
1084
+ }
1085
+ function changedWallIdsByLevel(before, current, candidateIds) {
1086
+ const changes = new Map();
1087
+ const wallIds = new Set(candidateIds);
1088
+ if (!candidateIds) {
1089
+ for (const node of Object.values(before)) {
1090
+ if (node?.type === 'wall')
1091
+ wallIds.add(node.id);
794
1092
  }
795
- if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
796
- data.polygon = matchingSpace.polygon;
1093
+ for (const node of Object.values(current)) {
1094
+ if (node?.type === 'wall')
1095
+ wallIds.add(node.id);
797
1096
  }
798
- if (Object.keys(data).length > 0)
799
- update.push({ id: zone.id, data });
800
1097
  }
801
- return { update };
1098
+ const markChanged = (levelId, wallId) => {
1099
+ if (!levelId)
1100
+ return;
1101
+ const ids = changes.get(levelId) ?? new Set();
1102
+ ids.add(wallId);
1103
+ changes.set(levelId, ids);
1104
+ };
1105
+ for (const wallId of wallIds) {
1106
+ const previous = before[wallId]?.type === 'wall' ? before[wallId] : null;
1107
+ const next = current[wallId]?.type === 'wall' ? current[wallId] : null;
1108
+ if (previous === next)
1109
+ continue;
1110
+ markChanged(previous?.parentId, wallId);
1111
+ markChanged(next?.parentId, wallId);
1112
+ }
1113
+ return changes;
802
1114
  }
803
- export function resolveAutoZonePolygon(zone, resolve) {
804
- if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
805
- return zone.polygon;
806
- const walls = zone.boundaryWallIds.flatMap((id) => {
807
- const node = resolve(id);
808
- return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
809
- ? [node]
810
- : [];
811
- });
812
- if (walls.length !== zone.boundaryWallIds.length)
813
- return zone.polygon;
814
- const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
815
- return room ? room.polygon.map(pointToTuple) : zone.polygon;
1115
+ function descendantLevelIds(nodes, rootId) {
1116
+ const levelIds = new Set();
1117
+ const queue = [rootId];
1118
+ const visited = new Set();
1119
+ while (queue.length > 0) {
1120
+ const id = queue.pop();
1121
+ if (visited.has(id))
1122
+ continue;
1123
+ visited.add(id);
1124
+ const node = nodes[id];
1125
+ if (!node)
1126
+ continue;
1127
+ if (node.type === 'level')
1128
+ levelIds.add(node.id);
1129
+ if ('children' in node && Array.isArray(node.children))
1130
+ queue.push(...node.children);
1131
+ }
1132
+ return levelIds;
1133
+ }
1134
+ function fallbackLevelIdsForCandidates(before, current, candidateIds) {
1135
+ const levelIds = new Set();
1136
+ const addLevelAndLower = (levelId, nodes) => {
1137
+ if (!levelId)
1138
+ return;
1139
+ levelIds.add(levelId);
1140
+ const lower = getLevelBelow(levelId, nodes);
1141
+ if (lower)
1142
+ levelIds.add(lower.id);
1143
+ };
1144
+ for (const id of candidateIds) {
1145
+ for (const nodes of [before, current]) {
1146
+ const node = nodes[id];
1147
+ if (!node)
1148
+ continue;
1149
+ if (node.type === 'level' || node.type === 'building' || node.type === 'site') {
1150
+ for (const levelId of descendantLevelIds(nodes, node.id))
1151
+ levelIds.add(levelId);
1152
+ }
1153
+ else if (node.type === 'slab') {
1154
+ addLevelAndLower(node.parentId, nodes);
1155
+ }
1156
+ else if (node.type === 'zone') {
1157
+ if (node.parentId)
1158
+ levelIds.add(node.parentId);
1159
+ }
1160
+ }
1161
+ }
1162
+ return levelIds;
816
1163
  }
817
- export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {}) {
818
- const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls);
819
- const manualSignatures = new Set(manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))));
820
- const manualPolygons = manualSlabs.map((slab) => slab.polygon.map(pointFromTuple));
1164
+ function sameStringSet(a, b) {
1165
+ if (a.length !== b.length)
1166
+ return false;
1167
+ const right = new Set(b);
1168
+ return a.every((value) => right.has(value));
1169
+ }
1170
+ function matchAutoSurfaces(roomPolygons, existingSurfaces, mergeSettingsSignature) {
1171
+ const manualSurfaces = existingSurfaces.filter((surface) => !surface.autoFromWalls);
1172
+ const manualSignatures = new Set(manualSurfaces.map((surface) => polygonSignature(surface.polygon.map(pointFromTuple))));
1173
+ const manualPolygons = manualSurfaces.map((surface) => surface.polygon.map(pointFromTuple));
821
1174
  const detectedAll = roomPolygons
822
1175
  .map((poly) => ({
823
1176
  poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
@@ -834,86 +1187,185 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
834
1187
  bbox: bboxOf(room.poly),
835
1188
  }));
836
1189
  const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
837
- const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls);
838
- const existingAutoMeta = existingAuto.map((slab) => {
839
- const poly = slab.polygon.map(pointFromTuple);
1190
+ const existingAuto = existingSurfaces.filter((surface) => surface.autoFromWalls);
1191
+ const metadata = existingAuto.map((surface) => {
1192
+ const poly = surface.polygon.map(pointFromTuple);
840
1193
  return {
841
- slab,
1194
+ surface,
842
1195
  sig: polygonSignature(poly),
843
1196
  centroid: polygonCentroid(poly),
844
1197
  area: Math.abs(polygonArea(poly)),
845
1198
  bbox: bboxOf(poly),
846
1199
  };
847
1200
  });
848
- const matchedSlabIds = new Set();
849
- const matchedDetectedIdx = new Set();
850
- const updatesById = new Map();
1201
+ const conflictingSurfaceIds = new Set();
1202
+ const conflictingRoomIndices = new Set();
1203
+ const compatibleMergesByRoomIndex = new Map();
1204
+ detected.forEach((room, roomIndex) => {
1205
+ const contributors = existingAuto.filter((surface) => polygonCoverageRatio(surface.polygon.map(pointFromTuple), [room.poly]) >=
1206
+ ORPHAN_MERGE_COVERAGE_THRESHOLD);
1207
+ if (contributors.length < 2)
1208
+ return;
1209
+ if (new Set(contributors.map(mergeSettingsSignature)).size > 1) {
1210
+ conflictingRoomIndices.add(roomIndex);
1211
+ for (const surface of contributors)
1212
+ conflictingSurfaceIds.add(surface.id);
1213
+ return;
1214
+ }
1215
+ compatibleMergesByRoomIndex.set(roomIndex, contributors);
1216
+ });
1217
+ const matchedSurfaceIds = new Set();
1218
+ const matchedDetectedIndices = new Set();
1219
+ const roomIndexBySurfaceId = new Map();
1220
+ const sourceSurfaceIdByRoomIndex = new Map();
1221
+ const polygonBySurfaceId = new Map();
851
1222
  const autoBySignature = new Map();
852
- for (const entry of existingAutoMeta) {
1223
+ for (const entry of metadata) {
853
1224
  const bucket = autoBySignature.get(entry.sig) ?? [];
854
1225
  bucket.push(entry);
855
1226
  autoBySignature.set(entry.sig, bucket);
856
1227
  }
857
1228
  detected.forEach((room, index) => {
1229
+ if (conflictingRoomIndices.has(index)) {
1230
+ matchedDetectedIndices.add(index);
1231
+ return;
1232
+ }
858
1233
  const existing = autoBySignature.get(room.sig)?.shift();
859
1234
  if (!existing)
860
1235
  return;
861
- matchedDetectedIdx.add(index);
862
- matchedSlabIds.add(existing.slab.id);
863
- const polygon = room.poly.map(pointToTuple);
864
- updatesById.set(existing.slab.id, {
865
- polygon,
866
- elevation: context.elevationForRoom?.(polygon),
867
- });
1236
+ matchedDetectedIndices.add(index);
1237
+ matchedSurfaceIds.add(existing.surface.id);
1238
+ roomIndexBySurfaceId.set(existing.surface.id, index);
1239
+ sourceSurfaceIdByRoomIndex.set(index, existing.surface.id);
1240
+ polygonBySurfaceId.set(existing.surface.id, room.poly.map(pointToTuple));
868
1241
  });
869
1242
  const remainingDetected = detected
870
1243
  .map((room, index) => ({ room, index }))
871
- .filter(({ index }) => !matchedDetectedIdx.has(index))
872
- .sort((a, b) => b.room.area - a.room.area);
873
- const remainingAuto = existingAutoMeta.filter((entry) => !matchedSlabIds.has(entry.slab.id));
1244
+ .filter(({ index }) => !matchedDetectedIndices.has(index))
1245
+ .sort((left, right) => right.room.area - left.room.area);
1246
+ const remainingAuto = metadata.filter((entry) => !matchedSurfaceIds.has(entry.surface.id));
874
1247
  for (const { room, index } of remainingDetected) {
875
1248
  let bestMatch = null;
876
1249
  for (const entry of remainingAuto) {
877
- if (matchedSlabIds.has(entry.slab.id))
1250
+ if (matchedSurfaceIds.has(entry.surface.id))
878
1251
  continue;
879
- const dx = room.centroid.x - entry.centroid.x;
880
- const dy = room.centroid.y - entry.centroid.y;
881
- const dist = Math.hypot(dx, dy);
1252
+ const distance = Math.hypot(room.centroid.x - entry.centroid.x, room.centroid.y - entry.centroid.y);
882
1253
  const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999;
883
1254
  const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)));
884
- const overlap = bboxOverlapArea(room.bbox, entry.bbox);
885
- if (overlap <= 0.0001 && dist > 1.5)
1255
+ if (bboxOverlapArea(room.bbox, entry.bbox) <= 0.0001 && distance > 1.5)
886
1256
  continue;
887
- const score = dist + areaPenalty * 0.35;
888
- if (!bestMatch || score < bestMatch.score) {
1257
+ const score = distance + areaPenalty * 0.35;
1258
+ if (!bestMatch || score < bestMatch.score)
889
1259
  bestMatch = { entry, score };
890
- }
891
1260
  }
892
1261
  if (!bestMatch)
893
1262
  continue;
894
- matchedDetectedIdx.add(index);
895
- matchedSlabIds.add(bestMatch.entry.slab.id);
896
- const polygon = room.poly.map(pointToTuple);
897
- updatesById.set(bestMatch.entry.slab.id, {
898
- polygon,
899
- elevation: context.elevationForRoom?.(polygon),
900
- });
1263
+ matchedDetectedIndices.add(index);
1264
+ matchedSurfaceIds.add(bestMatch.entry.surface.id);
1265
+ roomIndexBySurfaceId.set(bestMatch.entry.surface.id, index);
1266
+ sourceSurfaceIdByRoomIndex.set(index, bestMatch.entry.surface.id);
1267
+ polygonBySurfaceId.set(bestMatch.entry.surface.id, room.poly.map(pointToTuple));
901
1268
  }
1269
+ detected.forEach((room, index) => {
1270
+ if (sourceSurfaceIdByRoomIndex.has(index))
1271
+ return;
1272
+ let bestSource = null;
1273
+ for (const entry of metadata) {
1274
+ const coverage = polygonCoverageRatio(room.poly, [entry.surface.polygon.map(pointFromTuple)]);
1275
+ if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage))
1276
+ continue;
1277
+ bestSource = { id: entry.surface.id, coverage };
1278
+ }
1279
+ if (bestSource)
1280
+ sourceSurfaceIdByRoomIndex.set(index, bestSource.id);
1281
+ });
902
1282
  const detectedRoomPolygons = detectedAll.map((room) => room.poly);
903
- const slabsToDelete = [];
904
- const slabDemotions = [];
905
- for (const slab of existingAuto) {
906
- if (updatesById.has(slab.id))
1283
+ const deleted = [];
1284
+ const demote = [];
1285
+ for (const surface of existingAuto) {
1286
+ if (polygonBySurfaceId.has(surface.id))
1287
+ continue;
1288
+ if (conflictingSurfaceIds.has(surface.id)) {
1289
+ demote.push({ id: surface.id, data: { autoFromWalls: false } });
907
1290
  continue;
908
- const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons);
909
- if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
910
- slabsToDelete.push(slab.id);
911
1291
  }
912
- else {
913
- // Render offsets derive from level context at geometry build time, so
914
- // demotion leaves the stored polygon untouched (same as ceilings).
915
- slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } });
1292
+ const coverage = polygonCoverageRatio(surface.polygon.map(pointFromTuple), detectedRoomPolygons);
1293
+ if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD)
1294
+ deleted.push(surface.id);
1295
+ else
1296
+ demote.push({ id: surface.id, data: { autoFromWalls: false } });
1297
+ }
1298
+ return {
1299
+ detectedAll,
1300
+ detected,
1301
+ existingAuto,
1302
+ compatibleMergesByRoomIndex,
1303
+ matchedDetectedIndices,
1304
+ roomIndexBySurfaceId,
1305
+ sourceSurfaceIdByRoomIndex,
1306
+ polygonBySurfaceId,
1307
+ delete: deleted,
1308
+ demote,
1309
+ };
1310
+ }
1311
+ export function planAutoZonesForLevel(spaces, existingZones) {
1312
+ const update = [];
1313
+ for (const zone of existingZones) {
1314
+ const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple));
1315
+ const matchingSpace = zone.autoFromWalls && zone.boundaryWallIds.length >= 3
1316
+ ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
1317
+ : spaces.find((space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature);
1318
+ if (!matchingSpace)
1319
+ continue;
1320
+ const data = {};
1321
+ if (!zone.autoFromWalls)
1322
+ data.autoFromWalls = true;
1323
+ if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
1324
+ data.boundaryWallIds = matchingSpace.wallIds;
1325
+ }
1326
+ if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
1327
+ data.polygon = matchingSpace.polygon;
916
1328
  }
1329
+ if (Object.keys(data).length > 0)
1330
+ update.push({ id: zone.id, data });
1331
+ }
1332
+ return { update };
1333
+ }
1334
+ export function resolveAutoZonePolygon(zone, resolve) {
1335
+ if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3)
1336
+ return zone.polygon;
1337
+ const walls = zone.boundaryWallIds.flatMap((id) => {
1338
+ const node = resolve(id);
1339
+ return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
1340
+ ? [node]
1341
+ : [];
1342
+ });
1343
+ if (walls.length !== zone.boundaryWallIds.length)
1344
+ return zone.polygon;
1345
+ const room = extractRooms(walls).find((candidate) => sameStringSet([...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], zone.boundaryWallIds));
1346
+ return room ? room.polygon.map(pointToTuple) : zone.polygon;
1347
+ }
1348
+ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {}, namingSlabs = existingSlabs) {
1349
+ const match = matchAutoSurfaces(roomPolygons, existingSlabs, slabMergeSettingsSignature);
1350
+ const { detected, existingAuto, compatibleMergesByRoomIndex: compatibleMergeSlabsByRoomIndex, matchedDetectedIndices: matchedDetectedIdx, roomIndexBySurfaceId: roomIndexBySlabId, sourceSurfaceIdByRoomIndex: sourceSlabIdByRoomIndex, delete: slabsToDelete, demote: slabDemotions, } = match;
1351
+ const updatesById = new Map();
1352
+ for (const slab of existingAuto) {
1353
+ const polygon = match.polygonBySurfaceId.get(slab.id);
1354
+ if (!polygon)
1355
+ continue;
1356
+ updatesById.set(slab.id, {
1357
+ polygon,
1358
+ elevation: slabElevationForReconciledRoom(slab, polygon, context),
1359
+ });
1360
+ }
1361
+ const openingAssignmentsBySlabId = new Map();
1362
+ for (const slab of existingAuto) {
1363
+ const roomIndices = [...sourceSlabIdByRoomIndex.entries()]
1364
+ .filter(([, slabId]) => slabId === slab.id)
1365
+ .map(([roomIndex]) => roomIndex);
1366
+ if (roomIndices.length === 0)
1367
+ continue;
1368
+ openingAssignmentsBySlabId.set(slab.id, partitionSurfaceOpenings(slab, roomIndices, detected));
917
1369
  }
918
1370
  const slabsToUpdate = [
919
1371
  ...existingAuto
@@ -922,9 +1374,23 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
922
1374
  const update = updatesById.get(slab.id);
923
1375
  if (!update)
924
1376
  return [];
1377
+ const roomIndex = roomIndexBySlabId.get(slab.id);
1378
+ const openings = roomIndex == null
1379
+ ? { holes: slab.holes, holeMetadata: slab.holeMetadata }
1380
+ : compatibleMergeSlabsByRoomIndex.has(roomIndex)
1381
+ ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? [])
1382
+ : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? {
1383
+ holes: [],
1384
+ holeMetadata: [],
1385
+ });
925
1386
  const data = {};
926
- if (!sameTuplePolygon(slab.polygon, update.polygon))
1387
+ if (!sameTuplePolygonRotation(slab.polygon, update.polygon))
927
1388
  data.polygon = update.polygon;
1389
+ if (!sameTuplePolygons(slab.holes, openings.holes))
1390
+ data.holes = openings.holes;
1391
+ if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) {
1392
+ data.holeMetadata = openings.holeMetadata;
1393
+ }
928
1394
  if (update.elevation !== undefined &&
929
1395
  Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON) {
930
1396
  data.elevation = update.elevation;
@@ -933,7 +1399,7 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
933
1399
  }),
934
1400
  ...slabDemotions,
935
1401
  ];
936
- const plannedSlabsForNaming = [...existingSlabs];
1402
+ const plannedSlabsForNaming = [...namingSlabs];
937
1403
  const slabsToCreate = [];
938
1404
  for (let index = 0; index < detected.length; index += 1) {
939
1405
  if (matchedDetectedIdx.has(index))
@@ -944,14 +1410,28 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
944
1410
  const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab');
945
1411
  plannedSlabsForNaming.push({ name });
946
1412
  const polygon = room.poly.map(pointToTuple);
947
- const elevation = context.elevationForRoom?.(polygon);
1413
+ const sourceId = sourceSlabIdByRoomIndex.get(index);
1414
+ const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined;
1415
+ const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined;
1416
+ const elevation = source
1417
+ ? slabElevationForReconciledRoom(source, polygon, context)
1418
+ : context.elevationForRoom?.(polygon);
948
1419
  slabsToCreate.push(SlabNode.parse({
949
1420
  name,
950
1421
  polygon,
951
- holes: [],
1422
+ holes: openings?.holes ?? [],
1423
+ holeMetadata: openings?.holeMetadata ?? [],
952
1424
  elevation: elevation !== undefined && Number.isFinite(elevation)
953
1425
  ? elevation
954
1426
  : DEFAULT_AUTO_SLAB_ELEVATION,
1427
+ thickness: source?.thickness,
1428
+ recessed: source?.recessed,
1429
+ recessedRimElevation: source?.recessedRimElevation,
1430
+ fillToTerrain: source?.fillToTerrain,
1431
+ material: source?.material,
1432
+ materialPreset: source?.materialPreset,
1433
+ slots: source?.slots,
1434
+ visible: source?.visible,
955
1435
  autoFromWalls: true,
956
1436
  }));
957
1437
  }
@@ -961,8 +1441,8 @@ export function planAutoSlabsForLevel(roomPolygons, existingSlabs, context = {})
961
1441
  delete: slabsToDelete,
962
1442
  };
963
1443
  }
964
- function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore, context = {}) {
965
- const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context);
1444
+ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore, context = {}, namingSlabs = existingSlabs) {
1445
+ const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context, namingSlabs);
966
1446
  if (plan.delete.length > 0) {
967
1447
  sceneStore.getState().deleteNodes(plan.delete);
968
1448
  }
@@ -974,105 +1454,20 @@ function syncAutoSlabsForLevel(levelId, roomPolygons, existingSlabs, sceneStore,
974
1454
  }
975
1455
  return plan;
976
1456
  }
977
- export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}) {
1457
+ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context = {}, namingCeilings = existingCeilings) {
978
1458
  const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls);
979
- const manualSignatures = new Set(manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))));
980
- const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple));
981
- const detectedAll = roomPolygons
982
- .map((poly) => ({
983
- poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(pointFromTuple),
984
- sig: '',
985
- centroid: { x: 0, y: 0 },
986
- area: 0,
987
- bbox: bboxOf([]),
988
- }))
989
- .map((room) => ({
990
- ...room,
991
- sig: polygonSignature(room.poly),
992
- centroid: polygonCentroid(room.poly),
993
- area: Math.abs(polygonArea(room.poly)),
994
- bbox: bboxOf(room.poly),
995
- }));
996
- const detected = detectedAll.filter(({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons));
997
- const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls);
998
- const existingAutoMeta = existingAuto.map((ceiling) => {
999
- const poly = ceiling.polygon.map(pointFromTuple);
1000
- return {
1001
- ceiling,
1002
- sig: polygonSignature(poly),
1003
- centroid: polygonCentroid(poly),
1004
- area: Math.abs(polygonArea(poly)),
1005
- bbox: bboxOf(poly),
1006
- };
1007
- });
1008
- const matchedCeilingIds = new Set();
1009
- const matchedDetectedIdx = new Set();
1459
+ const match = matchAutoSurfaces(roomPolygons, existingCeilings, ceilingMergeSettingsSignature);
1460
+ const { detected, existingAuto, compatibleMergesByRoomIndex: compatibleMergeCeilingsByRoomIndex, matchedDetectedIndices: matchedDetectedIdx, roomIndexBySurfaceId: roomIndexByCeilingId, sourceSurfaceIdByRoomIndex: sourceCeilingIdByRoomIndex, delete: ceilingsToDelete, demote: ceilingDemotions, } = match;
1010
1461
  const updatesById = new Map();
1011
- const autoBySignature = new Map();
1012
- for (const entry of existingAutoMeta) {
1013
- const bucket = autoBySignature.get(entry.sig) ?? [];
1014
- bucket.push(entry);
1015
- autoBySignature.set(entry.sig, bucket);
1016
- }
1017
- detected.forEach((room, index) => {
1018
- const existing = autoBySignature.get(room.sig)?.shift();
1019
- if (!existing)
1020
- return;
1021
- matchedDetectedIdx.add(index);
1022
- matchedCeilingIds.add(existing.ceiling.id);
1023
- const polygon = room.poly.map(pointToTuple);
1024
- updatesById.set(existing.ceiling.id, {
1025
- polygon,
1026
- height: context.heightForRoom?.(polygon),
1027
- });
1028
- });
1029
- const remainingDetected = detected
1030
- .map((room, index) => ({ room, index }))
1031
- .filter(({ index }) => !matchedDetectedIdx.has(index))
1032
- .sort((a, b) => b.room.area - a.room.area);
1033
- const remainingAuto = existingAutoMeta.filter((entry) => !matchedCeilingIds.has(entry.ceiling.id));
1034
- for (const { room, index } of remainingDetected) {
1035
- let bestMatch = null;
1036
- for (const entry of remainingAuto) {
1037
- if (matchedCeilingIds.has(entry.ceiling.id))
1038
- continue;
1039
- const dx = room.centroid.x - entry.centroid.x;
1040
- const dy = room.centroid.y - entry.centroid.y;
1041
- const dist = Math.hypot(dx, dy);
1042
- const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999;
1043
- const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)));
1044
- const overlap = bboxOverlapArea(room.bbox, entry.bbox);
1045
- if (overlap <= 0.0001 && dist > 1.5)
1046
- continue;
1047
- const score = dist + areaPenalty * 0.35;
1048
- if (!bestMatch || score < bestMatch.score) {
1049
- bestMatch = { entry, score };
1050
- }
1051
- }
1052
- if (!bestMatch)
1462
+ for (const ceiling of existingAuto) {
1463
+ const polygon = match.polygonBySurfaceId.get(ceiling.id);
1464
+ if (!polygon)
1053
1465
  continue;
1054
- matchedDetectedIdx.add(index);
1055
- matchedCeilingIds.add(bestMatch.entry.ceiling.id);
1056
- const polygon = room.poly.map(pointToTuple);
1057
- updatesById.set(bestMatch.entry.ceiling.id, {
1466
+ updatesById.set(ceiling.id, {
1058
1467
  polygon,
1059
- height: context.heightForRoom?.(polygon),
1468
+ height: ceilingHeightForReconciledRoom(ceiling, polygon, context),
1060
1469
  });
1061
1470
  }
1062
- const detectedRoomPolygons = detectedAll.map((room) => room.poly);
1063
- const ceilingsToDelete = [];
1064
- const ceilingDemotions = [];
1065
- for (const ceiling of existingAuto) {
1066
- if (updatesById.has(ceiling.id))
1067
- continue;
1068
- const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons);
1069
- if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) {
1070
- ceilingsToDelete.push(ceiling.id);
1071
- }
1072
- else {
1073
- ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } });
1074
- }
1075
- }
1076
1471
  // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab
1077
1472
  // created, moved, or thickened on the level above can leave an EXISTING
1078
1473
  // manual explicit-height ceiling poking into its solid. Clamp explicit
@@ -1091,6 +1486,18 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1091
1486
  ? [{ id: ceiling.id, data: { height: bound } }]
1092
1487
  : [];
1093
1488
  });
1489
+ const openingAssignmentsByCeilingId = new Map();
1490
+ const childAssignmentsByCeilingId = new Map();
1491
+ for (const ceiling of existingAuto) {
1492
+ const roomIndices = [...sourceCeilingIdByRoomIndex.entries()]
1493
+ .filter(([, ceilingId]) => ceilingId === ceiling.id)
1494
+ .map(([roomIndex]) => roomIndex);
1495
+ if (roomIndices.length === 0)
1496
+ continue;
1497
+ openingAssignmentsByCeilingId.set(ceiling.id, partitionSurfaceOpenings(ceiling, roomIndices, detected));
1498
+ childAssignmentsByCeilingId.set(ceiling.id, partitionCeilingChildren(ceiling, roomIndices, detected, roomIndexByCeilingId.get(ceiling.id), context.childPosition));
1499
+ }
1500
+ const childReparents = [];
1094
1501
  const ceilingsToUpdate = [
1095
1502
  ...existingAuto
1096
1503
  .filter((ceiling) => updatesById.has(ceiling.id))
@@ -1098,9 +1505,40 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1098
1505
  const update = updatesById.get(ceiling.id);
1099
1506
  if (!update)
1100
1507
  return [];
1508
+ const roomIndex = roomIndexByCeilingId.get(ceiling.id);
1509
+ const openings = roomIndex == null
1510
+ ? { holes: ceiling.holes, holeMetadata: ceiling.holeMetadata }
1511
+ : compatibleMergeCeilingsByRoomIndex.has(roomIndex)
1512
+ ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? [])
1513
+ : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? {
1514
+ holes: [],
1515
+ holeMetadata: [],
1516
+ });
1101
1517
  const data = {};
1102
- if (!sameTuplePolygon(ceiling.polygon, update.polygon))
1518
+ if (!sameTuplePolygonRotation(ceiling.polygon, update.polygon))
1103
1519
  data.polygon = update.polygon;
1520
+ if (!sameTuplePolygons(ceiling.holes, openings.holes))
1521
+ data.holes = openings.holes;
1522
+ if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) {
1523
+ data.holeMetadata = openings.holeMetadata;
1524
+ }
1525
+ const mergeContributors = roomIndex == null ? undefined : compatibleMergeCeilingsByRoomIndex.get(roomIndex);
1526
+ const children = mergeContributors
1527
+ ? [
1528
+ ...new Set(mergeContributors.flatMap((contributor) => contributor.children)),
1529
+ ]
1530
+ : roomIndex == null
1531
+ ? ceiling.children
1532
+ : (childAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? ceiling.children);
1533
+ for (const contributor of mergeContributors ?? []) {
1534
+ if (contributor.id === ceiling.id)
1535
+ continue;
1536
+ for (const childId of contributor.children) {
1537
+ childReparents.push({ id: childId, parentId: ceiling.id });
1538
+ }
1539
+ }
1540
+ if (!sameStringSet(ceiling.children, children))
1541
+ data.children = children;
1104
1542
  if (update.height !== undefined &&
1105
1543
  (ceiling.height === undefined ||
1106
1544
  Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON)) {
@@ -1111,7 +1549,7 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1111
1549
  ...ceilingDemotions,
1112
1550
  ...manualClamps,
1113
1551
  ];
1114
- const plannedCeilingsForNaming = [...existingCeilings];
1552
+ const plannedCeilingsForNaming = [...namingCeilings];
1115
1553
  const ceilingsToCreate = [];
1116
1554
  for (let index = 0; index < detected.length; index += 1) {
1117
1555
  if (matchedDetectedIdx.has(index))
@@ -1122,32 +1560,54 @@ export function planAutoCeilingsForLevel(roomPolygons, existingCeilings, context
1122
1560
  const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling');
1123
1561
  plannedCeilingsForNaming.push({ name });
1124
1562
  const polygon = room.poly.map(pointToTuple);
1125
- const height = context.heightForRoom?.(polygon);
1126
- ceilingsToCreate.push(CeilingNode.parse({
1563
+ const sourceId = sourceCeilingIdByRoomIndex.get(index);
1564
+ const source = sourceId ? existingAuto.find((ceiling) => ceiling.id === sourceId) : undefined;
1565
+ const openings = sourceId ? openingAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined;
1566
+ const children = sourceId ? childAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined;
1567
+ const height = source
1568
+ ? ceilingHeightForReconciledRoom(source, polygon, context)
1569
+ : context.heightForRoom?.(polygon);
1570
+ const created = CeilingNode.parse({
1127
1571
  name,
1128
1572
  polygon,
1129
- holes: [],
1573
+ children: children ?? [],
1574
+ holes: openings?.holes ?? [],
1575
+ holeMetadata: openings?.holeMetadata ?? [],
1576
+ material: source?.material,
1577
+ materialPreset: source?.materialPreset,
1578
+ slots: source?.slots,
1579
+ visible: source?.visible,
1130
1580
  ...(height !== undefined && Number.isFinite(height) ? { height } : {}),
1131
1581
  autoFromWalls: true,
1132
- }));
1582
+ });
1583
+ ceilingsToCreate.push(created);
1584
+ for (const childId of children ?? []) {
1585
+ childReparents.push({ id: childId, parentId: created.id });
1586
+ }
1133
1587
  }
1134
1588
  return {
1135
1589
  create: ceilingsToCreate,
1136
1590
  update: ceilingsToUpdate,
1137
1591
  delete: ceilingsToDelete,
1592
+ reparent: childReparents,
1138
1593
  };
1139
1594
  }
1140
- function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore, context = {}) {
1141
- const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context);
1142
- if (plan.delete.length > 0) {
1143
- sceneStore.getState().deleteNodes(plan.delete);
1144
- }
1595
+ function syncAutoCeilingsForLevel(levelId, roomPolygons, existingCeilings, sceneStore, context = {}, namingCeilings = existingCeilings) {
1596
+ const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings);
1145
1597
  if (plan.update.length > 0) {
1146
1598
  sceneStore.getState().updateNodes(plan.update);
1147
1599
  }
1148
1600
  if (plan.create.length > 0) {
1149
1601
  sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })));
1150
1602
  }
1603
+ if (plan.reparent.length > 0) {
1604
+ sceneStore
1605
+ .getState()
1606
+ .updateNodes(plan.reparent.map(({ id, parentId }) => ({ id, data: { parentId } })));
1607
+ }
1608
+ if (plan.delete.length > 0) {
1609
+ sceneStore.getState().deleteNodes(plan.delete);
1610
+ }
1151
1611
  }
1152
1612
  function detectSpacesFromWalls(levelId, walls) {
1153
1613
  const rooms = extractRooms(walls);
@@ -1157,6 +1617,7 @@ function detectSpacesFromWalls(levelId, walls) {
1157
1617
  ...resolveWallSurfaceSides(wall, roomPolygons),
1158
1618
  }));
1159
1619
  return {
1620
+ rooms,
1160
1621
  roomPolygons,
1161
1622
  spaces: rooms.map((room) => buildSpace(levelId, room)),
1162
1623
  wallUpdates,
@@ -1165,7 +1626,7 @@ function detectSpacesFromWalls(levelId, walls) {
1165
1626
  export function detectSpacesForLevel(levelId, walls) {
1166
1627
  return detectSpacesFromWalls(levelId, walls);
1167
1628
  }
1168
- function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1629
+ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes, previousNodes, previousRoomsByLevel) {
1169
1630
  const { updateNodes } = sceneStore.getState();
1170
1631
  const existingSpaces = editorStore.getState().spaces;
1171
1632
  const nextSpaces = {};
@@ -1175,11 +1636,12 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1175
1636
  }
1176
1637
  }
1177
1638
  for (const levelId of levelIds) {
1178
- const walls = Object.values(nodes).filter((node) => node?.type === 'wall' && node.parentId === levelId);
1179
- const slabs = Object.values(nodes).filter((node) => node?.type === 'slab' && node.parentId === levelId);
1180
- const ceilings = Object.values(nodes).filter((node) => node?.type === 'ceiling' && node.parentId === levelId);
1181
- const zones = Object.values(nodes).filter((node) => node?.type === 'zone' && node.parentId === levelId);
1182
- const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls);
1639
+ const children = levelChildren(nodes, levelId);
1640
+ const walls = children.filter((node) => node?.type === 'wall' && node.parentId === levelId);
1641
+ const slabs = children.filter((node) => node?.type === 'slab');
1642
+ const ceilings = children.filter((node) => node?.type === 'ceiling');
1643
+ const zones = children.filter((node) => node?.type === 'zone');
1644
+ const { wallUpdates, spaces, rooms } = detectSpacesFromWalls(levelId, walls);
1183
1645
  const changedWallUpdates = wallUpdates.filter((update) => {
1184
1646
  const wall = nodes[update.wallId];
1185
1647
  return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide);
@@ -1198,18 +1660,42 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1198
1660
  ? getStoredLevelHeight(levelNode)
1199
1661
  : DEFAULT_LEVEL_HEIGHT;
1200
1662
  const parsedSlabs = slabs.map((slab) => SlabNode.parse(slab));
1663
+ const parsedCeilings = ceilings.map((ceiling) => CeilingNode.parse(ceiling));
1664
+ const previousRooms = previousRoomsByLevel.get(levelId) ?? [];
1665
+ const slabRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedSlabs);
1666
+ const ceilingRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedCeilings);
1201
1667
  const verticalPlacements = autoRoomVerticalPlacements(spaces, walls,
1202
1668
  // A derived floor cannot be evidence for its own next elevation: that
1203
1669
  // would lift unpinned walls, then lift the floor again on every pass.
1204
1670
  parsedSlabs.filter((slab) => !slab.autoFromWalls), nodes, storeyHeight);
1671
+ const previousChildren = levelChildren(previousNodes, levelId);
1672
+ const previousWalls = previousChildren.filter((node) => node?.type === 'wall' && node.parentId === levelId);
1673
+ const previousSlabs = previousChildren
1674
+ .filter((node) => node?.type === 'slab')
1675
+ .map((slab) => SlabNode.parse(slab));
1676
+ const previousLevelNode = previousNodes[levelId];
1677
+ const previousStoreyHeight = previousLevelNode?.type === 'level'
1678
+ ? getStoredLevelHeight(previousLevelNode)
1679
+ : DEFAULT_LEVEL_HEIGHT;
1680
+ const previousSpaces = detectSpacesFromWalls(levelId, previousWalls).spaces;
1681
+ const previousVerticalPlacements = autoRoomVerticalPlacements(previousSpaces, previousWalls, previousSlabs.filter((slab) => !slab.autoFromWalls), previousNodes, previousStoreyHeight);
1205
1682
  const placementFor = (polygon) => verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1206
- syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore, {
1683
+ const previousPlacementFor = (polygon) => previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1684
+ syncAutoSlabsForLevel(levelId, slabRooms.map((room) => room.polygon), parsedSlabs, sceneStore, {
1207
1685
  elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation,
1686
+ previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation,
1208
1687
  });
1209
- syncAutoCeilingsForLevel(levelId, roomPolygons, ceilings.map((ceiling) => CeilingNode.parse(ceiling)), sceneStore, {
1688
+ syncAutoCeilingsForLevel(levelId, ceilingRooms.map((room) => room.polygon), parsedCeilings, sceneStore, {
1210
1689
  storeyHeight,
1211
1690
  ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
1212
1691
  heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight,
1692
+ previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight,
1693
+ childPosition: (childId) => {
1694
+ const child = nodes[childId];
1695
+ return child && Array.isArray(child.position)
1696
+ ? [child.position[0], child.position[2]]
1697
+ : undefined;
1698
+ },
1213
1699
  });
1214
1700
  const zonePlan = planAutoZonesForLevel(spaces, zones.map((zone) => ZoneNode.parse(zone)));
1215
1701
  if (zonePlan.update.length > 0)
@@ -1217,7 +1703,104 @@ function runSpaceDetection(levelIds, sceneStore, editorStore, nodes) {
1217
1703
  for (const space of spaces) {
1218
1704
  nextSpaces[space.id] = space;
1219
1705
  }
1706
+ previousRoomsByLevel.set(levelId, rooms);
1707
+ }
1708
+ editorStore.getState().setSpaces(nextSpaces);
1709
+ }
1710
+ function runIndexedSpaceDetection(levelId, topologyDelta, sceneStore, editorStore, nodes, previousNodes) {
1711
+ const { updateNodes } = sceneStore.getState();
1712
+ const allRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon);
1713
+ const changedWallUpdates = topologyDelta.currentWalls
1714
+ .map((wall) => ({
1715
+ wallId: wall.id,
1716
+ ...resolveWallSurfaceSides(wall, allRoomPolygons),
1717
+ }))
1718
+ .filter((update) => {
1719
+ const wall = nodes[update.wallId];
1720
+ return (wall?.type === 'wall' &&
1721
+ (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide));
1722
+ });
1723
+ if (changedWallUpdates.length > 0) {
1724
+ updateNodes(changedWallUpdates.map((update) => ({
1725
+ id: update.wallId,
1726
+ data: { frontSide: update.frontSide, backSide: update.backSide },
1727
+ })));
1728
+ }
1729
+ const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms].map((room) => ({
1730
+ polygon: room.polygon,
1731
+ bbox: bboxOf(room.polygon),
1732
+ }));
1733
+ if (scopedRooms.length > 0) {
1734
+ const unaffectedRooms = topologyDelta.allCurrentRooms
1735
+ .filter((room) => !topologyDelta.currentRooms.includes(room))
1736
+ .map((room) => ({ polygon: room.polygon, bbox: bboxOf(room.polygon) }));
1737
+ const currentChildren = levelChildren(nodes, levelId);
1738
+ const allSlabs = currentChildren
1739
+ .filter((node) => node.type === 'slab')
1740
+ .map((slab) => SlabNode.parse(slab));
1741
+ const allCeilings = currentChildren
1742
+ .filter((node) => node.type === 'ceiling')
1743
+ .map((ceiling) => CeilingNode.parse(ceiling));
1744
+ const touchesScopedRooms = (surface) => {
1745
+ const polygon = surface.polygon.map(pointFromTuple);
1746
+ const bounded = { polygon, bbox: bboxOf(polygon) };
1747
+ return (surfaceTouchesRooms(bounded, scopedRooms) &&
1748
+ (!surface.autoFromWalls || !surfaceTouchesRooms(bounded, unaffectedRooms)));
1749
+ };
1750
+ const slabs = allSlabs.filter(touchesScopedRooms);
1751
+ const ceilings = allCeilings.filter(touchesScopedRooms);
1752
+ const slabRooms = roomsEligibleForAutoSurface(topologyDelta.beforeRooms, topologyDelta.currentRooms, slabs);
1753
+ const ceilingRooms = roomsEligibleForAutoSurface(topologyDelta.beforeRooms, topologyDelta.currentRooms, ceilings);
1754
+ const levelNode = nodes[levelId];
1755
+ const storeyHeight = levelNode?.type === 'level'
1756
+ ? getStoredLevelHeight(levelNode)
1757
+ : DEFAULT_LEVEL_HEIGHT;
1758
+ const currentSpaces = topologyDelta.currentRooms.map((room) => buildSpace(levelId, room));
1759
+ const verticalPlacements = autoRoomVerticalPlacements(currentSpaces, topologyDelta.currentWalls, allSlabs.filter((slab) => !slab.autoFromWalls), nodes, storeyHeight);
1760
+ const previousChildren = levelChildren(previousNodes, levelId);
1761
+ const previousSlabs = previousChildren
1762
+ .filter((node) => node.type === 'slab')
1763
+ .map((slab) => SlabNode.parse(slab));
1764
+ const previousLevelNode = previousNodes[levelId];
1765
+ const previousStoreyHeight = previousLevelNode?.type === 'level'
1766
+ ? getStoredLevelHeight(previousLevelNode)
1767
+ : DEFAULT_LEVEL_HEIGHT;
1768
+ const previousSpaces = topologyDelta.beforeRooms.map((room) => buildSpace(levelId, room));
1769
+ const previousVerticalPlacements = autoRoomVerticalPlacements(previousSpaces, topologyDelta.previousWalls, previousSlabs.filter((slab) => !slab.autoFromWalls), previousNodes, previousStoreyHeight);
1770
+ const placementFor = (polygon) => verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1771
+ const previousPlacementFor = (polygon) => previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple)));
1772
+ syncAutoSlabsForLevel(levelId, slabRooms.map((room) => room.polygon), slabs, sceneStore, {
1773
+ elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation,
1774
+ previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation,
1775
+ }, allSlabs);
1776
+ syncAutoCeilingsForLevel(levelId, ceilingRooms.map((room) => room.polygon), ceilings, sceneStore, {
1777
+ storeyHeight,
1778
+ ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
1779
+ heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight,
1780
+ previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight,
1781
+ childPosition: (childId) => {
1782
+ const child = nodes[childId];
1783
+ return child && Array.isArray(child.position)
1784
+ ? [child.position[0], child.position[2]]
1785
+ : undefined;
1786
+ },
1787
+ }, allCeilings);
1788
+ }
1789
+ const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room));
1790
+ const zones = levelChildren(nodes, levelId)
1791
+ .filter((node) => node.type === 'zone')
1792
+ .map((zone) => ZoneNode.parse(zone));
1793
+ const zonePlan = planAutoZonesForLevel(spaces, zones);
1794
+ if (zonePlan.update.length > 0)
1795
+ updateNodes(zonePlan.update);
1796
+ const existingSpaces = editorStore.getState().spaces;
1797
+ const nextSpaces = {};
1798
+ for (const [spaceId, space] of Object.entries(existingSpaces)) {
1799
+ if (space.levelId !== levelId)
1800
+ nextSpaces[spaceId] = space;
1220
1801
  }
1802
+ for (const space of spaces)
1803
+ nextSpaces[space.id] = space;
1221
1804
  editorStore.getState().setSpaces(nextSpaces);
1222
1805
  }
1223
1806
  // Refcount of outstanding pause requests, matching the pauseSceneHistory
@@ -1240,66 +1823,142 @@ export function resumeSpaceDetection() {
1240
1823
  export function isSpaceDetectionPaused() {
1241
1824
  return spaceDetectionPauseDepth > 0;
1242
1825
  }
1243
- export function initSpaceDetectionSync(sceneStore, editorStore) {
1826
+ export function initSpaceDetectionSync(sceneStore, editorStore, options = {}) {
1244
1827
  // Baseline from whatever is already in the store. Detection reacts to wall
1245
1828
  // edits made IN-SESSION (create / move / delete); it must not re-litigate a
1246
1829
  // scene that merely loaded — rerunning on hydration resurrected auto slabs
1247
1830
  // the user had deleted in an earlier session.
1248
- const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
1831
+ const initialNodes = sceneStore.getState().nodes;
1832
+ const previousRoomsByLevel = new Map();
1833
+ const topologyIndex = new RoomTopologyIndex({
1834
+ detectRooms: extractRooms,
1835
+ sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple),
1836
+ junctionTolerance: WALL_JUNCTION_TOLERANCE,
1837
+ });
1838
+ let previousNodes = initialNodes;
1249
1839
  let isProcessing = false;
1840
+ const adoptSceneBaseline = (nodes) => {
1841
+ topologyIndex.rebuild(nodes);
1842
+ const roomsByLevel = detectedRoomsByLevel(nodes);
1843
+ previousRoomsByLevel.clear();
1844
+ const spaces = {};
1845
+ for (const [levelId, rooms] of roomsByLevel) {
1846
+ previousRoomsByLevel.set(levelId, rooms);
1847
+ for (const room of rooms) {
1848
+ const space = buildSpace(levelId, room);
1849
+ spaces[space.id] = space;
1850
+ }
1851
+ }
1852
+ editorStore.getState().setSpaces(spaces);
1853
+ previousNodes = nodes;
1854
+ };
1855
+ adoptSceneBaseline(initialNodes);
1856
+ const unsubscribeCommits = subscribeSceneCommits((commit) => {
1857
+ if (commit.origin === 'local')
1858
+ return;
1859
+ adoptSceneBaseline(commit.current.nodes);
1860
+ });
1250
1861
  const unsubscribe = sceneStore.subscribe((state) => {
1251
1862
  if (isProcessing)
1252
1863
  return;
1253
1864
  if (getSceneHistoryPauseDepth() > 0)
1254
1865
  return;
1255
1866
  const nodes = state.nodes;
1256
- const currentSnapshots = levelStructureSnapshots(nodes);
1867
+ const candidateIds = activeSceneCommitNodeIds();
1257
1868
  // Paused: roll the snapshot forward so we don't backfill (and re-duplicate)
1258
1869
  // every paused change once detection resumes. Whatever the AI built while
1259
1870
  // paused becomes the new baseline; only future changes will reconcile.
1260
1871
  if (spaceDetectionPauseDepth > 0) {
1261
- previousSnapshots.clear();
1262
- for (const [levelId, snapshot] of currentSnapshots.entries()) {
1263
- previousSnapshots.set(levelId, snapshot);
1872
+ adoptSceneBaseline(nodes);
1873
+ return;
1874
+ }
1875
+ const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds);
1876
+ if (candidateIds && changedWalls.size > 0) {
1877
+ const fallbackLevels = fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds);
1878
+ for (const levelId of changedWalls.keys())
1879
+ fallbackLevels.delete(levelId);
1880
+ isProcessing = true;
1881
+ pauseSceneHistory(sceneStore);
1882
+ try {
1883
+ for (const [levelId, wallIds] of changedWalls) {
1884
+ const topologyDelta = topologyIndex.applyWallDelta(levelId, wallIds, previousNodes, nodes);
1885
+ runIndexedSpaceDetection(levelId, topologyDelta, sceneStore, editorStore, nodes, previousNodes);
1886
+ previousRoomsByLevel.set(levelId, topologyDelta.allCurrentRooms);
1887
+ options.onTopologyReconcile?.({
1888
+ levelId,
1889
+ strategy: topologyDelta.strategy,
1890
+ examinedWallIds: topologyDelta.examinedWallIds,
1891
+ affectedBeforeRoomCount: topologyDelta.beforeRooms.length,
1892
+ affectedCurrentRoomCount: topologyDelta.currentRooms.length,
1893
+ });
1894
+ }
1895
+ if (fallbackLevels.size > 0) {
1896
+ runSpaceDetection([...fallbackLevels], sceneStore, editorStore, sceneStore.getState().nodes, previousNodes, previousRoomsByLevel);
1897
+ const liveNodes = sceneStore.getState().nodes;
1898
+ for (const levelId of fallbackLevels)
1899
+ topologyIndex.rebuildLevel(levelId, liveNodes);
1900
+ }
1901
+ }
1902
+ finally {
1903
+ resumeSceneHistory(sceneStore);
1904
+ previousNodes = sceneStore.getState().nodes;
1905
+ isProcessing = false;
1264
1906
  }
1265
1907
  return;
1266
1908
  }
1267
1909
  const levelsToUpdate = new Set();
1268
- for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
1269
- // First sight of a level is a hydration baseline, not a wall edit —
1270
- // `setScene` delivers a loaded scene as one atomic update, and a level's
1271
- // first wall can't close a room anyway. Record it (below) and only
1272
- // react to subsequent changes.
1273
- const previous = previousSnapshots.get(levelId);
1274
- if (previous === undefined)
1275
- continue;
1276
- if (previous !== (currentSnapshots.get(levelId) ?? '')) {
1910
+ if (candidateIds) {
1911
+ for (const levelId of fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds)) {
1277
1912
  levelsToUpdate.add(levelId);
1278
1913
  }
1279
1914
  }
1915
+ else {
1916
+ const previousSnapshots = levelStructureSnapshots(previousNodes);
1917
+ const currentSnapshots = levelStructureSnapshots(nodes);
1918
+ for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
1919
+ // First sight of a level is a hydration baseline, not a wall edit —
1920
+ // `setScene` delivers a loaded scene as one atomic update, and a level's
1921
+ // first wall can't close a room anyway. Record it (below) and only
1922
+ // react to subsequent changes.
1923
+ const previous = previousSnapshots.get(levelId);
1924
+ if (previous === undefined)
1925
+ continue;
1926
+ if (previous !== (currentSnapshots.get(levelId) ?? '')) {
1927
+ levelsToUpdate.add(levelId);
1928
+ }
1929
+ }
1930
+ }
1280
1931
  if (levelsToUpdate.size === 0) {
1281
- previousSnapshots.clear();
1282
- for (const [levelId, snapshot] of currentSnapshots.entries()) {
1283
- previousSnapshots.set(levelId, snapshot);
1932
+ if (candidateIds) {
1933
+ previousNodes = nodes;
1934
+ return;
1935
+ }
1936
+ const currentRoomsByLevel = detectedRoomsByLevel(nodes);
1937
+ previousRoomsByLevel.clear();
1938
+ for (const [levelId, rooms] of currentRoomsByLevel) {
1939
+ previousRoomsByLevel.set(levelId, rooms);
1284
1940
  }
1941
+ previousNodes = nodes;
1285
1942
  return;
1286
1943
  }
1287
1944
  isProcessing = true;
1288
1945
  pauseSceneHistory(sceneStore);
1289
1946
  try {
1290
- runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes);
1947
+ runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes, previousNodes, previousRoomsByLevel);
1291
1948
  }
1292
1949
  finally {
1293
1950
  resumeSceneHistory(sceneStore);
1294
- previousSnapshots.clear();
1295
- const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes);
1296
- for (const [levelId, snapshot] of postRunSnapshots.entries()) {
1297
- previousSnapshots.set(levelId, snapshot);
1298
- }
1951
+ const liveNodes = sceneStore.getState().nodes;
1952
+ for (const levelId of levelsToUpdate)
1953
+ topologyIndex.rebuildLevel(levelId, liveNodes);
1954
+ previousNodes = liveNodes;
1299
1955
  isProcessing = false;
1300
1956
  }
1301
1957
  });
1302
- return unsubscribe;
1958
+ return () => {
1959
+ unsubscribe();
1960
+ unsubscribeCommits();
1961
+ };
1303
1962
  }
1304
1963
  export function wallTouchesOthers(wall, otherWalls) {
1305
1964
  const threshold = 0.1;
@@ -1315,18 +1974,3 @@ export function wallTouchesOthers(wall, otherWalls) {
1315
1974
  }
1316
1975
  return false;
1317
1976
  }
1318
- function distanceToSegment(point, segStart, segEnd) {
1319
- const [px, py] = point;
1320
- const [x1, y1] = segStart;
1321
- const [x2, y2] = segEnd;
1322
- const dx = x2 - x1;
1323
- const dy = y2 - y1;
1324
- const lenSq = dx * dx + dy * dy;
1325
- if (lenSq < 0.0001) {
1326
- return Math.hypot(px - x1, py - y1);
1327
- }
1328
- const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
1329
- const projX = x1 + t * dx;
1330
- const projY = y1 + t * dy;
1331
- return Math.hypot(px - projX, py - projY);
1332
- }