@ifc-lite/viewer 1.27.0 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.turbo/turbo-build.log +35 -42
  2. package/CHANGELOG.md +74 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
  5. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  6. package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
  7. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
  10. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  14. package/dist/assets/index-E9wB0zWt.css +1 -0
  15. package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
  24. package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
  26. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
  28. package/dist/assets/server-client-DVZ2huNS.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/App.tsx +1 -3
  35. package/src/components/mcp/playground-dispatcher.ts +3 -0
  36. package/src/components/mcp/playground-files.ts +33 -1
  37. package/src/components/viewer/BCFPanel.tsx +1 -16
  38. package/src/components/viewer/ChatPanel.tsx +11 -46
  39. package/src/components/viewer/CommandPalette.tsx +6 -1
  40. package/src/components/viewer/ComparePanel.tsx +420 -0
  41. package/src/components/viewer/HierarchyPanel.tsx +48 -183
  42. package/src/components/viewer/IDSPanel.tsx +1 -26
  43. package/src/components/viewer/MainToolbar.tsx +94 -187
  44. package/src/components/viewer/MobileToolbar.tsx +1 -9
  45. package/src/components/viewer/PropertiesPanel.tsx +98 -127
  46. package/src/components/viewer/ScriptPanel.tsx +8 -34
  47. package/src/components/viewer/Section2DPanel.tsx +32 -1
  48. package/src/components/viewer/ViewerLayout.tsx +5 -2
  49. package/src/components/viewer/Viewport.tsx +3 -0
  50. package/src/components/viewer/ViewportContainer.tsx +24 -42
  51. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  52. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  53. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  54. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  55. package/src/components/viewer/hierarchy/types.ts +1 -0
  56. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  57. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  58. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  59. package/src/hooks/federationLoadGate.test.ts +12 -2
  60. package/src/hooks/federationLoadGate.ts +9 -2
  61. package/src/hooks/ingest/federationAlign.ts +488 -0
  62. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  63. package/src/hooks/useCompare.ts +0 -0
  64. package/src/hooks/useCompareOverlay.ts +119 -0
  65. package/src/hooks/useDrawingGeneration.ts +234 -14
  66. package/src/hooks/useIfc.ts +1 -1
  67. package/src/hooks/useIfcCache.ts +100 -24
  68. package/src/hooks/useIfcFederation.ts +42 -811
  69. package/src/hooks/useIfcLoader.ts +349 -1517
  70. package/src/hooks/useIfcServer.ts +3 -0
  71. package/src/hooks/useLens.ts +5 -1
  72. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  73. package/src/lib/compare/buildFingerprints.ts +173 -0
  74. package/src/lib/compare/describeChange.ts +0 -0
  75. package/src/lib/compare/geometricData.test.ts +54 -0
  76. package/src/lib/compare/geometricData.ts +37 -0
  77. package/src/lib/compare/overlay.test.ts +99 -0
  78. package/src/lib/compare/overlay.ts +91 -0
  79. package/src/lib/geo/cesium-placement.ts +1 -1
  80. package/src/lib/geo/reproject.ts +4 -1
  81. package/src/lib/llm/script-edit-ops.ts +23 -0
  82. package/src/lib/llm/stream-client.ts +8 -1
  83. package/src/lib/search/result-export.ts +7 -1
  84. package/src/sdk/adapters/export-adapter.ts +6 -1
  85. package/src/services/cacheService.ts +9 -25
  86. package/src/services/desktop-export.ts +2 -59
  87. package/src/services/file-dialog.ts +8 -142
  88. package/src/store/constants.ts +23 -0
  89. package/src/store/globalId.ts +15 -13
  90. package/src/store/index.ts +19 -6
  91. package/src/store/slices/cesiumSlice.ts +8 -1
  92. package/src/store/slices/compareSlice.ts +96 -0
  93. package/src/store/slices/drawing2DSlice.ts +8 -0
  94. package/src/store/slices/lensSlice.ts +8 -0
  95. package/src/store/slices/visibilitySlice.ts +22 -1
  96. package/src/store/types.ts +1 -71
  97. package/src/utils/acquireFileBuffer.test.ts +12 -4
  98. package/src/utils/ifcConfig.ts +0 -12
  99. package/src/utils/loadingUtils.ts +32 -0
  100. package/src/utils/spatialHierarchy.test.ts +53 -1
  101. package/src/utils/spatialHierarchy.ts +42 -2
  102. package/src/vite-env.d.ts +2 -0
  103. package/vite.config.ts +6 -3
  104. package/DESKTOP_CONTRACT_VERSION +0 -1
  105. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  106. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  107. package/dist/assets/event-B0kAzHa-.js +0 -1
  108. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  109. package/dist/assets/index-ajK6D32J.css +0 -1
  110. package/dist/assets/lens-PYsLu_MA.js +0 -1
  111. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  112. package/dist/assets/raw-D9iw0tmc.js +0 -1
  113. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  114. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  115. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  116. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  117. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  118. package/src/components/viewer/SettingsPage.tsx +0 -581
  119. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  120. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  121. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  122. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
  123. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  124. package/src/lib/desktop-entitlement.ts +0 -43
  125. package/src/lib/desktop-product.ts +0 -130
  126. package/src/lib/platform.ts +0 -23
  127. package/src/services/desktop-cache.ts +0 -186
  128. package/src/services/desktop-harness.ts +0 -196
  129. package/src/services/desktop-logger.ts +0 -20
  130. package/src/services/desktop-native-metadata.ts +0 -230
  131. package/src/services/desktop-panel-actions.ts +0 -43
  132. package/src/services/desktop-preferences.ts +0 -44
  133. package/src/services/fs-cache.ts +0 -212
  134. package/src/services/tauri-core-stub.ts +0 -7
  135. package/src/services/tauri-dialog-stub.ts +0 -7
  136. package/src/services/tauri-fs-stub.ts +0 -7
  137. package/src/services/tauri-modules.d.ts +0 -50
  138. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  139. package/src/utils/desktopModelSnapshot.ts +0 -358
  140. package/src/utils/nativeSpatialDataStore.ts +0 -277
  141. package/src-tauri/Cargo.toml +0 -29
  142. package/src-tauri/build.rs +0 -7
  143. package/src-tauri/capabilities/default.json +0 -18
  144. package/src-tauri/icons/128x128.png +0 -0
  145. package/src-tauri/icons/128x128@2x.png +0 -0
  146. package/src-tauri/icons/32x32.png +0 -0
  147. package/src-tauri/icons/Square107x107Logo.png +0 -0
  148. package/src-tauri/icons/Square142x142Logo.png +0 -0
  149. package/src-tauri/icons/Square150x150Logo.png +0 -0
  150. package/src-tauri/icons/Square284x284Logo.png +0 -0
  151. package/src-tauri/icons/Square30x30Logo.png +0 -0
  152. package/src-tauri/icons/Square310x310Logo.png +0 -0
  153. package/src-tauri/icons/Square44x44Logo.png +0 -0
  154. package/src-tauri/icons/Square71x71Logo.png +0 -0
  155. package/src-tauri/icons/Square89x89Logo.png +0 -0
  156. package/src-tauri/icons/StoreLogo.png +0 -0
  157. package/src-tauri/icons/icon.icns +0 -0
  158. package/src-tauri/icons/icon.ico +0 -0
  159. package/src-tauri/icons/icon.png +0 -0
  160. package/src-tauri/src/lib.rs +0 -21
  161. package/src-tauri/src/main.rs +0 -10
  162. package/src-tauri/tauri.conf.json +0 -39
@@ -386,6 +386,9 @@ export function useIfcServer() {
386
386
 
387
387
  buildSpatialIndexGuarded(allMeshes, dataStore, setIfcDataStore);
388
388
  } catch (err) {
389
+ if (!isStale?.()) {
390
+ console.warn('[useIfc] Server data model fetch/decode failed; geometry shown without properties:', err);
391
+ }
389
392
  }
390
393
  })(); // End of async data model fetch block - runs in background, doesn't block
391
394
 
@@ -56,6 +56,7 @@ export function useLens() {
56
56
  useViewerStore.getState().setLensRuleCounts(new Map());
57
57
  useViewerStore.getState().setLensRuleEntityIds(new Map());
58
58
  useViewerStore.getState().setLensAutoColorLegend([]);
59
+ useViewerStore.getState().setLensAppliedColors(null);
59
60
 
60
61
  // Send empty map to signal "clear overlays" to useGeometryStreaming
61
62
  useViewerStore.getState().setPendingColorUpdates(new Map());
@@ -101,7 +102,10 @@ export function useLens() {
101
102
  useViewerStore.getState().setLensAutoColorLegend([]);
102
103
  }
103
104
 
104
- // Apply colors via overlay system — original batches are never modified
105
+ // Apply colors via overlay system — original batches are never modified.
106
+ // Remember the exact overlay so the compare overlay can restore it on
107
+ // teardown instead of blanking the channel the lens still owns.
108
+ useViewerStore.getState().setLensAppliedColors(colorMap.size > 0 ? colorMap : null);
105
109
  if (colorMap.size > 0) {
106
110
  useViewerStore.getState().setPendingColorUpdates(colorMap);
107
111
  }
@@ -248,6 +248,11 @@ async function parseAnnotations(
248
248
  const processor = new GeometryProcessor();
249
249
  try {
250
250
  await processor.init();
251
+ // SymbolicRepresentationCollection and each getPolyline/getCircle/getText/
252
+ // getFill item are wasm-bindgen handles owning WASM memory — free them
253
+ // deterministically (AGENTS.md §7). Leaking them to GC lets the
254
+ // FinalizationRegistry free them later against an already-grown/reused
255
+ // shared dlmalloc heap, corrupting the allocator free-list.
251
256
  const collection = processor.parseSymbolicRepresentations(source);
252
257
  if (debugEnabled()) {
253
258
  console.log(
@@ -257,7 +262,9 @@ async function parseAnnotations(
257
262
  : 'null',
258
263
  );
259
264
  }
260
- if (!collection || collection.isEmpty) return result;
265
+ if (!collection) return result;
266
+ try {
267
+ if (collection.isEmpty) return result;
261
268
 
262
269
  // Resolve a bucket by elevation rather than by storey id.
263
270
  //
@@ -313,34 +320,44 @@ async function parseAnnotations(
313
320
  for (let i = 0; i < collection.polylineCount; i++) {
314
321
  const poly = collection.getPolyline(i);
315
322
  if (!poly) continue;
316
- if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
317
- const bucket = ensureBucket(poly.expressId, poly.worldY, poly.ifcType);
318
- const looseTarget = poly.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
319
- const out = bucket ? bucket.lines : looseTarget;
320
- polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
323
+ try {
324
+ if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
325
+ const bucket = ensureBucket(poly.expressId, poly.worldY, poly.ifcType);
326
+ const looseTarget = poly.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
327
+ const out = bucket ? bucket.lines : looseTarget;
328
+ // poly.points is consumed synchronously here (not stored), so no copy needed.
329
+ polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
330
+ } finally {
331
+ poly.free();
332
+ }
321
333
  }
322
334
 
323
335
  for (let i = 0; i < collection.circleCount; i++) {
324
336
  const circle = collection.getCircle(i);
325
337
  if (!circle) continue;
326
- if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
327
- const bucket = ensureBucket(circle.expressId, circle.worldY, circle.ifcType);
328
- const looseTarget = circle.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
329
- const out = bucket ? bucket.lines : looseTarget;
330
- circleToSegments(
331
- circle.centerX,
332
- circle.centerY,
333
- circle.radius,
334
- circle.startAngle,
335
- circle.endAngle,
336
- circle.isFullCircle,
337
- out,
338
- );
338
+ try {
339
+ if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
340
+ const bucket = ensureBucket(circle.expressId, circle.worldY, circle.ifcType);
341
+ const looseTarget = circle.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
342
+ const out = bucket ? bucket.lines : looseTarget;
343
+ circleToSegments(
344
+ circle.centerX,
345
+ circle.centerY,
346
+ circle.radius,
347
+ circle.startAngle,
348
+ circle.endAngle,
349
+ circle.isFullCircle,
350
+ out,
351
+ );
352
+ } finally {
353
+ circle.free();
354
+ }
339
355
  }
340
356
 
341
357
  for (let i = 0; i < collection.textCount; i++) {
342
358
  const text = collection.getText(i);
343
359
  if (!text) continue;
360
+ try {
344
361
  if (text.ifcType !== 'IfcAnnotation' && text.ifcType !== 'IfcGridAxis') continue;
345
362
  // Skip empty literals so the renderer doesn't waste an instance slot.
346
363
  // Decode STEP escapes — `\X2\NNNN\X0\` (UTF-16 hex code units) and
@@ -398,30 +415,45 @@ async function parseAnnotations(
398
415
  };
399
416
  (bucket ? bucket.texts : looseTextTarget).push(t2d);
400
417
  }
418
+ } finally {
419
+ text.free();
420
+ }
401
421
  }
402
422
 
403
423
  for (let i = 0; i < collection.fillCount; i++) {
404
424
  const fill = collection.getFill(i);
405
425
  if (!fill) continue;
406
- if (fill.ifcType !== 'IfcAnnotation' && fill.ifcType !== 'IfcGridAxis') continue;
407
- const points = fill.points;
408
- if (points.length < 6) continue; // <3 vertices = no polygon
409
- const f2d: AnnotationFill2D = {
410
- points,
411
- holesOffsets: fill.holesOffsets,
412
- color: [fill.fillR, fill.fillG, fill.fillB, fill.fillA],
413
- hatching: fill.hasHatching
414
- ? {
415
- spacing: fill.hatchSpacing,
416
- angle: fill.hatchAngle,
417
- angleSecondary: Number.isNaN(fill.hatchAngleSecondary) ? null : fill.hatchAngleSecondary,
418
- lineWidth: fill.hatchLineWidth,
419
- }
420
- : undefined,
421
- };
422
- const bucket = ensureBucket(fill.expressId, fill.worldY, fill.ifcType);
423
- const looseFillTarget = fill.ifcType === 'IfcGridAxis' ? result.gridLooseFills : result.looseFills;
424
- (bucket ? bucket.fills : looseFillTarget).push(f2d);
426
+ try {
427
+ if (fill.ifcType !== 'IfcAnnotation' && fill.ifcType !== 'IfcGridAxis') continue;
428
+ // fill.points / fill.holesOffsets are getter results that may be views
429
+ // into WASM memory; they're STORED into f2d (outlive this iteration),
430
+ // so copy them before the handle is freed below. Element types match
431
+ // the AnnotationFill2D fields (Float32Array / Uint32Array).
432
+ const points = new Float32Array(fill.points);
433
+ if (points.length < 6) continue; // <3 vertices = no polygon
434
+ const holesOffsets = new Uint32Array(fill.holesOffsets);
435
+ const f2d: AnnotationFill2D = {
436
+ points,
437
+ holesOffsets,
438
+ color: [fill.fillR, fill.fillG, fill.fillB, fill.fillA],
439
+ hatching: fill.hasHatching
440
+ ? {
441
+ spacing: fill.hatchSpacing,
442
+ angle: fill.hatchAngle,
443
+ angleSecondary: Number.isNaN(fill.hatchAngleSecondary) ? null : fill.hatchAngleSecondary,
444
+ lineWidth: fill.hatchLineWidth,
445
+ }
446
+ : undefined,
447
+ };
448
+ const bucket = ensureBucket(fill.expressId, fill.worldY, fill.ifcType);
449
+ const looseFillTarget = fill.ifcType === 'IfcGridAxis' ? result.gridLooseFills : result.looseFills;
450
+ (bucket ? bucket.fills : looseFillTarget).push(f2d);
451
+ } finally {
452
+ fill.free();
453
+ }
454
+ }
455
+ } finally {
456
+ collection.free();
425
457
  }
426
458
  } finally {
427
459
  processor.dispose();
@@ -0,0 +1,173 @@
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
+ * Viewer adapter for the `@ifc-lite/diff` engine (issue #924).
7
+ *
8
+ * Turns a loaded model — its `IfcDataStore` plus the tessellated meshes from
9
+ * the geometry pass — into the per-entity {@link EntityFingerprint}s the
10
+ * store-agnostic engine matches and classifies. This is the viewer's
11
+ * counterpart to the CLI adapter; the canonical data fingerprint comes from
12
+ * `@ifc-lite/diff`'s {@link buildDataFingerprint} (the same hash the threejs
13
+ * compare example pioneered) and the geometry fingerprint is the RTC-invariant
14
+ * WASM hash riding on each `MeshData.geometryHash`.
15
+ *
16
+ * Scope: only entities that produced at least one mesh are fingerprinted —
17
+ * the engine needs a geometry hash to detect geometry changes, and the
18
+ * compare UI colours meshed elements in 3D. Data-only edits on those meshed
19
+ * entities are still detected via the data hash.
20
+ */
21
+
22
+ import {
23
+ buildDataFingerprint,
24
+ type DataFingerprintInput,
25
+ type EntityFingerprint,
26
+ } from '@ifc-lite/diff';
27
+ import { RelationshipType } from '@ifc-lite/data';
28
+ import {
29
+ extractAllEntityAttributes,
30
+ extractPropertiesOnDemand,
31
+ type IfcDataStore,
32
+ } from '@ifc-lite/parser';
33
+ import type { MeshData } from '@ifc-lite/geometry';
34
+ import { isGeometricDataName } from './geometricData.js';
35
+
36
+ /**
37
+ * Adapter handle threaded through the diff onto each {@link CompareDiffEntry}.
38
+ * Carries everything the compare UI needs downstream without re-deriving it:
39
+ * `globalId` colours the entity in the federated renderer, while `modelId` +
40
+ * `localId` drive selection / property lookup.
41
+ */
42
+ export interface CompareRef {
43
+ /** Federation model id this entity belongs to. */
44
+ modelId: string;
45
+ /** Original (pre-offset) express id — the key for `IfcDataStore` lookups. */
46
+ localId: number;
47
+ /** Federation global id (`localId + idOffset`) — the renderer mesh id. */
48
+ globalId: number;
49
+ }
50
+
51
+ export interface BuildFingerprintsModel {
52
+ /** Federation model id. */
53
+ modelId: string;
54
+ /** Parsed data store (local express ids). */
55
+ store: IfcDataStore;
56
+ /** Tessellated meshes. Express ids are federation-global (`local + idOffset`). */
57
+ meshes: readonly MeshData[];
58
+ /** This model's federation id offset (0 for the anchor / single-model load). */
59
+ idOffset: number;
60
+ }
61
+
62
+ /**
63
+ * Build one {@link EntityFingerprint} per meshed entity in a model.
64
+ *
65
+ * Entities are de-duplicated by express id (an entity emits several
66
+ * submeshes); the first mesh carrying a `geometryHash` wins (all submeshes of
67
+ * an entity share the whole-entity hash). The fingerprint `key` is the IFC
68
+ * `GlobalId` so the engine matches the same element across revisions; entities
69
+ * without a resolvable GlobalId fall back to a per-model synthetic key so they
70
+ * never collide across A/B and simply read as added/deleted.
71
+ */
72
+ export async function buildEntityFingerprints(
73
+ model: BuildFingerprintsModel,
74
+ ): Promise<EntityFingerprint<CompareRef>[]> {
75
+ const { store, meshes, idOffset, modelId } = model;
76
+
77
+ // local express id → first geometry hash seen for it (may be undefined when
78
+ // hashing was disabled or the WASM build predates it — data diff still works)
79
+ const geometryByLocalId = new Map<number, bigint | undefined>();
80
+ for (const mesh of meshes) {
81
+ const localId = mesh.expressId - idOffset;
82
+ if (!geometryByLocalId.has(localId)) {
83
+ geometryByLocalId.set(localId, mesh.geometryHash);
84
+ } else if (geometryByLocalId.get(localId) === undefined && mesh.geometryHash !== undefined) {
85
+ geometryByLocalId.set(localId, mesh.geometryHash);
86
+ }
87
+ }
88
+
89
+ const fingerprints: EntityFingerprint<CompareRef>[] = [];
90
+ let processed = 0;
91
+ for (const [localId, geometryHash] of geometryByLocalId) {
92
+ const ifcType = store.entities.getTypeName(localId) || 'IfcProduct';
93
+ const globalId = store.entities.getGlobalId(localId);
94
+ const key = globalId || `missing:${modelId}:${localId}`;
95
+
96
+ fingerprints.push({
97
+ key,
98
+ ifcType,
99
+ dataHash: buildDataFingerprint(buildDataInput(store, localId, ifcType)),
100
+ geometryHash,
101
+ ref: { modelId, localId, globalId: localId + idOffset },
102
+ });
103
+
104
+ // Per-entity property extraction reparses from the source buffer, so on a
105
+ // large model this loop is heavy; yield to the main thread periodically so
106
+ // the viewport stays responsive and the "Comparing…" spinner keeps
107
+ // animating instead of the UI freezing (#924).
108
+ if (++processed % 1500 === 0) {
109
+ await new Promise((resolve) => setTimeout(resolve, 0));
110
+ }
111
+ }
112
+
113
+ return fingerprints;
114
+ }
115
+
116
+ /** Does this side carry at least one usable geometry hash? Compares run on
117
+ * models loaded outside the WASM mesh path (e.g. huge native desktop loads)
118
+ * produce no hashes, which would make geometry diffs silently read every
119
+ * element as unchanged — callers warn when this is false. */
120
+ export function hasGeometryHashes(side: readonly EntityFingerprint<CompareRef>[]): boolean {
121
+ return side.some((fingerprint) => fingerprint.geometryHash !== undefined);
122
+ }
123
+
124
+ /**
125
+ * Assemble the canonical {@link DataFingerprintInput} for one entity from the
126
+ * store's on-demand extractors. Mirrors the extraction in
127
+ * `examples/threejs-viewer/src/compare.ts`; `@ifc-lite/diff` does the sorting
128
+ * + hashing so base and head produce byte-identical hashes for an unchanged
129
+ * entity.
130
+ */
131
+ function buildDataInput(
132
+ store: IfcDataStore,
133
+ localId: number,
134
+ ifcType: string,
135
+ ): DataFingerprintInput {
136
+ const predefinedType = extractAllEntityAttributes(store, localId).find(
137
+ (attribute) => attribute.name === 'PredefinedType',
138
+ )?.value;
139
+
140
+ // Data vs geometry: placement/coordinate data (elevation, level offsets, …)
141
+ // is owned by the geometry hash, so strip it from the data fingerprint — a
142
+ // pure move must read as a geometry change only, never "data · geometry"
143
+ // (see geometricData.ts). Quantities (Volume/Area/Length/…) are
144
+ // geometry-derived measurements and are excluded wholesale for the same
145
+ // reason: a reshape already shows up as a geometry change.
146
+ const propertySets = extractPropertiesOnDemand(store, localId)
147
+ .filter((set) => !isGeometricDataName(set.name))
148
+ .map((set) => ({
149
+ name: set.name,
150
+ properties: set.properties
151
+ .filter((property) => !isGeometricDataName(property.name))
152
+ .map((property) => ({ name: property.name, value: property.value })),
153
+ }))
154
+ .filter((set) => set.properties.length > 0);
155
+
156
+ const typeAssignments = store.relationships
157
+ .getRelated(localId, RelationshipType.DefinesByType, 'inverse')
158
+ .map((typeId) => ({
159
+ globalId: store.entities.getGlobalId(typeId) || undefined,
160
+ name: store.entities.getName(typeId) || undefined,
161
+ type: store.entities.getTypeName(typeId) || undefined,
162
+ }));
163
+
164
+ return {
165
+ ifcType,
166
+ name: store.entities.getName(localId) || undefined,
167
+ description: store.entities.getDescription(localId) || undefined,
168
+ objectType: store.entities.getObjectType(localId) || undefined,
169
+ predefinedType: predefinedType != null ? String(predefinedType) : undefined,
170
+ propertySets,
171
+ typeAssignments,
172
+ };
173
+ }
@@ -0,0 +1,54 @@
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';
7
+ import { isGeometricDataName } from './geometricData.js';
8
+
9
+ describe('isGeometricDataName — data/geometry boundary', () => {
10
+ it('flags placement/position data that authoring tools leak into property sets', () => {
11
+ for (const name of [
12
+ 'Elevation',
13
+ 'Elevation at Bottom',
14
+ 'Elevation at Top',
15
+ 'Height Offset From Level',
16
+ 'Base Offset',
17
+ 'Top Offset',
18
+ 'Bottom Offset',
19
+ 'Reference Level',
20
+ 'Z Coordinate',
21
+ 'Z Offset',
22
+ 'Placement',
23
+ 'ObjectPlacement',
24
+ ]) {
25
+ assert.equal(isGeometricDataName(name), true, `${name} should be geometric`);
26
+ }
27
+ });
28
+
29
+ it('does NOT flag semantic data — a move must not strip real attributes/properties', () => {
30
+ for (const name of [
31
+ 'Name',
32
+ 'Description',
33
+ 'ObjectType',
34
+ 'FireRating',
35
+ 'IsExternal',
36
+ 'LoadBearing',
37
+ 'Reference',
38
+ 'Pset_SlabCommon',
39
+ 'AcousticRating',
40
+ 'Combustible',
41
+ 'ThermalTransmittance',
42
+ 'Status',
43
+ 'Mark',
44
+ 'Material',
45
+ // Generic terms that are just as often semantic — must NOT be excluded.
46
+ 'Location',
47
+ 'Position',
48
+ 'Datum',
49
+ 'Offset',
50
+ ]) {
51
+ assert.equal(isGeometricDataName(name), false, `${name} should be semantic data`);
52
+ }
53
+ });
54
+ });
@@ -0,0 +1,37 @@
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
+ * The single boundary between **data** and **geometry** for model compare
7
+ * (issue #924).
8
+ *
9
+ * A change to where/how an element sits in space — placement, coordinates,
10
+ * elevation, level offsets — is a *geometry* change, fully captured by the
11
+ * per-entity geometry hash (world-space mesh). It must NOT also flip the data
12
+ * fingerprint, or a pure move reads as "data · geometry" instead of geometry
13
+ * only. Authoring tools leak placement into property sets (Revit's "Elevation",
14
+ * "Height Offset From Level", "Base/Top Offset", level references), so we filter
15
+ * those out of the data side by name.
16
+ *
17
+ * Used by both `buildFingerprints` (so the data hash ignores them) and
18
+ * `describeChange` (so the "what changed" panel doesn't list them as data).
19
+ * Quantities (Volume/Area/Length/…) are excluded wholesale at the call site —
20
+ * they are geometry-derived measurements, not in scope here.
21
+ */
22
+
23
+ // Deliberately narrow: only names that are *specifically* placement (elevation,
24
+ // (object)placement, coordinate) or a qualified level/axis offset. Generic
25
+ // `Location` / `Position` / `Datum` / bare `Offset` are NOT excluded — they are
26
+ // just as often ordinary semantic data (a custom "Location" string, a "Position"
27
+ // label), and wrongly dropping them from the data diff would hide real edits.
28
+ const GEOMETRIC_NAME =
29
+ /elevation|placement|coordinate|reference\s*level|(height|base|top|bottom|level|z)\s*offset/i;
30
+
31
+ /**
32
+ * True when an attribute / property / property-set name denotes geometric
33
+ * placement data that belongs to the geometry diff, not the data diff.
34
+ */
35
+ export function isGeometricDataName(name: string): boolean {
36
+ return GEOMETRIC_NAME.test(name);
37
+ }
@@ -0,0 +1,99 @@
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';
7
+ import type { DiffEntry, DiffState, ModelDiff } from '@ifc-lite/diff';
8
+ import { buildCompareOverlay, COMPARE_COLORS } from './overlay.js';
9
+ import type { CompareRef } from './buildFingerprints.js';
10
+
11
+ function ref(globalId: number): CompareRef {
12
+ // modelId/localId are irrelevant to the overlay (it keys on globalId).
13
+ return { modelId: 'm', localId: globalId, globalId };
14
+ }
15
+
16
+ function entry(
17
+ state: DiffState,
18
+ opts: { base?: number; head?: number; changeKinds?: ('data' | 'geometry')[] } = {},
19
+ ): DiffEntry<CompareRef> {
20
+ return {
21
+ key: `${state}:${opts.base ?? ''}:${opts.head ?? ''}`,
22
+ state,
23
+ changeKinds: opts.changeKinds ?? [],
24
+ base: opts.base !== undefined ? ({ key: '', ifcType: '', dataHash: '', ref: ref(opts.base) }) : undefined,
25
+ head: opts.head !== undefined ? ({ key: '', ifcType: '', dataHash: '', ref: ref(opts.head) }) : undefined,
26
+ };
27
+ }
28
+
29
+ function diffOf(entries: DiffEntry<CompareRef>[]): ModelDiff<CompareRef> {
30
+ // buildCompareOverlay only reads `entries`; the rest satisfies the type.
31
+ return { scope: 'both', entries, byKey: new Map(), counts: { added: 0, modified: 0, deleted: 0, unchanged: 0 } };
32
+ }
33
+
34
+ describe('buildCompareOverlay', () => {
35
+ it('colours added on the head, with nothing hidden', () => {
36
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(diffOf([entry('added', { head: 10 })]), false);
37
+ assert.deepStrictEqual(colorOverrides.get(10), COMPARE_COLORS.added);
38
+ assert.strictEqual(hiddenIds.size, 0);
39
+ });
40
+
41
+ it('colours deleted on the base (it only exists in A)', () => {
42
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(diffOf([entry('deleted', { base: 5 })]), false);
43
+ assert.deepStrictEqual(colorOverrides.get(5), COMPARE_COLORS.deleted);
44
+ assert.strictEqual(hiddenIds.size, 0);
45
+ });
46
+
47
+ it('colours modified on the head and hides the base copy', () => {
48
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(
49
+ diffOf([entry('modified', { base: 5, head: 1005, changeKinds: ['geometry'] })]),
50
+ false,
51
+ );
52
+ assert.deepStrictEqual(colorOverrides.get(1005), COMPARE_COLORS.modified);
53
+ assert.ok(!colorOverrides.has(5), 'base copy is not coloured');
54
+ assert.ok(hiddenIds.has(5), 'base copy is hidden to avoid double geometry');
55
+ assert.ok(!hiddenIds.has(1005), 'head copy stays visible');
56
+ });
57
+
58
+ it('hides both copies of an unchanged element when not showing unchanged', () => {
59
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(
60
+ diffOf([entry('unchanged', { base: 5, head: 1005 })]),
61
+ false,
62
+ );
63
+ assert.strictEqual(colorOverrides.size, 0);
64
+ assert.deepStrictEqual([...hiddenIds].sort((a, b) => a - b), [5, 1005]);
65
+ });
66
+
67
+ it('ghosts the head and hides the base for unchanged when showing unchanged', () => {
68
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(
69
+ diffOf([entry('unchanged', { base: 5, head: 1005 })]),
70
+ true,
71
+ );
72
+ assert.deepStrictEqual(colorOverrides.get(1005), COMPARE_COLORS.unchanged);
73
+ assert.ok(hiddenIds.has(5), 'base duplicate hidden');
74
+ assert.ok(!hiddenIds.has(1005), 'ghosted head stays visible');
75
+ });
76
+
77
+ it('composes a mixed diff without cross-contaminating colours/visibility', () => {
78
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(
79
+ diffOf([
80
+ entry('added', { head: 1001 }),
81
+ entry('deleted', { base: 2 }),
82
+ entry('modified', { base: 3, head: 1003 }),
83
+ entry('unchanged', { base: 4, head: 1004 }),
84
+ ]),
85
+ false,
86
+ );
87
+ assert.deepStrictEqual(colorOverrides.get(1001), COMPARE_COLORS.added);
88
+ assert.deepStrictEqual(colorOverrides.get(2), COMPARE_COLORS.deleted);
89
+ assert.deepStrictEqual(colorOverrides.get(1003), COMPARE_COLORS.modified);
90
+ // modified base + both unchanged copies are hidden; added/deleted are not.
91
+ assert.deepStrictEqual([...hiddenIds].sort((a, b) => a - b), [3, 4, 1004]);
92
+ });
93
+
94
+ it('skips entries that carry no usable ref', () => {
95
+ const { colorOverrides, hiddenIds } = buildCompareOverlay(diffOf([entry('added', {})]), false);
96
+ assert.strictEqual(colorOverrides.size, 0);
97
+ assert.strictEqual(hiddenIds.size, 0);
98
+ });
99
+ });
@@ -0,0 +1,91 @@
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
+ * Pure mapping from a model-diff to the renderer's colour + visibility
7
+ * channels (issue #924). Kept side-effect-free so it can be unit-tested in
8
+ * isolation; the `useCompareOverlay` hook wires the result into the store.
9
+ *
10
+ * Both models (A = base, B = head) are loaded in the federated scene at once,
11
+ * so an unchanged element exists twice at the same place. We therefore drive
12
+ * the comparison entirely from model B and suppress B-duplicated copies in
13
+ * model A:
14
+ *
15
+ * - added (B only) → green on B
16
+ * - modified (A and B) → yellow on B, **hide** the A copy
17
+ * - deleted (A only) → red on A
18
+ * - unchanged → ghost grey on B + hide A (when "show unchanged"),
19
+ * otherwise hide both
20
+ *
21
+ * Colours match the threejs compare example's palette. Keys are federation
22
+ * **global** ids (`CompareRef.globalId`) — exactly what the renderer's
23
+ * `setColorOverrides` / hidden set consume.
24
+ */
25
+
26
+ import type { ModelDiff } from '@ifc-lite/diff';
27
+ import type { CompareRef } from './buildFingerprints';
28
+
29
+ export type RGBA = [number, number, number, number];
30
+
31
+ /** Diff-state colour conventions. `unchanged` is a translucent ghost. */
32
+ export const COMPARE_COLORS = {
33
+ added: [0.22, 0.78, 0.44, 1] as RGBA,
34
+ modified: [1.0, 0.6, 0.18, 1] as RGBA,
35
+ deleted: [0.95, 0.3, 0.3, 1] as RGBA,
36
+ unchanged: [0.45, 0.52, 0.58, 0.32] as RGBA,
37
+ } as const;
38
+
39
+ export interface CompareOverlay {
40
+ /** Per global-id colour override fed to `scene.setColorOverrides`. */
41
+ colorOverrides: Map<number, RGBA>;
42
+ /** Global ids to hide (suppresses duplicated base-model geometry). */
43
+ hiddenIds: Set<number>;
44
+ }
45
+
46
+ /**
47
+ * Build the colour + hidden maps for a comparison.
48
+ *
49
+ * @param diff engine output (refs carry the federation global id)
50
+ * @param showUnchanged draw unchanged elements ghosted (true) or hide them
51
+ */
52
+ export function buildCompareOverlay(
53
+ diff: ModelDiff<CompareRef>,
54
+ showUnchanged: boolean,
55
+ ): CompareOverlay {
56
+ const colorOverrides = new Map<number, RGBA>();
57
+ const hiddenIds = new Set<number>();
58
+
59
+ for (const entry of diff.entries) {
60
+ const baseGlobal = entry.base?.ref.globalId;
61
+ const headGlobal = entry.head?.ref.globalId;
62
+
63
+ switch (entry.state) {
64
+ case 'added':
65
+ if (headGlobal !== undefined) colorOverrides.set(headGlobal, COMPARE_COLORS.added);
66
+ break;
67
+
68
+ case 'deleted':
69
+ if (baseGlobal !== undefined) colorOverrides.set(baseGlobal, COMPARE_COLORS.deleted);
70
+ break;
71
+
72
+ case 'modified':
73
+ if (headGlobal !== undefined) colorOverrides.set(headGlobal, COMPARE_COLORS.modified);
74
+ // Hide the old (base) copy so the yellow head reads cleanly.
75
+ if (baseGlobal !== undefined) hiddenIds.add(baseGlobal);
76
+ break;
77
+
78
+ case 'unchanged':
79
+ if (showUnchanged) {
80
+ if (headGlobal !== undefined) colorOverrides.set(headGlobal, COMPARE_COLORS.unchanged);
81
+ if (baseGlobal !== undefined) hiddenIds.add(baseGlobal);
82
+ } else {
83
+ if (headGlobal !== undefined) hiddenIds.add(headGlobal);
84
+ if (baseGlobal !== undefined) hiddenIds.add(baseGlobal);
85
+ }
86
+ break;
87
+ }
88
+ }
89
+
90
+ return { colorOverrides, hiddenIds };
91
+ }
@@ -41,7 +41,7 @@ export function shouldPreferOrthometricTerrain(
41
41
 
42
42
  export interface CesiumPlacementInput {
43
43
  coordinateInfo?: CoordinateInfo;
44
- projectedCRS?: Pick<ProjectedCRS, 'verticalDatum'> | Pick<ProjectedCRS, 'mapUnitScale'> & Pick<ProjectedCRS, 'verticalDatum'>;
44
+ projectedCRS?: Pick<ProjectedCRS, 'verticalDatum'> | Pick<ProjectedCRS, 'mapUnitScale' | 'verticalDatum'>;
45
45
  ifcOriginHeight: number;
46
46
  terrainHeight: number | null;
47
47
  storeyElevations?: Map<number, number>;
@@ -347,8 +347,11 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
347
347
  // string itself when possible. `+datum=` is rare in modern proj4 output;
348
348
  // the typical hint is `+ellps=` which we already accept as a weak signal
349
349
  // inside DATUM_TOWGS84 keys above.
350
+ // Only positive-cache a successful result, so a transient network
351
+ // failure (offline/CDN hiccup/timeout) can be retried later instead of
352
+ // permanently poisoning this EPSG code for the rest of the session.
350
353
  const fetched = raw ? sanitizeProj4(raw, code, null) : null;
351
- projDefCache.set(code, fetched);
354
+ if (fetched) projDefCache.set(code, fetched);
352
355
  return fetched;
353
356
  }
354
357