@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.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- 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
|
+
}
|
package/src/lib/lens/adapter.ts
CHANGED
|
@@ -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
|
}
|
package/src/store/constants.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
|
|
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
|
// ============================================================================
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
104
|
+
cesiumTerrainClamp: true,
|
|
105
105
|
cesiumTerrainHeight: null,
|
|
106
106
|
cesiumSourceModelId: null,
|
|
107
107
|
cesiumTerrainClipY: null,
|