@pascal-app/viewer 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +6 -2
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +41 -22
  7. package/dist/components/renderers/parametric-node-renderer.d.ts +5 -0
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -0
  9. package/dist/components/renderers/parametric-node-renderer.js +37 -0
  10. package/dist/components/renderers/scene-renderer.d.ts +1 -1
  11. package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
  12. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  13. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  14. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  15. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  16. package/dist/components/viewer/frame-limiter.js +39 -4
  17. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  18. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-interactive.js +394 -0
  20. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  21. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  23. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  24. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-replace-instances.js +61 -0
  26. package/dist/components/viewer/glb-scene.d.ts +54 -0
  27. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-scene.js +920 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  30. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  31. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  32. package/dist/components/viewer/index.d.ts +56 -3
  33. package/dist/components/viewer/index.d.ts.map +1 -1
  34. package/dist/components/viewer/index.js +280 -65
  35. package/dist/components/viewer/lights.d.ts +1 -1
  36. package/dist/components/viewer/lights.d.ts.map +1 -1
  37. package/dist/components/viewer/lights.js +201 -43
  38. package/dist/components/viewer/perf-monitor.d.ts +1 -1
  39. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  40. package/dist/components/viewer/perf-monitor.js +115 -12
  41. package/dist/components/viewer/post-processing.d.ts +7 -1
  42. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  43. package/dist/components/viewer/post-processing.js +395 -63
  44. package/dist/components/viewer/registered-systems.d.ts +12 -0
  45. package/dist/components/viewer/registered-systems.d.ts.map +1 -0
  46. package/dist/components/viewer/registered-systems.js +49 -0
  47. package/dist/components/viewer/scene-bvh.d.ts +18 -0
  48. package/dist/components/viewer/scene-bvh.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-bvh.js +97 -0
  50. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  51. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  52. package/dist/components/viewer/scene-bvh.test.js +13 -0
  53. package/dist/components/viewer/scene-environment.d.ts +2 -0
  54. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  55. package/dist/components/viewer/scene-environment.js +79 -0
  56. package/dist/components/viewer/selection-manager.d.ts +1 -1
  57. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  58. package/dist/components/viewer/selection-manager.js +37 -14
  59. package/dist/components/viewer/viewer-camera.d.ts +1 -1
  60. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  61. package/dist/components/viewer/walkthrough-controls.d.ts +2 -1
  62. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  63. package/dist/components/viewer/walkthrough-controls.js +20 -0
  64. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  65. package/dist/hooks/use-gltf-ktx2.js +3 -22
  66. package/dist/hooks/use-node-events.d.ts +6 -74
  67. package/dist/hooks/use-node-events.d.ts.map +1 -1
  68. package/dist/hooks/use-node-events.js +28 -8
  69. package/dist/index.d.ts +48 -5
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +82 -4
  72. package/dist/lib/backdrop.d.ts +33 -0
  73. package/dist/lib/backdrop.d.ts.map +1 -0
  74. package/dist/lib/backdrop.js +92 -0
  75. package/dist/lib/box-uv.d.ts +13 -0
  76. package/dist/lib/box-uv.d.ts.map +1 -0
  77. package/dist/lib/box-uv.js +32 -0
  78. package/dist/lib/csg-utils.d.ts +10 -0
  79. package/dist/lib/csg-utils.d.ts.map +1 -0
  80. package/dist/lib/csg-utils.js +93 -0
  81. package/dist/lib/csg-utils.test.d.ts +2 -0
  82. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  83. package/dist/lib/csg-utils.test.js +45 -0
  84. package/dist/lib/drawable-geometry.d.ts +21 -0
  85. package/dist/lib/drawable-geometry.d.ts.map +1 -0
  86. package/dist/lib/drawable-geometry.js +22 -0
  87. package/dist/lib/edge-style.d.ts +4 -0
  88. package/dist/lib/edge-style.d.ts.map +1 -0
  89. package/dist/lib/edge-style.js +31 -0
  90. package/dist/lib/gpu-perf.d.ts +4 -0
  91. package/dist/lib/gpu-perf.d.ts.map +1 -0
  92. package/dist/lib/gpu-perf.js +23 -0
  93. package/dist/lib/ink-edges.d.ts +9 -0
  94. package/dist/lib/ink-edges.d.ts.map +1 -0
  95. package/dist/lib/ink-edges.js +49 -0
  96. package/dist/lib/isolation.d.ts +39 -0
  97. package/dist/lib/isolation.d.ts.map +1 -0
  98. package/dist/lib/isolation.js +103 -0
  99. package/dist/lib/ktx2-loader.d.ts +43 -0
  100. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  101. package/dist/lib/ktx2-loader.js +136 -0
  102. package/dist/lib/layers.d.ts +28 -0
  103. package/dist/lib/layers.d.ts.map +1 -1
  104. package/dist/lib/layers.js +28 -0
  105. package/dist/lib/materials.d.ts +38 -15
  106. package/dist/lib/materials.d.ts.map +1 -1
  107. package/dist/lib/materials.js +364 -72
  108. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  109. package/dist/lib/merged-outline-node.js +7 -0
  110. package/dist/lib/polygon-union.d.ts +4 -0
  111. package/dist/lib/polygon-union.d.ts.map +1 -0
  112. package/dist/lib/polygon-union.js +289 -0
  113. package/dist/lib/polygon-union.test.d.ts +2 -0
  114. package/dist/lib/polygon-union.test.d.ts.map +1 -0
  115. package/dist/lib/polygon-union.test.js +105 -0
  116. package/dist/lib/scene-themes.d.ts +30 -0
  117. package/dist/lib/scene-themes.d.ts.map +1 -0
  118. package/dist/lib/scene-themes.js +202 -0
  119. package/dist/lib/shadow-only.d.ts +4 -0
  120. package/dist/lib/shadow-only.d.ts.map +1 -0
  121. package/dist/lib/shadow-only.js +36 -0
  122. package/dist/lib/tsl-compat.d.ts +12 -0
  123. package/dist/lib/tsl-compat.d.ts.map +1 -0
  124. package/dist/lib/tsl-compat.js +13 -0
  125. package/dist/store/use-viewer.d.ts +92 -12
  126. package/dist/store/use-viewer.d.ts.map +1 -1
  127. package/dist/store/use-viewer.js +201 -6
  128. package/dist/store/use-viewer.test.d.ts +2 -0
  129. package/dist/store/use-viewer.test.d.ts.map +1 -0
  130. package/dist/store/use-viewer.test.js +42 -0
  131. package/dist/systems/ceiling/ceiling-system.d.ts +6 -2
  132. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  133. package/dist/systems/ceiling/ceiling-system.js +66 -13
  134. package/dist/systems/door/door-animation.test.d.ts +2 -0
  135. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  136. package/dist/systems/door/door-animation.test.js +51 -0
  137. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  138. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  139. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  140. package/dist/systems/door/door-system.d.ts +24 -0
  141. package/dist/systems/door/door-system.d.ts.map +1 -1
  142. package/dist/systems/door/door-system.js +864 -180
  143. package/dist/systems/elevator/elevator-interaction-system.d.ts +2 -0
  144. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -0
  145. package/dist/systems/elevator/elevator-interaction-system.js +62 -0
  146. package/dist/systems/fence/fence-system.d.ts +20 -0
  147. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  148. package/dist/systems/fence/fence-system.js +229 -57
  149. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +20 -0
  150. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -0
  151. package/dist/systems/floor-elevation/floor-elevation-system.js +78 -0
  152. package/dist/systems/geometry/geometry-system.d.ts +41 -0
  153. package/dist/systems/geometry/geometry-system.d.ts.map +1 -0
  154. package/dist/systems/geometry/geometry-system.js +348 -0
  155. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  156. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  157. package/dist/systems/geometry/geometry-system.test.js +30 -0
  158. package/dist/systems/interactive/control-widget.d.ts +10 -0
  159. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  160. package/dist/systems/interactive/control-widget.js +35 -0
  161. package/dist/systems/interactive/interactive-system.d.ts +1 -1
  162. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  163. package/dist/systems/interactive/interactive-system.js +55 -56
  164. package/dist/systems/item/item-system.d.ts +7 -0
  165. package/dist/systems/item/item-system.d.ts.map +1 -1
  166. package/dist/systems/item/item-system.js +28 -17
  167. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  168. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  169. package/dist/systems/item-light/item-light-system.js +18 -5
  170. package/dist/systems/level/level-system.d.ts.map +1 -1
  171. package/dist/systems/level/level-system.js +28 -4
  172. package/dist/systems/level/level-utils.d.ts +0 -3
  173. package/dist/systems/level/level-utils.d.ts.map +1 -1
  174. package/dist/systems/level/level-utils.js +2 -41
  175. package/dist/systems/roof/roof-materials.d.ts +3 -2
  176. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  177. package/dist/systems/roof/roof-materials.js +59 -14
  178. package/dist/systems/roof/roof-system.d.ts +46 -6
  179. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  180. package/dist/systems/roof/roof-system.js +1365 -170
  181. package/dist/systems/slab/slab-system.d.ts +5 -3
  182. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  183. package/dist/systems/slab/slab-system.js +204 -68
  184. package/dist/systems/slab/slab-system.test.d.ts +2 -0
  185. package/dist/systems/slab/slab-system.test.d.ts.map +1 -0
  186. package/dist/systems/slab/slab-system.test.js +63 -0
  187. package/dist/systems/stair/stair-materials.d.ts +4 -3
  188. package/dist/systems/stair/stair-materials.d.ts.map +1 -1
  189. package/dist/systems/stair/stair-materials.js +30 -15
  190. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  191. package/dist/systems/stair/stair-system.js +55 -63
  192. package/dist/systems/surface-hole-geometry.d.ts +3 -0
  193. package/dist/systems/surface-hole-geometry.d.ts.map +1 -0
  194. package/dist/systems/surface-hole-geometry.js +4 -0
  195. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  196. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  197. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  198. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  199. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  200. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  201. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  202. package/dist/systems/wall/wall-cutout.js +62 -17
  203. package/dist/systems/wall/wall-materials.d.ts +12 -7
  204. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  205. package/dist/systems/wall/wall-materials.js +279 -46
  206. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  207. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  208. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  209. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  210. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  211. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  212. package/dist/systems/wall/wall-system.d.ts +2 -2
  213. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  214. package/dist/systems/wall/wall-system.js +493 -193
  215. package/dist/systems/window/window-animation-system.d.ts +12 -0
  216. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  217. package/dist/systems/window/window-animation-system.js +16 -5
  218. package/dist/systems/window/window-system.d.ts +7 -0
  219. package/dist/systems/window/window-system.d.ts.map +1 -1
  220. package/dist/systems/window/window-system.js +308 -18
  221. package/package.json +5 -6
  222. package/dist/components/renderers/building/building-renderer.d.ts +0 -5
  223. package/dist/components/renderers/building/building-renderer.d.ts.map +0 -1
  224. package/dist/components/renderers/building/building-renderer.js +0 -11
  225. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts +0 -5
  226. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +0 -1
  227. package/dist/components/renderers/ceiling/ceiling-renderer.js +0 -58
  228. package/dist/components/renderers/column/column-renderer.d.ts +0 -5
  229. package/dist/components/renderers/column/column-renderer.d.ts.map +0 -1
  230. package/dist/components/renderers/column/column-renderer.js +0 -597
  231. package/dist/components/renderers/door/door-renderer.d.ts +0 -5
  232. package/dist/components/renderers/door/door-renderer.d.ts.map +0 -1
  233. package/dist/components/renderers/door/door-renderer.js +0 -16
  234. package/dist/components/renderers/fence/fence-renderer.d.ts +0 -5
  235. package/dist/components/renderers/fence/fence-renderer.d.ts.map +0 -1
  236. package/dist/components/renderers/fence/fence-renderer.js +0 -15
  237. package/dist/components/renderers/guide/guide-renderer.d.ts +0 -5
  238. package/dist/components/renderers/guide/guide-renderer.d.ts.map +0 -1
  239. package/dist/components/renderers/guide/guide-renderer.js +0 -38
  240. package/dist/components/renderers/item/item-renderer.d.ts +0 -5
  241. package/dist/components/renderers/item/item-renderer.d.ts.map +0 -1
  242. package/dist/components/renderers/item/item-renderer.js +0 -172
  243. package/dist/components/renderers/level/level-renderer.d.ts +0 -5
  244. package/dist/components/renderers/level/level-renderer.d.ts.map +0 -1
  245. package/dist/components/renderers/level/level-renderer.js +0 -11
  246. package/dist/components/renderers/roof/roof-materials.d.ts +0 -4
  247. package/dist/components/renderers/roof/roof-materials.d.ts.map +0 -1
  248. package/dist/components/renderers/roof/roof-materials.js +0 -16
  249. package/dist/components/renderers/roof/roof-renderer.d.ts +0 -5
  250. package/dist/components/renderers/roof/roof-renderer.d.ts.map +0 -1
  251. package/dist/components/renderers/roof/roof-renderer.js +0 -32
  252. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +0 -5
  253. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +0 -1
  254. package/dist/components/renderers/roof-segment/roof-segment-renderer.js +0 -40
  255. package/dist/components/renderers/scan/scan-renderer.d.ts +0 -5
  256. package/dist/components/renderers/scan/scan-renderer.d.ts.map +0 -1
  257. package/dist/components/renderers/scan/scan-renderer.js +0 -54
  258. package/dist/components/renderers/site/site-renderer.d.ts +0 -5
  259. package/dist/components/renderers/site/site-renderer.d.ts.map +0 -1
  260. package/dist/components/renderers/site/site-renderer.js +0 -106
  261. package/dist/components/renderers/slab/slab-renderer.d.ts +0 -5
  262. package/dist/components/renderers/slab/slab-renderer.d.ts.map +0 -1
  263. package/dist/components/renderers/slab/slab-renderer.js +0 -58
  264. package/dist/components/renderers/spawn/spawn-renderer.d.ts +0 -5
  265. package/dist/components/renderers/spawn/spawn-renderer.d.ts.map +0 -1
  266. package/dist/components/renderers/spawn/spawn-renderer.js +0 -32
  267. package/dist/components/renderers/stair/stair-renderer.d.ts +0 -5
  268. package/dist/components/renderers/stair/stair-renderer.d.ts.map +0 -1
  269. package/dist/components/renderers/stair/stair-renderer.js +0 -603
  270. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +0 -5
  271. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +0 -1
  272. package/dist/components/renderers/stair-segment/stair-segment-renderer.js +0 -51
  273. package/dist/components/renderers/wall/wall-renderer.d.ts +0 -5
  274. package/dist/components/renderers/wall/wall-renderer.d.ts.map +0 -1
  275. package/dist/components/renderers/wall/wall-renderer.js +0 -16
  276. package/dist/components/renderers/window/window-renderer.d.ts +0 -5
  277. package/dist/components/renderers/window/window-renderer.d.ts.map +0 -1
  278. package/dist/components/renderers/window/window-renderer.js +0 -21
  279. package/dist/components/renderers/zone/zone-renderer.d.ts +0 -5
  280. package/dist/components/renderers/zone/zone-renderer.d.ts.map +0 -1
  281. package/dist/components/renderers/zone/zone-renderer.js +0 -181
  282. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  283. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  284. package/dist/components/viewer/ground-occluder.js +0 -79
@@ -1,6 +1,5 @@
1
- import { resolveLevelId, sceneRegistry, spatialGridManager, syncAutoStairOpenings, useScene, } from '@pascal-app/core';
1
+ import { getEffectiveNode, getFloorStackedPosition, sceneRegistry, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
- import { useEffect, useRef } from 'react';
4
3
  import * as THREE from 'three';
5
4
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
6
5
  const pendingStairUpdates = new Set();
@@ -17,26 +16,6 @@ export const StairSystem = () => {
17
16
  const dirtyNodes = useScene((state) => state.dirtyNodes);
18
17
  const clearDirty = useScene((state) => state.clearDirty);
19
18
  const rootNodeIds = useScene((state) => state.rootNodeIds);
20
- const syncingAutoOpeningsRef = useRef(false);
21
- useEffect(() => {
22
- const applyUpdates = (updates) => {
23
- if (updates.length === 0)
24
- return;
25
- syncingAutoOpeningsRef.current = true;
26
- useScene.getState().updateNodes(updates);
27
- queueMicrotask(() => {
28
- syncingAutoOpeningsRef.current = false;
29
- });
30
- };
31
- applyUpdates(syncAutoStairOpenings(useScene.getState().nodes));
32
- return useScene.subscribe((state, prevState) => {
33
- if (syncingAutoOpeningsRef.current)
34
- return;
35
- if (state.nodes === prevState.nodes)
36
- return;
37
- applyUpdates(syncAutoStairOpenings(state.nodes));
38
- });
39
- }, []);
40
19
  useFrame(() => {
41
20
  if (rootNodeIds.length === 0) {
42
21
  pendingStairUpdates.clear();
@@ -58,8 +37,13 @@ export const StairSystem = () => {
58
37
  if (mesh) {
59
38
  const isVisible = mesh.parent?.visible !== false;
60
39
  if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
61
- // Geometry will be updated; chained position is applied in the parent sync pass below
62
- updateStairSegmentGeometry(node, mesh);
40
+ // Geometry will be updated; chained position is applied in the parent sync pass below.
41
+ // Merge live overrides so width / length / height drags update the
42
+ // mesh in real time — the resize arrows publish to
43
+ // `useLiveNodeOverrides` and only commit to scene on pointer-up, so
44
+ // without this the geometry would rebuild from the pre-drag values.
45
+ const effectiveSegment = getEffectiveNode(node);
46
+ updateStairSegmentGeometry(effectiveSegment, mesh);
63
47
  if (node.parentId)
64
48
  parentsNeedingSegmentSync.add(node.parentId);
65
49
  segmentsProcessed++;
@@ -68,11 +52,9 @@ export const StairSystem = () => {
68
52
  return; // Over budget — keep dirty, process next frame
69
53
  }
70
54
  else if (mesh.geometry.type === 'BoxGeometry') {
71
- // Replace BoxGeometry placeholder with empty geometry
55
+ // Replace BoxGeometry placeholder with a non-drawing degenerate one.
72
56
  mesh.geometry.dispose();
73
- const placeholder = new THREE.BufferGeometry();
74
- placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
75
- mesh.geometry = placeholder;
57
+ mesh.geometry = createEmptyGeometry();
76
58
  }
77
59
  clearDirty(id);
78
60
  }
@@ -93,9 +75,17 @@ export const StairSystem = () => {
93
75
  });
94
76
  // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
95
77
  for (const stairId of parentsNeedingSegmentSync) {
96
- const stairNode = nodes[stairId];
97
- if (!stairNode || stairNode.type !== 'stair')
78
+ const baseStairNode = nodes[stairId];
79
+ if (baseStairNode?.type !== 'stair')
98
80
  continue;
81
+ // Merge any in-flight drag override (e.g. parent-stair rotate handle)
82
+ // so slab-elevation spatial queries match where the segments are
83
+ // actually being rendered. Without this, dragging the rotate gizmo
84
+ // looks up slabs at the pre-drag world XZ — if rotation carries a
85
+ // segment off the original slab footprint, the floor-stack
86
+ // resolver would otherwise read the pre-drag footprint and drop
87
+ // the flight or landing below the floor mid-drag.
88
+ const stairNode = getEffectiveNode(baseStairNode);
99
89
  const group = sceneRegistry.nodes.get(stairId);
100
90
  if (group) {
101
91
  syncStairGroupElevation(stairNode, group, nodes);
@@ -108,7 +98,7 @@ export const StairSystem = () => {
108
98
  if (stairsProcessed >= MAX_STAIRS_PER_FRAME)
109
99
  break;
110
100
  const node = nodes[id];
111
- if (!node || node.type !== 'stair') {
101
+ if (node?.type !== 'stair') {
112
102
  pendingStairUpdates.delete(id);
113
103
  continue;
114
104
  }
@@ -217,9 +207,14 @@ function updateStairSegmentGeometry(node, mesh) {
217
207
  * not by the node's stored position field.
218
208
  */
219
209
  function syncSegmentMeshTransforms(stairNode, nodes) {
210
+ // Merge live overrides into each segment so the chain math reflects the
211
+ // in-flight drag (a width / length change shifts every downstream segment's
212
+ // anchor). Without this, dragging a width handle would resize the dragged
213
+ // segment's mesh but leave subsequent segments at their pre-drag positions.
220
214
  const segments = (stairNode.children ?? [])
221
215
  .map((childId) => nodes[childId])
222
- .filter((n) => n?.type === 'stair-segment');
216
+ .filter((n) => n?.type === 'stair-segment')
217
+ .map((n) => getEffectiveNode(n));
223
218
  if (segments.length === 0)
224
219
  return;
225
220
  const transforms = computeSegmentTransforms(segments);
@@ -234,35 +229,20 @@ function syncSegmentMeshTransforms(stairNode, nodes) {
234
229
  }
235
230
  }
236
231
  function syncStairGroupElevation(stairNode, group, nodes) {
237
- const levelId = resolveLevelId(stairNode, nodes);
238
- const slabElevation = getStairSlabElevation(levelId, stairNode, nodes);
239
- group.position.y = stairNode.position[1] + slabElevation;
240
- }
241
- function getStairSlabElevation(levelId, stairNode, nodes) {
242
- const segments = (stairNode.children ?? [])
243
- .map((childId) => nodes[childId])
244
- .filter((n) => n?.type === 'stair-segment');
245
- if (segments.length === 0)
246
- return 0;
247
- const transforms = computeSegmentTransforms(segments);
248
- let maxElevation = Number.NEGATIVE_INFINITY;
249
- for (let i = 0; i < segments.length; i++) {
250
- const segment = segments[i];
251
- const transform = transforms[i];
252
- const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation);
253
- const centerInGroupX = transform.position[0] + centerOffsetX;
254
- const centerInGroupZ = transform.position[2] + centerOffsetZ;
255
- const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(centerInGroupX, centerInGroupZ, stairNode.rotation);
256
- const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, [
257
- stairNode.position[0] + centerOffsetWorldX,
258
- stairNode.position[1] + transform.position[1],
259
- stairNode.position[2] + centerOffsetWorldZ,
260
- ], [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], [0, stairNode.rotation + transform.rotation, 0]);
261
- if (slabElevation > maxElevation) {
262
- maxElevation = slabElevation;
232
+ const effectiveNodes = { ...nodes, [stairNode.id]: stairNode };
233
+ for (const childId of stairNode.children ?? []) {
234
+ const segment = nodes[childId];
235
+ if (segment?.type === 'stair-segment') {
236
+ effectiveNodes[segment.id] = getEffectiveNode(segment);
263
237
  }
264
238
  }
265
- return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation;
239
+ const visualPosition = getFloorStackedPosition({
240
+ node: stairNode,
241
+ nodes: effectiveNodes,
242
+ position: stairNode.position,
243
+ rotation: stairNode.rotation,
244
+ });
245
+ group.position.y = visualPosition[1];
266
246
  }
267
247
  // ============================================================================
268
248
  // MERGED STAIR GEOMETRY
@@ -281,9 +261,14 @@ function updateMergedStairGeometry(stairNode, group, nodes) {
281
261
  return;
282
262
  }
283
263
  const children = stairNode.children ?? [];
264
+ // Merge live overrides — same reason as `syncSegmentMeshTransforms`: a
265
+ // width / length / height drag publishes the new value to
266
+ // `useLiveNodeOverrides`, so the merged geometry has to read through that
267
+ // overlay or the merged mesh stays at pre-drag values until pointer-up.
284
268
  const segments = children
285
269
  .map((childId) => nodes[childId])
286
- .filter((n) => n?.type === 'stair-segment');
270
+ .filter((n) => n?.type === 'stair-segment')
271
+ .map((n) => getEffectiveNode(n));
287
272
  if (segments.length === 0) {
288
273
  replaceMeshGeometry(mergedMesh, createEmptyGeometry());
289
274
  return;
@@ -444,7 +429,14 @@ function rotateXZ(x, z, angle) {
444
429
  }
445
430
  function createEmptyGeometry() {
446
431
  const geometry = new THREE.BufferGeometry();
447
- geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
432
+ // Three zero-vertices (one degenerate, invisible triangle), not an empty
433
+ // attribute: an empty position (count 0) leaves WebGPU vertex buffer slot 0
434
+ // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
435
+ // poisoning the command encoder. The count-0 groups keep nothing drawn.
436
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
437
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
438
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
439
+ geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
448
440
  geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX);
449
441
  geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX);
450
442
  return geometry;
@@ -479,7 +471,7 @@ function generateStairRailingGeometry(stairNode, segments, transforms) {
479
471
  for (let index = 1; index < segmentRailPaths.length; index++) {
480
472
  const previousPath = segmentRailPaths[index - 1];
481
473
  const currentPath = segmentRailPaths[index];
482
- if (!(previousPath && currentPath && currentPath.connectFromPrevious))
474
+ if (!(previousPath && currentPath?.connectFromPrevious))
483
475
  continue;
484
476
  if (previousPath.segment.segmentType === 'landing')
485
477
  continue;
@@ -756,7 +748,7 @@ function computeAbsoluteHeight(node) {
756
748
  if (!node.parentId)
757
749
  return 0;
758
750
  const parent = nodes[node.parentId];
759
- if (!parent || parent.type !== 'stair')
751
+ if (parent?.type !== 'stair')
760
752
  return 0;
761
753
  const stair = parent;
762
754
  const segments = (stair.children ?? [])
@@ -0,0 +1,3 @@
1
+ import { type Point2D } from '../lib/polygon-union';
2
+ export declare function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][];
3
+ //# sourceMappingURL=surface-hole-geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface-hole-geometry.d.ts","sourceRoot":"","sources":["../../src/systems/surface-hole-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAiB,MAAM,sBAAsB,CAAA;AAElE,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,EAAE,CAExE"}
@@ -0,0 +1,4 @@
1
+ import { unionPolygons } from '../lib/polygon-union';
2
+ export function mergeSurfaceHolePolygons(holes) {
3
+ return unionPolygons(holes);
4
+ }
@@ -0,0 +1,31 @@
1
+ import type { DoorNode, WindowNode } from '@pascal-app/core';
2
+ import * as THREE from 'three';
3
+ export type OpeningCutoutNode = DoorNode | WindowNode;
4
+ export type OpeningCutoutRect = {
5
+ left: number;
6
+ right: number;
7
+ bottom: number;
8
+ top: number;
9
+ };
10
+ export declare function getOpeningCutoutProxyDepth(wallThickness: number): number;
11
+ /**
12
+ * Pure cutout profile for a shaped door / window opening. `rect` is in
13
+ * the caller's coordinate frame — the wall CSG pipeline passes wall-local
14
+ * coords, the roof-wall pipeline an origin-centered rect — so the same
15
+ * radii / arch math serves both hosts.
16
+ */
17
+ export declare function buildOpeningCutoutShape(opening: OpeningCutoutNode, rect: OpeningCutoutRect): THREE.Shape;
18
+ export declare function buildOpeningCutoutGeometry(opening: OpeningCutoutNode, rect: OpeningCutoutRect, depth: number, wallThickness: number): THREE.BufferGeometry;
19
+ /**
20
+ * Whether the cutout profile's bottom edge is a flat chord. Cuts whose
21
+ * bottom sits coplanar with the host wall base get extended slightly
22
+ * downward to keep CSG away from coplanar faces — but only a flat chord
23
+ * may extend; shifting a rounded bottom would distort the profile.
24
+ */
25
+ export declare function hasFlatOpeningCutoutBottom(opening: OpeningCutoutNode): boolean;
26
+ /**
27
+ * Extends floor-level flat cutouts below the host wall so CSG never has to
28
+ * subtract a face exactly coplanar with the wall base.
29
+ */
30
+ export declare function getOpeningCutoutBottomPadding(opening: OpeningCutoutNode, bottom: number): number;
31
+ //# sourceMappingURL=opening-cutout-geometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opening-cutout-geometry.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/opening-cutout-geometry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC5D,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,UAAU,CAAA;AAErD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;CACZ,CAAA;AAYD,wBAAgB,0BAA0B,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExE;AASD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,iBAAiB,GACtB,KAAK,CAAC,KAAK,CAuCb;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,iBAAiB,EACvB,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,GACpB,KAAK,CAAC,cAAc,CAqBtB;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAW9E;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEhG"}
@@ -0,0 +1,167 @@
1
+ import * as THREE from 'three';
2
+ // The cutout proxy doubles as the invisible raycast hit target for an opening:
3
+ // centered on the wall and extending past both faces so it wins the scene
4
+ // raycast over the recessed door/window body for front AND back selection +
5
+ // paint. Wall CSG rebuilds opening cuts directly from node data, so this proxy
6
+ // only needs to clear the wall thickness plus a small proud margin. A snug depth
7
+ // keeps the hit target useful without blanketing the room floor in a top-down
8
+ // view (the bug a 1m-deep proxy caused in narrow hallways).
9
+ const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08;
10
+ const OPENING_CUTOUT_BOTTOM_PADDING = 0.02;
11
+ export function getOpeningCutoutProxyDepth(wallThickness) {
12
+ return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN;
13
+ }
14
+ /**
15
+ * Pure cutout profile for a shaped door / window opening. `rect` is in
16
+ * the caller's coordinate frame — the wall CSG pipeline passes wall-local
17
+ * coords, the roof-wall pipeline an origin-centered rect — so the same
18
+ * radii / arch math serves both hosts.
19
+ */
20
+ export function buildOpeningCutoutShape(opening, rect) {
21
+ const { left, right, bottom, top } = rect;
22
+ const width = Math.max(right - left, 1e-6);
23
+ const height = Math.max(top - bottom, 1e-6);
24
+ const shape = new THREE.Shape();
25
+ if (opening.openingShape === 'arch') {
26
+ const halfWidth = width / 2;
27
+ const centerX = (left + right) / 2;
28
+ const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height);
29
+ const springY = top - archHeight;
30
+ const segments = 32;
31
+ shape.moveTo(left, bottom);
32
+ shape.lineTo(right, bottom);
33
+ shape.lineTo(right, springY);
34
+ for (let index = 1; index <= segments; index += 1) {
35
+ const x = right + (left - right) * (index / segments);
36
+ const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1);
37
+ const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0));
38
+ shape.lineTo(x, y);
39
+ }
40
+ shape.lineTo(left, bottom);
41
+ shape.closePath();
42
+ return shape;
43
+ }
44
+ if (opening.openingShape === 'rounded') {
45
+ const radii = getRoundedOpeningRadii(opening, width, height);
46
+ applyRoundedOpeningShape(shape, left, right, bottom, top, radii);
47
+ return shape;
48
+ }
49
+ shape.moveTo(left, bottom);
50
+ shape.lineTo(right, bottom);
51
+ shape.lineTo(right, top);
52
+ shape.lineTo(left, top);
53
+ shape.closePath();
54
+ return shape;
55
+ }
56
+ export function buildOpeningCutoutGeometry(opening, rect, depth, wallThickness) {
57
+ const shape = buildOpeningCutoutShape(opening, rect);
58
+ const bevelSize = opening.openingShape === 'rounded'
59
+ ? Math.min(Math.max(opening.openingRevealRadius ?? 0.025, 0), Math.max(wallThickness * 0.45, 0.001), Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001))
60
+ : 0;
61
+ const geometry = new THREE.ExtrudeGeometry(shape, {
62
+ depth,
63
+ bevelEnabled: bevelSize > 0,
64
+ bevelSegments: bevelSize > 0 ? 8 : 0,
65
+ bevelSize,
66
+ bevelThickness: bevelSize,
67
+ curveSegments: 24,
68
+ });
69
+ geometry.translate(0, 0, -depth / 2);
70
+ return geometry;
71
+ }
72
+ /**
73
+ * Whether the cutout profile's bottom edge is a flat chord. Cuts whose
74
+ * bottom sits coplanar with the host wall base get extended slightly
75
+ * downward to keep CSG away from coplanar faces — but only a flat chord
76
+ * may extend; shifting a rounded bottom would distort the profile.
77
+ */
78
+ export function hasFlatOpeningCutoutBottom(opening) {
79
+ if (opening.openingShape !== 'rounded' || opening.type !== 'window')
80
+ return true;
81
+ if (opening.openingRadiusMode === 'individual') {
82
+ const [, , bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [
83
+ 0.15, 0.15, 0.15, 0.15,
84
+ ];
85
+ return bottomRight <= 1e-6 && bottomLeft <= 1e-6;
86
+ }
87
+ return Math.max(opening.cornerRadius ?? 0.15, 0) <= 1e-6;
88
+ }
89
+ /**
90
+ * Extends floor-level flat cutouts below the host wall so CSG never has to
91
+ * subtract a face exactly coplanar with the wall base.
92
+ */
93
+ export function getOpeningCutoutBottomPadding(opening, bottom) {
94
+ return bottom < 0.005 && hasFlatOpeningCutoutBottom(opening) ? OPENING_CUTOUT_BOTTOM_PADDING : 0;
95
+ }
96
+ function getRoundedOpeningRadii(opening, width, height) {
97
+ if (opening.type !== 'window') {
98
+ if (opening.openingRadiusMode === 'individual') {
99
+ const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15];
100
+ return normalizeCornerRadii({
101
+ topLeft: Math.max(topLeft, 0),
102
+ topRight: Math.max(topRight, 0),
103
+ bottomRight: 0,
104
+ bottomLeft: 0,
105
+ }, width, height);
106
+ }
107
+ const maxRadius = Math.min(width / 2, height);
108
+ const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
109
+ return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 };
110
+ }
111
+ if (opening.openingRadiusMode === 'individual') {
112
+ const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15];
113
+ return normalizeCornerRadii({
114
+ topLeft: Math.max(topLeft, 0),
115
+ topRight: Math.max(topRight, 0),
116
+ bottomRight: Math.max(bottomRight, 0),
117
+ bottomLeft: Math.max(bottomLeft, 0),
118
+ }, width, height);
119
+ }
120
+ const maxRadius = Math.min(width / 2, height / 2);
121
+ const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
122
+ return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius };
123
+ }
124
+ function normalizeCornerRadii(radii, width, height) {
125
+ const next = { ...radii };
126
+ const maxScale = Math.min(1, width / Math.max(next.topLeft + next.topRight, 1e-6), width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), height / Math.max(next.topLeft + next.bottomLeft, 1e-6), height / Math.max(next.topRight + next.bottomRight, 1e-6));
127
+ if (maxScale < 1) {
128
+ next.topLeft *= maxScale;
129
+ next.topRight *= maxScale;
130
+ next.bottomRight *= maxScale;
131
+ next.bottomLeft *= maxScale;
132
+ }
133
+ return next;
134
+ }
135
+ function applyRoundedOpeningShape(shape, left, right, bottom, top, radii) {
136
+ const { topLeft, topRight, bottomRight, bottomLeft } = radii;
137
+ shape.moveTo(left + bottomLeft, bottom);
138
+ shape.lineTo(right - bottomRight, bottom);
139
+ if (bottomRight > 1e-6) {
140
+ shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false);
141
+ }
142
+ else {
143
+ shape.lineTo(right, bottom);
144
+ }
145
+ shape.lineTo(right, top - topRight);
146
+ if (topRight > 1e-6) {
147
+ shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false);
148
+ }
149
+ else {
150
+ shape.lineTo(right, top);
151
+ }
152
+ shape.lineTo(left + topLeft, top);
153
+ if (topLeft > 1e-6) {
154
+ shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false);
155
+ }
156
+ else {
157
+ shape.lineTo(left, top);
158
+ }
159
+ shape.lineTo(left, bottom + bottomLeft);
160
+ if (bottomLeft > 1e-6) {
161
+ shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false);
162
+ }
163
+ else {
164
+ shape.lineTo(left, bottom);
165
+ }
166
+ shape.closePath();
167
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=opening-cutout-geometry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opening-cutout-geometry.test.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/opening-cutout-geometry.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,172 @@
1
+ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
2
+ // depend on @types/bun so the import type is unresolved at compile time.
3
+ import { describe, expect, test } from 'bun:test';
4
+ import { DoorNode, WindowNode } from '@pascal-app/core';
5
+ import { buildOpeningCutoutGeometry, buildOpeningCutoutShape, getOpeningCutoutBottomPadding, hasFlatOpeningCutoutBottom, } from './opening-cutout-geometry';
6
+ function containsPoint(points, x, y) {
7
+ return points.some((point) => Math.abs(point.x - x) < 1e-6 && Math.abs(point.y - y) < 1e-6);
8
+ }
9
+ function getBounds(points) {
10
+ let minX = Number.POSITIVE_INFINITY;
11
+ let maxX = Number.NEGATIVE_INFINITY;
12
+ let minY = Number.POSITIVE_INFINITY;
13
+ let maxY = Number.NEGATIVE_INFINITY;
14
+ for (const point of points) {
15
+ minX = Math.min(minX, point.x);
16
+ maxX = Math.max(maxX, point.x);
17
+ minY = Math.min(minY, point.y);
18
+ maxY = Math.max(maxY, point.y);
19
+ }
20
+ return { minX, maxX, minY, maxY };
21
+ }
22
+ describe('buildOpeningCutoutShape', () => {
23
+ test('rectangle profile passes the rect through unchanged', () => {
24
+ const door = DoorNode.parse({});
25
+ const rect = { left: 1.2, right: 2.1, bottom: 0, top: 2.1 };
26
+ const points = buildOpeningCutoutShape(door, rect).getPoints();
27
+ expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
28
+ expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
29
+ expect(containsPoint(points, rect.right, rect.top)).toBe(true);
30
+ expect(containsPoint(points, rect.left, rect.top)).toBe(true);
31
+ const bounds = getBounds(points);
32
+ expect(bounds.minX).toBeCloseTo(rect.left, 9);
33
+ expect(bounds.maxX).toBeCloseTo(rect.right, 9);
34
+ expect(bounds.minY).toBeCloseTo(rect.bottom, 9);
35
+ expect(bounds.maxY).toBeCloseTo(rect.top, 9);
36
+ });
37
+ test('door rounded profile rounds only the top corners', () => {
38
+ const door = DoorNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 });
39
+ const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 };
40
+ const points = buildOpeningCutoutShape(door, rect).getPoints();
41
+ expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
42
+ expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
43
+ expect(containsPoint(points, rect.left, rect.top)).toBe(false);
44
+ expect(containsPoint(points, rect.right, rect.top)).toBe(false);
45
+ expect(containsPoint(points, rect.left, rect.top - 0.2)).toBe(true);
46
+ expect(containsPoint(points, rect.left + 0.2, rect.top)).toBe(true);
47
+ expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true);
48
+ expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true);
49
+ });
50
+ test('window rounded profile rounds all four corners', () => {
51
+ const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 });
52
+ const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 };
53
+ const points = buildOpeningCutoutShape(window, rect).getPoints();
54
+ expect(containsPoint(points, rect.left, rect.bottom)).toBe(false);
55
+ expect(containsPoint(points, rect.right, rect.bottom)).toBe(false);
56
+ expect(containsPoint(points, rect.left, rect.top)).toBe(false);
57
+ expect(containsPoint(points, rect.right, rect.top)).toBe(false);
58
+ expect(containsPoint(points, rect.left + 0.2, rect.bottom)).toBe(true);
59
+ expect(containsPoint(points, rect.left, rect.bottom + 0.2)).toBe(true);
60
+ expect(containsPoint(points, rect.right - 0.2, rect.top)).toBe(true);
61
+ expect(containsPoint(points, rect.right, rect.top - 0.2)).toBe(true);
62
+ const bounds = getBounds(points);
63
+ expect(bounds.minX).toBeCloseTo(rect.left, 9);
64
+ expect(bounds.maxX).toBeCloseTo(rect.right, 9);
65
+ expect(bounds.minY).toBeCloseTo(rect.bottom, 9);
66
+ expect(bounds.maxY).toBeCloseTo(rect.top, 9);
67
+ });
68
+ test('shared corner radius is clamped to the opening half-extent', () => {
69
+ const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 10 });
70
+ const rect = { left: -0.75, right: 0.75, bottom: 0.9, top: 2.4 };
71
+ const points = buildOpeningCutoutShape(window, rect).getPoints();
72
+ // 1.5 × 1.5 opening → radius clamps to 0.75; arcs meet at edge midpoints.
73
+ expect(containsPoint(points, rect.left, rect.bottom + 0.75)).toBe(true);
74
+ expect(containsPoint(points, rect.right, rect.top - 0.75)).toBe(true);
75
+ const bounds = getBounds(points);
76
+ expect(bounds.minX).toBeCloseTo(rect.left, 9);
77
+ expect(bounds.maxX).toBeCloseTo(rect.right, 9);
78
+ });
79
+ test('individual radii normalize when their sum exceeds the opening width', () => {
80
+ const window = WindowNode.parse({
81
+ openingShape: 'rounded',
82
+ openingRadiusMode: 'individual',
83
+ openingCornerRadii: [4, 4, 0, 0],
84
+ });
85
+ const rect = { left: -0.5, right: 0.5, bottom: 0, top: 2 };
86
+ const points = buildOpeningCutoutShape(window, rect).getPoints();
87
+ // Width 1 with top radii summing to 8 → scaled down to 0.5 each.
88
+ expect(containsPoint(points, rect.left, rect.top - 0.5)).toBe(true);
89
+ expect(containsPoint(points, rect.left + 0.5, rect.top)).toBe(true);
90
+ expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
91
+ expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
92
+ });
93
+ test('arch profile springs at top - archHeight and peaks at the rect top', () => {
94
+ const door = DoorNode.parse({ openingShape: 'arch', archHeight: 0.45 });
95
+ const rect = { left: -0.45, right: 0.45, bottom: 0, top: 2.1 };
96
+ const points = buildOpeningCutoutShape(door, rect).getPoints();
97
+ expect(containsPoint(points, rect.left, rect.bottom)).toBe(true);
98
+ expect(containsPoint(points, rect.right, rect.bottom)).toBe(true);
99
+ expect(containsPoint(points, rect.right, rect.top - 0.45)).toBe(true);
100
+ expect(containsPoint(points, 0, rect.top)).toBe(true);
101
+ expect(containsPoint(points, rect.left, rect.top)).toBe(false);
102
+ expect(containsPoint(points, rect.right, rect.top)).toBe(false);
103
+ });
104
+ test('profiles are origin-agnostic — offset rects yield translated points', () => {
105
+ const window = WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0.2 });
106
+ const centered = buildOpeningCutoutShape(window, {
107
+ left: -0.75,
108
+ right: 0.75,
109
+ bottom: -0.75,
110
+ top: 0.75,
111
+ }).getPoints();
112
+ const offset = buildOpeningCutoutShape(window, {
113
+ left: 2.25,
114
+ right: 3.75,
115
+ bottom: 0.9,
116
+ top: 2.4,
117
+ }).getPoints();
118
+ for (const point of centered) {
119
+ expect(containsPoint(offset, point.x + 3, point.y + 1.65)).toBe(true);
120
+ }
121
+ for (const point of offset) {
122
+ expect(containsPoint(centered, point.x - 3, point.y - 1.65)).toBe(true);
123
+ }
124
+ });
125
+ });
126
+ describe('buildOpeningCutoutGeometry', () => {
127
+ test('extrudes the rect through the depth, centered on the mid-plane', () => {
128
+ const door = DoorNode.parse({});
129
+ const geometry = buildOpeningCutoutGeometry(door, { left: -0.45, right: 0.45, bottom: -1.05, top: 1.05 }, 0.24, 0.1);
130
+ geometry.computeBoundingBox();
131
+ // Float32 position buffer → ~1e-7 relative precision.
132
+ const box = geometry.boundingBox;
133
+ expect(box.min.x).toBeCloseTo(-0.45, 6);
134
+ expect(box.max.x).toBeCloseTo(0.45, 6);
135
+ expect(box.min.y).toBeCloseTo(-1.05, 6);
136
+ expect(box.max.y).toBeCloseTo(1.05, 6);
137
+ expect(box.min.z).toBeCloseTo(-0.12, 6);
138
+ expect(box.max.z).toBeCloseTo(0.12, 6);
139
+ });
140
+ });
141
+ describe('hasFlatOpeningCutoutBottom', () => {
142
+ test('flat for rectangles, arches, and door rounded (top-only radii)', () => {
143
+ expect(hasFlatOpeningCutoutBottom(DoorNode.parse({}))).toBe(true);
144
+ expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'arch' }))).toBe(true);
145
+ expect(hasFlatOpeningCutoutBottom(DoorNode.parse({ openingShape: 'rounded' }))).toBe(true);
146
+ });
147
+ test('rounded windows depend on their bottom radii', () => {
148
+ expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded' }))).toBe(false);
149
+ expect(hasFlatOpeningCutoutBottom(WindowNode.parse({ openingShape: 'rounded', cornerRadius: 0 }))).toBe(true);
150
+ expect(hasFlatOpeningCutoutBottom(WindowNode.parse({
151
+ openingShape: 'rounded',
152
+ openingRadiusMode: 'individual',
153
+ openingCornerRadii: [0.2, 0.2, 0, 0],
154
+ }))).toBe(true);
155
+ expect(hasFlatOpeningCutoutBottom(WindowNode.parse({
156
+ openingShape: 'rounded',
157
+ openingRadiusMode: 'individual',
158
+ openingCornerRadii: [0.2, 0.2, 0.2, 0.2],
159
+ }))).toBe(false);
160
+ });
161
+ });
162
+ describe('getOpeningCutoutBottomPadding', () => {
163
+ test('pads floor-level openings with a flat bottom', () => {
164
+ expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0)).toBe(0.02);
165
+ expect(getOpeningCutoutBottomPadding(DoorNode.parse({ openingShape: 'rounded' }), 0)).toBe(0.02);
166
+ expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'arch' }), 0)).toBe(0.02);
167
+ });
168
+ test('does not pad openings above the floor or rounded window bottoms', () => {
169
+ expect(getOpeningCutoutBottomPadding(DoorNode.parse({}), 0.9)).toBe(0);
170
+ expect(getOpeningCutoutBottomPadding(WindowNode.parse({ openingShape: 'rounded' }), 0)).toBe(0);
171
+ });
172
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAsCA,eAAO,MAAM,UAAU,YAkHtB,CAAA"}
1
+ {"version":3,"file":"wall-cutout.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-cutout.tsx"],"names":[],"mappings":"AAqDA,eAAO,MAAM,UAAU,YAyKtB,CAAA"}