@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -0,0 +1,166 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Terrain elevation lookup pipeline.
7
+ *
8
+ * Multiple sources are tried fast-first with diagnostic logging and a
9
+ * sanity range check, so callers get a usable elevation regardless of
10
+ * whether the user has Cesium-ion terrain, Google Photorealistic 3D
11
+ * Tiles, or no Cesium-side terrain at all (Open-Meteo handles that).
12
+ */
13
+
14
+ import { queryTerrainElevation } from './reproject';
15
+
16
+ // Module-level cache so bridge rebuilds (georef edits, clamp toggles)
17
+ // re-use values within the session instead of re-hitting the network.
18
+ const terrainElevationCache = new Map<string, number>();
19
+
20
+ function terrainCacheKey(lat: number, lon: number): string {
21
+ // 5 decimal places ≈ 1.1m precision — plenty for site-level elevation.
22
+ return `${lat.toFixed(5)},${lon.toFixed(5)}`;
23
+ }
24
+
25
+ // Earth's plausible terrestrial elevation range. Mariana Trench ≈ −11 km
26
+ // (no buildings there) and Everest summit ≈ 8.85 km. Anything outside this
27
+ // band is depth-buffer / uninitialised garbage and must be discarded.
28
+ const ELEV_MIN = -1000;
29
+ const ELEV_MAX = 9000;
30
+
31
+ function isPlausibleElevation(h: number): boolean {
32
+ return Number.isFinite(h) && h > ELEV_MIN && h < ELEV_MAX;
33
+ }
34
+
35
+ /**
36
+ * Clear the session terrain cache. Call when switching terrain providers,
37
+ * data sources, or whenever a stale cached value would be misleading.
38
+ */
39
+ export function clearTerrainElevationCache(): void {
40
+ terrainElevationCache.clear();
41
+ }
42
+
43
+ /**
44
+ * Resolve terrain elevation at a WGS84 lat/lon.
45
+ *
46
+ * Order:
47
+ * 1. Cache (instant — re-bridge after georef edit).
48
+ * 2. globe.getHeight (sync, terrain provider — exact-zero treated as
49
+ * "no data" since the default ellipsoid provider returns 0 for every
50
+ * lat/lon).
51
+ * 3. scene.sampleHeight (sync, queries 3D Tiles + terrain — only works
52
+ * if tiles for the location are already rendered).
53
+ * 4. scene.sampleHeightMostDetailed with a bounded timeout — forces tile
54
+ * load and returns the height of the actually-rendered surface (what
55
+ * the user SEES in Google Photorealistic 3D Tiles). Tried before
56
+ * Open-Meteo because the visible-tile elevation is what models need
57
+ * to sit on; Open-Meteo's DEM ignores buildings/road surfaces.
58
+ * 5. Open-Meteo elevation API — bare-earth fallback when tiles can't be
59
+ * sampled (offline, no 3D tileset, timeout, etc.).
60
+ */
61
+ const SAMPLE_DETAILED_TIMEOUT_MS = 3500;
62
+
63
+ export async function resolveTerrainElevation(
64
+ Cesium: typeof import('cesium'),
65
+ viewer: InstanceType<typeof import('cesium').Viewer>,
66
+ lat: number,
67
+ lon: number,
68
+ ): Promise<number | null> {
69
+ const cacheKey = terrainCacheKey(lat, lon);
70
+ const cached = terrainElevationCache.get(cacheKey);
71
+ if (cached !== undefined) {
72
+ console.debug(`[TerrainElevation] cached at ${cacheKey}: ${cached.toFixed(2)}m`);
73
+ return cached;
74
+ }
75
+
76
+ const position = Cesium.Cartographic.fromDegrees(lon, lat);
77
+ const accept = (h: number, source: string, ms?: number): number => {
78
+ terrainElevationCache.set(cacheKey, h);
79
+ const t = ms !== undefined ? ` (${ms.toFixed(0)}ms)` : '';
80
+ console.debug(`[TerrainElevation] via ${source}: ${h.toFixed(2)}m at ${cacheKey}${t}`);
81
+ return h;
82
+ };
83
+ const skip = (h: unknown, source: string) => {
84
+ console.debug(`[TerrainElevation] ${source} returned implausible value ${h}; skipping`);
85
+ };
86
+
87
+ // 1. Sync globe.getHeight. The default ellipsoid provider returns 0 for
88
+ // every lat/lon, so when no real terrain provider is wired we'd lock
89
+ // in 0 and never reach the network fallbacks. Treat exact-zero from
90
+ // this source specifically as "no data" — Open-Meteo can still return
91
+ // a true 0 elsewhere in the chain for legitimate sea-level sites.
92
+ try {
93
+ const h = viewer.scene.globe.getHeight(position);
94
+ if (h !== undefined && isPlausibleElevation(h) && Math.abs(h) > 1e-3) {
95
+ return accept(h, 'globe.getHeight');
96
+ }
97
+ if (h !== undefined && !isPlausibleElevation(h)) skip(h, 'globe.getHeight');
98
+ } catch (err) {
99
+ console.warn('[TerrainElevation] globe.getHeight threw:', err);
100
+ }
101
+
102
+ // 2. Sync scene.sampleHeight — works with 3D Tiles when tiles for this
103
+ // location are already rendered.
104
+ if (viewer.scene.sampleHeightSupported) {
105
+ try {
106
+ const h = viewer.scene.sampleHeight(position);
107
+ if (h !== undefined && isPlausibleElevation(h)) {
108
+ return accept(h, 'scene.sampleHeight');
109
+ }
110
+ if (h !== undefined) skip(h, 'scene.sampleHeight');
111
+ } catch (err) {
112
+ console.warn('[TerrainElevation] scene.sampleHeight threw:', err);
113
+ }
114
+ }
115
+
116
+ // 3. Force-load the 3D-Tile tile at this location and sample the
117
+ // rendered surface. This is what Google Photorealistic 3D Tiles
118
+ // show on screen, so the model lands on the SAME surface the user
119
+ // sees — no "below the visible ground" mismatch with Open-Meteo's
120
+ // DEM. Bounded by a timeout so a slow tile fetch doesn't keep the
121
+ // bridge waiting forever; Open-Meteo runs after as a backstop.
122
+ if (viewer.scene.sampleHeightSupported) {
123
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
124
+ try {
125
+ const t0 = performance.now();
126
+ const detailed = viewer.scene.sampleHeightMostDetailed([position]);
127
+ const timeout = new Promise<null>((resolve) => {
128
+ timeoutId = setTimeout(() => resolve(null), SAMPLE_DETAILED_TIMEOUT_MS);
129
+ });
130
+ const winner = await Promise.race([detailed, timeout]);
131
+ const ms = performance.now() - t0;
132
+ if (winner !== null) {
133
+ const r0 = winner[0] as { height?: number } | undefined;
134
+ if (r0?.height !== undefined && isPlausibleElevation(r0.height)) {
135
+ return accept(r0.height, 'scene.sampleHeightMostDetailed', ms);
136
+ }
137
+ if (r0?.height !== undefined) skip(r0.height, 'scene.sampleHeightMostDetailed');
138
+ } else {
139
+ console.debug(`[TerrainElevation] sampleHeightMostDetailed timed out after ${ms.toFixed(0)}ms`);
140
+ }
141
+ } catch (err) {
142
+ console.warn('[TerrainElevation] sampleHeightMostDetailed threw:', err);
143
+ } finally {
144
+ // Cancel the timeout if the detailed sample resolved first, so the
145
+ // timer doesn't dangle and resolve to null after we've moved on.
146
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
147
+ }
148
+ }
149
+
150
+ // 4. Open-Meteo bare-earth elevation. Used as a network fallback when
151
+ // the visible-tile sample didn't resolve in time.
152
+ try {
153
+ const t0 = performance.now();
154
+ const elev = await queryTerrainElevation({ lat, lon });
155
+ const ms = performance.now() - t0;
156
+ if (elev !== null && isPlausibleElevation(elev)) {
157
+ return accept(elev, 'Open-Meteo', ms);
158
+ }
159
+ if (elev !== null) skip(elev, 'Open-Meteo');
160
+ } catch (err) {
161
+ console.warn('[TerrainElevation] Open-Meteo threw:', err);
162
+ }
163
+
164
+ console.warn(`[TerrainElevation] no source returned a plausible value at ${cacheKey}`);
165
+ return null;
166
+ }
@@ -236,7 +236,7 @@ export function createLensDataProvider(
236
236
  if (info.layers?.length) return info.layers[0].materialName;
237
237
  if (info.constituents?.length) return info.constituents[0].materialName;
238
238
  if (info.profiles?.length) return info.profiles[0].materialName;
239
- if (info.materials?.length) return info.materials[0];
239
+ if (info.materials?.length) return info.materials[0]?.name;
240
240
  return undefined;
241
241
  },
242
242
  };
@@ -139,7 +139,7 @@ function collectSelectedEntities(state: ReturnType<typeof useViewerStore.getStat
139
139
  const propertySets = (selectionKind === 'type' ? ownTypePropertySets : instancePropertySets).slice(0, 6);
140
140
  const typePropertySets = (selectionKind === 'type' ? [] : inheritedTypePropertySets).slice(0, 6);
141
141
  const quantitySets = (rawQsets ?? []).map((qset) => qset.name ?? qset.Name).filter((value): value is string => Boolean(value)).slice(0, 6);
142
- const materialName = rawMaterial?.name ?? rawMaterial?.materials?.[0];
142
+ const materialName = rawMaterial?.name ?? rawMaterial?.materials?.[0]?.name;
143
143
  const classifications = rawClassifications
144
144
  .map((classification) => classification.identification ?? classification.name ?? classification.system)
145
145
  .filter((value): value is string => Boolean(value))
@@ -0,0 +1,92 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { MemoryAccounting } from './memoryAccounting.js';
8
+
9
+ describe('MemoryAccounting', () => {
10
+ it('tracks peak JS heap and per-phase records', () => {
11
+ const acc = new MemoryAccounting();
12
+ acc.reset();
13
+ acc.setSourceBytes(50 * 1024 * 1024);
14
+ acc.recordPhase({ phase: 'upload', jsHeapBytes: 80_000_000 });
15
+ acc.recordPhase({ phase: 'parser-spawn', jsHeapBytes: 90_000_000 });
16
+ acc.recordPhase({ phase: 'parser-complete', jsHeapBytes: 120_000_000 });
17
+
18
+ const snap = acc.snapshot();
19
+ assert.equal(snap.length, 3);
20
+ assert.equal(snap[0].phase, 'upload');
21
+ assert.equal(snap[2].jsHeapBytes, 120_000_000);
22
+
23
+ const summary = acc.summary();
24
+ assert.equal(summary.peakJsHeapBytes, 120_000_000);
25
+ assert.equal(summary.sourceBytes, 50 * 1024 * 1024);
26
+ });
27
+
28
+ it('aggregates WASM heap across multiple workers using max value per worker', () => {
29
+ const acc = new MemoryAccounting();
30
+ acc.reset();
31
+ acc.recordWorkerMemory('w0', 200_000_000);
32
+ acc.recordWorkerMemory('w1', 180_000_000);
33
+ acc.recordWorkerMemory('w2', 160_000_000);
34
+ acc.recordPhase({ phase: 'geometry-complete' });
35
+
36
+ const snap = acc.snapshot();
37
+ // Sum of three workers (200 + 180 + 160) = 540 MB
38
+ assert.equal(snap[0].wasmHeapBytes, 540_000_000);
39
+ });
40
+
41
+ it('accumulates geometry bytes across batches', () => {
42
+ const acc = new MemoryAccounting();
43
+ acc.reset();
44
+ acc.addGeometryBytes(1_000_000);
45
+ acc.addGeometryBytes(2_000_000);
46
+ acc.addGeometryBytes(500_000);
47
+ acc.recordPhase({ phase: 'geometry-complete' });
48
+ assert.equal(acc.snapshot()[0].geometryBytes, 3_500_000);
49
+ });
50
+
51
+ it('computes parse/geometry overlap from phase ranges', async () => {
52
+ const acc = new MemoryAccounting();
53
+ acc.reset();
54
+ // Two phases overlap by ~10 ms (use sleeps small enough to be reliable)
55
+ acc.beginPhase('parser-worker');
56
+ await new Promise(r => setTimeout(r, 10));
57
+ acc.beginPhase('geometry');
58
+ await new Promise(r => setTimeout(r, 20));
59
+ acc.endPhase('parser-worker');
60
+ await new Promise(r => setTimeout(r, 10));
61
+ acc.endPhase('geometry');
62
+ acc.recordPhase({ phase: 'done' });
63
+
64
+ const summary = acc.summary();
65
+ assert.ok(summary.parseGeometryOverlapMs > 5, `expected overlap > 5, got ${summary.parseGeometryOverlapMs}`);
66
+ assert.ok(summary.parseGeometryOverlapMs < 200, `expected reasonable overlap, got ${summary.parseGeometryOverlapMs}`);
67
+ });
68
+
69
+ it('formatSummary produces a one-line summary', () => {
70
+ const acc = new MemoryAccounting();
71
+ acc.reset();
72
+ acc.setSourceBytes(10 * 1024 * 1024);
73
+ acc.recordPhase({ phase: 'done', jsHeapBytes: 50 * 1024 * 1024, geometryBytes: 5 * 1024 * 1024 });
74
+ const line = acc.formatSummary();
75
+ assert.match(line, /mem-summary/);
76
+ assert.match(line, /peakJs=50\.0MB/);
77
+ assert.match(line, /source=10\.0MB/);
78
+ });
79
+
80
+ it('reset() clears all state between loads', () => {
81
+ const acc = new MemoryAccounting();
82
+ acc.reset();
83
+ acc.setSourceBytes(100);
84
+ acc.recordPhase({ phase: 'first' });
85
+ acc.reset();
86
+ acc.recordPhase({ phase: 'second' });
87
+ const snap = acc.snapshot();
88
+ assert.equal(snap.length, 1);
89
+ assert.equal(snap[0].phase, 'second');
90
+ assert.equal(acc.summary().sourceBytes, 0);
91
+ });
92
+ });
@@ -0,0 +1,235 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Per-load memory accounting for the viewer.
7
+ *
8
+ * Records JS heap, WASM heap, source-buffer, geometry, and transport bytes
9
+ * across the parse + geometry pipeline so we can verify the parser-worker
10
+ * refactor doesn't double-buffer or leak. Surfaces results via console
11
+ * lines (in dev or with `?perfMem=1`) and exposes a programmatic snapshot
12
+ * API for the timing telemetry sink.
13
+ *
14
+ * The module is intentionally a singleton (one active load at a time
15
+ * matches the upload-driven viewer flow). `reset()` should be called at
16
+ * the start of each load.
17
+ */
18
+
19
+ export interface MemoryPhaseRecord {
20
+ phase: string;
21
+ /** Wall-clock ms since the load started. */
22
+ tMs: number;
23
+ /** `performance.memory.usedJSHeapSize` on the main thread (Chromium). */
24
+ jsHeapBytes?: number;
25
+ /** WASM heap (sum across all geometry workers + parser worker if reported). */
26
+ wasmHeapBytes?: number;
27
+ /** Total bytes of geometry buffers received so far. */
28
+ geometryBytes?: number;
29
+ /** SAB byte length (the file source). */
30
+ sourceBytes?: number;
31
+ /** Bytes that crossed the worker→main transport (typed arrays + maps). */
32
+ transportBytes?: number;
33
+ }
34
+
35
+ export interface MemorySummary {
36
+ peakJsHeapBytes: number;
37
+ peakWasmHeapBytes: number;
38
+ totalGeometryBytes: number;
39
+ sourceBytes: number;
40
+ transportBytes: number;
41
+ /**
42
+ * Fraction of total wall-clock during which both parser and geometry
43
+ * workers ran concurrently. Computed from phase timestamps so it
44
+ * surfaces the actual overlap delivered by the refactor.
45
+ */
46
+ parseGeometryOverlapMs: number;
47
+ totalDurationMs: number;
48
+ }
49
+
50
+ interface PhaseRange {
51
+ start: number;
52
+ end: number;
53
+ }
54
+
55
+ interface JsHeapPerf {
56
+ memory?: { usedJSHeapSize: number };
57
+ }
58
+
59
+ function readMainJsHeapBytes(): number | undefined {
60
+ if (typeof performance === 'undefined') return undefined;
61
+ const perf = performance as unknown as JsHeapPerf;
62
+ return perf.memory?.usedJSHeapSize;
63
+ }
64
+
65
+ function bytesToMB(n: number | undefined): string {
66
+ if (n === undefined || !Number.isFinite(n)) return '?';
67
+ return (n / (1024 * 1024)).toFixed(1);
68
+ }
69
+
70
+ function isLogEnabled(): boolean {
71
+ if (typeof globalThis === 'undefined') return false;
72
+ // import.meta.env.DEV is replaced at build time by Vite. The check is
73
+ // wrapped so non-Vite consumers (tests, SSR) don't blow up.
74
+ const importMeta = (globalThis as unknown as { __VITE_DEV__?: boolean }).__VITE_DEV__;
75
+ if (importMeta === true) return true;
76
+ const win = (globalThis as unknown as { location?: { search: string } }).location;
77
+ if (win?.search && win.search.includes('perfMem=1')) return true;
78
+ return false;
79
+ }
80
+
81
+ export class MemoryAccounting {
82
+ private records: MemoryPhaseRecord[] = [];
83
+ private startedAt: number | null = null;
84
+ /** Tracks per-worker latest WASM heap reading so the aggregator can sum. */
85
+ private workerWasmHeap = new Map<string, number>();
86
+ /** Cumulative geometry bytes across all batches in this load. */
87
+ private cumulativeGeometryBytes = 0;
88
+ /** SAB size for this load. */
89
+ private currentSourceBytes = 0;
90
+ /** Captured during phase markers. */
91
+ private phaseRanges = new Map<string, PhaseRange>();
92
+
93
+ /** Reset state for a new load. Call at upload start. */
94
+ reset(): void {
95
+ this.records = [];
96
+ this.startedAt = performance.now();
97
+ this.workerWasmHeap.clear();
98
+ this.cumulativeGeometryBytes = 0;
99
+ this.currentSourceBytes = 0;
100
+ this.phaseRanges.clear();
101
+ }
102
+
103
+ /** Note the file size as soon as the SAB is allocated. */
104
+ setSourceBytes(bytes: number): void {
105
+ this.currentSourceBytes = bytes;
106
+ }
107
+
108
+ /** Record the start time of a named phase (used for overlap calculations). */
109
+ beginPhase(phase: string): void {
110
+ if (this.startedAt === null) this.startedAt = performance.now();
111
+ const range = this.phaseRanges.get(phase) ?? { start: -1, end: -1 };
112
+ range.start = performance.now() - this.startedAt;
113
+ this.phaseRanges.set(phase, range);
114
+ }
115
+
116
+ /** Record the end time of a named phase. */
117
+ endPhase(phase: string): void {
118
+ const range = this.phaseRanges.get(phase);
119
+ if (!range || this.startedAt === null) return;
120
+ range.end = performance.now() - this.startedAt;
121
+ }
122
+
123
+ /** Update the cumulative WASM heap reading for a specific worker. */
124
+ recordWorkerMemory(workerKey: string, wasmHeapBytes: number): void {
125
+ if (wasmHeapBytes > 0) this.workerWasmHeap.set(workerKey, wasmHeapBytes);
126
+ }
127
+
128
+ /** Add bytes received in a geometry batch (positions + normals + indices). */
129
+ addGeometryBytes(bytes: number): void {
130
+ this.cumulativeGeometryBytes += bytes;
131
+ }
132
+
133
+ /**
134
+ * Record a snapshot for the named phase. Pulls main-thread JS heap and
135
+ * the running WASM heap sum. Optional fields override the running totals.
136
+ */
137
+ recordPhase(input: { phase: string } & Partial<Omit<MemoryPhaseRecord, 'phase' | 'tMs'>>): void {
138
+ if (this.startedAt === null) this.startedAt = performance.now();
139
+ const tMs = performance.now() - this.startedAt;
140
+
141
+ let wasmHeapBytes = 0;
142
+ for (const v of this.workerWasmHeap.values()) wasmHeapBytes += v;
143
+
144
+ const record: MemoryPhaseRecord = {
145
+ phase: input.phase,
146
+ tMs,
147
+ jsHeapBytes: input.jsHeapBytes ?? readMainJsHeapBytes(),
148
+ wasmHeapBytes: input.wasmHeapBytes ?? (wasmHeapBytes > 0 ? wasmHeapBytes : undefined),
149
+ geometryBytes: input.geometryBytes ?? (this.cumulativeGeometryBytes > 0 ? this.cumulativeGeometryBytes : undefined),
150
+ sourceBytes: input.sourceBytes ?? (this.currentSourceBytes > 0 ? this.currentSourceBytes : undefined),
151
+ transportBytes: input.transportBytes,
152
+ };
153
+ this.records.push(record);
154
+
155
+ if (isLogEnabled()) {
156
+ console.log(
157
+ `[mem] phase=${record.phase} t=${Math.round(tMs)}ms ` +
158
+ `jsHeap=${bytesToMB(record.jsHeapBytes)}MB ` +
159
+ `wasmHeap=${bytesToMB(record.wasmHeapBytes)}MB ` +
160
+ `geom=${bytesToMB(record.geometryBytes)}MB ` +
161
+ `source=${bytesToMB(record.sourceBytes)}MB ` +
162
+ (record.transportBytes !== undefined ? `transport=${bytesToMB(record.transportBytes)}MB ` : ''),
163
+ );
164
+ }
165
+ }
166
+
167
+ /** Per-phase records in insertion order. */
168
+ snapshot(): MemoryPhaseRecord[] {
169
+ return this.records.slice();
170
+ }
171
+
172
+ /** Roll-up across the entire load. */
173
+ summary(): MemorySummary {
174
+ let peakJsHeapBytes = 0;
175
+ let peakWasmHeapBytes = 0;
176
+ let totalGeometryBytes = 0;
177
+ let transportBytes = 0;
178
+ let sourceBytes = this.currentSourceBytes;
179
+
180
+ for (const r of this.records) {
181
+ if (r.jsHeapBytes && r.jsHeapBytes > peakJsHeapBytes) peakJsHeapBytes = r.jsHeapBytes;
182
+ if (r.wasmHeapBytes && r.wasmHeapBytes > peakWasmHeapBytes) peakWasmHeapBytes = r.wasmHeapBytes;
183
+ if (r.geometryBytes && r.geometryBytes > totalGeometryBytes) totalGeometryBytes = r.geometryBytes;
184
+ if (r.transportBytes) transportBytes += r.transportBytes;
185
+ if (r.sourceBytes && r.sourceBytes > sourceBytes) sourceBytes = r.sourceBytes;
186
+ }
187
+
188
+ const parser = this.phaseRanges.get('parser-worker');
189
+ const geometry = this.phaseRanges.get('geometry');
190
+ const overlap = computeOverlapMs(parser, geometry);
191
+ const totalDurationMs = this.records.length > 0
192
+ ? this.records[this.records.length - 1].tMs
193
+ : 0;
194
+
195
+ return {
196
+ peakJsHeapBytes,
197
+ peakWasmHeapBytes,
198
+ totalGeometryBytes,
199
+ sourceBytes,
200
+ transportBytes,
201
+ parseGeometryOverlapMs: overlap,
202
+ totalDurationMs,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Format a one-line summary suitable for the console / telemetry sink.
208
+ * Designed to be greppable next to the existing `[useIfc]` lines.
209
+ */
210
+ formatSummary(): string {
211
+ const s = this.summary();
212
+ return (
213
+ `[mem-summary] peakJs=${bytesToMB(s.peakJsHeapBytes)}MB ` +
214
+ `peakWasm=${bytesToMB(s.peakWasmHeapBytes)}MB ` +
215
+ `geom=${bytesToMB(s.totalGeometryBytes)}MB ` +
216
+ `source=${bytesToMB(s.sourceBytes)}MB ` +
217
+ `transport=${bytesToMB(s.transportBytes)}MB ` +
218
+ `overlap=${Math.round(s.parseGeometryOverlapMs)}ms ` +
219
+ `total=${Math.round(s.totalDurationMs)}ms`
220
+ );
221
+ }
222
+ }
223
+
224
+ function computeOverlapMs(a?: PhaseRange, b?: PhaseRange): number {
225
+ if (!a || !b) return 0;
226
+ if (a.start < 0 || b.start < 0) return 0;
227
+ const aEnd = a.end >= 0 ? a.end : Infinity;
228
+ const bEnd = b.end >= 0 ? b.end : Infinity;
229
+ const overlapStart = Math.max(a.start, b.start);
230
+ const overlapEnd = Math.min(aEnd, bEnd);
231
+ return Math.max(0, overlapEnd - overlapStart);
232
+ }
233
+
234
+ /** Process-wide singleton used by the upload pipeline. */
235
+ export const memoryAccounting = new MemoryAccounting();
@@ -100,7 +100,7 @@ export function mergeAttributeMutations(
100
100
  const mutations = mutationView.getAttributeMutationsForEntity(expressId);
101
101
  if (mutations.length === 0) return baseAttributes;
102
102
 
103
- const merged = new Map<string, string>();
103
+ const merged = new Map<string, string | number | boolean>();
104
104
  for (const attr of baseAttributes) {
105
105
  merged.set(attr.name, attr.value);
106
106
  }
@@ -26,8 +26,17 @@ export const SECTION_PLANE_DEFAULTS = {
26
26
  AXIS: 'down' as const,
27
27
  /** Default section plane position (percentage of model bounds) */
28
28
  POSITION: 50,
29
- /** Default enabled state */
30
- ENABLED: true,
29
+ /**
30
+ * Default enabled state.
31
+ *
32
+ * MUST be `false`: opening the section tool (button or `x` shortcut)
33
+ * should leave the model uncut and arm pick mode instead — the cut
34
+ * appears only after the user clicks a face (or moves the slider /
35
+ * picks an axis). With `enabled: true` here the user saw a Down cut
36
+ * appear immediately on tool open even though the panel's mount
37
+ * effect was about to arm pick mode (issue #243 follow-up).
38
+ */
39
+ ENABLED: false,
31
40
  /** Default flipped state */
32
41
  FLIPPED: false,
33
42
  /** Default: render filled/hatched cap surfaces at the cut */
@@ -77,6 +86,27 @@ function getInitialTheme(): 'light' | 'dark' | 'colorful' {
77
86
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
78
87
  }
79
88
 
89
+ /**
90
+ * localStorage key for the "Merge Multilayer Walls" load-time toggle
91
+ * (issue #540). Reading the same key both here and on application
92
+ * boot keeps the user's choice sticky between sessions.
93
+ */
94
+ export const MERGE_LAYERS_STORAGE_KEY = 'ifc-lite-merge-layers';
95
+
96
+ /**
97
+ * Resolve the initial value of the merge-layers toggle from
98
+ * localStorage. Default `false` matches the IFC-Lite WASM default
99
+ * — toggling the UI without ever loading a model is a no-op.
100
+ */
101
+ function getInitialMergeLayers(): boolean {
102
+ if (typeof window === 'undefined') return false;
103
+ try {
104
+ return localStorage.getItem(MERGE_LAYERS_STORAGE_KEY) === 'true';
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
80
110
  export const UI_DEFAULTS = {
81
111
  /** Default active tool */
82
112
  ACTIVE_TOOL: 'select',
@@ -104,6 +134,13 @@ export const UI_DEFAULTS = {
104
134
  SEPARATION_LINES_INTENSITY: 0.38,
105
135
  /** Separation-line radius in pixels */
106
136
  SEPARATION_LINES_RADIUS: 1.0,
137
+ /**
138
+ * Issue #540: load-time toggle that asks the WASM geometry engine
139
+ * to merge Revit-style multilayer walls into a single solid. Read
140
+ * from localStorage on boot so the user's preference survives
141
+ * reloads. Default `false` keeps existing per-layer rendering.
142
+ */
143
+ MERGE_LAYERS: getInitialMergeLayers(),
107
144
  } as const;
108
145
 
109
146
  // ============================================================================
@@ -19,6 +19,8 @@ import { createUISlice, type UISlice } from './slices/uiSlice.js';
19
19
  import { createHoverSlice, type HoverSlice } from './slices/hoverSlice.js';
20
20
  import { createCameraSlice, type CameraSlice } from './slices/cameraSlice.js';
21
21
  import { createSectionSlice, type SectionSlice } from './slices/sectionSlice.js';
22
+ export { customPlaneCenter, loadLastSectionMode } from './slices/sectionSlice.js';
23
+ export type { LastSectionMode } from './slices/sectionSlice.js';
22
24
  import { createMeasurementSlice, type MeasurementSlice } from './slices/measurementSlice.js';
23
25
  import { createDataSlice, type DataSlice } from './slices/dataSlice.js';
24
26
  import { createModelSlice, type ModelSlice } from './slices/modelSlice.js';
@@ -269,7 +271,10 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
269
271
  cesiumAvailable: false,
270
272
  cesiumEnabled: false,
271
273
  cesiumTerrainHeight: null,
272
- cesiumTerrainClamp: false,
274
+ // Default the clamp toggle ON so models authored at sea-level
275
+ // OrthogonalHeight don't load buried below the 3D-tiles terrain on
276
+ // first activation. Users can still uncheck it manually.
277
+ cesiumTerrainClamp: true,
273
278
  cesiumSourceModelId: null,
274
279
  cesiumTerrainClipY: null,
275
280
  cesiumGlbLoaded: false,
@@ -101,7 +101,7 @@ export const createCesiumSlice: StateCreator<CesiumSlice, [], [], CesiumSlice> =
101
101
  cesiumDataSource: loadDataSource(),
102
102
  cesiumIonToken: resolveIonToken(),
103
103
  cesiumTerrainEnabled: true,
104
- cesiumTerrainClamp: false,
104
+ cesiumTerrainClamp: true,
105
105
  cesiumTerrainHeight: null,
106
106
  cesiumSourceModelId: null,
107
107
  cesiumTerrainClipY: null,