@pascal-app/viewer 1.0.0-beta.5 → 1.0.1

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 (208) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -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 +198 -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 +76 -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 +70 -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/capture/use-json-artifact.d.ts +2 -0
  49. package/dist/capture/use-json-artifact.d.ts.map +1 -0
  50. package/dist/capture/use-json-artifact.js +42 -0
  51. package/dist/components/viewer/batched-mesh-spike.d.ts +14 -0
  52. package/dist/components/viewer/batched-mesh-spike.d.ts.map +1 -0
  53. package/dist/components/viewer/batched-mesh-spike.js +64 -0
  54. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  55. package/dist/components/viewer/frame-limiter.js +4 -3
  56. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  57. package/dist/components/viewer/glb-scene.js +3 -1
  58. package/dist/components/viewer/index.d.ts.map +1 -1
  59. package/dist/components/viewer/index.js +70 -62
  60. package/dist/components/viewer/lights.d.ts.map +1 -1
  61. package/dist/components/viewer/lights.js +123 -89
  62. package/dist/components/viewer/perf-monitor.d.ts +6 -1
  63. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  64. package/dist/components/viewer/perf-monitor.js +307 -116
  65. package/dist/components/viewer/perf-panel.d.ts +3 -0
  66. package/dist/components/viewer/perf-panel.d.ts.map +1 -0
  67. package/dist/components/viewer/perf-panel.js +178 -0
  68. package/dist/components/viewer/pointer-raycast-layers.d.ts +15 -0
  69. package/dist/components/viewer/pointer-raycast-layers.d.ts.map +1 -0
  70. package/dist/components/viewer/pointer-raycast-layers.js +28 -0
  71. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  72. package/dist/components/viewer/post-processing.js +130 -83
  73. package/dist/components/viewer/registered-systems.d.ts +14 -5
  74. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  75. package/dist/components/viewer/registered-systems.js +22 -10
  76. package/dist/components/viewer/scene-atmosphere.d.ts +30 -0
  77. package/dist/components/viewer/scene-atmosphere.d.ts.map +1 -0
  78. package/dist/components/viewer/scene-atmosphere.js +89 -0
  79. package/dist/components/viewer/scene-environment.d.ts.map +1 -1
  80. package/dist/components/viewer/scene-environment.js +19 -5
  81. package/dist/components/viewer/scene-ground-replacement.d.ts +5 -0
  82. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -0
  83. package/dist/components/viewer/scene-ground-replacement.js +35 -0
  84. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  85. package/dist/components/viewer/selection-manager.js +8 -16
  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 +16 -7
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +16 -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/gpu-perf.d.ts +0 -2
  103. package/dist/lib/gpu-perf.d.ts.map +1 -1
  104. package/dist/lib/gpu-perf.js +7 -21
  105. package/dist/lib/isolation.d.ts +14 -30
  106. package/dist/lib/isolation.d.ts.map +1 -1
  107. package/dist/lib/isolation.js +62 -66
  108. package/dist/lib/layer-pass.d.ts +28 -0
  109. package/dist/lib/layer-pass.d.ts.map +1 -0
  110. package/dist/lib/layer-pass.js +254 -0
  111. package/dist/lib/materials.d.ts +12 -0
  112. package/dist/lib/materials.d.ts.map +1 -1
  113. package/dist/lib/materials.js +52 -17
  114. package/dist/lib/merged-outline-node.d.ts +17 -8
  115. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  116. package/dist/lib/merged-outline-node.js +194 -52
  117. package/dist/lib/perf-actions.d.ts +55 -0
  118. package/dist/lib/perf-actions.d.ts.map +1 -0
  119. package/dist/lib/perf-actions.js +211 -0
  120. package/dist/lib/perf-observers.d.ts +2 -0
  121. package/dist/lib/perf-observers.d.ts.map +1 -0
  122. package/dist/lib/perf-observers.js +17 -0
  123. package/dist/lib/perf-panel-store.d.ts +71 -0
  124. package/dist/lib/perf-panel-store.d.ts.map +1 -0
  125. package/dist/lib/perf-panel-store.js +29 -0
  126. package/dist/lib/perf-tracks.d.ts +43 -0
  127. package/dist/lib/perf-tracks.d.ts.map +1 -0
  128. package/dist/lib/perf-tracks.js +113 -0
  129. package/dist/lib/pointer-events.d.ts +7 -0
  130. package/dist/lib/pointer-events.d.ts.map +1 -0
  131. package/dist/lib/pointer-events.js +680 -0
  132. package/dist/lib/post-processing-resources.d.ts +12 -0
  133. package/dist/lib/post-processing-resources.d.ts.map +1 -0
  134. package/dist/lib/post-processing-resources.js +19 -0
  135. package/dist/lib/renderer-capability.d.ts.map +1 -1
  136. package/dist/lib/renderer-capability.js +4 -2
  137. package/dist/lib/scene-visibility.d.ts +2 -1
  138. package/dist/lib/scene-visibility.d.ts.map +1 -1
  139. package/dist/lib/scene-visibility.js +25 -2
  140. package/dist/lib/snapshot-pipeline.d.ts +3 -1
  141. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  142. package/dist/lib/snapshot-pipeline.js +32 -16
  143. package/dist/store/use-viewer.d.ts +5 -3
  144. package/dist/store/use-viewer.d.ts.map +1 -1
  145. package/dist/store/use-viewer.js +2 -0
  146. package/dist/systems/ceiling/ceiling-system.js +1 -1
  147. package/dist/systems/door/door-animation-system.d.ts.map +1 -1
  148. package/dist/systems/door/door-animation-system.js +6 -9
  149. package/dist/systems/door/door-system.d.ts.map +1 -1
  150. package/dist/systems/door/door-system.js +13 -2
  151. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  152. package/dist/systems/floor-elevation/floor-elevation-system.js +7 -3
  153. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  154. package/dist/systems/geometry/geometry-system.js +7 -2
  155. package/dist/systems/level/level-system.d.ts.map +1 -1
  156. package/dist/systems/level/level-system.js +1 -1
  157. package/dist/systems/level/level-utils.d.ts +9 -0
  158. package/dist/systems/level/level-utils.d.ts.map +1 -1
  159. package/dist/systems/level/level-utils.js +14 -0
  160. package/dist/systems/perf-action-settle/perf-action-settle-system.d.ts +8 -0
  161. package/dist/systems/perf-action-settle/perf-action-settle-system.d.ts.map +1 -0
  162. package/dist/systems/perf-action-settle/perf-action-settle-system.js +32 -0
  163. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  164. package/dist/systems/roof/roof-system.js +1196 -58
  165. package/dist/systems/scan/scan-system.js +6 -5
  166. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  167. package/dist/systems/slab/slab-system.js +1 -2
  168. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  169. package/dist/systems/stair/stair-system.js +9 -8
  170. package/dist/systems/surface-hole-geometry.d.ts +1 -1
  171. package/dist/systems/surface-hole-geometry.d.ts.map +1 -1
  172. package/dist/systems/surface-hole-geometry.js +1 -1
  173. package/dist/systems/wall/level-miter-cache.d.ts.map +1 -1
  174. package/dist/systems/wall/wall-build-lifecycle.d.ts +9 -0
  175. package/dist/systems/wall/wall-build-lifecycle.d.ts.map +1 -0
  176. package/dist/systems/wall/wall-build-lifecycle.js +74 -0
  177. package/dist/systems/wall/wall-cutout-cache.d.ts +57 -0
  178. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -0
  179. package/dist/systems/wall/wall-cutout-cache.js +304 -0
  180. package/dist/systems/wall/wall-cutout.d.ts +12 -1
  181. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  182. package/dist/systems/wall/wall-cutout.js +24 -161
  183. package/dist/systems/wall/wall-material-variant.d.ts +31 -0
  184. package/dist/systems/wall/wall-material-variant.d.ts.map +1 -0
  185. package/dist/systems/wall/wall-material-variant.js +10 -0
  186. package/dist/systems/wall/wall-materials.d.ts +6 -0
  187. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  188. package/dist/systems/wall/wall-materials.js +29 -13
  189. package/dist/systems/wall/wall-placeholder-sweep.d.ts +50 -0
  190. package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -0
  191. package/dist/systems/wall/wall-placeholder-sweep.js +51 -0
  192. package/dist/systems/wall/wall-rebuild-notifications.d.ts +5 -0
  193. package/dist/systems/wall/wall-rebuild-notifications.d.ts.map +1 -0
  194. package/dist/systems/wall/wall-rebuild-notifications.js +23 -0
  195. package/dist/systems/wall/wall-system.d.ts +11 -4
  196. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  197. package/dist/systems/wall/wall-system.js +405 -153
  198. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-animation-system.js +9 -8
  200. package/dist/systems/window/window-system.d.ts +1 -0
  201. package/dist/systems/window/window-system.d.ts.map +1 -1
  202. package/dist/systems/window/window-system.js +16 -2
  203. package/dist/systems/zone/zone-system.d.ts.map +1 -1
  204. package/dist/systems/zone/zone-system.js +28 -2
  205. package/package.json +17 -4
  206. package/dist/lib/polygon-union.d.ts +0 -4
  207. package/dist/lib/polygon-union.d.ts.map +0 -1
  208. package/dist/lib/polygon-union.js +0 -289
@@ -3,13 +3,19 @@ import { useFrame } from '@react-three/fiber';
3
3
  import { useEffect } from 'react';
4
4
  import * as THREE from 'three';
5
5
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
6
- import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
6
+ import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
7
7
  import { computeBoundsTree } from 'three-mesh-bvh';
8
8
  import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils';
9
9
  import { setGroupsSortedByMaterial } from '../../lib/geometry-groups';
10
+ import { timeSpan } from '../../lib/perf-tracks';
10
11
  import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill';
11
12
  import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache';
12
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';
13
19
  // Reusable CSG evaluator for better performance
14
20
  const csgEvaluator = new Evaluator();
15
21
  csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'];
@@ -43,6 +49,125 @@ function computeGeometryBoundsTree(geometry) {
43
49
  function csgGeometry(brush) {
44
50
  return brush.geometry;
45
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
+ }
46
171
  function insetCurvedWallBoundaryPointsFor3D(wall, boundaryPoints, miterData) {
47
172
  if (!(boundaryPoints && isCurvedWall(wall))) {
48
173
  return boundaryPoints;
@@ -335,168 +460,275 @@ const DRAG_FLUSH_MS = 80;
335
460
  const MAX_WALL_REBUILDS_PER_FRAME = 8;
336
461
  const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME;
337
462
  const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8;
463
+ const HEAVY_WALL_OPENINGS = 6;
338
464
  let lastWallDirtyAtMs = 0;
339
- const pendingAdjacentByLevel = new Map();
340
- // Walls whose geometry this system replaced since the last drain.
341
- //
342
- // The store's dirty mark is cleared the moment a wall is rebuilt, so anything
343
- // running later in the same frame would never see it. This is that same
344
- // signal, held until a consumer picks it up. Neighbours rebuilt by the
345
- // trailing-edge flush land here too — those never carry a dirty mark at all.
346
- const rebuiltWalls = new Set();
347
- /** Moves every rebuild notice collected so far into `into`. */
348
- export function drainRebuiltWalls(into) {
349
- for (const wallId of rebuiltWalls)
350
- into.add(wallId);
351
- rebuiltWalls.clear();
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
+ }
488
+ }
489
+ return null;
490
+ }
491
+ export function shouldDeferWallRebuild(wallId, nodes, rebuiltThisFrame, elapsedMs) {
492
+ return wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs) !== null;
352
493
  }
353
494
  /** Rebuilds this system still owes — neighbours deferred during a drag. */
354
495
  export function getPendingWallRebuildCount() {
355
- let count = 0;
356
- for (const ids of pendingAdjacentByLevel.values()) {
357
- count += ids.size;
358
- }
359
- return count;
496
+ return drainStats.pendingNeighbours;
360
497
  }
498
+ let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL;
361
499
  export const WallSystem = () => {
362
- const dirtyNodes = useScene((state) => state.dirtyNodes);
363
- const clearDirty = useScene((state) => state.clearDirty);
364
- // Subscribe so override-only changes (no scene write) still re-run
365
- // this component, which lets the gate below pick up the latest
366
- // `dirtyNodes` set from the same render pass that received the
367
- // override-publishing `markDirty` call. Without this, very fast
368
- // drags could land an override and a markDirty in the same React
369
- // tick and the next `useFrame` would still see the stale closure.
500
+ useScene((state) => state.dirtyNodes);
370
501
  useLiveNodeOverrides((s) => s.overrides);
371
- // The miter cache is module-level, so it outlives this mount. Editor
372
- // teardown resets the other shared singletons; without the same reset here a
373
- // remount in the same tab keeps every previous level's walls reachable.
374
502
  useEffect(() => () => clearLevelMiterCache(), []);
375
- useFrame(() => {
376
- const hasDirty = dirtyNodes.size > 0;
377
- const hasPending = pendingAdjacentByLevel.size > 0;
378
- if (!hasDirty && !hasPending)
379
- return;
380
- const nodes = useScene.getState().nodes;
381
- const now = performance.now();
382
- // Collect dirty walls and their levels
383
- const dirtyWallsByLevel = new Map();
384
- let dirtyWallCount = 0;
385
- useFrameNb += 1;
386
- if (hasDirty) {
387
- dirtyNodes.forEach((id) => {
388
- const node = nodes[id];
389
- if (node?.type !== 'wall')
390
- return;
391
- const levelId = node.parentId;
392
- if (!levelId)
393
- return;
394
- if (!dirtyWallsByLevel.has(levelId)) {
395
- dirtyWallsByLevel.set(levelId, new Set());
396
- }
397
- dirtyWallsByLevel.get(levelId)?.add(id);
398
- dirtyWallCount += 1;
399
- });
400
- }
401
- const hasDirtyWalls = dirtyWallsByLevel.size > 0;
402
- if (hasDirtyWalls) {
403
- lastWallDirtyAtMs = now;
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;
404
586
  }
405
- const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
406
- let rebuiltWallsThisFrame = 0;
407
- const rebuildFrameStartedAt = now;
408
- // Process each level that has dirty walls
409
- for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
410
- 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;
411
599
  break;
412
600
  }
413
- const levelWalls = getLevelWalls(levelId);
414
- const miterData = getCachedLevelMiters(levelId, levelWalls);
415
- const rebuiltWallIds = new Set();
416
- // Update dirty walls — always, no throttling. The dragged wall must
417
- // follow the cursor with full fidelity (cutouts and all). Large imports
418
- // enter the progressive path so initial load can't lock the tab.
419
- for (const wallId of dirtyWallIds) {
420
- if (useProgressiveWallRebuilds) {
421
- if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
422
- break;
423
- }
424
- if (rebuiltWallsThisFrame > 0 &&
425
- performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS) {
426
- break;
427
- }
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++;
428
612
  }
429
- const mesh = sceneRegistry.nodes.get(wallId);
430
- if (mesh) {
431
- updateWallGeometry(wallId, miterData);
432
- clearDirty(wallId);
433
- rebuiltWalls.add(wallId);
613
+ else {
614
+ drainStats.reinvalidationBuilds++;
615
+ }
616
+ if (!initialBuild || !firstBuild)
434
617
  rebuiltWallIds.add(wallId);
435
- 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;
436
624
  }
437
- // If mesh not found, keep it dirty for next frame
438
- }
439
- if (rebuiltWallIds.size === 0) {
440
- continue;
441
625
  }
442
- // Adjacent walls sharing junctions — *defer* during active drag
443
- // (dirty arrived this frame), flush on the trailing edge.
444
- const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds);
445
- let pending = pendingAdjacentByLevel.get(levelId);
446
- if (!pending) {
447
- pending = new Set();
448
- pendingAdjacentByLevel.set(levelId, pending);
449
- }
450
- for (const wallId of adjacentWallIds) {
451
- if (!dirtyWallIds.has(wallId)) {
452
- pending.add(wallId);
453
- }
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++;
454
648
  }
455
649
  }
456
- // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the
457
- // drag has ended — rebuild the queued neighbors so corners snap into
458
- // their correct miter joins.
459
- const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS;
460
- if (quiet && pendingAdjacentByLevel.size > 0) {
461
- const pendingCount = getPendingWallRebuildCount();
462
- const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
463
- let rebuiltAdjacentThisFrame = 0;
464
- const adjacentFrameStartedAt = performance.now();
465
- for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
466
- if (pendingIds.size === 0)
467
- continue;
468
- const levelWalls = getLevelWalls(levelId);
469
- const miterData = getCachedLevelMiters(levelId, levelWalls);
470
- for (const wallId of Array.from(pendingIds)) {
471
- if (useProgressiveAdjacentRebuilds) {
472
- if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
473
- break;
474
- }
475
- if (rebuiltAdjacentThisFrame > 0 &&
476
- performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS) {
477
- break;
478
- }
479
- }
480
- const mesh = sceneRegistry.nodes.get(wallId);
481
- if (mesh) {
482
- updateWallGeometry(wallId, miterData);
483
- rebuiltWalls.add(wallId);
484
- }
485
- pendingIds.delete(wallId);
486
- 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;
487
677
  }
488
- if (pendingIds.size === 0) {
489
- 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
+ }
490
691
  }
491
- if (useProgressiveAdjacentRebuilds &&
492
- 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;
493
698
  break;
494
699
  }
495
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
+ }
496
710
  }
497
- }, 4);
498
- return null;
499
- };
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
+ }
500
732
  /**
501
733
  * Merge any live override for a wall into the scene record. Lets the
502
734
  * 2D move handler publish `{ start, end, curveOffset }` to
@@ -581,6 +813,9 @@ function updateWallGeometry(wallId, miterData) {
581
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);
582
814
  const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix);
583
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;
584
819
  mesh.geometry = newGeo;
585
820
  // Update collision mesh
586
821
  const collisionMesh = mesh.getObjectByName('collision-mesh');
@@ -857,7 +1092,6 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
857
1092
  computeGeometryBoundsTree(cutoutGeometry);
858
1093
  baseProfileCutouts.push(new Brush(cutoutGeometry));
859
1094
  }
860
- // Apply base-profile and opening cutouts in one CSG pass.
861
1095
  const cutoutBrushes = [
862
1096
  ...baseProfileCutouts,
863
1097
  ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
@@ -875,21 +1109,39 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
875
1109
  computeGeometryBoundsTree(geometry);
876
1110
  const wallBrush = new Brush(geometry);
877
1111
  wallBrush.updateMatrixWorld();
878
- // Subtract each cutout from the wall
1112
+ let mergedCutter = null;
879
1113
  let resultBrush = wallBrush;
880
- for (const cutoutBrush of cutoutBrushes) {
881
- prepareBrushForCSG(cutoutBrush);
882
- const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION);
883
- prepareBrushForCSG(newResult);
884
- 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)
885
1136
  csgGeometry(resultBrush).dispose();
886
- }
887
- resultBrush = newResult;
1137
+ throw error;
888
1138
  }
889
- // Clean up
890
- csgGeometry(wallBrush).dispose();
891
- for (const brush of cutoutBrushes) {
892
- 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();
893
1145
  }
894
1146
  const resultGeometry = csgGeometry(resultBrush);
895
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"}