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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/dist/capture/asset-url.d.ts +2 -0
  4. package/dist/capture/asset-url.d.ts.map +1 -0
  5. package/dist/capture/asset-url.js +13 -0
  6. package/dist/capture/capture-runtime.d.ts +43 -0
  7. package/dist/capture/capture-runtime.d.ts.map +1 -0
  8. package/dist/capture/capture-runtime.js +228 -0
  9. package/dist/capture/frame.d.ts +4 -0
  10. package/dist/capture/frame.d.ts.map +1 -0
  11. package/dist/capture/frame.js +21 -0
  12. package/dist/capture/index.d.ts +12 -0
  13. package/dist/capture/index.d.ts.map +1 -0
  14. package/dist/capture/index.js +11 -0
  15. package/dist/capture/layer-visibility.d.ts +5 -0
  16. package/dist/capture/layer-visibility.d.ts.map +1 -0
  17. package/dist/capture/layer-visibility.js +11 -0
  18. package/dist/capture/layers/clay-matcap.d.ts +3 -0
  19. package/dist/capture/layers/clay-matcap.d.ts.map +1 -0
  20. package/dist/capture/layers/clay-matcap.js +26 -0
  21. package/dist/capture/layers/device-motion-layer.d.ts +7 -0
  22. package/dist/capture/layers/device-motion-layer.d.ts.map +1 -0
  23. package/dist/capture/layers/device-motion-layer.js +78 -0
  24. package/dist/capture/layers/point-cloud-layer.d.ts +15 -0
  25. package/dist/capture/layers/point-cloud-layer.d.ts.map +1 -0
  26. package/dist/capture/layers/point-cloud-layer.js +106 -0
  27. package/dist/capture/layers/room-model-layer.d.ts +9 -0
  28. package/dist/capture/layers/room-model-layer.d.ts.map +1 -0
  29. package/dist/capture/layers/room-model-layer.js +71 -0
  30. package/dist/capture/layers/surface-mesh-data.d.ts +9 -0
  31. package/dist/capture/layers/surface-mesh-data.d.ts.map +1 -0
  32. package/dist/capture/layers/surface-mesh-data.js +74 -0
  33. package/dist/capture/layers/surface-mesh-layer.d.ts +7 -0
  34. package/dist/capture/layers/surface-mesh-layer.d.ts.map +1 -0
  35. package/dist/capture/layers/surface-mesh-layer.js +16 -0
  36. package/dist/capture/preview.d.ts +3 -0
  37. package/dist/capture/preview.d.ts.map +1 -0
  38. package/dist/capture/preview.js +2 -0
  39. package/dist/capture/source-state.d.ts +22 -0
  40. package/dist/capture/source-state.d.ts.map +1 -0
  41. package/dist/capture/source-state.js +182 -0
  42. package/dist/capture/stream-rendering.d.ts +14 -0
  43. package/dist/capture/stream-rendering.d.ts.map +1 -0
  44. package/dist/capture/stream-rendering.js +69 -0
  45. package/dist/capture/trajectory.d.ts +20 -0
  46. package/dist/capture/trajectory.d.ts.map +1 -0
  47. package/dist/capture/trajectory.js +77 -0
  48. package/dist/components/viewer/batched-mesh-spike.d.ts +14 -0
  49. package/dist/components/viewer/batched-mesh-spike.d.ts.map +1 -0
  50. package/dist/components/viewer/batched-mesh-spike.js +64 -0
  51. package/dist/components/viewer/frame-limiter.d.ts +11 -0
  52. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  53. package/dist/components/viewer/frame-limiter.js +59 -18
  54. package/dist/components/viewer/index.d.ts +15 -0
  55. package/dist/components/viewer/index.d.ts.map +1 -1
  56. package/dist/components/viewer/index.js +74 -82
  57. package/dist/components/viewer/lights.d.ts.map +1 -1
  58. package/dist/components/viewer/lights.js +123 -89
  59. package/dist/components/viewer/perf-monitor.d.ts +6 -1
  60. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  61. package/dist/components/viewer/perf-monitor.js +306 -115
  62. package/dist/components/viewer/perf-panel.d.ts +3 -0
  63. package/dist/components/viewer/perf-panel.d.ts.map +1 -0
  64. package/dist/components/viewer/perf-panel.js +178 -0
  65. package/dist/components/viewer/pointer-raycast-layers.d.ts +15 -0
  66. package/dist/components/viewer/pointer-raycast-layers.d.ts.map +1 -0
  67. package/dist/components/viewer/pointer-raycast-layers.js +28 -0
  68. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  69. package/dist/components/viewer/post-processing.js +124 -83
  70. package/dist/components/viewer/registered-systems.d.ts +14 -5
  71. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  72. package/dist/components/viewer/registered-systems.js +22 -10
  73. package/dist/components/viewer/scene-atmosphere.d.ts +30 -0
  74. package/dist/components/viewer/scene-atmosphere.d.ts.map +1 -0
  75. package/dist/components/viewer/scene-atmosphere.js +89 -0
  76. package/dist/components/viewer/scene-environment.d.ts.map +1 -1
  77. package/dist/components/viewer/scene-environment.js +19 -5
  78. package/dist/components/viewer/scene-ground-replacement.d.ts +5 -0
  79. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -0
  80. package/dist/components/viewer/scene-ground-replacement.js +35 -0
  81. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  82. package/dist/components/viewer/selection-manager.js +7 -2
  83. package/dist/components/viewer/unsupported-gpu-fallback.d.ts +2 -0
  84. package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -0
  85. package/dist/components/viewer/unsupported-gpu-fallback.js +4 -0
  86. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  87. package/dist/components/viewer/viewer-camera.js +4 -1
  88. package/dist/components/viewer/viewer-presentations.d.ts +67 -0
  89. package/dist/components/viewer/viewer-presentations.d.ts.map +1 -0
  90. package/dist/components/viewer/viewer-presentations.js +120 -0
  91. package/dist/hooks/use-library-materials-version.d.ts +7 -0
  92. package/dist/hooks/use-library-materials-version.d.ts.map +1 -0
  93. package/dist/hooks/use-library-materials-version.js +10 -0
  94. package/dist/hooks/use-node-events.d.ts.map +1 -1
  95. package/dist/hooks/use-node-events.js +1 -0
  96. package/dist/index.d.ts +20 -8
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +19 -7
  99. package/dist/lib/csg-utils.d.ts +1 -0
  100. package/dist/lib/csg-utils.d.ts.map +1 -1
  101. package/dist/lib/csg-utils.js +6 -1
  102. package/dist/lib/dispose-object3d.d.ts +4 -0
  103. package/dist/lib/dispose-object3d.d.ts.map +1 -0
  104. package/dist/lib/dispose-object3d.js +27 -0
  105. package/dist/lib/geometry-groups.d.ts +17 -0
  106. package/dist/lib/geometry-groups.d.ts.map +1 -0
  107. package/dist/lib/geometry-groups.js +53 -0
  108. package/dist/lib/gpu-perf.d.ts +0 -2
  109. package/dist/lib/gpu-perf.d.ts.map +1 -1
  110. package/dist/lib/gpu-perf.js +7 -21
  111. package/dist/lib/isolation.d.ts +8 -8
  112. package/dist/lib/isolation.d.ts.map +1 -1
  113. package/dist/lib/isolation.js +18 -30
  114. package/dist/lib/layer-pass.d.ts +28 -0
  115. package/dist/lib/layer-pass.d.ts.map +1 -0
  116. package/dist/lib/layer-pass.js +254 -0
  117. package/dist/lib/layers.d.ts +16 -0
  118. package/dist/lib/layers.d.ts.map +1 -1
  119. package/dist/lib/layers.js +18 -0
  120. package/dist/lib/materials.d.ts +12 -0
  121. package/dist/lib/materials.d.ts.map +1 -1
  122. package/dist/lib/materials.js +51 -17
  123. package/dist/lib/merged-outline-node.d.ts +17 -8
  124. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  125. package/dist/lib/merged-outline-node.js +194 -52
  126. package/dist/lib/perf-actions.d.ts +55 -0
  127. package/dist/lib/perf-actions.d.ts.map +1 -0
  128. package/dist/lib/perf-actions.js +211 -0
  129. package/dist/lib/perf-observers.d.ts +2 -0
  130. package/dist/lib/perf-observers.d.ts.map +1 -0
  131. package/dist/lib/perf-observers.js +17 -0
  132. package/dist/lib/perf-panel-store.d.ts +71 -0
  133. package/dist/lib/perf-panel-store.d.ts.map +1 -0
  134. package/dist/lib/perf-panel-store.js +29 -0
  135. package/dist/lib/perf-tracks.d.ts +43 -0
  136. package/dist/lib/perf-tracks.d.ts.map +1 -0
  137. package/dist/lib/perf-tracks.js +113 -0
  138. package/dist/lib/pointer-events.d.ts +7 -0
  139. package/dist/lib/pointer-events.d.ts.map +1 -0
  140. package/dist/lib/pointer-events.js +680 -0
  141. package/dist/lib/post-processing-resources.d.ts +12 -0
  142. package/dist/lib/post-processing-resources.d.ts.map +1 -0
  143. package/dist/lib/post-processing-resources.js +19 -0
  144. package/dist/lib/renderer-capability.d.ts +56 -0
  145. package/dist/lib/renderer-capability.d.ts.map +1 -0
  146. package/dist/lib/renderer-capability.js +116 -0
  147. package/dist/lib/scene-visibility.d.ts +15 -0
  148. package/dist/lib/scene-visibility.d.ts.map +1 -0
  149. package/dist/lib/scene-visibility.js +69 -0
  150. package/dist/lib/shadow-only.d.ts +16 -0
  151. package/dist/lib/shadow-only.d.ts.map +1 -1
  152. package/dist/lib/shadow-only.js +8 -17
  153. package/dist/lib/snapshot-pipeline.d.ts +9 -0
  154. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  155. package/dist/lib/snapshot-pipeline.js +33 -9
  156. package/dist/store/use-viewer.d.ts +7 -6
  157. package/dist/store/use-viewer.d.ts.map +1 -1
  158. package/dist/systems/ceiling/ceiling-system.js +1 -1
  159. package/dist/systems/door/door-animation-system.d.ts.map +1 -1
  160. package/dist/systems/door/door-animation-system.js +6 -9
  161. package/dist/systems/door/door-system.d.ts.map +1 -1
  162. package/dist/systems/door/door-system.js +13 -2
  163. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  164. package/dist/systems/floor-elevation/floor-elevation-system.js +9 -0
  165. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  166. package/dist/systems/geometry/geometry-system.js +5 -28
  167. package/dist/systems/level/level-system.d.ts.map +1 -1
  168. package/dist/systems/level/level-system.js +8 -2
  169. package/dist/systems/level/level-utils.d.ts +9 -0
  170. package/dist/systems/level/level-utils.d.ts.map +1 -1
  171. package/dist/systems/level/level-utils.js +14 -0
  172. package/dist/systems/perf-action-settle/perf-action-settle-system.d.ts +8 -0
  173. package/dist/systems/perf-action-settle/perf-action-settle-system.d.ts.map +1 -0
  174. package/dist/systems/perf-action-settle/perf-action-settle-system.js +32 -0
  175. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  176. package/dist/systems/roof/roof-system.js +1196 -58
  177. package/dist/systems/scan/scan-system.js +6 -5
  178. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  179. package/dist/systems/slab/slab-system.js +1 -2
  180. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  181. package/dist/systems/stair/stair-system.js +9 -8
  182. package/dist/systems/surface-hole-geometry.d.ts +1 -1
  183. package/dist/systems/surface-hole-geometry.d.ts.map +1 -1
  184. package/dist/systems/surface-hole-geometry.js +1 -1
  185. package/dist/systems/wall/level-miter-cache.d.ts +10 -0
  186. package/dist/systems/wall/level-miter-cache.d.ts.map +1 -0
  187. package/dist/systems/wall/level-miter-cache.js +40 -0
  188. package/dist/systems/wall/wall-build-lifecycle.d.ts +9 -0
  189. package/dist/systems/wall/wall-build-lifecycle.d.ts.map +1 -0
  190. package/dist/systems/wall/wall-build-lifecycle.js +74 -0
  191. package/dist/systems/wall/wall-cutout-cache.d.ts +57 -0
  192. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -0
  193. package/dist/systems/wall/wall-cutout-cache.js +304 -0
  194. package/dist/systems/wall/wall-cutout.d.ts +24 -1
  195. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  196. package/dist/systems/wall/wall-cutout.js +33 -140
  197. package/dist/systems/wall/wall-material-variant.d.ts +31 -0
  198. package/dist/systems/wall/wall-material-variant.d.ts.map +1 -0
  199. package/dist/systems/wall/wall-material-variant.js +10 -0
  200. package/dist/systems/wall/wall-materials.d.ts +6 -0
  201. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  202. package/dist/systems/wall/wall-materials.js +29 -13
  203. package/dist/systems/wall/wall-placeholder-sweep.d.ts +50 -0
  204. package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -0
  205. package/dist/systems/wall/wall-placeholder-sweep.js +51 -0
  206. package/dist/systems/wall/wall-rebuild-notifications.d.ts +5 -0
  207. package/dist/systems/wall/wall-rebuild-notifications.d.ts.map +1 -0
  208. package/dist/systems/wall/wall-rebuild-notifications.js +23 -0
  209. package/dist/systems/wall/wall-system.d.ts +13 -2
  210. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  211. package/dist/systems/wall/wall-system.js +413 -148
  212. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  213. package/dist/systems/window/window-animation-system.js +9 -8
  214. package/dist/systems/window/window-system.d.ts +1 -0
  215. package/dist/systems/window/window-system.d.ts.map +1 -1
  216. package/dist/systems/window/window-system.js +16 -2
  217. package/package.json +18 -3
  218. package/dist/components/viewer/scene-bvh.test.d.ts +0 -2
  219. package/dist/components/viewer/scene-bvh.test.d.ts.map +0 -1
  220. package/dist/components/viewer/scene-bvh.test.js +0 -13
  221. package/dist/lib/csg-utils.test.d.ts +0 -2
  222. package/dist/lib/csg-utils.test.d.ts.map +0 -1
  223. package/dist/lib/csg-utils.test.js +0 -45
  224. package/dist/lib/materials.test.d.ts +0 -2
  225. package/dist/lib/materials.test.d.ts.map +0 -1
  226. package/dist/lib/materials.test.js +0 -25
  227. package/dist/lib/merged-outline-node.test.d.ts +0 -2
  228. package/dist/lib/merged-outline-node.test.d.ts.map +0 -1
  229. package/dist/lib/merged-outline-node.test.js +0 -22
  230. package/dist/lib/polygon-union.d.ts +0 -4
  231. package/dist/lib/polygon-union.d.ts.map +0 -1
  232. package/dist/lib/polygon-union.js +0 -289
  233. package/dist/lib/polygon-union.test.d.ts +0 -2
  234. package/dist/lib/polygon-union.test.d.ts.map +0 -1
  235. package/dist/lib/polygon-union.test.js +0 -105
  236. package/dist/lib/texture-reference.test.d.ts +0 -2
  237. package/dist/lib/texture-reference.test.d.ts.map +0 -1
  238. package/dist/lib/texture-reference.test.js +0 -86
  239. package/dist/store/use-viewer.test.d.ts +0 -2
  240. package/dist/store/use-viewer.test.d.ts.map +0 -1
  241. package/dist/store/use-viewer.test.js +0 -61
  242. package/dist/systems/door/door-animation.test.d.ts +0 -2
  243. package/dist/systems/door/door-animation.test.d.ts.map +0 -1
  244. package/dist/systems/door/door-animation.test.js +0 -51
  245. package/dist/systems/door/door-floor-alignment.test.d.ts +0 -2
  246. package/dist/systems/door/door-floor-alignment.test.d.ts.map +0 -1
  247. package/dist/systems/door/door-floor-alignment.test.js +0 -50
  248. package/dist/systems/geometry/geometry-system.test.d.ts +0 -2
  249. package/dist/systems/geometry/geometry-system.test.d.ts.map +0 -1
  250. package/dist/systems/geometry/geometry-system.test.js +0 -30
  251. package/dist/systems/slab/slab-system.test.d.ts +0 -2
  252. package/dist/systems/slab/slab-system.test.d.ts.map +0 -1
  253. package/dist/systems/slab/slab-system.test.js +0 -102
  254. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +0 -2
  255. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +0 -1
  256. package/dist/systems/wall/opening-cutout-geometry.test.js +0 -172
  257. package/dist/systems/wall/wall-opening-cutout.test.d.ts +0 -2
  258. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +0 -1
  259. package/dist/systems/wall/wall-opening-cutout.test.js +0 -59
  260. package/dist/systems/wall/wall-support-extension.test.d.ts +0 -2
  261. package/dist/systems/wall/wall-support-extension.test.d.ts.map +0 -1
  262. package/dist/systems/wall/wall-support-extension.test.js +0 -116
  263. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +0 -2
  264. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +0 -1
  265. package/dist/systems/wall/wall-terrain-bottom.test.js +0 -34
@@ -1,12 +1,21 @@
1
- import { calculateLevelMiters, DEFAULT_LEVEL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, resolveWallTop, sceneRegistry, spatialGridManager, terrainSupportLift, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
1
+ import { DEFAULT_LEVEL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, resolveWallTop, sceneRegistry, spatialGridManager, terrainSupportLift, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
+ import { useEffect } from 'react';
3
4
  import * as THREE from 'three';
4
5
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
5
- import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
6
+ import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
6
7
  import { computeBoundsTree } from 'three-mesh-bvh';
7
8
  import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils';
9
+ import { setGroupsSortedByMaterial } from '../../lib/geometry-groups';
10
+ import { timeSpan } from '../../lib/perf-tracks';
8
11
  import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill';
12
+ import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache';
9
13
  import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry';
14
+ import { drainStats, endInitialBuild, initiallyBuiltWalls, isWallInitialBuildActive, pendingAdjacentByLevel, publishWallDrainStats, } from './wall-build-lifecycle';
15
+ import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep';
16
+ import { notifyWallRebuilt } from './wall-rebuild-notifications';
17
+ export { isWallInitialBuildActive } from './wall-build-lifecycle';
18
+ export { drainRebuiltWalls } from './wall-rebuild-notifications';
10
19
  // Reusable CSG evaluator for better performance
11
20
  const csgEvaluator = new Evaluator();
12
21
  csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'];
@@ -40,6 +49,125 @@ function computeGeometryBoundsTree(geometry) {
40
49
  function csgGeometry(brush) {
41
50
  return brush.geometry;
42
51
  }
52
+ function isBoxCutout(brush, bounds) {
53
+ const geometry = csgGeometry(brush);
54
+ const positions = geometry.getAttribute('position');
55
+ if ((geometry.index?.count ?? positions.count) !== 36)
56
+ return false;
57
+ const vertex = new THREE.Vector3();
58
+ const corners = new Set();
59
+ for (let index = 0; index < positions.count; index++) {
60
+ vertex.fromBufferAttribute(positions, index).applyMatrix4(brush.matrixWorld);
61
+ let corner = 0;
62
+ for (const [bit, axis] of ['x', 'y', 'z'].entries()) {
63
+ const coordinate = axis;
64
+ if (Math.abs(vertex[coordinate] - bounds.min[coordinate]) <= 1e-6)
65
+ continue;
66
+ if (Math.abs(vertex[coordinate] - bounds.max[coordinate]) > 1e-6)
67
+ return false;
68
+ corner |= 1 << bit;
69
+ }
70
+ corners.add(corner);
71
+ }
72
+ // A rotated box's AABB can contain another cutter without the solid doing so.
73
+ return corners.size === 8;
74
+ }
75
+ export function mergeWallCutoutBrushes(brushes) {
76
+ const cutouts = brushes.map((brush) => {
77
+ prepareBrushForCSG(brush);
78
+ const geometry = csgGeometry(brush);
79
+ geometry.computeBoundingBox();
80
+ const bounds = geometry.boundingBox.clone().applyMatrix4(brush.matrixWorld);
81
+ return {
82
+ brush,
83
+ bounds,
84
+ containerBounds: bounds.clone().expandByScalar(1e-5),
85
+ isBox: isBoxCutout(brush, bounds),
86
+ };
87
+ });
88
+ const retained = [];
89
+ for (const cutout of cutouts) {
90
+ if (cutout.isBox) {
91
+ if (retained.some((other) => other.isBox && other.containerBounds.containsBox(cutout.bounds))) {
92
+ continue;
93
+ }
94
+ for (let index = retained.length - 1; index >= 0; index--) {
95
+ const other = retained[index];
96
+ if (other.isBox && cutout.containerBounds.containsBox(other.bounds)) {
97
+ retained.splice(index, 1);
98
+ }
99
+ }
100
+ }
101
+ retained.push(cutout);
102
+ }
103
+ const droppedCount = cutouts.length - retained.length;
104
+ const bounds = retained.map((cutout) => cutout.bounds.clone().expandByScalar(1e-6));
105
+ const parents = retained.map((_, index) => index);
106
+ const root = (index) => {
107
+ while (parents[index] !== index) {
108
+ parents[index] = parents[parents[index]];
109
+ index = parents[index];
110
+ }
111
+ return index;
112
+ };
113
+ for (let a = 0; a < retained.length; a++) {
114
+ for (let b = a + 1; b < retained.length; b++) {
115
+ if (bounds[a].intersectsBox(bounds[b]))
116
+ parents[root(b)] = root(a);
117
+ }
118
+ }
119
+ const groups = new Map();
120
+ retained.forEach(({ brush }, index) => {
121
+ const key = root(index);
122
+ const group = groups.get(key) ?? [];
123
+ group.push(brush);
124
+ groups.set(key, group);
125
+ });
126
+ const geometries = [];
127
+ const intermediateGeometries = new Set();
128
+ const fallbackBrushes = [];
129
+ try {
130
+ for (const group of groups.values()) {
131
+ // Long unions of coplanar openings can grow explosively; subtract these directly.
132
+ if (group.length > 4) {
133
+ fallbackBrushes.push(...group);
134
+ continue;
135
+ }
136
+ let result = group[0];
137
+ for (let index = 1; index < group.length; index++) {
138
+ const next = csgEvaluator.evaluate(result, group[index], ADDITION);
139
+ intermediateGeometries.add(csgGeometry(next));
140
+ if (intermediateGeometries.delete(csgGeometry(result)))
141
+ csgGeometry(result).dispose();
142
+ result = next;
143
+ }
144
+ const source = csgGeometry(result);
145
+ const geometry = source.index ? source.toNonIndexed() : source.clone();
146
+ geometries.push(geometry);
147
+ geometry.applyMatrix4(result.matrixWorld);
148
+ for (const attribute of Object.keys(geometry.attributes)) {
149
+ if (!csgEvaluator.attributes.includes(attribute))
150
+ geometry.deleteAttribute(attribute);
151
+ }
152
+ }
153
+ if (geometries.length === 0)
154
+ return { cutter: null, fallbackBrushes, droppedCount };
155
+ // CSG material indices are temporary: assignWallMaterialGroups classifies
156
+ // the final faces, including reveals, into the wall's semantic slots.
157
+ const merged = mergeGeometries(geometries, false);
158
+ if (!merged)
159
+ throw new Error('Unable to merge wall cutout geometries');
160
+ const cutter = new Brush(merged);
161
+ prepareBrushForCSG(cutter);
162
+ return { cutter, fallbackBrushes, droppedCount };
163
+ }
164
+ finally {
165
+ for (const geometry of geometries)
166
+ geometry.dispose();
167
+ for (const geometry of intermediateGeometries)
168
+ geometry.dispose();
169
+ }
170
+ }
43
171
  function insetCurvedWallBoundaryPointsFor3D(wall, boundaryPoints, miterData) {
44
172
  if (!(boundaryPoints && isCurvedWall(wall))) {
45
173
  return boundaryPoints;
@@ -210,18 +338,7 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHe
210
338
  }
211
339
  triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y, effectiveWallHeight);
212
340
  }
213
- geometry.clearGroups();
214
- let currentMaterial = triangleMaterials[0] ?? 0;
215
- let groupStart = 0;
216
- for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
217
- const materialIndex = triangleMaterials[triangleIndex] ?? 0;
218
- if (materialIndex === currentMaterial)
219
- continue;
220
- geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial);
221
- groupStart = triangleIndex;
222
- currentMaterial = materialIndex;
223
- }
224
- geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
341
+ setGroupsSortedByMaterial(geometry, triangleMaterials);
225
342
  }
226
343
  function interpolateSplitVertex(a, b, t) {
227
344
  return {
@@ -343,147 +460,275 @@ const DRAG_FLUSH_MS = 80;
343
460
  const MAX_WALL_REBUILDS_PER_FRAME = 8;
344
461
  const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME;
345
462
  const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8;
463
+ const HEAVY_WALL_OPENINGS = 6;
346
464
  let lastWallDirtyAtMs = 0;
347
- const pendingAdjacentByLevel = new Map();
348
- function getPendingAdjacentCount() {
349
- let count = 0;
350
- for (const ids of pendingAdjacentByLevel.values()) {
351
- count += ids.size;
465
+ let unmountedFrames = 0;
466
+ let stalledHydrationToken = null;
467
+ function wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs, initialBuild = false) {
468
+ if (!initialBuild && rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME)
469
+ return 'cap';
470
+ if (rebuiltThisFrame === 0)
471
+ return null;
472
+ if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS)
473
+ return 'budget';
474
+ const wall = nodes[wallId];
475
+ if (wall?.type !== 'wall')
476
+ return null;
477
+ let cutouts = 0;
478
+ for (const childId of getEffectiveWall(wall).children ?? []) {
479
+ const child = nodes[childId];
480
+ if (child?.type === 'door' ||
481
+ child?.type === 'window' ||
482
+ (child?.type === 'item' &&
483
+ sceneRegistry.nodes.get(childId)?.getObjectByName('cutout')?.geometry?.getAttribute('position')?.count)) {
484
+ cutouts++;
485
+ if (cutouts >= HEAVY_WALL_OPENINGS)
486
+ return 'heavy';
487
+ }
352
488
  }
353
- return count;
489
+ return null;
354
490
  }
491
+ export function shouldDeferWallRebuild(wallId, nodes, rebuiltThisFrame, elapsedMs) {
492
+ return wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs) !== null;
493
+ }
494
+ /** Rebuilds this system still owes — neighbours deferred during a drag. */
495
+ export function getPendingWallRebuildCount() {
496
+ return drainStats.pendingNeighbours;
497
+ }
498
+ let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL;
355
499
  export const WallSystem = () => {
356
- const dirtyNodes = useScene((state) => state.dirtyNodes);
357
- const clearDirty = useScene((state) => state.clearDirty);
358
- // Subscribe so override-only changes (no scene write) still re-run
359
- // this component, which lets the gate below pick up the latest
360
- // `dirtyNodes` set from the same render pass that received the
361
- // override-publishing `markDirty` call. Without this, very fast
362
- // drags could land an override and a markDirty in the same React
363
- // tick and the next `useFrame` would still see the stale closure.
500
+ useScene((state) => state.dirtyNodes);
364
501
  useLiveNodeOverrides((s) => s.overrides);
365
- useFrame(() => {
366
- const hasDirty = dirtyNodes.size > 0;
367
- const hasPending = pendingAdjacentByLevel.size > 0;
368
- if (!hasDirty && !hasPending)
369
- return;
370
- const nodes = useScene.getState().nodes;
371
- const now = performance.now();
372
- // Collect dirty walls and their levels
373
- const dirtyWallsByLevel = new Map();
374
- let dirtyWallCount = 0;
375
- useFrameNb += 1;
376
- if (hasDirty) {
377
- dirtyNodes.forEach((id) => {
378
- const node = nodes[id];
379
- if (node?.type !== 'wall')
380
- return;
381
- const levelId = node.parentId;
382
- if (!levelId)
383
- return;
384
- if (!dirtyWallsByLevel.has(levelId)) {
385
- dirtyWallsByLevel.set(levelId, new Set());
386
- }
387
- dirtyWallsByLevel.get(levelId)?.add(id);
388
- dirtyWallCount += 1;
389
- });
390
- }
391
- const hasDirtyWalls = dirtyWallsByLevel.size > 0;
392
- if (hasDirtyWalls) {
393
- lastWallDirtyAtMs = now;
502
+ useEffect(() => () => clearLevelMiterCache(), []);
503
+ useFrame(runWallBuildFrame, 4);
504
+ return null;
505
+ };
506
+ export function runWallBuildFrame() {
507
+ const initialBuild = isWallInitialBuildActive();
508
+ const token = useScene.getState().hydrationToken;
509
+ if (token !== stalledHydrationToken) {
510
+ unmountedFrames = 0;
511
+ stalledHydrationToken = token;
512
+ }
513
+ drainStats.wallsConsumedThisFrame = 0;
514
+ try {
515
+ consumeWallBuildFrame(initialBuild);
516
+ }
517
+ finally {
518
+ publishWallDrainStats();
519
+ }
520
+ }
521
+ function consumeWallBuildFrame(initialBuild) {
522
+ const clearDirty = useScene.getState().clearDirty;
523
+ // Self-heal: any registered wall still on its mount-time placeholder
524
+ // geometry with NO dirty mark gets re-marked, so a lost mark (system
525
+ // mounted late, suspense remount, mark consumed elsewhere) can never
526
+ // strand a wall as a degenerate point forever (QA f2 probe5/probe6 —
527
+ // scene loaded with the X-ray active never built any of its 24 walls).
528
+ placeholderSweepCountdown -= 1;
529
+ if (placeholderSweepCountdown <= 0) {
530
+ placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL;
531
+ const sceneState = useScene.getState();
532
+ sweepUnbuiltWalls({
533
+ wallIds: sceneRegistry.byType.wall ?? [],
534
+ geometryOf: (wallId) => sceneRegistry.nodes.get(wallId)?.geometry ?? null,
535
+ isDirty: (wallId) => sceneState.dirtyNodes.has(wallId),
536
+ markDirty: (wallId) => sceneState.markDirty(wallId),
537
+ });
538
+ }
539
+ const dirtyNodes = useScene.getState().dirtyNodes;
540
+ const hasDirty = dirtyNodes.size > 0;
541
+ const hasPending = pendingAdjacentByLevel.size > 0;
542
+ if (!hasDirty && !hasPending) {
543
+ endInitialBuild();
544
+ return;
545
+ }
546
+ const nodes = useScene.getState().nodes;
547
+ const now = performance.now();
548
+ // Collect dirty walls and their levels
549
+ const dirtyWallsByLevel = new Map();
550
+ let dirtyWallCount = 0;
551
+ let unmountedWallCount = 0;
552
+ useFrameNb += 1;
553
+ if (hasDirty) {
554
+ dirtyNodes.forEach((id) => {
555
+ const node = nodes[id];
556
+ if (node?.type !== 'wall')
557
+ return;
558
+ dirtyWallCount += 1;
559
+ if (!sceneRegistry.nodes.has(id))
560
+ unmountedWallCount++;
561
+ const levelId = node.parentId;
562
+ if (!levelId)
563
+ return;
564
+ if (!dirtyWallsByLevel.has(levelId)) {
565
+ dirtyWallsByLevel.set(levelId, new Set());
566
+ }
567
+ dirtyWallsByLevel.get(levelId)?.add(id);
568
+ });
569
+ }
570
+ const hasDirtyWalls = dirtyWallCount > unmountedWallCount;
571
+ if (hasDirtyWalls) {
572
+ lastWallDirtyAtMs = now;
573
+ }
574
+ const useProgressiveWallRebuilds = initialBuild || dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
575
+ let rebuiltWallsThisFrame = 0;
576
+ const rebuildFrameStartedAt = now;
577
+ let deferWallRebuilds = false;
578
+ let exitReason = null;
579
+ // Process each level that has dirty walls
580
+ for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
581
+ if (!initialBuild &&
582
+ useProgressiveWallRebuilds &&
583
+ rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
584
+ exitReason = 'cap';
585
+ break;
394
586
  }
395
- const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
396
- let rebuiltWallsThisFrame = 0;
397
- const rebuildFrameStartedAt = now;
398
- // Process each level that has dirty walls
399
- for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
400
- if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
587
+ const levelWalls = getLevelWalls(levelId);
588
+ const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls));
589
+ const rebuiltWallIds = new Set();
590
+ // Update dirty walls — always, no throttling. The dragged wall must
591
+ // follow the cursor with full fidelity (cutouts and all). Large imports
592
+ // enter the progressive path so initial load can't lock the tab.
593
+ for (const wallId of dirtyWallIds) {
594
+ exitReason = useProgressiveWallRebuilds
595
+ ? wallRebuildExitReason(wallId, nodes, rebuiltWallsThisFrame, performance.now() - rebuildFrameStartedAt, initialBuild)
596
+ : null;
597
+ if (exitReason) {
598
+ deferWallRebuilds = true;
401
599
  break;
402
600
  }
403
- const levelWalls = getLevelWalls(levelId);
404
- const miterData = calculateLevelMiters(levelWalls);
405
- const rebuiltWallIds = new Set();
406
- // Update dirty walls — always, no throttling. The dragged wall must
407
- // follow the cursor with full fidelity (cutouts and all). Large imports
408
- // enter the progressive path so initial load can't lock the tab.
409
- for (const wallId of dirtyWallIds) {
410
- if (useProgressiveWallRebuilds) {
411
- if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
412
- break;
413
- }
414
- if (rebuiltWallsThisFrame > 0 &&
415
- performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS) {
416
- break;
417
- }
601
+ const mesh = sceneRegistry.nodes.get(wallId);
602
+ if (mesh) {
603
+ timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), {
604
+ properties: [['node', wallId]],
605
+ });
606
+ clearDirty(wallId);
607
+ notifyWallRebuilt(wallId);
608
+ const firstBuild = !initiallyBuiltWalls.has(wallId);
609
+ if (firstBuild) {
610
+ initiallyBuiltWalls.add(wallId);
611
+ drainStats.firstBuilds++;
418
612
  }
419
- const mesh = sceneRegistry.nodes.get(wallId);
420
- if (mesh) {
421
- updateWallGeometry(wallId, miterData);
422
- clearDirty(wallId);
613
+ else {
614
+ drainStats.reinvalidationBuilds++;
615
+ }
616
+ if (!initialBuild || !firstBuild)
423
617
  rebuiltWallIds.add(wallId);
424
- rebuiltWallsThisFrame += 1;
618
+ rebuiltWallsThisFrame += 1;
619
+ drainStats.wallsConsumedThisFrame++;
620
+ if (initialBuild && wallRebuildExitReason(wallId, nodes, 1, 0, true) === 'heavy') {
621
+ exitReason = 'heavy';
622
+ deferWallRebuilds = true;
623
+ break;
425
624
  }
426
- // If mesh not found, keep it dirty for next frame
427
- }
428
- if (rebuiltWallIds.size === 0) {
429
- continue;
430
- }
431
- // Adjacent walls sharing junctions — *defer* during active drag
432
- // (dirty arrived this frame), flush on the trailing edge.
433
- const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds);
434
- let pending = pendingAdjacentByLevel.get(levelId);
435
- if (!pending) {
436
- pending = new Set();
437
- pendingAdjacentByLevel.set(levelId, pending);
438
625
  }
439
- for (const wallId of adjacentWallIds) {
440
- if (!dirtyWallIds.has(wallId)) {
441
- pending.add(wallId);
442
- }
626
+ // If mesh not found, keep it dirty for next frame
627
+ }
628
+ if (rebuiltWallIds.size === 0) {
629
+ if (deferWallRebuilds)
630
+ break;
631
+ continue;
632
+ }
633
+ // First builds use the same hydrated inputs as every queued neighbour.
634
+ // Only subsequent invalidations need the adjacency scan and trailing flush.
635
+ // Adjacent walls sharing junctions — *defer* during active drag
636
+ // (dirty arrived this frame), flush on the trailing edge.
637
+ const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds);
638
+ let pending = pendingAdjacentByLevel.get(levelId);
639
+ if (!pending) {
640
+ pending = new Set();
641
+ pendingAdjacentByLevel.set(levelId, pending);
642
+ }
643
+ for (const wallId of adjacentWallIds) {
644
+ if (!dirtyWallIds.has(wallId) && !pending.has(wallId)) {
645
+ pending.add(wallId);
646
+ drainStats.pendingNeighbours++;
647
+ drainStats.neighbourEnqueues++;
443
648
  }
444
649
  }
445
- // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the
446
- // drag has ended — rebuild the queued neighbors so corners snap into
447
- // their correct miter joins.
448
- const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS;
449
- if (quiet && pendingAdjacentByLevel.size > 0) {
450
- const pendingCount = getPendingAdjacentCount();
451
- const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
452
- let rebuiltAdjacentThisFrame = 0;
453
- const adjacentFrameStartedAt = performance.now();
454
- for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
455
- if (pendingIds.size === 0)
456
- continue;
457
- const levelWalls = getLevelWalls(levelId);
458
- const miterData = calculateLevelMiters(levelWalls);
459
- for (const wallId of Array.from(pendingIds)) {
460
- if (useProgressiveAdjacentRebuilds) {
461
- if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
462
- break;
463
- }
464
- if (rebuiltAdjacentThisFrame > 0 &&
465
- performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS) {
466
- break;
467
- }
468
- }
469
- const mesh = sceneRegistry.nodes.get(wallId);
470
- if (mesh)
471
- updateWallGeometry(wallId, miterData);
472
- pendingIds.delete(wallId);
473
- rebuiltAdjacentThisFrame += 1;
650
+ if (pending.size === 0)
651
+ pendingAdjacentByLevel.delete(levelId);
652
+ if (deferWallRebuilds)
653
+ break;
654
+ }
655
+ // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the
656
+ // drag has ended — rebuild the queued neighbors so corners snap into
657
+ // their correct miter joins.
658
+ const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS;
659
+ if (quiet && pendingAdjacentByLevel.size > 0) {
660
+ const pendingCount = getPendingWallRebuildCount();
661
+ const useProgressiveAdjacentRebuilds = initialBuild || pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
662
+ let rebuiltAdjacentThisFrame = 0;
663
+ const adjacentFrameStartedAt = performance.now();
664
+ let deferAdjacentRebuilds = false;
665
+ for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
666
+ if (pendingIds.size === 0)
667
+ continue;
668
+ const levelWalls = getLevelWalls(levelId);
669
+ const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls));
670
+ for (const wallId of Array.from(pendingIds)) {
671
+ exitReason = useProgressiveAdjacentRebuilds
672
+ ? wallRebuildExitReason(wallId, nodes, rebuiltAdjacentThisFrame, performance.now() - adjacentFrameStartedAt, initialBuild)
673
+ : null;
674
+ if (exitReason) {
675
+ deferAdjacentRebuilds = true;
676
+ break;
474
677
  }
475
- if (pendingIds.size === 0) {
476
- pendingAdjacentByLevel.delete(levelId);
678
+ const mesh = sceneRegistry.nodes.get(wallId);
679
+ if (mesh) {
680
+ timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), {
681
+ properties: [['node', wallId]],
682
+ });
683
+ notifyWallRebuilt(wallId);
684
+ drainStats.wallsConsumedThisFrame++;
685
+ if (initiallyBuiltWalls.has(wallId))
686
+ drainStats.reinvalidationBuilds++;
687
+ else {
688
+ initiallyBuiltWalls.add(wallId);
689
+ drainStats.firstBuilds++;
690
+ }
477
691
  }
478
- if (useProgressiveAdjacentRebuilds &&
479
- rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
692
+ pendingIds.delete(wallId);
693
+ drainStats.pendingNeighbours--;
694
+ rebuiltAdjacentThisFrame += 1;
695
+ if (initialBuild && wallRebuildExitReason(wallId, nodes, 1, 0, true) === 'heavy') {
696
+ exitReason = 'heavy';
697
+ deferAdjacentRebuilds = true;
480
698
  break;
481
699
  }
482
700
  }
701
+ if (pendingIds.size === 0) {
702
+ pendingAdjacentByLevel.delete(levelId);
703
+ }
704
+ if (deferAdjacentRebuilds ||
705
+ (!initialBuild &&
706
+ useProgressiveAdjacentRebuilds &&
707
+ rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME)) {
708
+ break;
709
+ }
483
710
  }
484
- }, 4);
485
- return null;
486
- };
711
+ }
712
+ if (initialBuild && drainStats.wallsConsumedThisFrame === 0 && unmountedWallCount > 0) {
713
+ unmountedFrames++;
714
+ if (unmountedFrames >= WALL_PLACEHOLDER_SWEEP_INTERVAL) {
715
+ useScene.getState().invalidateHydration();
716
+ }
717
+ }
718
+ else
719
+ unmountedFrames = 0;
720
+ if (exitReason === 'budget')
721
+ drainStats.budgetExits++;
722
+ else if (exitReason === 'heavy')
723
+ drainStats.heavyExits++;
724
+ else if (exitReason === 'cap')
725
+ drainStats.capExits++;
726
+ if (dirtyWallCount === rebuiltWallsThisFrame && drainStats.pendingNeighbours === 0) {
727
+ if (drainStats.wallsConsumedThisFrame > 0 || drainStats.initialBuildActive)
728
+ drainStats.drainedExits++;
729
+ endInitialBuild();
730
+ }
731
+ }
487
732
  /**
488
733
  * Merge any live override for a wall into the scene record. Lets the
489
734
  * 2D move handler publish `{ start, end, curveOffset }` to
@@ -568,6 +813,9 @@ function updateWallGeometry(wallId, miterData) {
568
813
  const wallWorldMatrix = new THREE.Matrix4().compose(new THREE.Vector3(node.start[0], slabElevation, node.start[1]), new THREE.Quaternion().setFromAxisAngle(WALL_UV_Y_AXIS, -wallAngle), WALL_UV_UNIT_SCALE);
569
814
  const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix);
570
815
  mesh.geometry.dispose();
816
+ // A degenerate rebuild (zero-length or fully cut wall) yields as few vertices
817
+ // as the mount-time placeholder; the stamp keeps the sweep from re-marking it.
818
+ newGeo.userData.built = true;
571
819
  mesh.geometry = newGeo;
572
820
  // Update collision mesh
573
821
  const collisionMesh = mesh.getObjectByName('collision-mesh');
@@ -844,7 +1092,6 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
844
1092
  computeGeometryBoundsTree(cutoutGeometry);
845
1093
  baseProfileCutouts.push(new Brush(cutoutGeometry));
846
1094
  }
847
- // Apply base-profile and opening cutouts in one CSG pass.
848
1095
  const cutoutBrushes = [
849
1096
  ...baseProfileCutouts,
850
1097
  ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
@@ -862,21 +1109,39 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
862
1109
  computeGeometryBoundsTree(geometry);
863
1110
  const wallBrush = new Brush(geometry);
864
1111
  wallBrush.updateMatrixWorld();
865
- // Subtract each cutout from the wall
1112
+ let mergedCutter = null;
866
1113
  let resultBrush = wallBrush;
867
- for (const cutoutBrush of cutoutBrushes) {
868
- prepareBrushForCSG(cutoutBrush);
869
- const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION);
870
- prepareBrushForCSG(newResult);
871
- if (resultBrush !== wallBrush) {
1114
+ try {
1115
+ const properties = [];
1116
+ const merged = timeSpan('wall-csg-union', () => {
1117
+ const cutouts = mergeWallCutoutBrushes(cutoutBrushes);
1118
+ properties.push(['droppedCutouts', String(cutouts.droppedCount)]);
1119
+ return cutouts;
1120
+ }, { properties });
1121
+ mergedCutter = merged.cutter;
1122
+ timeSpan('wall-csg', () => {
1123
+ if (mergedCutter) {
1124
+ resultBrush = csgEvaluator.evaluate(resultBrush, mergedCutter, SUBTRACTION);
1125
+ }
1126
+ for (const cutter of merged.fallbackBrushes) {
1127
+ const next = csgEvaluator.evaluate(resultBrush, cutter, SUBTRACTION);
1128
+ if (resultBrush !== wallBrush)
1129
+ csgGeometry(resultBrush).dispose();
1130
+ resultBrush = next;
1131
+ }
1132
+ });
1133
+ }
1134
+ catch (error) {
1135
+ if (resultBrush !== wallBrush)
872
1136
  csgGeometry(resultBrush).dispose();
873
- }
874
- resultBrush = newResult;
1137
+ throw error;
875
1138
  }
876
- // Clean up
877
- csgGeometry(wallBrush).dispose();
878
- for (const brush of cutoutBrushes) {
879
- csgGeometry(brush).dispose();
1139
+ finally {
1140
+ csgGeometry(wallBrush).dispose();
1141
+ if (mergedCutter)
1142
+ csgGeometry(mergedCutter).dispose();
1143
+ for (const brush of cutoutBrushes)
1144
+ csgGeometry(brush).dispose();
880
1145
  }
881
1146
  const resultGeometry = csgGeometry(resultBrush);
882
1147
  const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
@@ -1 +1 @@
1
- {"version":3,"file":"window-animation-system.d.ts","sourceRoot":"","sources":["../../../src/systems/window/window-animation-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,UAAU,EAChB,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAsBrC;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,QAAQ,GAAG,SAAS,EAC1B,KAAK,EAAE,MAAM,GACZ,OAAO,CAqFT;AAQD,eAAO,MAAM,qBAAqB,YA+CjC,CAAA"}
1
+ {"version":3,"file":"window-animation-system.d.ts","sourceRoot":"","sources":["../../../src/systems/window/window-animation-system.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,UAAU,EAChB,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAiBrC;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,QAAQ,GAAG,SAAS,EAC1B,KAAK,EAAE,MAAM,GACZ,OAAO,CAqFT;AAQD,eAAO,MAAM,qBAAqB,YAoDjC,CAAA"}