@ifc-lite/viewer 1.26.0 → 1.28.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 (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -2,16 +2,11 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { IfcParser, parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
6
- import { WorkerParser } from '@ifc-lite/parser/browser';
7
- import { GeometryProcessor, GeometryQuality, type CoordinateInfo, type DynamicBatchConfig, type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
5
+ import { parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
6
+ import { type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
8
7
  import { loadGLBToMeshData } from '@ifc-lite/cache';
9
8
  import type { SchemaVersion } from '../../store/types.js';
10
- import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
11
- import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
12
- import { watchedGeometryStream } from './watchedGeometryStream.js';
13
-
14
- type RgbaColor = [number, number, number, number];
9
+ import { calculateMeshBounds, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
15
10
 
16
11
  interface RawIfcxMesh {
17
12
  expressId?: number;
@@ -31,40 +26,6 @@ export interface ViewerModelPayload {
31
26
  schemaVersion: SchemaVersion;
32
27
  }
33
28
 
34
- export interface StepBatchEvent {
35
- batchIndex: number;
36
- estimatedTotal: number;
37
- totalSoFar: number;
38
- meshes: MeshData[];
39
- coordinateInfo?: CoordinateInfo | null;
40
- }
41
-
42
- export interface StepRtcOffsetEvent {
43
- rtcOffset: { x: number; y: number; z: number };
44
- }
45
-
46
- export interface StepBufferIngestOptions {
47
- fileName: string;
48
- buffer: ArrayBuffer;
49
- fileSizeMB: number;
50
- getDynamicBatchSize: (fileSizeMB: number) => number | DynamicBatchConfig;
51
- onProgress?: (progress: { phase: string; percent: number }) => void;
52
- onBatch?: (event: StepBatchEvent) => void;
53
- onColorUpdate?: (updates: Map<number, RgbaColor>) => void;
54
- onSpatialReady?: (dataStore: IfcDataStore) => void;
55
- onRtcOffset?: (event: StepRtcOffsetEvent) => void;
56
- shouldAbort?: () => boolean;
57
- /** Shared RTC offset from first federated model (IFC Z-up coords).
58
- * When set, this model uses the same RTC as the first model instead of
59
- * computing its own, ensuring all models share the same coordinate space. */
60
- sharedRtcOffset?: { x: number; y: number; z: number };
61
- }
62
-
63
- export interface StepBufferIngestResult extends ViewerModelPayload {
64
- allMeshes: MeshData[];
65
- cumulativeColorUpdates: Map<number, RgbaColor>;
66
- }
67
-
68
29
  export function convertIfcxMeshes(rawMeshes: RawIfcxMesh[]): MeshData[] {
69
30
  return rawMeshes.map((mesh) => {
70
31
  const positions = mesh.positions instanceof Float32Array ? mesh.positions : new Float32Array(mesh.positions || []);
@@ -99,16 +60,6 @@ export function createMinimalGlbDataStore(buffer: ArrayBuffer, meshCount: number
99
60
  } as unknown as IfcDataStore;
100
61
  }
101
62
 
102
- export function normalizeDataStoreStoreys(dataStore: IfcDataStore): IfcDataStore {
103
- if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
104
- const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
105
- for (const [storeyId, height] of calculatedHeights) {
106
- dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
107
- }
108
- }
109
- return dataStore;
110
- }
111
-
112
63
  export function getMaxExpressId(dataStore: IfcDataStore, meshes: MeshData[]): number {
113
64
  const maxExpressIdFromMeshes = meshes.reduce((max, mesh) => Math.max(max, mesh.expressId), 0);
114
65
  let maxExpressIdFromEntities = 0;
@@ -213,163 +164,3 @@ export async function parseGlbViewerModel(buffer: ArrayBuffer): Promise<ViewerMo
213
164
  schemaVersion: 'IFC4',
214
165
  };
215
166
  }
216
-
217
- export async function parseStepBufferViewerModel(options: StepBufferIngestOptions): Promise<StepBufferIngestResult> {
218
- const geometryProcessor = new GeometryProcessor({ quality: GeometryQuality.Balanced });
219
- await geometryProcessor.init();
220
-
221
- const parser = new IfcParser();
222
- const wasmApi = geometryProcessor.getApi();
223
- const canShareSource = WorkerParser.isSupported();
224
- const sharedSource = canShareSource ? new SharedArrayBuffer(options.buffer.byteLength) : null;
225
- if (sharedSource) {
226
- new Uint8Array(sharedSource).set(new Uint8Array(options.buffer));
227
- }
228
- const geometryWillEmitEntityIndex =
229
- sharedSource !== null
230
- && options.fileSizeMB >= 2
231
- && typeof Worker !== 'undefined'
232
- && typeof navigator !== 'undefined'
233
- && (navigator.hardwareConcurrency ?? 1) > 1;
234
- let workerParser: WorkerParser | null = null;
235
- const allMeshes: MeshData[] = [];
236
- const cumulativeColorUpdates = new Map<number, RgbaColor>();
237
- let finalCoordinateInfo: CoordinateInfo | null = null;
238
- let batchIndex = 0;
239
- let estimatedTotal = 0;
240
- let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
241
-
242
- const handleSpatialReady = (partialStore: IfcDataStore) => {
243
- if (options.shouldAbort?.()) {
244
- return;
245
- }
246
- options.onSpatialReady?.(normalizeDataStoreStoreys(partialStore));
247
- };
248
- const dataStorePromise = sharedSource
249
- ? (() => {
250
- workerParser = new WorkerParser();
251
- return workerParser.parseColumnar(sharedSource, {
252
- waitForEntityIndex: geometryWillEmitEntityIndex,
253
- onSpatialReady: handleSpatialReady,
254
- }).catch((error) => {
255
- console.warn('[viewerModelIngest] Parser worker failed, falling back to main-thread parse:', error);
256
- return parser.parseColumnar(options.buffer, {
257
- wasmApi: wasmApi ?? undefined,
258
- onSpatialReady: handleSpatialReady,
259
- });
260
- });
261
- })()
262
- : parser.parseColumnar(options.buffer, {
263
- wasmApi: wasmApi ?? undefined,
264
- onSpatialReady: handleSpatialReady,
265
- });
266
-
267
- const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(options.buffer);
268
- const geometryStream = geometryProcessor.processAdaptive(geometryView, {
269
- sizeThreshold: 2 * 1024 * 1024,
270
- batchSize: options.getDynamicBatchSize(options.fileSizeMB),
271
- sharedRtcOffset: options.sharedRtcOffset,
272
- existingSab: sharedSource ?? undefined,
273
- onEntityIndex: (ids, starts, lengths) => {
274
- workerParser?.setEntityIndex(ids, starts, lengths);
275
- },
276
- });
277
- let lastTotalMeshes = 0;
278
- // The federated/added-model path was missing the size-aware stream watchdog
279
- // the single-model loader has, so a geometry worker that failed to spawn would
280
- // hang the load forever on "Processing geometry (N meshes)" instead of
281
- // surfacing a recoverable error. watchedGeometryStream re-yields each event
282
- // under that watchdog and bounds iterator teardown on every exit path.
283
- try {
284
- for await (const event of watchedGeometryStream(geometryStream, {
285
- fileName: options.fileName,
286
- fileSizeMB: options.fileSizeMB,
287
- shouldAbort: options.shouldAbort,
288
- getBatchCount: () => batchIndex,
289
- getLastTotalMeshes: () => lastTotalMeshes,
290
- })) {
291
- switch (event.type) {
292
- case 'start':
293
- estimatedTotal = event.totalEstimate;
294
- break;
295
- case 'colorUpdate':
296
- for (const [expressId, color] of event.updates) {
297
- cumulativeColorUpdates.set(expressId, color);
298
- }
299
- options.onColorUpdate?.(event.updates);
300
- break;
301
- case 'rtcOffset':
302
- if (event.hasRtc) {
303
- capturedRtcOffset = event.rtcOffset;
304
- options.onRtcOffset?.({ rtcOffset: event.rtcOffset });
305
- }
306
- break;
307
- case 'batch':
308
- batchIndex += 1;
309
- for (let i = 0; i < event.meshes.length; i++) {
310
- allMeshes.push(event.meshes[i]);
311
- }
312
- finalCoordinateInfo = event.coordinateInfo ?? null;
313
- lastTotalMeshes = event.totalSoFar;
314
- options.onBatch?.({
315
- batchIndex,
316
- estimatedTotal,
317
- totalSoFar: event.totalSoFar,
318
- meshes: event.meshes,
319
- coordinateInfo: event.coordinateInfo ?? null,
320
- });
321
- options.onProgress?.({
322
- phase: `Processing geometry (${event.totalSoFar} meshes)`,
323
- percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
324
- });
325
- break;
326
- case 'complete':
327
- finalCoordinateInfo = event.coordinateInfo ?? null;
328
- break;
329
- }
330
- }
331
- } catch (err) {
332
- // Watchdog stall (or other stream error): the parser worker may be
333
- // blocked in `waitForEntityIndex`, which only the geometry pre-pass would
334
- // unblock. Terminate it here so it doesn't leak — the normal path below
335
- // still awaits it via resolveDataStoreOrAbort. watchedGeometryStream's
336
- // finally has already bounded teardown of the geometry iterator itself.
337
- workerParser?.terminate();
338
- throw err;
339
- }
340
-
341
- // If the load was cancelled, don't await dataStorePromise: a worker parse
342
- // started with waitForEntityIndex blocks until the geometry pre-pass hands
343
- // over the entity index, which never happens once the geometry loop has been
344
- // aborted above. resolveDataStoreOrAbort terminates the worker and throws an
345
- // AbortError instead of hanging here.
346
- const dataStore = normalizeDataStoreStoreys(
347
- await resolveDataStoreOrAbort(dataStorePromise, {
348
- aborted: options.shouldAbort?.() ?? false,
349
- terminate: () => workerParser?.terminate(),
350
- }),
351
- );
352
- if (!finalCoordinateInfo) {
353
- finalCoordinateInfo = createCoordinateInfo(calculateMeshBounds(allMeshes).bounds);
354
- }
355
- if (capturedRtcOffset) {
356
- finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
357
- }
358
-
359
- return {
360
- dataStore,
361
- geometryResult: {
362
- meshes: allMeshes,
363
- totalVertices: allMeshes.reduce((sum, mesh) => sum + mesh.positions.length / 3, 0),
364
- totalTriangles: allMeshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
365
- coordinateInfo: finalCoordinateInfo,
366
- },
367
- schemaVersion: dataStore.schemaVersion === 'IFC4X3'
368
- ? 'IFC4X3'
369
- : dataStore.schemaVersion === 'IFC4'
370
- ? 'IFC4'
371
- : 'IFC2X3',
372
- allMeshes,
373
- cumulativeColorUpdates,
374
- };
375
- }
@@ -0,0 +1,35 @@
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 type { IfcDataStore } from '@ifc-lite/parser';
6
+
7
+ // Per-source-object memo so the full-content hash runs only once per loaded
8
+ // buffer (the same Uint8Array instance is reused across re-renders).
9
+ const SOURCE_KEY_CACHE = new WeakMap<Uint8Array, string>();
10
+
11
+ /**
12
+ * Stable per-source cache key — a **full-content** FNV-1a hash (not sampled
13
+ * byte windows), so two distinct IFC binaries can't collide and reuse the wrong
14
+ * cache entry (which would render another model's overlay). The O(n) hash is
15
+ * memoised per source object via a WeakMap, so it runs once per loaded buffer.
16
+ *
17
+ * Shared by the per-source overlay hooks (alignment + grid lines) so they stay
18
+ * in lockstep — see #967 review (CodeRabbit aliasing finding applied to both).
19
+ */
20
+ export function sourceKey(store: IfcDataStore | null | undefined): string | null {
21
+ const source = store?.source;
22
+ if (!source || source.byteLength === 0) return null;
23
+
24
+ const cached = SOURCE_KEY_CACHE.get(source);
25
+ if (cached) return cached;
26
+
27
+ let h = 0x811c9dc5;
28
+ for (let i = 0; i < source.length; i++) {
29
+ h ^= source[i];
30
+ h = Math.imul(h, 0x01000193);
31
+ }
32
+ const key = `b${source.byteLength}-${(h >>> 0).toString(16)}`;
33
+ SOURCE_KEY_CACHE.set(source, key);
34
+ return key;
35
+ }
@@ -23,35 +23,10 @@ import { GeometryProcessor } from '@ifc-lite/geometry';
23
23
  import { useViewerStore } from '@/store';
24
24
  import { useShallow } from 'zustand/react/shallow';
25
25
  import type { IfcDataStore } from '@ifc-lite/parser';
26
+ import { sourceKey } from './source-key.js';
26
27
 
27
28
  const EMPTY_F32 = new Float32Array(0);
28
29
 
29
- /**
30
- * Stable per-source cache key — FNV-1a over head/mid/tail byte windows folded
31
- * with the length, so two structurally distinct sources can't alias even when
32
- * they share an exact byte length (a real risk in federated views). Identical
33
- * scheme to `useSymbolicAnnotations`' `sourceKey`.
34
- */
35
- function sourceKey(store: IfcDataStore | null | undefined): string | null {
36
- const source = store?.source;
37
- if (!source || source.byteLength === 0) return null;
38
- const len = source.byteLength;
39
- const sampleLen = Math.min(32, len);
40
- const head = source.subarray(0, sampleLen);
41
- const tail = source.subarray(len - sampleLen, len);
42
- const midOffset = Math.max(0, Math.floor(len / 2) - Math.floor(sampleLen / 2));
43
- const mid = source.subarray(midOffset, Math.min(midOffset + sampleLen, len));
44
- const hashOne = (bytes: Uint8Array): string => {
45
- let h = 0x811c9dc5;
46
- for (let i = 0; i < bytes.length; i++) {
47
- h ^= bytes[i];
48
- h = Math.imul(h, 0x01000193);
49
- }
50
- return (h >>> 0).toString(16);
51
- };
52
- return `b${len}-${hashOne(head)}-${hashOne(mid)}-${hashOne(tail)}`;
53
- }
54
-
55
30
  // ─── Shared parse cache ──────────────────────────────────────────────────────
56
31
  // One WASM walk per model source; cached so re-renders (and federated views
57
32
  // that share a source) don't re-parse.
Binary file
@@ -0,0 +1,119 @@
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
+ * Applies the model-comparison result to the 3D viewport (issue #924):
7
+ * per-entity colour overrides (added/modified/deleted/unchanged) plus a hidden
8
+ * set that suppresses the duplicated base-model geometry.
9
+ *
10
+ * Colours go through the same single overlay channel `useLens` uses
11
+ * (`setPendingColorUpdates` → `scene.setColorOverrides` — overlay batches drawn
12
+ * over the original geometry, instant to clear). The hidden set is reconciled
13
+ * with ownership tracking lifted from `useOverlayCompositor`: we remember which
14
+ * ids we hid and whether the user had already hidden them, so teardown only
15
+ * un-hides what we actually contributed.
16
+ *
17
+ * Mounted inside `ComparePanel`, so closing the panel (or clearing the result)
18
+ * restores the scene automatically.
19
+ */
20
+
21
+ import { useEffect, useRef } from 'react';
22
+ import { useViewerStore } from '@/store';
23
+ import { buildCompareOverlay } from '@/lib/compare/overlay';
24
+
25
+ type ViewerStore = ReturnType<typeof useViewerStore.getState>;
26
+
27
+ /** Hand the shared colour channel back to its prior owner when compare lets go:
28
+ * if a lens is still active, restore its overlay; otherwise clear. Prevents the
29
+ * compare panel from blanking lens colours on close (an active lens only has
30
+ * its panel hidden, not deactivated). */
31
+ function handBackColorChannel(store: ViewerStore): void {
32
+ const lensColors = store.lensAppliedColors;
33
+ store.setPendingColorUpdates(lensColors && lensColors.size > 0 ? new Map(lensColors) : new Map());
34
+ }
35
+
36
+ /** Re-show ids we hid that the user wasn't already hiding, then drop ownership. */
37
+ function restoreOwnedHidden(owned: Map<number, boolean>, store: ViewerStore): void {
38
+ if (owned.size === 0) return;
39
+ const toShow: number[] = [];
40
+ for (const [id, wasHidden] of owned) {
41
+ if (wasHidden === false) toShow.push(id);
42
+ }
43
+ if (toShow.length > 0) store.showEntities(toShow);
44
+ owned.clear();
45
+ }
46
+
47
+ /** Reconcile the desired hidden set against what we previously contributed. */
48
+ function reconcileHidden(
49
+ nextHidden: Set<number>,
50
+ ownedRef: { current: Map<number, boolean> },
51
+ store: ViewerStore,
52
+ ): void {
53
+ const prev = ownedRef.current;
54
+ const currentlyHidden = store.hiddenEntities ?? new Set<number>();
55
+
56
+ const toShow: number[] = [];
57
+ for (const [id, wasHidden] of prev) {
58
+ if (!nextHidden.has(id) && wasHidden === false) toShow.push(id);
59
+ }
60
+
61
+ const next = new Map<number, boolean>();
62
+ const toHide: number[] = [];
63
+ for (const id of nextHidden) {
64
+ if (prev.has(id)) {
65
+ next.set(id, prev.get(id)!);
66
+ } else {
67
+ const wasHidden = currentlyHidden.has(id);
68
+ next.set(id, wasHidden);
69
+ if (!wasHidden) toHide.push(id);
70
+ }
71
+ }
72
+
73
+ if (toShow.length > 0) store.showEntities(toShow);
74
+ if (toHide.length > 0) store.hideEntities(toHide);
75
+ ownedRef.current = next;
76
+ }
77
+
78
+ export function useCompareOverlay(): void {
79
+ // global id → "was the user already hiding this when we took over?"
80
+ const ownedHiddenRef = useRef<Map<number, boolean>>(new Map());
81
+ const colorActiveRef = useRef(false);
82
+
83
+ const compareResult = useViewerStore((s) => s.compareResult);
84
+ const showUnchanged = useViewerStore((s) => s.compareShowUnchanged);
85
+
86
+ useEffect(() => {
87
+ const store = useViewerStore.getState();
88
+
89
+ if (!compareResult) {
90
+ if (colorActiveRef.current) {
91
+ handBackColorChannel(store);
92
+ colorActiveRef.current = false;
93
+ }
94
+ restoreOwnedHidden(ownedHiddenRef.current, store);
95
+ return;
96
+ }
97
+
98
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(compareResult.diff, showUnchanged);
99
+ reconcileHidden(hiddenIds, ownedHiddenRef, store);
100
+ // Empty map signals the consumer to clear overlays (lens contract).
101
+ store.setPendingColorUpdates(colorOverrides);
102
+ // We own the colour channel whenever a comparison is shown — even an empty
103
+ // override map clobbered any prior lens colours — so teardown must hand the
104
+ // channel back regardless of the map size (don't gate on `.size`).
105
+ colorActiveRef.current = true;
106
+ }, [compareResult, showUnchanged]);
107
+
108
+ // Teardown on unmount (panel closed) — restore the scene we touched.
109
+ useEffect(() => {
110
+ return () => {
111
+ const store = useViewerStore.getState();
112
+ if (colorActiveRef.current) {
113
+ handBackColorChannel(store);
114
+ colorActiveRef.current = false;
115
+ }
116
+ restoreOwnedHidden(ownedHiddenRef.current, store);
117
+ };
118
+ }, []);
119
+ }
@@ -179,18 +179,29 @@ export function useDrawingGeneration({
179
179
  try {
180
180
  await processor.init();
181
181
 
182
+ // SymbolicRepresentationCollection and each getPolyline/getCircle
183
+ // item are wasm-bindgen handles owning WASM memory — free them
184
+ // deterministically (AGENTS.md §7). Leaking them to GC lets the
185
+ // FinalizationRegistry free them later against an already-grown/
186
+ // reused shared dlmalloc heap, corrupting the allocator free-list.
182
187
  const symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source);
183
188
  // For single-model (legacy) mode, model index is always 0
184
189
  // Multi-model symbolic parsing would require iterating over each model separately
185
190
  const symbolicModelIndex = 0;
186
191
 
187
- if (symbolicCollection && !symbolicCollection.isEmpty) {
192
+ if (symbolicCollection) {
193
+ try {
194
+ if (!symbolicCollection.isEmpty) {
188
195
  // Process polylines
189
196
  for (let i = 0; i < symbolicCollection.polylineCount; i++) {
190
197
  const poly = symbolicCollection.getPolyline(i);
191
198
  if (!poly) continue;
199
+ try {
192
200
 
193
201
  entitiesWithSymbols.add(poly.expressId);
202
+ // poly.points is consumed synchronously within this iteration
203
+ // (centroid sum + segment pushes read scalar values out of it);
204
+ // the array itself is never stored, so no copy is needed.
194
205
  const points = poly.points;
195
206
  const pointCount = poly.pointCount;
196
207
  // WASM exposes `worldY` on every symbolic primitive — the
@@ -249,12 +260,16 @@ export function useDrawingGeneration({
249
260
  worldZ: polyWorldZ,
250
261
  });
251
262
  }
263
+ } finally {
264
+ poly.free();
265
+ }
252
266
  }
253
267
 
254
268
  // Process circles/arcs
255
269
  for (let i = 0; i < symbolicCollection.circleCount; i++) {
256
270
  const circle = symbolicCollection.getCircle(i);
257
271
  if (!circle) continue;
272
+ try {
258
273
 
259
274
  entitiesWithSymbols.add(circle.expressId);
260
275
  const numSegments = circle.isFullCircle ? 32 : 16;
@@ -293,6 +308,13 @@ export function useDrawingGeneration({
293
308
  worldZ: circleWorldZ,
294
309
  });
295
310
  }
311
+ } finally {
312
+ circle.free();
313
+ }
314
+ }
315
+ }
316
+ } finally {
317
+ symbolicCollection.free();
296
318
  }
297
319
  }
298
320
  } finally {
@@ -0,0 +1,140 @@
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
+ * Extraction of IfcGrid / IfcGridAxis centerlines for the 3D viewport
7
+ * (issue #967, follow-up to #945/#966).
8
+ *
9
+ * IfcGrid carries its axes as IfcGridAxis curves (not a `Representation`), so
10
+ * they never produce a mesh in the streaming batch mesher. The WASM
11
+ * `parseGridLines` API resolves every axis through the same placement +
12
+ * unit-scale + RTC pipeline as the meshes and returns a flat 3D line-list in
13
+ * renderer Y-up world space, which we feed to `renderer.uploadGridLines3D`.
14
+ * This mirrors `useAlignmentLines3D`.
15
+ *
16
+ * Unlike alignment (always-on), grids are gated by the `ifcGrid` type-visibility
17
+ * toggle — but the parse itself is unconditional and cached; the Viewport only
18
+ * uploads/clears based on the toggle.
19
+ */
20
+
21
+ import { useEffect, useMemo, useState } from 'react';
22
+ import { GeometryProcessor } from '@ifc-lite/geometry';
23
+ import { useViewerStore } from '@/store';
24
+ import { useShallow } from 'zustand/react/shallow';
25
+ import type { IfcDataStore } from '@ifc-lite/parser';
26
+ import { sourceKey } from './source-key.js';
27
+
28
+ const EMPTY_F32 = new Float32Array(0);
29
+
30
+ // ─── Shared parse cache ──────────────────────────────────────────────────────
31
+ // One WASM walk per model source; cached so re-renders (and federated views
32
+ // that share a source) don't re-parse.
33
+ const PARSE_CACHE = new Map<string, Float32Array>();
34
+ const PARSE_INFLIGHT = new Map<string, Promise<void>>();
35
+
36
+ type CacheListener = () => void;
37
+ const CACHE_LISTENERS = new Set<CacheListener>();
38
+ function notifyCacheChange(): void {
39
+ for (const fn of CACHE_LISTENERS) fn();
40
+ }
41
+
42
+ async function parseGridLinesFor(store: IfcDataStore): Promise<Float32Array> {
43
+ const source = store.source;
44
+ if (!source || source.byteLength === 0) return EMPTY_F32;
45
+ const processor = new GeometryProcessor();
46
+ try {
47
+ await processor.init();
48
+ const verts = processor.parseGridLines(source);
49
+ return verts && verts.length > 0 ? verts : EMPTY_F32;
50
+ } finally {
51
+ processor.dispose();
52
+ }
53
+ }
54
+
55
+ function ensureParseFor(stores: IfcDataStore[]): void {
56
+ for (const store of stores) {
57
+ const key = sourceKey(store);
58
+ if (!key) continue;
59
+ if (PARSE_CACHE.has(key)) continue;
60
+ if (PARSE_INFLIGHT.has(key)) continue;
61
+
62
+ const promise = (async () => {
63
+ try {
64
+ const verts = await parseGridLinesFor(store);
65
+ PARSE_CACHE.set(key, verts);
66
+ notifyCacheChange();
67
+ } catch (error) {
68
+ // Cache empty on failure so we don't retry a doomed parse every tick.
69
+ // eslint-disable-next-line no-console
70
+ console.warn('[useGridLines3D] parse failed:', error);
71
+ PARSE_CACHE.set(key, EMPTY_F32);
72
+ notifyCacheChange();
73
+ } finally {
74
+ PARSE_INFLIGHT.delete(key);
75
+ }
76
+ })();
77
+ PARSE_INFLIGHT.set(key, promise);
78
+ }
79
+ }
80
+
81
+ /** Read the active store set from the viewer store. Federation-aware. */
82
+ function useActiveStores(): IfcDataStore[] {
83
+ const { models, ifcDataStore } = useViewerStore(
84
+ useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
85
+ );
86
+ return useMemo(() => {
87
+ const out: IfcDataStore[] = [];
88
+ if (models.size > 0) {
89
+ for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
90
+ } else if (ifcDataStore) {
91
+ out.push(ifcDataStore);
92
+ }
93
+ return out;
94
+ }, [models, ifcDataStore]);
95
+ }
96
+
97
+ /**
98
+ * Sample every loaded model's IfcGridAxis lines into a single flat
99
+ * `[x0,y0,z0, x1,y1,z1, …]` line-list in renderer world space (Y-up,
100
+ * RTC-subtracted, metres). Returns a stable empty array when no model carries a
101
+ * grid. Parsing is unconditional + cached; the Viewport gates rendering on the
102
+ * `ifcGrid` type-visibility toggle.
103
+ */
104
+ export function useGridLines3D(): Float32Array {
105
+ const stores = useActiveStores();
106
+ const [version, setVersion] = useState(0);
107
+
108
+ useEffect(() => {
109
+ ensureParseFor(stores);
110
+ const listener: CacheListener = () => setVersion((v) => v + 1);
111
+ CACHE_LISTENERS.add(listener);
112
+ return () => {
113
+ CACHE_LISTENERS.delete(listener);
114
+ };
115
+ }, [stores]);
116
+
117
+ return useMemo(() => {
118
+ void version; // depend on parse-completion ticks
119
+ const arrays: Float32Array[] = [];
120
+ let total = 0;
121
+ for (const store of stores) {
122
+ const key = sourceKey(store);
123
+ if (!key) continue;
124
+ const cached = PARSE_CACHE.get(key);
125
+ if (cached && cached.length > 0) {
126
+ arrays.push(cached);
127
+ total += cached.length;
128
+ }
129
+ }
130
+ if (total === 0) return EMPTY_F32;
131
+ if (arrays.length === 1) return arrays[0];
132
+ const merged = new Float32Array(total);
133
+ let offset = 0;
134
+ for (const a of arrays) {
135
+ merged.set(a, offset);
136
+ offset += a.length;
137
+ }
138
+ return merged;
139
+ }, [stores, version]);
140
+ }
@@ -82,7 +82,7 @@ export function useIfc() {
82
82
  findModelForEntity,
83
83
  resolveGlobalId,
84
84
  realignFederation,
85
- } = useIfcFederation();
85
+ } = useIfcFederation(loadFile);
86
86
 
87
87
  // Memoize query to prevent recreation on every render
88
88
  // For single-model backward compatibility