@pascal-app/core 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/dist/events/bus.d.ts +30 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
  4. package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
  5. package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
  6. package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
  7. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  8. package/dist/hooks/scene-registry/scene-registry.js +27 -1
  9. package/dist/hooks/scene-registry/scene-registry.test.d.ts +2 -0
  10. package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +1 -0
  11. package/dist/hooks/scene-registry/scene-registry.test.js +26 -0
  12. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +93 -0
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +69 -12
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  15. package/dist/hooks/spatial-grid/spatial-grid-manager.js +476 -195
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.js +19 -1
  18. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
  19. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
  20. package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
  21. package/dist/index.d.ts +16 -6
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +15 -5
  24. package/dist/lib/level-name.d.ts +4 -0
  25. package/dist/lib/level-name.d.ts.map +1 -0
  26. package/dist/lib/level-name.js +10 -0
  27. package/dist/lib/measurement-geometry.d.ts +19 -0
  28. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  29. package/dist/lib/measurement-geometry.js +232 -0
  30. package/dist/lib/measurement-geometry.test.d.ts +2 -0
  31. package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
  32. package/dist/lib/measurement-geometry.test.js +102 -0
  33. package/dist/lib/polygon-geometry.d.ts +0 -1
  34. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  35. package/dist/lib/polygon-geometry.js +0 -18
  36. package/dist/lib/selection-proxy.d.ts +4 -0
  37. package/dist/lib/selection-proxy.d.ts.map +1 -0
  38. package/dist/lib/selection-proxy.js +12 -0
  39. package/dist/lib/slab-polygon.d.ts +31 -2
  40. package/dist/lib/slab-polygon.d.ts.map +1 -1
  41. package/dist/lib/slab-polygon.js +525 -31
  42. package/dist/lib/slab-polygon.test.d.ts +2 -0
  43. package/dist/lib/slab-polygon.test.d.ts.map +1 -0
  44. package/dist/lib/slab-polygon.test.js +635 -0
  45. package/dist/lib/slots.d.ts +14 -0
  46. package/dist/lib/slots.d.ts.map +1 -0
  47. package/dist/lib/slots.js +26 -0
  48. package/dist/lib/space-detection.d.ts +25 -2
  49. package/dist/lib/space-detection.d.ts.map +1 -1
  50. package/dist/lib/space-detection.js +414 -118
  51. package/dist/lib/space-detection.test.js +301 -2
  52. package/dist/lib/wall-distance.d.ts +60 -0
  53. package/dist/lib/wall-distance.d.ts.map +1 -0
  54. package/dist/lib/wall-distance.js +85 -0
  55. package/dist/lib/zone-quantities.d.ts +21 -0
  56. package/dist/lib/zone-quantities.d.ts.map +1 -0
  57. package/dist/lib/zone-quantities.js +183 -0
  58. package/dist/lib/zone-quantities.test.d.ts +2 -0
  59. package/dist/lib/zone-quantities.test.d.ts.map +1 -0
  60. package/dist/lib/zone-quantities.test.js +82 -0
  61. package/dist/material-library.d.ts +20 -1
  62. package/dist/material-library.d.ts.map +1 -1
  63. package/dist/material-library.js +2181 -441
  64. package/dist/registry/handles.d.ts +75 -2
  65. package/dist/registry/handles.d.ts.map +1 -1
  66. package/dist/registry/index.d.ts +3 -3
  67. package/dist/registry/index.d.ts.map +1 -1
  68. package/dist/registry/index.js +1 -1
  69. package/dist/registry/registry.d.ts +46 -3
  70. package/dist/registry/registry.d.ts.map +1 -1
  71. package/dist/registry/registry.js +82 -1
  72. package/dist/registry/registry.test.js +14 -1
  73. package/dist/registry/subtree.d.ts.map +1 -1
  74. package/dist/registry/subtree.js +4 -0
  75. package/dist/registry/subtree.test.js +30 -0
  76. package/dist/registry/types.d.ts +717 -38
  77. package/dist/registry/types.d.ts.map +1 -1
  78. package/dist/schema/index.d.ts +21 -5
  79. package/dist/schema/index.d.ts.map +1 -1
  80. package/dist/schema/index.js +17 -3
  81. package/dist/schema/material.d.ts +3 -2
  82. package/dist/schema/material.d.ts.map +1 -1
  83. package/dist/schema/material.js +7 -5
  84. package/dist/schema/material.test.d.ts +2 -0
  85. package/dist/schema/material.test.d.ts.map +1 -0
  86. package/dist/schema/material.test.js +43 -0
  87. package/dist/schema/nodes/box-vent.d.ts +2 -2
  88. package/dist/schema/nodes/cabinet.d.ts +534 -0
  89. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  90. package/dist/schema/nodes/cabinet.js +137 -0
  91. package/dist/schema/nodes/ceiling.d.ts +3 -2
  92. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  93. package/dist/schema/nodes/ceiling.js +4 -0
  94. package/dist/schema/nodes/chimney.d.ts +4 -4
  95. package/dist/schema/nodes/column.d.ts +3 -2
  96. package/dist/schema/nodes/column.d.ts.map +1 -1
  97. package/dist/schema/nodes/column.js +3 -0
  98. package/dist/schema/nodes/cupola.d.ts +2 -2
  99. package/dist/schema/nodes/door.d.ts +13 -5
  100. package/dist/schema/nodes/door.d.ts.map +1 -1
  101. package/dist/schema/nodes/door.js +12 -0
  102. package/dist/schema/nodes/dormer.d.ts +8 -8
  103. package/dist/schema/nodes/dormer.js +1 -1
  104. package/dist/schema/nodes/downspout.d.ts +2 -2
  105. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  106. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  107. package/dist/schema/nodes/duct-fitting.js +92 -0
  108. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  109. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  110. package/dist/schema/nodes/duct-segment.js +73 -0
  111. package/dist/schema/nodes/duct-terminal.d.ts +57 -0
  112. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  113. package/dist/schema/nodes/duct-terminal.js +51 -0
  114. package/dist/schema/nodes/elevator.d.ts +3 -2
  115. package/dist/schema/nodes/elevator.d.ts.map +1 -1
  116. package/dist/schema/nodes/elevator.js +3 -0
  117. package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
  118. package/dist/schema/nodes/fence.d.ts +18 -2
  119. package/dist/schema/nodes/fence.d.ts.map +1 -1
  120. package/dist/schema/nodes/fence.js +25 -2
  121. package/dist/schema/nodes/gutter.d.ts +2 -2
  122. package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
  123. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  124. package/dist/schema/nodes/hvac-equipment.js +55 -0
  125. package/dist/schema/nodes/item.d.ts +8 -0
  126. package/dist/schema/nodes/item.d.ts.map +1 -1
  127. package/dist/schema/nodes/item.js +11 -0
  128. package/dist/schema/nodes/level.d.ts +1 -1
  129. package/dist/schema/nodes/level.d.ts.map +1 -1
  130. package/dist/schema/nodes/level.js +2 -0
  131. package/dist/schema/nodes/lineset.d.ts +42 -0
  132. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  133. package/dist/schema/nodes/lineset.js +38 -0
  134. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  135. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  136. package/dist/schema/nodes/liquid-line.js +24 -0
  137. package/dist/schema/nodes/measurement.d.ts +306 -0
  138. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  139. package/dist/schema/nodes/measurement.js +87 -0
  140. package/dist/schema/nodes/measurement.test.d.ts +2 -0
  141. package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
  142. package/dist/schema/nodes/measurement.test.js +139 -0
  143. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  144. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  145. package/dist/schema/nodes/pipe-fitting.js +45 -0
  146. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  147. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  148. package/dist/schema/nodes/pipe-segment.js +36 -0
  149. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  150. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  151. package/dist/schema/nodes/pipe-trap.js +36 -0
  152. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
  153. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
  154. package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
  155. package/dist/schema/nodes/ridge-vent.d.ts +18 -3
  156. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  157. package/dist/schema/nodes/ridge-vent.js +221 -3
  158. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  159. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  160. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  161. package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
  162. package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
  163. package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
  164. package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
  165. package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
  166. package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
  167. package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
  168. package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
  169. package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
  170. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  171. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  172. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  173. package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
  174. package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
  175. package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
  176. package/dist/schema/nodes/roof-segment.d.ts +84 -8
  177. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  178. package/dist/schema/nodes/roof-segment.js +290 -5
  179. package/dist/schema/nodes/roof.d.ts +8 -8
  180. package/dist/schema/nodes/shelf.d.ts +3 -2
  181. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  182. package/dist/schema/nodes/shelf.js +6 -0
  183. package/dist/schema/nodes/skylight.d.ts +10 -10
  184. package/dist/schema/nodes/slab.d.ts +3 -2
  185. package/dist/schema/nodes/slab.d.ts.map +1 -1
  186. package/dist/schema/nodes/slab.js +4 -0
  187. package/dist/schema/nodes/solar-panel.d.ts +4 -4
  188. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  189. package/dist/schema/nodes/stair.d.ts +13 -12
  190. package/dist/schema/nodes/stair.d.ts.map +1 -1
  191. package/dist/schema/nodes/stair.js +3 -0
  192. package/dist/schema/nodes/turbine-vent.d.ts +2 -2
  193. package/dist/schema/nodes/wall.d.ts +245 -6
  194. package/dist/schema/nodes/wall.d.ts.map +1 -1
  195. package/dist/schema/nodes/wall.js +257 -0
  196. package/dist/schema/nodes/wall.test.d.ts +2 -0
  197. package/dist/schema/nodes/wall.test.d.ts.map +1 -0
  198. package/dist/schema/nodes/wall.test.js +208 -0
  199. package/dist/schema/nodes/window.d.ts +11 -3
  200. package/dist/schema/nodes/window.d.ts.map +1 -1
  201. package/dist/schema/nodes/window.js +12 -0
  202. package/dist/schema/nodes/zone.d.ts +2 -0
  203. package/dist/schema/nodes/zone.d.ts.map +1 -1
  204. package/dist/schema/nodes/zone.js +6 -0
  205. package/dist/schema/scene-material.d.ts +41 -0
  206. package/dist/schema/scene-material.d.ts.map +1 -0
  207. package/dist/schema/scene-material.js +9 -0
  208. package/dist/schema/types.d.ts +1089 -94
  209. package/dist/schema/types.d.ts.map +1 -1
  210. package/dist/schema/types.js +23 -0
  211. package/dist/services/alignment-anchors.d.ts.map +1 -1
  212. package/dist/services/alignment-anchors.js +30 -1
  213. package/dist/services/alignment.d.ts +5 -0
  214. package/dist/services/alignment.d.ts.map +1 -1
  215. package/dist/services/alignment.js +2 -0
  216. package/dist/services/hosting.d.ts +8 -0
  217. package/dist/services/hosting.d.ts.map +1 -1
  218. package/dist/services/hosting.js +13 -4
  219. package/dist/services/hosting.test.js +32 -1
  220. package/dist/services/index.d.ts +8 -2
  221. package/dist/services/index.d.ts.map +1 -1
  222. package/dist/services/index.js +8 -2
  223. package/dist/services/level-height.d.ts +28 -0
  224. package/dist/services/level-height.d.ts.map +1 -0
  225. package/dist/services/level-height.js +62 -0
  226. package/dist/services/opening-guides.d.ts +141 -0
  227. package/dist/services/opening-guides.d.ts.map +1 -0
  228. package/dist/services/opening-guides.js +267 -0
  229. package/dist/services/opening-guides.test.d.ts +2 -0
  230. package/dist/services/opening-guides.test.d.ts.map +1 -0
  231. package/dist/services/opening-guides.test.js +208 -0
  232. package/dist/services/port-connectivity.d.ts +115 -0
  233. package/dist/services/port-connectivity.d.ts.map +1 -0
  234. package/dist/services/port-connectivity.js +300 -0
  235. package/dist/services/port-connectivity.test.d.ts +2 -0
  236. package/dist/services/port-connectivity.test.d.ts.map +1 -0
  237. package/dist/services/port-connectivity.test.js +297 -0
  238. package/dist/services/riser-diagram.d.ts +40 -0
  239. package/dist/services/riser-diagram.d.ts.map +1 -0
  240. package/dist/services/riser-diagram.js +110 -0
  241. package/dist/services/riser-diagram.test.d.ts +2 -0
  242. package/dist/services/riser-diagram.test.d.ts.map +1 -0
  243. package/dist/services/riser-diagram.test.js +66 -0
  244. package/dist/services/snap.d.ts +9 -1
  245. package/dist/services/snap.d.ts.map +1 -1
  246. package/dist/services/snap.js +21 -1
  247. package/dist/services/snap.test.js +43 -1
  248. package/dist/services/system-graph.d.ts +30 -0
  249. package/dist/services/system-graph.d.ts.map +1 -0
  250. package/dist/services/system-graph.js +164 -0
  251. package/dist/services/system-graph.test.d.ts +2 -0
  252. package/dist/services/system-graph.test.d.ts.map +1 -0
  253. package/dist/services/system-graph.test.js +138 -0
  254. package/dist/services/validate-dwv.d.ts +32 -0
  255. package/dist/services/validate-dwv.d.ts.map +1 -0
  256. package/dist/services/validate-dwv.js +118 -0
  257. package/dist/services/validate-dwv.test.d.ts +2 -0
  258. package/dist/services/validate-dwv.test.d.ts.map +1 -0
  259. package/dist/services/validate-dwv.test.js +109 -0
  260. package/dist/store/actions/node-actions.d.ts.map +1 -1
  261. package/dist/store/actions/node-actions.js +94 -1
  262. package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
  263. package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
  264. package/dist/store/actions/ridge-vent-update.test.js +264 -0
  265. package/dist/store/history-control.d.ts +11 -0
  266. package/dist/store/history-control.d.ts.map +1 -1
  267. package/dist/store/history-control.js +33 -0
  268. package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
  269. package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
  270. package/dist/store/use-scene-dirty-tracking.test.js +68 -0
  271. package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
  272. package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
  273. package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
  274. package/dist/store/use-scene-plugins.test.d.ts +2 -0
  275. package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
  276. package/dist/store/use-scene-plugins.test.js +51 -0
  277. package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
  278. package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
  279. package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
  280. package/dist/store/use-scene-window-migration.test.d.ts +2 -0
  281. package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
  282. package/dist/store/use-scene-window-migration.test.js +85 -0
  283. package/dist/store/use-scene.d.ts +17 -2
  284. package/dist/store/use-scene.d.ts.map +1 -1
  285. package/dist/store/use-scene.js +358 -51
  286. package/dist/systems/elevator/elevator-service.js +1 -1
  287. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  288. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  289. package/dist/systems/fence/fence-centerline.js +37 -0
  290. package/dist/systems/fence/fence-spline.d.ts +39 -0
  291. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  292. package/dist/systems/fence/fence-spline.js +196 -0
  293. package/dist/systems/fence/fence-spline.test.d.ts +2 -0
  294. package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
  295. package/dist/systems/fence/fence-spline.test.js +136 -0
  296. package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
  297. package/dist/systems/stair/stair-footprint.js +21 -11
  298. package/dist/systems/stair/stair-opening-preview.d.ts +1139 -469
  299. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  300. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  301. package/dist/systems/stair/stair-opening-sync.js +1 -18
  302. package/dist/systems/stair/stair-opening-sync.test.js +39 -0
  303. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  304. package/dist/systems/wall/wall-mitering.js +38 -2
  305. package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
  306. package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
  307. package/dist/systems/wall/wall-mitering.test.js +63 -0
  308. package/dist/utils/clone-scene-graph.d.ts +1 -0
  309. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  310. package/dist/utils/clone-scene-graph.js +23 -3
  311. package/dist/utils/clone-scene-graph.test.js +3 -0
  312. package/dist/utils/heal-scene-graph.d.ts +18 -0
  313. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  314. package/dist/utils/heal-scene-graph.js +90 -0
  315. package/dist/utils/heal-scene-graph.test.d.ts +2 -0
  316. package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
  317. package/dist/utils/heal-scene-graph.test.js +88 -0
  318. package/dist/validation/validate-build-json.d.ts +1 -0
  319. package/dist/validation/validate-build-json.d.ts.map +1 -1
  320. package/dist/validation/validate-build-json.js +31 -2
  321. package/package.json +3 -3
@@ -1,7 +1,10 @@
1
+ import { getRenderableSlabPolygon } from '../../lib/slab-polygon';
2
+ import { nodeRegistry } from '../../registry';
1
3
  import { getScaledDimensions, isLowProfileItemSurface } from '../../schema';
2
4
  import useScene from '../../store/use-scene';
3
- import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve';
5
+ import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve';
4
6
  import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint';
7
+ import { getFloorPlacedFootprints } from './floor-placed-elevation';
5
8
  import { SpatialGrid } from './spatial-grid';
6
9
  import { WallSpatialGrid } from './wall-spatial-grid';
7
10
  // ============================================================================
@@ -40,6 +43,24 @@ function getItemFootprint(position, dimensions, rotation, inset = 0) {
40
43
  [x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)],
41
44
  ];
42
45
  }
46
+ /**
47
+ * Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The
48
+ * rotated width/depth is the same conservative bound the floor-placement draft
49
+ * uses, so a draft and an existing node are compared with identical math.
50
+ */
51
+ function footprintBoundsXZ(position, dimensions, yRot) {
52
+ const [width, , depth] = dimensions;
53
+ const cos = Math.abs(Math.cos(yRot));
54
+ const sin = Math.abs(Math.sin(yRot));
55
+ const rotatedW = width * cos + depth * sin;
56
+ const rotatedD = width * sin + depth * cos;
57
+ return {
58
+ minX: position[0] - rotatedW / 2,
59
+ maxX: position[0] + rotatedW / 2,
60
+ minZ: position[2] - rotatedD / 2,
61
+ maxZ: position[2] + rotatedD / 2,
62
+ };
63
+ }
43
64
  function getItemLocalBounds(item) {
44
65
  const [width, height, depth] = getScaledDimensions(item);
45
66
  const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2;
@@ -99,6 +120,24 @@ function resolveNodeLevelId(node, nodes) {
99
120
  }
100
121
  return 'default';
101
122
  }
123
+ function expandIgnoredNodeIds(ignoreIds, nodes) {
124
+ const ignored = new Set(ignoreIds ?? []);
125
+ const queue = [...ignored];
126
+ while (queue.length > 0) {
127
+ const id = queue.pop();
128
+ const node = nodes[id];
129
+ const children = node?.children;
130
+ if (!Array.isArray(children))
131
+ continue;
132
+ for (const childId of children) {
133
+ if (typeof childId !== 'string' || ignored.has(childId))
134
+ continue;
135
+ ignored.add(childId);
136
+ queue.push(childId);
137
+ }
138
+ }
139
+ return ignored;
140
+ }
102
141
  /**
103
142
  * Test if two line segments (a1->a2) and (b1->b2) intersect.
104
143
  */
@@ -164,39 +203,228 @@ export function itemOverlapsPolygon(position, dimensions, rotation, polygon, ins
164
203
  }
165
204
  return false;
166
205
  }
206
+ function pointSegmentDistance(px, pz, ax, az, bx, bz) {
207
+ const dx = bx - ax;
208
+ const dz = bz - az;
209
+ const lengthSquared = dx * dx + dz * dz;
210
+ if (lengthSquared < 1e-18)
211
+ return Math.hypot(px - ax, pz - az);
212
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared));
213
+ return Math.hypot(px - (ax + dx * t), pz - (az + dz * t));
214
+ }
215
+ // Ray-cast pointInPolygon is unreliable for points exactly on the polygon
216
+ // boundary: the answer flips depending on which side of the polygon the edge
217
+ // is on. Interval classification below therefore treats "within this distance
218
+ // of the boundary" as inside explicitly, so walls sitting exactly on a slab
219
+ // edge (the common case — auto-slab polygons derive from wall centerlines)
220
+ // classify identically on every side of the slab.
221
+ const ON_BOUNDARY_EPSILON = 1e-4;
222
+ function pointOnPolygonBoundary(px, pz, polygon) {
223
+ const n = polygon.length;
224
+ for (let i = 0; i < n; i++) {
225
+ const [ax, az] = polygon[i];
226
+ const [bx, bz] = polygon[(i + 1) % n];
227
+ if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON)
228
+ return true;
229
+ }
230
+ return false;
231
+ }
232
+ function mergeIntervals(intervals) {
233
+ if (intervals.length <= 1)
234
+ return intervals;
235
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
236
+ const merged = [[sorted[0][0], sorted[0][1]]];
237
+ for (let i = 1; i < sorted.length; i++) {
238
+ const [intervalStart, intervalEnd] = sorted[i];
239
+ const last = merged[merged.length - 1];
240
+ if (intervalStart <= last[1] + 1e-9) {
241
+ last[1] = Math.max(last[1], intervalEnd);
242
+ }
243
+ else {
244
+ merged.push([intervalStart, intervalEnd]);
245
+ }
246
+ }
247
+ return merged;
248
+ }
249
+ /** Total length of a merged (sorted, disjoint) interval list. */
250
+ function intervalsLength(intervals) {
251
+ let total = 0;
252
+ for (const [intervalStart, intervalEnd] of intervals)
253
+ total += intervalEnd - intervalStart;
254
+ return total;
255
+ }
256
+ /** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
257
+ function subtractIntervals(base, cut) {
258
+ if (base.length === 0 || cut.length === 0)
259
+ return mergeIntervals(base);
260
+ const cuts = mergeIntervals(cut);
261
+ const result = [];
262
+ for (const [baseStart, baseEnd] of mergeIntervals(base)) {
263
+ let cursor = baseStart;
264
+ for (const [cutStart, cutEnd] of cuts) {
265
+ if (cutEnd <= cursor)
266
+ continue;
267
+ if (cutStart >= baseEnd)
268
+ break;
269
+ if (cutStart > cursor)
270
+ result.push([cursor, cutStart]);
271
+ cursor = cutEnd;
272
+ if (cursor >= baseEnd)
273
+ break;
274
+ }
275
+ if (cursor < baseEnd)
276
+ result.push([cursor, baseEnd]);
277
+ }
278
+ return result;
279
+ }
280
+ /**
281
+ * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
282
+ * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
283
+ * segment. The segment is split at every crossing with a polygon edge and
284
+ * each sub-interval is classified by its midpoint, so no test point ever
285
+ * sits on a crossing.
286
+ */
287
+ function segmentInsideIntervals(ax, az, bx, bz, polygon, includeBoundary) {
288
+ const dx = bx - ax;
289
+ const dz = bz - az;
290
+ const length = Math.hypot(dx, dz);
291
+ if (length < 1e-9)
292
+ return [];
293
+ const ts = [0, 1];
294
+ const n = polygon.length;
295
+ for (let i = 0; i < n; i++) {
296
+ const [px, pz] = polygon[i];
297
+ const [qx, qz] = polygon[(i + 1) % n];
298
+ const ex = qx - px;
299
+ const ez = qz - pz;
300
+ const denom = dx * ez - dz * ex;
301
+ if (Math.abs(denom) < 1e-12)
302
+ continue; // parallel/collinear — nothing to split at
303
+ const t = ((px - ax) * ez - (pz - az) * ex) / denom;
304
+ const s = ((px - ax) * dz - (pz - az) * dx) / denom;
305
+ if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9)
306
+ ts.push(t);
307
+ }
308
+ ts.sort((a, b) => a - b);
309
+ const inside = [];
310
+ for (let i = 1; i < ts.length; i++) {
311
+ const t0 = ts[i - 1];
312
+ const t1 = ts[i];
313
+ if (t1 - t0 < 1e-9)
314
+ continue;
315
+ const tm = (t0 + t1) / 2;
316
+ const mx = ax + dx * tm;
317
+ const mz = az + dz * tm;
318
+ const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
319
+ ? includeBoundary
320
+ : pointInPolygon(mx, mz, polygon);
321
+ if (midpointInside)
322
+ inside.push([t0, t1]);
323
+ }
324
+ return inside;
325
+ }
326
+ function polylineLength(points) {
327
+ let total = 0;
328
+ for (let i = 1; i < points.length; i++) {
329
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
330
+ }
331
+ return total;
332
+ }
167
333
  /**
168
- * Check if wall segment (a) is substantially on polygon edge segment (b).
169
- * Returns true only if BOTH endpoints of the wall are on or very close to the edge.
170
- * This prevents walls that just touch one point from being detected.
334
+ * Inside sub-intervals of a polyline against a polygon, in cumulative
335
+ * arc-length units from the polyline start (merged, disjoint). Boundary
336
+ * contact counts as inside for slab support (walls sit exactly on slab
337
+ * edges — see ON_BOUNDARY_EPSILON above); hole callers pass
338
+ * `includeBoundary: false` so a wall running along a stairwell hole's
339
+ * rim keeps the rim's support.
171
340
  */
172
- function segmentsCollinearAndOverlap(ax1, az1, ax2, az2, bx1, bz1, bx2, bz2) {
173
- const EPSILON = 1e-6;
174
- // Cross product to check collinearity
175
- const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1);
176
- const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1);
177
- if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) {
178
- return false; // Not collinear
179
- }
180
- // Check if a point is on segment b
181
- const onSegment = (px, pz, qx, qz, rx, rz) => Math.min(px, qx) - EPSILON <= rx &&
182
- rx <= Math.max(px, qx) + EPSILON &&
183
- Math.min(pz, qz) - EPSILON <= rz &&
184
- rz <= Math.max(pz, qz) + EPSILON;
185
- // BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
186
- const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1);
187
- const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2);
188
- return a1OnB && a2OnB;
341
+ function polylineInsideIntervals(points, polygon, includeBoundary = true) {
342
+ const intervals = [];
343
+ let offset = 0;
344
+ for (let i = 1; i < points.length; i++) {
345
+ const a = points[i - 1];
346
+ const b = points[i];
347
+ const segmentLength = Math.hypot(b.x - a.x, b.y - a.y);
348
+ if (segmentLength < 1e-9)
349
+ continue;
350
+ for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
351
+ intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]);
352
+ }
353
+ offset += segmentLength;
354
+ }
355
+ return mergeIntervals(intervals);
189
356
  }
357
+ function polylineInsideLength(points, polygon) {
358
+ return intervalsLength(polylineInsideIntervals(points, polygon));
359
+ }
360
+ // Minimum length of wall that must lie on/inside a slab polygon before the
361
+ // wall counts as overlapping it. Point contact (a perpendicular wall butting
362
+ // into a room's edge) clips to ~zero length and never reaches this, so such
363
+ // walls don't follow the slab's elevation.
364
+ const WALL_SLAB_MIN_OVERLAP = 0.05;
190
365
  /**
191
- * Test if a wall segment overlaps with a polygon.
192
- * A wall is considered to overlap if:
193
- * - Its midpoint is inside the polygon (wall crosses through)
194
- * - At least one endpoint is inside (wall partially or fully in slab)
195
- * - It's collinear with and overlaps a polygon edge (wall on slab boundary)
196
- * - (curved walls) any sample along the centerline is inside
366
+ * Centerline of the wall plus its two face lines (centerline offset by
367
+ * ±halfThickness). The face lines catch walls whose centerline sits on or
368
+ * just outside the slab boundary but whose body reaches onto the slab —
369
+ * e.g. slab polygons drawn to the room's interior faces.
370
+ */
371
+ function wallTestPolylines(start, end, curveOffset, halfThickness) {
372
+ const wallLike = { start, end, curveOffset };
373
+ if (curveOffset !== 0 && isCurvedWall(wallLike)) {
374
+ const count = 16;
375
+ const center = [];
376
+ const left = [];
377
+ const right = [];
378
+ for (let i = 0; i <= count; i++) {
379
+ const frame = getWallCurveFrameAt(wallLike, i / count);
380
+ center.push(frame.point);
381
+ left.push({
382
+ x: frame.point.x + frame.normal.x * halfThickness,
383
+ y: frame.point.y + frame.normal.y * halfThickness,
384
+ });
385
+ right.push({
386
+ x: frame.point.x - frame.normal.x * halfThickness,
387
+ y: frame.point.y - frame.normal.y * halfThickness,
388
+ });
389
+ }
390
+ return halfThickness > 0 ? [center, left, right] : [center];
391
+ }
392
+ const center = [
393
+ { x: start[0], y: start[1] },
394
+ { x: end[0], y: end[1] },
395
+ ];
396
+ const dx = end[0] - start[0];
397
+ const dz = end[1] - start[1];
398
+ const len = Math.hypot(dx, dz);
399
+ if (len < 1e-10 || halfThickness <= 0)
400
+ return [center];
401
+ const nx = (-dz / len) * halfThickness;
402
+ const nz = (dx / len) * halfThickness;
403
+ return [
404
+ center,
405
+ [
406
+ { x: start[0] + nx, y: start[1] + nz },
407
+ { x: end[0] + nx, y: end[1] + nz },
408
+ ],
409
+ [
410
+ { x: start[0] - nx, y: start[1] - nz },
411
+ { x: end[0] - nx, y: end[1] - nz },
412
+ ],
413
+ ];
414
+ }
415
+ /**
416
+ * Test whether a wall overlaps a slab polygon along a segment of its length.
417
+ *
418
+ * The wall's centerline and both face lines are clipped against the polygon;
419
+ * the wall overlaps when the longest clipped inside-or-on-boundary length
420
+ * exceeds a threshold (5cm, halved for very short walls). Because interval
421
+ * midpoints classify "on the boundary" as inside explicitly (never by
422
+ * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
423
+ * identically on every side of the slab.
197
424
  *
198
- * Note: A wall with just one endpoint touching the edge but the rest outside
199
- * is NOT considered overlapping (adjacent only).
425
+ * A wall that only touches the polygon at a point a perpendicular wall
426
+ * butting into a room's edge, or a corner-to-corner touch — clips to ~zero
427
+ * length and does NOT overlap.
200
428
  */
201
429
  export function wallOverlapsPolygon(startOrWall, endOrPolygon, polygonArg) {
202
430
  // Two call shapes:
@@ -220,112 +448,154 @@ export function wallOverlapsPolygon(startOrWall, endOrPolygon, polygonArg) {
220
448
  polygon = endOrPolygon;
221
449
  }
222
450
  const halfThickness = Math.max(thickness / 2, 0);
223
- // Curved walls: sample the centerline. The chord-based checks below miss
224
- // walls that bow into/out of the slab — e.g. endpoints on the slab
225
- // boundary with the curve arcing inward through the slab interior. Without
226
- // this, `getSlabElevationForWall` returns 0 (wall drops to floor) and
227
- // `markNodesOverlappingSlab` never re-dirties the wall when the slab Y
228
- // moves.
229
- if (curveOffset !== 0) {
230
- const wallLike = { start, end, curveOffset };
231
- if (isCurvedWall(wallLike)) {
232
- const samples = sampleWallCenterline(wallLike, 16);
233
- for (let i = 0; i < samples.length; i++) {
234
- const point = samples[i];
235
- if (pointInPolygon(point.x, point.y, polygon))
236
- return true;
237
- // Also test ±halfThickness perpendicular at each sample so a curve
238
- // skirting the slab edge with its centerline just outside still
239
- // registers its body sits inside the slab.
240
- if (halfThickness > 0 && i > 0) {
241
- const prev = samples[i - 1];
242
- const sx = point.x - prev.x;
243
- const sz = point.y - prev.y;
244
- const sl = Math.sqrt(sx * sx + sz * sz);
245
- if (sl > 1e-10) {
246
- const tnx = (-sz / sl) * halfThickness;
247
- const tnz = (sx / sl) * halfThickness;
248
- if (pointInPolygon(point.x + tnx, point.y + tnz, polygon))
249
- return true;
250
- if (pointInPolygon(point.x - tnx, point.y - tnz, polygon))
251
- return true;
252
- }
253
- }
451
+ const polylines = wallTestPolylines(start, end, curveOffset, halfThickness);
452
+ const centerLength = polylineLength(polylines[0]);
453
+ if (centerLength < 1e-9)
454
+ return false;
455
+ let overlap = 0;
456
+ for (const line of polylines) {
457
+ overlap = Math.max(overlap, polylineInsideLength(line, polygon));
458
+ }
459
+ const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5));
460
+ return overlap >= threshold;
461
+ }
462
+ // A slab elevation must support at least this fraction of the wall's
463
+ // length before it can dictate the wall's base. Below majority, a raised
464
+ // slab reaching one endpoint would hoist the whole wall off the floor
465
+ // that actually carries it.
466
+ const WALL_SLAB_SUPPORT_MAJORITY = 0.5;
467
+ // Slabs whose elevations differ by less than this pool their support:
468
+ // a wall shared between two rooms' slabs is covered roughly half by
469
+ // each, and must still follow their common elevation.
470
+ const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4;
471
+ export function computeWallSlabSupport(wallLike, slabs, levelWalls) {
472
+ const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike;
473
+ const halfThickness = Math.max(thickness / 2, 0);
474
+ const polylines = wallTestPolylines(start, end, curveOffset, halfThickness);
475
+ const polylineLengths = polylines.map(polylineLength);
476
+ const wallLength = polylineLengths[0];
477
+ if (wallLength < 1e-9) {
478
+ return { elevation: 0, baseElevation: 0, baseSegments: [] };
479
+ }
480
+ const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5));
481
+ const groups = [];
482
+ for (const slab of slabs) {
483
+ if (slab.polygon.length < 3)
484
+ continue;
485
+ const renderedPolygon = getRenderableSlabPolygon(slab, {
486
+ walls: levelWalls,
487
+ siblingSlabs: slabs.filter((other) => other.id !== slab.id),
488
+ });
489
+ let supported = 0;
490
+ const perPolyline = polylines.map((line) => {
491
+ let intervals = polylineInsideIntervals(line, renderedPolygon);
492
+ for (const hole of slab.holes || []) {
493
+ if (intervals.length === 0)
494
+ break;
495
+ if (hole.length < 3)
496
+ continue;
497
+ intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false));
254
498
  }
499
+ supported = Math.max(supported, intervalsLength(intervals));
500
+ return intervals;
501
+ });
502
+ if (supported < minSupport)
503
+ continue;
504
+ const elevation = slab.elevation ?? 0.05;
505
+ let group = groups.find((candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON);
506
+ if (!group) {
507
+ group = { elevation, perPolyline: polylines.map(() => []) };
508
+ groups.push(group);
509
+ }
510
+ for (let i = 0; i < perPolyline.length; i++) {
511
+ group.perPolyline[i].push(...perPolyline[i]);
255
512
  }
256
513
  }
257
- const dx = end[0] - start[0];
258
- const dz = end[1] - start[1];
259
- const len = Math.sqrt(dx * dx + dz * dz);
260
- // Nudge endpoint test points a tiny step inward along the wall direction before
261
- // testing containment. pointInPolygon (ray casting) produces false positives for
262
- // points exactly on polygon vertices or edges — specifically the minimum-z corner
263
- // of an axis-aligned polygon returns "inside" because the ray hits the opposite
264
- // vertical edge exactly at its base. Nudging by 1e-6 m avoids this: a wall that
265
- // merely starts at a slab corner and extends outward will have its nudged point
266
- // clearly outside, while a wall that genuinely starts inside stays inside.
267
- if (len > 1e-10) {
268
- const step = Math.min(1e-6, len * 0.01);
269
- const nx = (dx / len) * step;
270
- const nz = (dz / len) * step;
271
- if (pointInPolygon(start[0] + nx, start[1] + nz, polygon))
272
- return true;
273
- if (pointInPolygon(end[0] - nx, end[1] - nz, polygon))
274
- return true;
275
- // Also nudge perpendicular to the wall (into the slab interior) for walls that
276
- // lie exactly on the slab boundary. The along-wall nudge keeps points on the
277
- // boundary where pointInPolygon is unreliable; a perpendicular inward nudge
278
- // moves the point clearly inside (or outside) the polygon.
279
- // Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.
280
- const PERP_STEP = 1e-4;
281
- const pnx = (-nz / step) * PERP_STEP; // perpendicular left
282
- const pnz = (nx / step) * PERP_STEP;
283
- for (const t of [0.25, 0.5, 0.75]) {
284
- const bx = start[0] + dx * t;
285
- const bz = start[1] + dz * t;
286
- if (pointInPolygon(bx + pnx, bz + pnz, polygon))
287
- return true;
288
- if (pointInPolygon(bx - pnx, bz - pnz, polygon))
289
- return true;
290
- }
291
- // Wall-thickness perpendicular test. Walls aren't infinitely thin lines;
292
- // a wall whose centerline sits just outside the slab boundary still has
293
- // half its body inside the slab and should follow the slab elevation.
294
- // Without this, the perimeter walls of a room often miss the slab-overlap
295
- // detection (the slab polygon is the room's interior, the wall centerline
296
- // sits on or just outside its edge) and stay at Y=0 while the slab moves
297
- // up.
298
- if (halfThickness > 0) {
299
- const ux = dx / len;
300
- const uz = dz / len;
301
- const tnx = -uz * halfThickness;
302
- const tnz = ux * halfThickness;
303
- for (const t of [0, 0.25, 0.5, 0.75, 1]) {
304
- const bx = start[0] + dx * t;
305
- const bz = start[1] + dz * t;
306
- if (pointInPolygon(bx + tnx, bz + tnz, polygon))
307
- return true;
308
- if (pointInPolygon(bx - tnx, bz - tnz, polygon))
309
- return true;
514
+ const evaluatedGroups = groups.map((group) => {
515
+ let coverage = 0;
516
+ const mergedPerPolyline = group.perPolyline.map(mergeIntervals);
517
+ for (let i = 0; i < group.perPolyline.length; i++) {
518
+ const lineLength = polylineLengths[i];
519
+ if (lineLength < 1e-9)
520
+ continue;
521
+ coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]) / lineLength);
522
+ }
523
+ return { ...group, coverage, mergedPerPolyline };
524
+ });
525
+ let majorityElevation = Number.NEGATIVE_INFINITY;
526
+ let bestElevation = Number.NEGATIVE_INFINITY;
527
+ let bestCoverage = -1;
528
+ for (const group of evaluatedGroups) {
529
+ if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
530
+ majorityElevation = Math.max(majorityElevation, group.elevation);
531
+ }
532
+ if (group.coverage > bestCoverage + 1e-6 ||
533
+ (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)) {
534
+ bestCoverage = group.coverage;
535
+ bestElevation = group.elevation;
536
+ }
537
+ }
538
+ const elevation = majorityElevation !== Number.NEGATIVE_INFINITY
539
+ ? majorityElevation
540
+ : bestElevation === Number.NEGATIVE_INFINITY
541
+ ? 0
542
+ : bestElevation;
543
+ const normalizedIntervals = (group, polylineIndex) => {
544
+ const lineLength = polylineLengths[polylineIndex];
545
+ if (lineLength < 1e-9)
546
+ return [];
547
+ return group.mergedPerPolyline[polylineIndex].map(([intervalStart, intervalEnd]) => [intervalStart / lineLength, intervalEnd / lineLength]);
548
+ };
549
+ const normalizedByGroup = evaluatedGroups.map((group) => ({
550
+ elevation: group.elevation,
551
+ perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
552
+ }));
553
+ const breakpoints = [0, 1];
554
+ for (const group of normalizedByGroup) {
555
+ for (const intervals of group.perPolyline) {
556
+ for (const [intervalStart, intervalEnd] of intervals) {
557
+ breakpoints.push(intervalStart, intervalEnd);
310
558
  }
311
559
  }
312
560
  }
313
- // Check if midpoint is inside (catches walls crossing through)
314
- const midX = (start[0] + end[0]) / 2;
315
- const midZ = (start[1] + end[1]) / 2;
316
- if (pointInPolygon(midX, midZ, polygon))
317
- return true;
318
- // Check if the wall is collinear with and overlaps any polygon edge
319
- const n = polygon.length;
320
- for (let i = 0; i < n; i++) {
321
- const j = (i + 1) % n;
322
- const [p1x, p1z] = polygon[i];
323
- const [p2x, p2z] = polygon[j];
324
- if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) {
325
- return true;
561
+ breakpoints.sort((left, right) => left - right);
562
+ const uniqueBreakpoints = breakpoints.filter((value, index) => index === 0 || value - breakpoints[index - 1] > 1e-7);
563
+ const highestAt = (polylineIndex, t) => {
564
+ let highest = Number.NEGATIVE_INFINITY;
565
+ for (const group of normalizedByGroup) {
566
+ if (group.perPolyline[polylineIndex]?.some(([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7)) {
567
+ highest = Math.max(highest, group.elevation);
568
+ }
569
+ }
570
+ return highest;
571
+ };
572
+ const baseSegments = [];
573
+ for (let index = 1; index < uniqueBreakpoints.length; index++) {
574
+ const start = uniqueBreakpoints[index - 1];
575
+ const end = uniqueBreakpoints[index];
576
+ if (end - start < 1e-7)
577
+ continue;
578
+ const midpoint = (start + end) / 2;
579
+ const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY;
580
+ const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY;
581
+ const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite);
582
+ const segmentElevation = faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0);
583
+ const previous = baseSegments[baseSegments.length - 1];
584
+ if (previous &&
585
+ Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) {
586
+ previous.end = end;
587
+ }
588
+ else {
589
+ baseSegments.push({ start, end, elevation: segmentElevation });
326
590
  }
327
591
  }
328
- return false;
592
+ if (baseSegments.length === 0)
593
+ baseSegments.push({ start: 0, end: 1, elevation });
594
+ const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation));
595
+ return { elevation, baseElevation, baseSegments };
596
+ }
597
+ export function computeWallSlabElevation(wallLike, slabs, levelWalls) {
598
+ return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation;
329
599
  }
330
600
  export class SpatialGridManager {
331
601
  floorGrids = new Map(); // levelId -> grid
@@ -512,37 +782,51 @@ export class SpatialGridManager {
512
782
  }
513
783
  // Query methods
514
784
  canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds) {
785
+ return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds);
786
+ }
787
+ canPlaceOnFloorFootprints(levelId, footprints, ignoreIds) {
515
788
  const nodes = useScene.getState().nodes;
516
- const ignoreSet = new Set(ignoreIds ?? []);
517
- const [width, , depth] = dimensions;
518
- const yRot = rotation[1];
519
- const cos = Math.abs(Math.cos(yRot));
520
- const sin = Math.abs(Math.sin(yRot));
521
- const rotatedW = width * cos + depth * sin;
522
- const rotatedD = width * sin + depth * cos;
523
- const draftBounds = {
524
- minX: position[0] - rotatedW / 2,
525
- maxX: position[0] + rotatedW / 2,
526
- minZ: position[2] - rotatedD / 2,
527
- maxZ: position[2] + rotatedD / 2,
528
- };
789
+ const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes);
790
+ const draftBounds = footprints.map((footprint) => footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0));
791
+ for (let i = 0; i < draftBounds.length; i += 1) {
792
+ const a = draftBounds[i];
793
+ for (let j = i + 1; j < draftBounds.length; j += 1) {
794
+ const b = draftBounds[j];
795
+ if (intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) &&
796
+ intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ)) {
797
+ return { valid: false, conflictIds: [] };
798
+ }
799
+ }
800
+ }
801
+ // A floor placement conflicts with any other COLLIDING floor-resting node,
802
+ // not just items — every kind whose `floorPlaced.collides` is set (item /
803
+ // shelf / column / cabinet / stair) contributes its footprint(s) as an
804
+ // obstacle. Each candidate's XZ extent is read from the same declarative
805
+ // footprint the elevation + sync paths use, so adding a colliding kind
806
+ // needs no change here.
529
807
  const conflicts = [];
530
808
  for (const node of Object.values(nodes)) {
531
- if (node.type !== 'item')
809
+ if (ignoreSet.has(node.id))
532
810
  continue;
533
- const item = node;
534
- if (item.asset.attachTo)
811
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced;
812
+ if (!floorPlaced?.collides)
535
813
  continue;
536
- if (isLowProfileItemSurface(item))
814
+ if (floorPlaced.applies && !floorPlaced.applies(node))
537
815
  continue;
538
- if (ignoreSet.has(item.id))
816
+ // Low-profile item surfaces (rugs, mats) are stack-on targets, not
817
+ // obstacles — keep the long-standing item-only exemption.
818
+ if (node.type === 'item' && isLowProfileItemSurface(node))
539
819
  continue;
540
- if (resolveNodeLevelId(item, nodes) !== levelId)
820
+ if (resolveNodeLevelId(node, nodes) !== levelId)
541
821
  continue;
542
- const bounds = getItemParentAabb(item);
543
- if (intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
544
- intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)) {
545
- conflicts.push(item.id);
822
+ for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
823
+ const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0;
824
+ const bounds = footprintBoundsXZ(footprint.position ?? node.position, footprint.dimensions, fpRotation);
825
+ if (draftBounds.some((draft) => intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) &&
826
+ intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ))) {
827
+ conflicts.push(node.id);
828
+ break;
829
+ }
546
830
  }
547
831
  }
548
832
  return { valid: conflicts.length === 0, conflictIds: conflicts };
@@ -674,62 +958,59 @@ export class SpatialGridManager {
674
958
  }
675
959
  /**
676
960
  * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes).
677
- * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
678
961
  * Returns the highest slab elevation found, or 0 if none.
679
962
  *
680
963
  * Accepts an optional `curveOffset` so curved walls evaluate overlap
681
964
  * against their actual centerline samples, not just the chord.
682
965
  */
683
966
  getSlabElevationForWall(levelId, start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS) {
967
+ return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation;
968
+ }
969
+ getSlabSupportForWall(levelId, start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS) {
684
970
  const slabMap = this.slabsByLevel.get(levelId);
685
- if (!slabMap)
686
- return 0;
687
- const wallLike = { start, end, curveOffset, thickness };
688
- const isCurved = curveOffset !== 0 && isCurvedWall(wallLike);
689
- const holeSamplePoints = isCurved
690
- ? sampleWallCenterline(wallLike, 8)
691
- : [0, 0.25, 0.5, 0.75, 1].map((t) => ({
692
- x: start[0] + (end[0] - start[0]) * t,
693
- y: start[1] + (end[1] - start[1]) * t,
694
- }));
695
- let maxElevation = Number.NEGATIVE_INFINITY;
696
- for (const slab of slabMap.values()) {
697
- if (slab.polygon.length < 3)
698
- continue;
699
- if (!wallOverlapsPolygon(wallLike, slab.polygon))
700
- continue;
701
- const holes = slab.holes || [];
702
- if (holes.length === 0) {
703
- // No holes: wall is on this slab
704
- const elevation = slab.elevation ?? 0.05;
705
- if (elevation > maxElevation)
706
- maxElevation = elevation;
971
+ if (!slabMap) {
972
+ return {
973
+ elevation: 0,
974
+ baseElevation: 0,
975
+ baseSegments: [{ start: 0, end: 1, elevation: 0 }],
976
+ };
977
+ }
978
+ return computeWallSlabSupport({ start, end, curveOffset, thickness }, [...slabMap.values()], this.getLevelWallNodes(levelId));
979
+ }
980
+ /**
981
+ * Walls on a level, resolved fresh from the scene store (the manager's
982
+ * own wall map is only maintained on create/delete, not on updates).
983
+ * Cached per scene `nodes` record so per-pointer-tick callers
984
+ * (door/window move) don't rescan the node map.
985
+ */
986
+ levelWallsCache = new WeakMap();
987
+ getLevelWallNodes(levelId) {
988
+ const nodes = useScene.getState().nodes;
989
+ let byLevel = this.levelWallsCache.get(nodes);
990
+ if (!byLevel) {
991
+ byLevel = new Map();
992
+ this.levelWallsCache.set(nodes, byLevel);
993
+ }
994
+ const cached = byLevel.get(levelId);
995
+ if (cached)
996
+ return cached;
997
+ const walls = [];
998
+ for (const node of Object.values(nodes)) {
999
+ if (node.type !== 'wall')
707
1000
  continue;
1001
+ // Walk the parent chain to the owning level (guarded against cycles).
1002
+ let current = node;
1003
+ let guard = 0;
1004
+ while (current && current.type !== 'level' && guard < 16) {
1005
+ current = current.parentId ? nodes[current.parentId] : undefined;
1006
+ guard += 1;
708
1007
  }
709
- // Sample multiple points along the wall to check whether any portion lies on
710
- // solid slab (not inside any hole). Checking only the midpoint fails when the
711
- // midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
712
- let hasValidPoint = false;
713
- for (const sample of holeSamplePoints) {
714
- let inHole = false;
715
- for (const hole of holes) {
716
- if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) {
717
- inHole = true;
718
- break;
719
- }
720
- }
721
- if (!inHole) {
722
- hasValidPoint = true;
723
- break;
724
- }
725
- }
726
- if (hasValidPoint) {
727
- const elevation = slab.elevation ?? 0.05;
728
- if (elevation > maxElevation)
729
- maxElevation = elevation;
1008
+ if (current?.type === 'level' && current.id === levelId) {
1009
+ walls.push(node);
730
1010
  }
731
1011
  }
732
- return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation;
1012
+ byLevel.set(levelId, walls);
1013
+ return walls;
733
1014
  }
734
1015
  /**
735
1016
  * Check if an item can be placed on a ceiling.