@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
@@ -0,0 +1,635 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { SlabNode, WallNode } from '../schema';
3
+ import { pointInPolygon } from './polygon-relations';
4
+ import { getRenderableSlabPolygon, snapSlabEdgeToWallBand } from './slab-polygon';
5
+ function wallOf(start, end, thickness = 0.1) {
6
+ return WallNode.parse({ start, end, thickness });
7
+ }
8
+ function slabOf(polygon, autoFromWalls = true, elevation) {
9
+ return SlabNode.parse(elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation });
10
+ }
11
+ function xs(polygon) {
12
+ return polygon.map((point) => point[0]);
13
+ }
14
+ function zs(polygon) {
15
+ return polygon.map((point) => point[1]);
16
+ }
17
+ /** Assert the ring contains every expected vertex (order-independent). */
18
+ function expectRingToInclude(polygon, points) {
19
+ const missing = points.filter(([x, z]) => !polygon.some((p) => Math.abs(p[0] - x) < 1e-6 && Math.abs(p[1] - z) < 1e-6));
20
+ expect(missing).toEqual([]);
21
+ }
22
+ const roomA = [
23
+ [0, 0],
24
+ [4, 0],
25
+ [4, 3],
26
+ [0, 3],
27
+ ];
28
+ const roomB = [
29
+ [4, 0],
30
+ [8, 0],
31
+ [8, 3],
32
+ [4, 3],
33
+ ];
34
+ // Two rooms side by side sharing the centerline wall at x=4.
35
+ const twoRoomWalls = [
36
+ wallOf([0, 0], [4, 0]),
37
+ wallOf([4, 0], [8, 0]),
38
+ wallOf([8, 0], [8, 3]),
39
+ wallOf([8, 3], [4, 3]),
40
+ wallOf([4, 3], [0, 3]),
41
+ wallOf([0, 3], [0, 0]),
42
+ wallOf([4, 0], [4, 3]),
43
+ ];
44
+ describe('getRenderableSlabPolygon', () => {
45
+ test('adjacent room slabs share the exact centerline seam without gap or overlap', () => {
46
+ const slabA = slabOf(roomA);
47
+ const slabB = slabOf(roomB);
48
+ const polyA = getRenderableSlabPolygon(slabA, {
49
+ walls: twoRoomWalls,
50
+ siblingSlabs: [slabB],
51
+ });
52
+ const polyB = getRenderableSlabPolygon(slabB, {
53
+ walls: twoRoomWalls,
54
+ siblingSlabs: [slabA],
55
+ });
56
+ // A: exterior edges flush with the 0.1-thick facade (+0.05), shared
57
+ // edge exactly on the wall centerline x=4.
58
+ expect(Math.min(...xs(polyA))).toBeCloseTo(-0.05);
59
+ expect(Math.max(...xs(polyA))).toBeCloseTo(4);
60
+ expect(Math.min(...zs(polyA))).toBeCloseTo(-0.05);
61
+ expect(Math.max(...zs(polyA))).toBeCloseTo(3.05);
62
+ expect(Math.min(...xs(polyB))).toBeCloseTo(4);
63
+ expect(Math.max(...xs(polyB))).toBeCloseTo(8.05);
64
+ // No overlap across the shared wall (FP noise only)...
65
+ expect(Math.max(...xs(polyA))).toBeLessThanOrEqual(Math.min(...xs(polyB)) + 1e-9);
66
+ // ...and the seam is EXACTLY shared: both rings project the shared
67
+ // edge onto the same centerline x=4 with matching endpoints.
68
+ const seamZs = (poly) => poly
69
+ .filter((point) => point[0] === 4)
70
+ .map((point) => point[1])
71
+ .sort((left, right) => left - right);
72
+ const seamA = seamZs(polyA);
73
+ const seamB = seamZs(polyB);
74
+ expect(seamA).toHaveLength(2);
75
+ expect(seamB).toHaveLength(2);
76
+ expect(seamA[0]).toBeCloseTo(seamB[0], 12);
77
+ expect(seamA[1]).toBeCloseTo(seamB[1], 12);
78
+ // Grid-sample the strip under the shared wall band: every point is
79
+ // inside at least one slab — the old relief slit is gone, so deleting
80
+ // the wall would expose a continuous floor.
81
+ for (let x = 3.95; x <= 4.0501; x += 0.01) {
82
+ for (let z = 0; z <= 3.001; z += 0.15) {
83
+ expect(pointInPolygon([x, z], polyA) || pointInPolygon([x, z], polyB)).toBe(true);
84
+ }
85
+ }
86
+ });
87
+ test('exterior edge expands by half of THAT wall thickness', () => {
88
+ const walls = [
89
+ wallOf([0, 0], [4, 0]),
90
+ wallOf([4, 0], [4, 3]),
91
+ wallOf([4, 3], [0, 3]),
92
+ // Non-default thickness on the left facade only.
93
+ wallOf([0, 3], [0, 0], 0.3),
94
+ ];
95
+ const poly = getRenderableSlabPolygon(slabOf(roomA), { walls, siblingSlabs: [] });
96
+ expect(Math.min(...xs(poly))).toBeCloseTo(-0.15);
97
+ expect(Math.max(...xs(poly))).toBeCloseTo(4.05);
98
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.05);
99
+ expect(Math.max(...zs(poly))).toBeCloseTo(3.05);
100
+ });
101
+ test('freehand edges away from any wall render exactly as drawn', () => {
102
+ const drawn = [
103
+ [10, 10],
104
+ [12, 10],
105
+ [12, 12],
106
+ [10, 12],
107
+ ];
108
+ const poly = getRenderableSlabPolygon(slabOf(drawn, false), {
109
+ walls: twoRoomWalls,
110
+ siblingSlabs: [],
111
+ });
112
+ expect(poly).toEqual(drawn);
113
+ });
114
+ test('manual slabs follow the same per-edge rule as auto slabs', () => {
115
+ const poly = getRenderableSlabPolygon(slabOf(roomA, false), {
116
+ walls: twoRoomWalls,
117
+ siblingSlabs: [slabOf(roomB)],
118
+ });
119
+ expect(Math.max(...xs(poly))).toBeCloseTo(4);
120
+ expect(Math.min(...xs(poly))).toBeCloseTo(-0.05);
121
+ });
122
+ test('T-junction: a neighbour edge that is a sub-segment still reads as interior', () => {
123
+ // Big 6×5 room; a 2×2 bay hangs below, sealed against the interior of
124
+ // the big room's bottom wall between x=1 and x=3.
125
+ const big = slabOf([
126
+ [0, 0],
127
+ [6, 0],
128
+ [6, 5],
129
+ [0, 5],
130
+ ]);
131
+ const bay = slabOf([
132
+ [1, -2],
133
+ [3, -2],
134
+ [3, 0],
135
+ [1, 0],
136
+ ]);
137
+ const walls = [
138
+ wallOf([0, 0], [6, 0]),
139
+ wallOf([6, 0], [6, 5]),
140
+ wallOf([6, 5], [0, 5]),
141
+ wallOf([0, 5], [0, 0]),
142
+ wallOf([1, 0], [1, -2]),
143
+ wallOf([1, -2], [3, -2]),
144
+ wallOf([3, -2], [3, 0]),
145
+ ];
146
+ // The bay's top edge lies on a sub-segment of the big slab's bottom
147
+ // edge — interior, seamed on the shared wall centerline z=0, while
148
+ // its free-standing sides stay on-wall.
149
+ const bayPoly = getRenderableSlabPolygon(bay, { walls, siblingSlabs: [big] });
150
+ expect(Math.max(...zs(bayPoly))).toBeCloseTo(0);
151
+ expect(Math.min(...zs(bayPoly))).toBeCloseTo(-2.05);
152
+ expect(Math.min(...xs(bayPoly))).toBeCloseTo(0.95);
153
+ expect(Math.max(...xs(bayPoly))).toBeCloseTo(3.05);
154
+ // The big slab's bottom edge is backed differently along its span:
155
+ // centerline seam across the bay (z=0), facade-flush elsewhere
156
+ // (z=-0.05), joined by step connectors at the bay junction walls
157
+ // x=1 and x=3 (the old whole-edge rule pulled the entire edge back).
158
+ const bigPoly = getRenderableSlabPolygon(big, { walls, siblingSlabs: [bay] });
159
+ expect(Math.min(...zs(bigPoly))).toBeCloseTo(-0.05);
160
+ expect(Math.max(...zs(bigPoly))).toBeCloseTo(5.05);
161
+ expectRingToInclude(bigPoly, [
162
+ [1, -0.05],
163
+ [1, 0],
164
+ [3, 0],
165
+ [3, -0.05],
166
+ ]);
167
+ });
168
+ test('an edge on a wall longer than itself still reaches the facade', () => {
169
+ // Slab edge [1,0]→[3,0] sits mid-span on a 6m wall.
170
+ const poly = getRenderableSlabPolygon(slabOf([
171
+ [1, 0],
172
+ [3, 0],
173
+ [3, 2],
174
+ [1, 2],
175
+ ]), { walls: [wallOf([0, 0], [6, 0])], siblingSlabs: [] });
176
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.05);
177
+ // The other three edges are free — rendered as drawn.
178
+ expect(Math.max(...zs(poly))).toBeCloseTo(2);
179
+ expect(Math.min(...xs(poly))).toBeCloseTo(1);
180
+ expect(Math.max(...xs(poly))).toBeCloseTo(3);
181
+ });
182
+ test('legacy edge stored at the inner wall face projects to the outer face', () => {
183
+ // Wall centerline z=0, thickness 0.1 — the legacy slab edge sits at the
184
+ // inner face z=0.05. Absolute projection must land the rendered edge on
185
+ // the OUTER face (-0.05), not at inner face + t/2 (= centerline).
186
+ const poly = getRenderableSlabPolygon(slabOf([
187
+ [0, 0.05],
188
+ [4, 0.05],
189
+ [4, 3],
190
+ [0, 3],
191
+ ], false), { walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] });
192
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.05);
193
+ });
194
+ test('legacy edge stored at the outer wall face stays at the face (no overshoot)', () => {
195
+ const poly = getRenderableSlabPolygon(slabOf([
196
+ [0, -0.05],
197
+ [4, -0.05],
198
+ [4, 3],
199
+ [0, 3],
200
+ ], false), { walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] });
201
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.05);
202
+ });
203
+ test('a thick wall adopts a face-aligned edge beyond the old fixed tolerance', () => {
204
+ // t=0.3: inner face is 0.15 off the centerline — past the old fixed 0.1
205
+ // tolerance, so this edge used to classify FREE and never expand.
206
+ const poly = getRenderableSlabPolygon(slabOf([
207
+ [0, 0.15],
208
+ [4, 0.15],
209
+ [4, 3],
210
+ [0, 3],
211
+ ], false), { walls: [wallOf([0, 0], [4, 0], 0.3)], siblingSlabs: [] });
212
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.15);
213
+ });
214
+ test('edges outside the adoption band stay free', () => {
215
+ // Band for t=0.1 is half + 0.06 = 0.11 — an edge 0.12 away is kept as drawn.
216
+ const poly = getRenderableSlabPolygon(slabOf([
217
+ [0, 0.12],
218
+ [4, 0.12],
219
+ [4, 3],
220
+ [0, 3],
221
+ ], false), { walls: [wallOf([0, 0], [4, 0])], siblingSlabs: [] });
222
+ expect(Math.min(...zs(poly))).toBeCloseTo(0.12);
223
+ });
224
+ test('two parallel close walls: the nearest centerline wins', () => {
225
+ // Thin wall at z=0 (band 0.11) and thick wall at z=0.3 (t=0.3, band
226
+ // 0.21). An edge at z=0.1 is inside BOTH bands (laterals 0.1 and 0.2);
227
+ // it must adopt the nearer thin wall and land on ITS outer face.
228
+ const poly = getRenderableSlabPolygon(slabOf([
229
+ [0, 0.1],
230
+ [4, 0.1],
231
+ [4, 3],
232
+ [0, 3],
233
+ ], false), {
234
+ walls: [wallOf([0, 0], [4, 0]), wallOf([0, 0.3], [4, 0.3], 0.3)],
235
+ siblingSlabs: [],
236
+ });
237
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.05);
238
+ });
239
+ test('span overlap below the minimum leaves the edge free', () => {
240
+ // The wall only overlaps the last 2cm of the edge span — under the 5cm
241
+ // classification minimum.
242
+ const poly = getRenderableSlabPolygon(slabOf([
243
+ [0, 0],
244
+ [4, 0],
245
+ [4, 3],
246
+ [0, 3],
247
+ ], false), { walls: [wallOf([3.98, 0], [4.5, 0])], siblingSlabs: [] });
248
+ expect(Math.min(...zs(poly))).toBeCloseTo(0);
249
+ });
250
+ test('legacy face-aligned rooms across a thick wall still tile without overlap', () => {
251
+ // Both rooms stored at the INNER faces of the shared t=0.3 wall at x=4
252
+ // (edges 0.3 apart — far beyond the direct sibling tolerance). Each edge
253
+ // is inside the wall band with the sibling across the same band, so both
254
+ // classify interior and land exactly on the CENTERLINE instead of
255
+ // projecting to opposite outer faces (which would overlap by a full
256
+ // thickness).
257
+ const walls = [
258
+ wallOf([0, 0], [8, 0]),
259
+ wallOf([8, 0], [8, 3]),
260
+ wallOf([8, 3], [0, 3]),
261
+ wallOf([0, 3], [0, 0]),
262
+ wallOf([4, 0], [4, 3], 0.3),
263
+ ];
264
+ const legacyA = slabOf([
265
+ [0, 0],
266
+ [3.85, 0],
267
+ [3.85, 3],
268
+ [0, 3],
269
+ ], false);
270
+ const legacyB = slabOf([
271
+ [4.15, 0],
272
+ [8, 0],
273
+ [8, 3],
274
+ [4.15, 3],
275
+ ], false);
276
+ const polyA = getRenderableSlabPolygon(legacyA, { walls, siblingSlabs: [legacyB] });
277
+ const polyB = getRenderableSlabPolygon(legacyB, { walls, siblingSlabs: [legacyA] });
278
+ expect(Math.max(...xs(polyA))).toBeCloseTo(4);
279
+ expect(Math.min(...xs(polyB))).toBeCloseTo(4);
280
+ expect(Math.max(...xs(polyA))).toBeLessThanOrEqual(Math.min(...xs(polyB)) + 1e-9);
281
+ });
282
+ test('the higher room carries the wall band to the lower room face', () => {
283
+ // Real user repro shape: two rooms in an L/offset arrangement share the
284
+ // z=0 wall over x ∈ [-1, 0.5] only; the north room's floor is raised
285
+ // (0.34) above the south room's (0.05). Slabs extrude 0 → elevation and
286
+ // the higher slab closes the full wall band while the lower slab meets
287
+ // it at its own wall face. The reach comes from the matched wall's real
288
+ // thickness, not a default-width expansion.
289
+ const walls = [
290
+ wallOf([-1, 3], [-1, 0]),
291
+ wallOf([-1, 0], [0.5, 0]),
292
+ wallOf([0.5, 0], [2, 0]),
293
+ wallOf([2, 0], [2, 3]),
294
+ wallOf([2, 3], [-1, 3]),
295
+ wallOf([0.5, 0], [0.5, -4]),
296
+ wallOf([0.5, -4], [-1, -4]),
297
+ wallOf([-1, -4], [-1, 0]),
298
+ ];
299
+ const high = slabOf([
300
+ [-1, 3],
301
+ [-1, 0],
302
+ [2, 0],
303
+ [2, 3],
304
+ ], false, 0.34);
305
+ const low = slabOf([
306
+ [0.5, 0],
307
+ [-1, 0],
308
+ [-1, -4],
309
+ [0.5, -4],
310
+ ], true, 0.05);
311
+ const polyHigh = getRenderableSlabPolygon(high, { walls, siblingSlabs: [low] });
312
+ const polyLow = getRenderableSlabPolygon(low, { walls, siblingSlabs: [high] });
313
+ // The shared and facade spans fuse because both land on z=-0.05.
314
+ expectRingToInclude(polyHigh, [
315
+ [0.5, -0.05],
316
+ [2.05, -0.05],
317
+ ]);
318
+ expect(Math.min(...zs(polyHigh))).toBeCloseTo(-0.05);
319
+ expect(Math.max(...zs(polyLow))).toBeCloseTo(-0.05);
320
+ // The high slab owns the entire band; the lower room begins at its face.
321
+ for (let x = -0.95; x <= 0.4501; x += 0.05) {
322
+ for (let z = -0.045; z <= 0.0451; z += 0.015) {
323
+ expect(pointInPolygon([x, z], polyHigh, { includeBoundary: false })).toBe(true);
324
+ }
325
+ }
326
+ });
327
+ test('legacy face-aligned unequal rooms self-heal to the lower room face', () => {
328
+ // Same stored-at-inner-faces legacy data as above (edges a full 0.3
329
+ // apart across the t=0.3 wall at x=4), but with the west room raised.
330
+ // Both edges classify interior through the band-sibling rule and adopt
331
+ // the east/lower room face despite being stored at opposite faces.
332
+ const walls = [
333
+ wallOf([0, 0], [8, 0]),
334
+ wallOf([8, 0], [8, 3]),
335
+ wallOf([8, 3], [0, 3]),
336
+ wallOf([0, 3], [0, 0]),
337
+ wallOf([4, 0], [4, 3], 0.3),
338
+ ];
339
+ const legacyHigh = slabOf([
340
+ [0, 0],
341
+ [3.85, 0],
342
+ [3.85, 3],
343
+ [0, 3],
344
+ ], false, 0.4);
345
+ const legacyLow = slabOf([
346
+ [4.15, 0],
347
+ [8, 0],
348
+ [8, 3],
349
+ [4.15, 3],
350
+ ], false, 0.05);
351
+ const polyHigh = getRenderableSlabPolygon(legacyHigh, { walls, siblingSlabs: [legacyLow] });
352
+ const polyLow = getRenderableSlabPolygon(legacyLow, { walls, siblingSlabs: [legacyHigh] });
353
+ expect(Math.max(...xs(polyHigh))).toBeCloseTo(4.15);
354
+ expect(Math.min(...xs(polyLow))).toBeCloseTo(4.15);
355
+ expect(Math.max(...xs(polyHigh))).toBeLessThanOrEqual(Math.min(...xs(polyLow)) + 1e-9);
356
+ });
357
+ test('stacked slabs are not mistaken for rooms across a wall', () => {
358
+ const floor = slabOf(roomA, false, 0.05);
359
+ const platform = slabOf(roomA, false, 0.4);
360
+ const walls = [
361
+ wallOf([0, 0], [4, 0]),
362
+ wallOf([4, 0], [4, 3]),
363
+ wallOf([4, 3], [0, 3]),
364
+ wallOf([0, 3], [0, 0]),
365
+ ];
366
+ const floorPolygon = getRenderableSlabPolygon(floor, {
367
+ walls,
368
+ siblingSlabs: [platform],
369
+ });
370
+ const platformPolygon = getRenderableSlabPolygon(platform, {
371
+ walls,
372
+ siblingSlabs: [floor],
373
+ });
374
+ for (const polygon of [floorPolygon, platformPolygon]) {
375
+ expect(Math.min(...xs(polygon))).toBeCloseTo(-0.05);
376
+ expect(Math.max(...xs(polygon))).toBeCloseTo(4.05);
377
+ expect(Math.min(...zs(polygon))).toBeCloseTo(-0.05);
378
+ expect(Math.max(...zs(polygon))).toBeCloseTo(3.05);
379
+ }
380
+ });
381
+ test('sibling winding does not change a shared seam decision', () => {
382
+ const slabA = slabOf(roomA);
383
+ const slabB = slabOf([...roomB].reverse());
384
+ const polyA = getRenderableSlabPolygon(slabA, {
385
+ walls: twoRoomWalls,
386
+ siblingSlabs: [slabB],
387
+ });
388
+ const polyB = getRenderableSlabPolygon(slabB, {
389
+ walls: twoRoomWalls,
390
+ siblingSlabs: [slabA],
391
+ });
392
+ expect(Math.max(...xs(polyA))).toBeCloseTo(4);
393
+ expect(Math.min(...xs(polyB))).toBeCloseTo(4);
394
+ });
395
+ test('wall-less butted slabs at different elevations keep the midline seam', () => {
396
+ // No wall backs the seam, so there is no band to hide a pocket under —
397
+ // the exposed step face at the joint is correct. Elevation must not
398
+ // move a wall-less seam off the midline.
399
+ const stepHigh = slabOf([
400
+ [0, 0],
401
+ [4, 0],
402
+ [4, 3],
403
+ [0, 3],
404
+ ], false, 0.3);
405
+ const stepLow = slabOf([
406
+ [4, 0],
407
+ [8, 0],
408
+ [8, 3],
409
+ [4, 3],
410
+ ], false, 0.05);
411
+ const polyHigh = getRenderableSlabPolygon(stepHigh, { walls: [], siblingSlabs: [stepLow] });
412
+ const polyLow = getRenderableSlabPolygon(stepLow, { walls: [], siblingSlabs: [stepHigh] });
413
+ expect(Math.max(...xs(polyHigh))).toBeCloseTo(4);
414
+ expect(Math.min(...xs(polyLow))).toBeCloseTo(4);
415
+ });
416
+ test('offset rooms sharing a partial wall span: interior beside the sibling, facade elsewhere', () => {
417
+ // Rooms offset diagonally share the x=4 wall only for z ∈ [1.5, 3].
418
+ // Each room's long edge is interior for the shared span and exterior
419
+ // (its own facade) for the rest — the case sub-edge classification
420
+ // exists for.
421
+ const offsetA = slabOf([
422
+ [0, 0],
423
+ [4, 0],
424
+ [4, 3],
425
+ [0, 3],
426
+ ]);
427
+ const offsetB = slabOf([
428
+ [4, 1.5],
429
+ [8, 1.5],
430
+ [8, 4.5],
431
+ [4, 4.5],
432
+ ]);
433
+ const walls = [
434
+ wallOf([0, 0], [4, 0]),
435
+ wallOf([0, 3], [0, 0]),
436
+ wallOf([0, 3], [4, 3]),
437
+ wallOf([4, 0], [4, 4.5]),
438
+ wallOf([4, 1.5], [8, 1.5]),
439
+ wallOf([8, 1.5], [8, 4.5]),
440
+ wallOf([8, 4.5], [4, 4.5]),
441
+ ];
442
+ const polyA = getRenderableSlabPolygon(offsetA, { walls, siblingSlabs: [offsetB] });
443
+ const polyB = getRenderableSlabPolygon(offsetB, { walls, siblingSlabs: [offsetA] });
444
+ // A's right edge: facade-flush below the junction, exactly on the
445
+ // centerline beside B, joined by the step connector at the junction z=1.5.
446
+ expectRingToInclude(polyA, [
447
+ [4.05, -0.05],
448
+ [4.05, 1.5],
449
+ [4, 1.5],
450
+ [4, 3.05],
451
+ ]);
452
+ // B's left edge mirrors it: centerline seam beside A, facade-flush
453
+ // above, step at the junction z=3.
454
+ expectRingToInclude(polyB, [
455
+ [4, 1.45],
456
+ [4, 3],
457
+ [3.95, 3],
458
+ [3.95, 4.55],
459
+ ]);
460
+ // Along the shared span both slabs reach exactly the wall centerline:
461
+ // the strip under the shared wall is fully covered with no interior
462
+ // overlap — deleting the wall would expose a continuous floor...
463
+ for (let z = 1.6; z <= 2.95; z += 0.1) {
464
+ expect(pointInPolygon([3.99, z], polyA, { includeBoundary: false })).toBe(true);
465
+ expect(pointInPolygon([4.01, z], polyB, { includeBoundary: false })).toBe(true);
466
+ expect(pointInPolygon([4.01, z], polyA, { includeBoundary: false })).toBe(false);
467
+ expect(pointInPolygon([3.99, z], polyB, { includeBoundary: false })).toBe(false);
468
+ for (let x = 3.96; x <= 4.0401; x += 0.01) {
469
+ expect(pointInPolygon([x, z], polyA) || pointInPolygon([x, z], polyB)).toBe(true);
470
+ }
471
+ }
472
+ // ...while each unshared portion reaches its own facade face.
473
+ expect(pointInPolygon([4.04, 0.75], polyA, { includeBoundary: false })).toBe(true);
474
+ expect(pointInPolygon([3.96, 3.75], polyB, { includeBoundary: false })).toBe(true);
475
+ // Any residual overlap is confined to the shared wall's footprint
476
+ // right at the junction corners — hidden under the wall bodies, the
477
+ // same corner pockets the outer-face projection has always produced
478
+ // where two rooms' facades meet a wall junction.
479
+ for (let x = 3.5; x <= 4.5; x += 0.02) {
480
+ for (let z = -0.2; z <= 4.7; z += 0.02) {
481
+ const overlapping = pointInPolygon([x, z], polyA, { includeBoundary: false }) &&
482
+ pointInPolygon([x, z], polyB, { includeBoundary: false });
483
+ if (!overlapping)
484
+ continue;
485
+ expect(Math.abs(x - 4)).toBeLessThanOrEqual(0.05 + 1e-9);
486
+ expect(Math.min(Math.abs(z - 1.5), Math.abs(z - 3))).toBeLessThanOrEqual(0.05 + 1e-9);
487
+ }
488
+ }
489
+ });
490
+ test('offset unequal rooms give the higher slab the full shared wall band', () => {
491
+ const high = slabOf([
492
+ [0, 0],
493
+ [4, 0],
494
+ [4, 3],
495
+ [0, 3],
496
+ ], true, 0.4);
497
+ const low = slabOf([
498
+ [4, 1.5],
499
+ [8, 1.5],
500
+ [8, 4.5],
501
+ [4, 4.5],
502
+ ], true, 0.05);
503
+ const walls = [
504
+ wallOf([0, 0], [4, 0]),
505
+ wallOf([0, 3], [0, 0]),
506
+ wallOf([0, 3], [4, 3]),
507
+ wallOf([4, 0], [4, 4.5]),
508
+ wallOf([4, 1.5], [8, 1.5]),
509
+ wallOf([8, 1.5], [8, 4.5]),
510
+ wallOf([8, 4.5], [4, 4.5]),
511
+ ];
512
+ const highPolygon = getRenderableSlabPolygon(high, { walls, siblingSlabs: [low] });
513
+ const lowPolygon = getRenderableSlabPolygon(low, { walls, siblingSlabs: [high] });
514
+ expect(Math.max(...xs(highPolygon))).toBeCloseTo(4.05);
515
+ expectRingToInclude(lowPolygon, [
516
+ [4.05, 1.45],
517
+ [4.05, 3],
518
+ [3.95, 3],
519
+ ]);
520
+ for (let z = 1.6; z <= 2.95; z += 0.1) {
521
+ expect(pointInPolygon([3.975, z], highPolygon, { includeBoundary: false })).toBe(true);
522
+ expect(pointInPolygon([3.975, z], lowPolygon, { includeBoundary: false })).toBe(false);
523
+ expect(pointInPolygon([4.025, z], highPolygon, { includeBoundary: false })).toBe(true);
524
+ expect(pointInPolygon([4.025, z], lowPolygon, { includeBoundary: false })).toBe(false);
525
+ expect(pointInPolygon([4.075, z], lowPolygon, { includeBoundary: false })).toBe(true);
526
+ expect(pointInPolygon([4.075, z], highPolygon, { includeBoundary: false })).toBe(false);
527
+ }
528
+ });
529
+ test('a wall backing only part of an edge: flush over the wall, as drawn beyond it', () => {
530
+ const poly = getRenderableSlabPolygon(slabOf([
531
+ [0, 0],
532
+ [4, 0],
533
+ [4, 3],
534
+ [0, 3],
535
+ ], false), { walls: [wallOf([0, 0], [2, 0])], siblingSlabs: [] });
536
+ expectRingToInclude(poly, [
537
+ [0, -0.05],
538
+ [2, -0.05],
539
+ [2, 0],
540
+ [4, 0],
541
+ ]);
542
+ expect(Math.min(...zs(poly))).toBeCloseTo(-0.05);
543
+ expect(Math.max(...xs(poly))).toBeCloseTo(4);
544
+ });
545
+ test('two collinear walls of different thickness along one edge: each face wins its own span', () => {
546
+ const poly = getRenderableSlabPolygon(slabOf([
547
+ [0, 0],
548
+ [4, 0],
549
+ [4, 3],
550
+ [0, 3],
551
+ ], false), { walls: [wallOf([0, 0], [2, 0]), wallOf([2, 0], [4, 0], 0.3)], siblingSlabs: [] });
552
+ // Thin-wall face for x < 2, thick-wall face beyond, stepped at x=2
553
+ // (the old whole-edge rule let one wall win the entire edge).
554
+ expectRingToInclude(poly, [
555
+ [0, -0.05],
556
+ [2, -0.05],
557
+ [2, -0.15],
558
+ [4, -0.15],
559
+ ]);
560
+ });
561
+ test('breakpoints within the minimum sub-edge length merge into one step', () => {
562
+ // The wall ends at x=2; the sibling starts at x=2.02 — the two
563
+ // breakpoints are 2cm apart, under the 5cm minimum, so they collapse
564
+ // into a single step at x=2 instead of leaving a sliver sub-edge.
565
+ const sibling = slabOf([
566
+ [2.02, -2],
567
+ [4, -2],
568
+ [4, 0],
569
+ [2.02, 0],
570
+ ]);
571
+ const poly = getRenderableSlabPolygon(slabOf([
572
+ [0, 0],
573
+ [4, 0],
574
+ [4, 3],
575
+ [0, 3],
576
+ ], false), { walls: [wallOf([0, 0], [2, 0])], siblingSlabs: [sibling] });
577
+ expectRingToInclude(poly, [
578
+ [2, -0.05],
579
+ [2, 0],
580
+ ]);
581
+ expect(poly).toHaveLength(6);
582
+ });
583
+ test('nearly collinear corners do not create unbounded wall-offset miters', () => {
584
+ const stored = [
585
+ [3.1318685080276216, -4.3412497001265],
586
+ [11.261660174864861, -4.341249992437693],
587
+ [11.26165987489183, -1.3166851233565569],
588
+ [3.131868208054532, -1.3166857310453555],
589
+ [1.7318390128066048, -1.3166857522417288],
590
+ [1.7318388127796835, -4.341249921322843],
591
+ ];
592
+ const poly = getRenderableSlabPolygon(slabOf(stored, false), {
593
+ walls: [
594
+ wallOf([1.7318387751077626, -4.323741654294752], [11.300000049514956, -4.341249931281993]),
595
+ ],
596
+ siblingSlabs: [],
597
+ });
598
+ const storedExtent = Math.max(...stored.flat().map(Math.abs));
599
+ const renderedExtent = Math.max(...poly.flat().map(Math.abs));
600
+ const longestEdge = Math.max(...poly.map((point, index) => {
601
+ const next = poly[(index + 1) % poly.length];
602
+ return Math.hypot(point[0] - next[0], point[1] - next[1]);
603
+ }));
604
+ expect(renderedExtent - storedExtent).toBeLessThan(0.2);
605
+ expect(longestEdge).toBeLessThan(8.4);
606
+ });
607
+ });
608
+ describe('snapSlabEdgeToWallBand', () => {
609
+ test('an edge inside the band snaps onto the wall centerline', () => {
610
+ const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])]);
611
+ expect(snap).not.toBeNull();
612
+ expect(snap.edge[0][1]).toBeCloseTo(0);
613
+ expect(snap.edge[1][1]).toBeCloseTo(0);
614
+ // Tangential positions are preserved — pure perpendicular translation.
615
+ expect(snap.edge[0][0]).toBeCloseTo(0.5);
616
+ expect(snap.edge[1][0]).toBeCloseTo(3.5);
617
+ });
618
+ test('an edge outside the band does not snap', () => {
619
+ const snap = snapSlabEdgeToWallBand([0.5, 0.2], [3.5, 0.2], [wallOf([0, 0], [4, 0])]);
620
+ expect(snap).toBeNull();
621
+ });
622
+ test('maxLateral tightens the stick distance', () => {
623
+ const walls = [wallOf([0, 0], [4, 0])];
624
+ expect(snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], walls, { maxLateral: 0.05 })).toBeNull();
625
+ expect(snapSlabEdgeToWallBand([0.5, 0.04], [3.5, 0.04], walls, { maxLateral: 0.05 })).not.toBeNull();
626
+ });
627
+ test('the nearest of two candidate walls wins', () => {
628
+ const near = wallOf([0, 0], [4, 0]);
629
+ const far = wallOf([0, 0.3], [4, 0.3], 0.3);
630
+ const snap = snapSlabEdgeToWallBand([0.5, 0.1], [3.5, 0.1], [far, near]);
631
+ expect(snap).not.toBeNull();
632
+ expect(snap.wallId).toBe(near.id);
633
+ expect(snap.edge[0][1]).toBeCloseTo(0);
634
+ });
635
+ });
@@ -0,0 +1,14 @@
1
+ export declare const SLOT_MATERIAL_PREFIX = "slot_";
2
+ /** A glTF material name marks a paintable slot when it starts with `slot_` (case-insensitive). */
3
+ export declare function isSlotMaterialName(name: string): boolean;
4
+ /**
5
+ * Derive the stable slot id from a glTF material name:
6
+ * strip the `slot_` prefix (case-insensitive), drop Blender numeric dedupe
7
+ * suffixes like `.001`, lowercase the remainder. Returns null when the name
8
+ * is not a slot material. Used by BOTH the upload scan (later) and the
9
+ * renderer so DB metadata and runtime meshes can never drift.
10
+ */
11
+ export declare function deriveSlotId(materialName: string): string | null;
12
+ /** slot id -> display label: underscores to spaces, sentence case. e.g. 'bed_frame' -> 'Bed frame'. */
13
+ export declare function slotLabelFromId(slotId: string): string;
14
+ //# sourceMappingURL=slots.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../../src/lib/slots.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,oBAAoB,UAAU,CAAA;AAE3C,kGAAkG;AAClG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKhE;AAED,uGAAuG;AACvG,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAItD"}
@@ -0,0 +1,26 @@
1
+ export const SLOT_MATERIAL_PREFIX = 'slot_';
2
+ /** A glTF material name marks a paintable slot when it starts with `slot_` (case-insensitive). */
3
+ export function isSlotMaterialName(name) {
4
+ return name.toLowerCase().startsWith(SLOT_MATERIAL_PREFIX);
5
+ }
6
+ /**
7
+ * Derive the stable slot id from a glTF material name:
8
+ * strip the `slot_` prefix (case-insensitive), drop Blender numeric dedupe
9
+ * suffixes like `.001`, lowercase the remainder. Returns null when the name
10
+ * is not a slot material. Used by BOTH the upload scan (later) and the
11
+ * renderer so DB metadata and runtime meshes can never drift.
12
+ */
13
+ export function deriveSlotId(materialName) {
14
+ if (!isSlotMaterialName(materialName))
15
+ return null;
16
+ let rest = materialName.slice(SLOT_MATERIAL_PREFIX.length);
17
+ rest = rest.replace(/\.\d+$/, '');
18
+ return rest.toLowerCase();
19
+ }
20
+ /** slot id -> display label: underscores to spaces, sentence case. e.g. 'bed_frame' -> 'Bed frame'. */
21
+ export function slotLabelFromId(slotId) {
22
+ const spaced = slotId.replace(/_/g, ' ').trim();
23
+ if (!spaced)
24
+ return spaced;
25
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
26
+ }