@ifc-lite/viewer 1.27.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 (97) hide show
  1. package/.turbo/turbo-build.log +38 -38
  2. package/CHANGELOG.md +64 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-BNRDNuUJ.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-DCwCuP7n.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-DNGgs8Ur.js} +1 -1
  7. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-B9v81gi9.js} +1249 -1140
  10. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-D-YCLS4g.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-CCpq-5d3.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-DbgS5EUA.wasm} +0 -0
  14. package/dist/assets/{index-COYokSKc.js → index-Bgb3_Pu_.js} +41073 -38715
  15. package/dist/assets/index-BtbXFKsX.css +1 -0
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-CWfqZyyr.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-DGOAeUqU.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-XPLU2Wkq.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-1PMSCHwX.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-C65U9lNM.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-XxXos6yI.js} +2 -2
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-BdMWXC3m.js} +1 -1
  24. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-CRwaZf3s.js} +10 -10
  26. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-0sDo3g3m.js} +2960 -2552
  28. package/dist/assets/server-client-cTCJ-853.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-BtakWX7W.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-B1YOg2QB.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-CmwsbxmM.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/components/mcp/playground-dispatcher.ts +3 -0
  35. package/src/components/mcp/playground-files.ts +33 -1
  36. package/src/components/viewer/CommandPalette.tsx +6 -1
  37. package/src/components/viewer/ComparePanel.tsx +420 -0
  38. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  39. package/src/components/viewer/MainToolbar.tsx +19 -2
  40. package/src/components/viewer/PropertiesPanel.tsx +71 -2
  41. package/src/components/viewer/ViewerLayout.tsx +5 -0
  42. package/src/components/viewer/Viewport.tsx +3 -0
  43. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  44. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  45. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  46. package/src/components/viewer/hierarchy/types.ts +1 -0
  47. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  48. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  49. package/src/hooks/federationLoadGate.test.ts +12 -2
  50. package/src/hooks/federationLoadGate.ts +9 -2
  51. package/src/hooks/ingest/federationAlign.ts +481 -0
  52. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  53. package/src/hooks/useCompare.ts +0 -0
  54. package/src/hooks/useCompareOverlay.ts +119 -0
  55. package/src/hooks/useDrawingGeneration.ts +23 -1
  56. package/src/hooks/useIfc.ts +1 -1
  57. package/src/hooks/useIfcCache.ts +32 -9
  58. package/src/hooks/useIfcFederation.ts +42 -810
  59. package/src/hooks/useIfcLoader.ts +361 -488
  60. package/src/hooks/useIfcServer.ts +3 -0
  61. package/src/hooks/useLens.ts +5 -1
  62. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  63. package/src/lib/compare/buildFingerprints.ts +173 -0
  64. package/src/lib/compare/describeChange.ts +0 -0
  65. package/src/lib/compare/geometricData.test.ts +54 -0
  66. package/src/lib/compare/geometricData.ts +37 -0
  67. package/src/lib/compare/overlay.test.ts +99 -0
  68. package/src/lib/compare/overlay.ts +91 -0
  69. package/src/lib/geo/cesium-placement.ts +1 -1
  70. package/src/lib/geo/reproject.ts +4 -1
  71. package/src/lib/llm/script-edit-ops.ts +23 -0
  72. package/src/lib/llm/stream-client.ts +8 -1
  73. package/src/lib/search/result-export.ts +7 -1
  74. package/src/sdk/adapters/export-adapter.ts +6 -1
  75. package/src/store/globalId.ts +15 -13
  76. package/src/store/index.ts +16 -1
  77. package/src/store/slices/cesiumSlice.ts +8 -1
  78. package/src/store/slices/compareSlice.ts +96 -0
  79. package/src/store/slices/lensSlice.ts +8 -0
  80. package/src/utils/acquireFileBuffer.test.ts +12 -4
  81. package/src/utils/desktopModelSnapshot.ts +2 -1
  82. package/src/utils/loadingUtils.ts +32 -0
  83. package/src/utils/spatialHierarchy.test.ts +53 -1
  84. package/src/utils/spatialHierarchy.ts +42 -2
  85. package/src/vite-env.d.ts +2 -0
  86. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  87. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  88. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  89. package/dist/assets/index-ajK6D32J.css +0 -1
  90. package/dist/assets/lens-PYsLu_MA.js +0 -1
  91. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  92. package/dist/assets/raw-D9iw0tmc.js +0 -1
  93. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  94. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  95. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  96. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  97. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -456,6 +456,29 @@ export function applyScriptEditOperations(params: {
456
456
  return { ok: true, content, selection, revision, appliedOpIds, changes, status: 'ok' };
457
457
  }
458
458
 
459
+ if (operations.length > 1 && operations.some((op) => op.type === 'replaceAll')) {
460
+ const diagnostic = createPatchDiagnostic(
461
+ 'patch_semantic_error',
462
+ 'A replaceAll edit must be the only operation in its batch; it cannot be combined with positional ops.',
463
+ 'error',
464
+ {
465
+ failureKind: 'mixed_repair_scopes',
466
+ fixHint:
467
+ 'Emit replaceAll on its own, or use only positional ops (insert/replaceRange/append) in one batch.',
468
+ },
469
+ );
470
+ return {
471
+ ok: false,
472
+ content: params.content,
473
+ selection: params.selection,
474
+ revision,
475
+ appliedOpIds: [],
476
+ status: 'semantic_error',
477
+ error: diagnostic.message,
478
+ diagnostic,
479
+ };
480
+ }
481
+
459
482
  if (params.intent === 'repair') {
460
483
  const metadataError = validateRepairBatchMetadata(operations);
461
484
  if (metadataError) {
@@ -139,7 +139,14 @@ export async function readSseStream(
139
139
  if (!line.startsWith('data: ')) continue;
140
140
  const data = line.slice(6);
141
141
  if (data === '[DONE]') continue;
142
- try { onEvent(data); } catch { /* skip malformed */ }
142
+ try {
143
+ onEvent(data);
144
+ } catch (err) {
145
+ // Malformed JSON payloads are expected and skipped, but a genuine
146
+ // callback failure (onChunk/onUsageInfo/logCacheHit/fullText) would
147
+ // otherwise be silently dropped — surface it for diagnosability.
148
+ console.debug('[sse] skipped event', err);
149
+ }
143
150
  }
144
151
  }
145
152
  };
@@ -33,9 +33,15 @@ function cellToString(v: unknown): string {
33
33
  }
34
34
 
35
35
  /** RFC-4180-style escaping: quote any cell containing comma, quote, or
36
- * newline; double-up embedded quotes inside the wrapped cell. */
36
+ * newline; double-up embedded quotes inside the wrapped cell. Also
37
+ * neutralises spreadsheet formula triggers (CWE-1236) so user/model-
38
+ * controlled cell values are treated as text on open. */
37
39
  function escapeCsvCell(raw: string): string {
38
40
  if (raw.length === 0) return '';
41
+ // CWE-1236: neutralise spreadsheet formula triggers in the leading
42
+ // position. Prefixing first ensures the needsQuotes check below still
43
+ // wraps values that also contain comma/quote/newline.
44
+ if (/^[=+\-@\t\r]/.test(raw)) raw = `'${raw}`;
39
45
  const needsQuotes = raw.includes(',') || raw.includes('"') || raw.includes('\n') || raw.includes('\r');
40
46
  if (!needsQuotes) return raw;
41
47
  return `"${raw.replace(/"/g, '""')}"`;
@@ -108,7 +108,12 @@ export function resolveVisibilityFilterSets(
108
108
  * double-quotes, or newlines.
109
109
  */
110
110
  function escapeCsv(value: string, sep: string): string {
111
- if (value.includes(sep) || value.includes('"') || value.includes('\n')) {
111
+ // Neutralize spreadsheet formula injection (CWE-1236): a leading
112
+ // =, +, -, @, TAB or CR makes a cell execute as a formula in Excel/
113
+ // LibreOffice/Sheets. IFC values are attacker-controllable, so prefix
114
+ // such cells with an apostrophe.
115
+ if (/^[=+\-@\t\r]/.test(value)) value = `'${value}`;
116
+ if (value.includes(sep) || value.includes('"') || value.includes('\n') || value.includes('\r')) {
112
117
  return `"${value.replace(/"/g, '""')}"`;
113
118
  }
114
119
  return value;
@@ -44,23 +44,17 @@ export function fromGlobalIdFromModels(
44
44
  models: ReverseModelMapLike,
45
45
  globalId: number,
46
46
  ): EntityRef | undefined {
47
- if (models.size <= 1) {
48
- const firstModelId = models.keys().next().value;
49
- if (firstModelId) {
50
- return {
51
- modelId: firstModelId,
52
- expressId: globalId,
53
- };
54
- }
55
- return {
56
- modelId: 'legacy',
57
- expressId: globalId,
58
- };
47
+ // No models loaded — legacy single-store fallback (expressId === globalId).
48
+ if (models.size === 0) {
49
+ return { modelId: 'legacy', expressId: globalId };
59
50
  }
60
51
 
52
+ // Resolve through every model by its offset range, regardless of count.
53
+ // For a true single model with idOffset 0 this still yields expressId === globalId.
54
+ // The `>= 0` boundary matches the canonical resolveGlobalIdFromModels (modelSlice.ts).
61
55
  for (const [modelId, model] of models.entries()) {
62
56
  const localExpressId = globalId - model.idOffset;
63
- if (localExpressId > 0 && localExpressId <= model.maxExpressId) {
57
+ if (localExpressId >= 0 && localExpressId <= model.maxExpressId) {
64
58
  return {
65
59
  modelId,
66
60
  expressId: localExpressId,
@@ -68,6 +62,14 @@ export function fromGlobalIdFromModels(
68
62
  }
69
63
  }
70
64
 
65
+ // Single-model graceful fallback: if exactly one model and the offset
66
+ // range check missed (e.g. overlay-allocated id above maxExpressId),
67
+ // still return that model with the offset-corrected id rather than undefined.
68
+ if (models.size === 1) {
69
+ const [modelId, model] = models.entries().next().value!;
70
+ return { modelId, expressId: globalId - model.idOffset };
71
+ }
72
+
71
73
  return undefined;
72
74
  }
73
75
 
@@ -34,6 +34,7 @@ import { createListSlice, type ListSlice } from './slices/listSlice.js';
34
34
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
35
35
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
36
36
  import { createClashSlice, type ClashSlice } from './slices/clashSlice.js';
37
+ import { createCompareSlice, type CompareSlice } from './slices/compareSlice.js';
37
38
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
38
39
  import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
39
40
  import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
@@ -85,6 +86,7 @@ export type { PinboardSlice } from './slices/pinboardSlice.js';
85
86
 
86
87
  // Re-export Lens types
87
88
  export type { LensSlice, Lens, LensRule, LensCriteria } from './slices/lensSlice.js';
89
+ export type { CompareSlice, CompareResult } from './slices/compareSlice.js';
88
90
 
89
91
  // Re-export Script types
90
92
  export type { ScriptSlice } from './slices/scriptSlice.js';
@@ -131,6 +133,7 @@ export type ViewerState = LoadingSlice &
131
133
  PinboardSlice &
132
134
  LensSlice &
133
135
  ClashSlice &
136
+ CompareSlice &
134
137
  ScriptSlice &
135
138
  ChatSlice &
136
139
  CesiumSlice &
@@ -155,7 +158,7 @@ export type ViewerState = LoadingSlice &
155
158
  * the right panel. Routed through by the toolbar, command palette, and the
156
159
  * BCF overlay so every entry point behaves identically.
157
160
  */
158
- openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') => void;
161
+ openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions') => void;
159
162
  };
160
163
 
161
164
  /**
@@ -182,6 +185,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
182
185
  ...createPinboardSlice(...args),
183
186
  ...createLensSlice(...args),
184
187
  ...createClashSlice(...args),
188
+ ...createCompareSlice(...args),
185
189
  ...createScriptSlice(...args),
186
190
  ...createChatSlice(...args),
187
191
  ...createCesiumSlice(...args),
@@ -211,6 +215,8 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
211
215
  // Selection (multi-model)
212
216
  selectedEntity: null,
213
217
  selectedEntitiesSet: new Set(),
218
+ selectedEntities: [],
219
+ selectedModelId: null,
214
220
 
215
221
  // Visibility (legacy)
216
222
  hiddenEntities: new Set(),
@@ -235,6 +241,14 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
235
241
  pendingColorUpdates: null,
236
242
  pendingMeshColorUpdates: null,
237
243
 
244
+ // Compare (#924): drop any stale diff result — it references models by
245
+ // id and the loaded set is changing. Keep panel visibility + A/B/scope
246
+ // choices (UI prefs); the user re-runs against the new set.
247
+ compareResult: null,
248
+ compareSelectedKey: null,
249
+ compareRunning: false,
250
+ compareError: null,
251
+
238
252
  // Hover/Context
239
253
  hoverState: { entityId: null, screenX: 0, screenY: 0 },
240
254
  contextMenu: { isOpen: false, entityId: null, screenX: 0, screenY: 0 },
@@ -460,6 +474,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
460
474
  idsPanelVisible: panel === 'ids',
461
475
  lensPanelVisible: panel === 'lens',
462
476
  clashPanelVisible: panel === 'clash',
477
+ comparePanelVisible: panel === 'compare',
463
478
  extensionsPanelVisible: panel === 'extensions',
464
479
  rightPanelCollapsed: false,
465
480
  });
@@ -108,8 +108,15 @@ const STORAGE_KEY_DATA_SOURCE = 'ifc-lite:cesium-data-source';
108
108
  * Default Cesium ion token provided at build time.
109
109
  * Set via VITE_CESIUM_ION_TOKEN in .env or CI environment.
110
110
  * This means users never need to configure a token manually.
111
+ *
112
+ * NOTE: `import.meta.env` is undefined under the Vitest/Node test runner (the
113
+ * Vite define plugin doesn't run there), so this module-top-level read would
114
+ * crash with "Cannot read properties of undefined" — every viewer test imports
115
+ * the store, which imports this slice. The optional chaining on `.env` keeps the
116
+ * read safe in that environment. `import.meta.env` is typed via vite-env.d.ts so
117
+ * no `as any` cast is needed. Do NOT drop the optional chaining.
111
118
  */
112
- const DEFAULT_ION_TOKEN: string = (import.meta as any).env?.VITE_CESIUM_ION_TOKEN ?? '';
119
+ const DEFAULT_ION_TOKEN: string = import.meta.env?.VITE_CESIUM_ION_TOKEN ?? '';
113
120
 
114
121
  function loadFromStorage(key: string, fallback: string): string {
115
122
  try {
@@ -0,0 +1,96 @@
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
+ * Model-comparison panel state (issue #924). Holds the panel's UI state, the
7
+ * A/B model selection, the data-vs-geometry scope, and the last `@ifc-lite/diff`
8
+ * result. The orchestration (building per-entity fingerprints from each model's
9
+ * `IfcDataStore` + geometry hashes, running `diffModels`, applying the 3D
10
+ * colour/visibility overlay) lives in the `useCompare` hook — this slice is
11
+ * deliberately dumb, mirroring `clashSlice` + `useClash`.
12
+ */
13
+
14
+ import type { StateCreator } from 'zustand';
15
+ import type { DiffScope, ModelDiff } from '@ifc-lite/diff';
16
+ import type { CompareRef } from '@/lib/compare/buildFingerprints';
17
+
18
+ /** A completed comparison: the engine result plus the A/B context it ran on. */
19
+ export interface CompareResult {
20
+ /** Federation model id chosen as the base (version A). */
21
+ baseModelId: string;
22
+ /** Federation model id chosen as the head (version B). */
23
+ headModelId: string;
24
+ /** Display name of the base model. */
25
+ baseName: string;
26
+ /** Display name of the head model. */
27
+ headName: string;
28
+ /** The scope the diff was computed with. */
29
+ scope: DiffScope;
30
+ /** True when a compared model carries no geometry hashes (loaded outside the
31
+ * WASM mesh path), so geometry-scope changes can't be detected. */
32
+ geometryUnavailable: boolean;
33
+ /** The engine output — entries keyed by GlobalId, with per-entity refs. */
34
+ diff: ModelDiff<CompareRef>;
35
+ }
36
+
37
+ export interface CompareSlice {
38
+ comparePanelVisible: boolean;
39
+ /** Selected base (A) / head (B) federation model ids. */
40
+ compareBaseModelId: string | null;
41
+ compareHeadModelId: string | null;
42
+ /** What counts as a change: data, geometry, or both. */
43
+ compareScope: DiffScope;
44
+ /** Whether unchanged elements are drawn (ghosted) or hidden. */
45
+ compareShowUnchanged: boolean;
46
+ /** Last comparison result (null when idle / not yet run). */
47
+ compareResult: CompareResult | null;
48
+ compareRunning: boolean;
49
+ compareError: string | null;
50
+ /** GlobalId of the entry focused in the list (for highlight). */
51
+ compareSelectedKey: string | null;
52
+
53
+ setComparePanelVisible: (visible: boolean) => void;
54
+ toggleComparePanel: () => void;
55
+ setCompareBaseModelId: (id: string | null) => void;
56
+ setCompareHeadModelId: (id: string | null) => void;
57
+ setCompareScope: (scope: DiffScope) => void;
58
+ setCompareShowUnchanged: (show: boolean) => void;
59
+ setCompareResult: (result: CompareResult | null) => void;
60
+ setCompareRunning: (running: boolean) => void;
61
+ setCompareError: (error: string | null) => void;
62
+ setCompareSelectedKey: (key: string | null) => void;
63
+ /** Clear the run result + selection; keeps the A/B + scope choices. */
64
+ clearCompare: () => void;
65
+ }
66
+
67
+ export const createCompareSlice: StateCreator<CompareSlice, [], [], CompareSlice> = (set) => ({
68
+ comparePanelVisible: false,
69
+ compareBaseModelId: null,
70
+ compareHeadModelId: null,
71
+ compareScope: 'both',
72
+ compareShowUnchanged: false,
73
+ compareResult: null,
74
+ compareRunning: false,
75
+ compareError: null,
76
+ compareSelectedKey: null,
77
+
78
+ setComparePanelVisible: (comparePanelVisible) => set({ comparePanelVisible }),
79
+ toggleComparePanel: () => set((s) => ({ comparePanelVisible: !s.comparePanelVisible })),
80
+ setCompareBaseModelId: (compareBaseModelId) => set({ compareBaseModelId }),
81
+ setCompareHeadModelId: (compareHeadModelId) => set({ compareHeadModelId }),
82
+ setCompareScope: (compareScope) => set({ compareScope }),
83
+ setCompareShowUnchanged: (compareShowUnchanged) => set({ compareShowUnchanged }),
84
+ setCompareResult: (compareResult) => set({ compareResult }),
85
+ setCompareRunning: (compareRunning) => set({ compareRunning }),
86
+ setCompareError: (compareError) => set({ compareError }),
87
+ setCompareSelectedKey: (compareSelectedKey) => set({ compareSelectedKey }),
88
+
89
+ clearCompare: () =>
90
+ set({
91
+ compareResult: null,
92
+ compareRunning: false,
93
+ compareError: null,
94
+ compareSelectedKey: null,
95
+ }),
96
+ });
@@ -98,6 +98,11 @@ export interface LensSlice {
98
98
  lensPanelVisible: boolean;
99
99
  /** Computed: globalId → hex color for entities matched by active lens */
100
100
  lensColorMap: Map<number, string>;
101
+ /** The exact RGBA overlay the active lens last pushed to the shared color
102
+ * channel, or null when no lens is active. Lets another channel owner
103
+ * (e.g. the compare overlay) hand control back to the lens on teardown
104
+ * instead of clearing it. */
105
+ lensAppliedColors: Map<number, [number, number, number, number]> | null;
101
106
  /** Computed: globalIds to hide via lens rules */
102
107
  lensHiddenIds: Set<number>;
103
108
  /** Computed: ruleId → matched entity count for the active lens */
@@ -117,6 +122,7 @@ export interface LensSlice {
117
122
  toggleLensPanel: () => void;
118
123
  setLensPanelVisible: (visible: boolean) => void;
119
124
  setLensColorMap: (map: Map<number, string>) => void;
125
+ setLensAppliedColors: (map: Map<number, [number, number, number, number]> | null) => void;
120
126
  setLensHiddenIds: (ids: Set<number>) => void;
121
127
  setLensRuleCounts: (counts: Map<string, number>) => void;
122
128
  setLensRuleEntityIds: (ids: Map<string, number[]>) => void;
@@ -147,6 +153,7 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
147
153
  activeLensId: null,
148
154
  lensPanelVisible: false,
149
155
  lensColorMap: new Map(),
156
+ lensAppliedColors: null,
150
157
  lensHiddenIds: new Set(),
151
158
  lensRuleCounts: new Map(),
152
159
  lensRuleEntityIds: new Map(),
@@ -183,6 +190,7 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
183
190
  setLensPanelVisible: (lensPanelVisible) => set({ lensPanelVisible }),
184
191
 
185
192
  setLensColorMap: (lensColorMap) => set({ lensColorMap }),
193
+ setLensAppliedColors: (lensAppliedColors) => set({ lensAppliedColors }),
186
194
  setLensHiddenIds: (lensHiddenIds) => set({ lensHiddenIds }),
187
195
  setLensRuleCounts: (lensRuleCounts) => set({ lensRuleCounts }),
188
196
  setLensRuleEntityIds: (lensRuleEntityIds) => set({ lensRuleEntityIds }),
@@ -192,15 +192,23 @@ describe('acquireFileBuffer', () => {
192
192
  );
193
193
  }
194
194
 
195
- // Sanity check: the IFC addModel path SHOULD still use acquireFileBuffer
196
- // (STEP/IFC binary path benefits from SAB streaming).
195
+ // Sanity check: the IFC/STEP path still SAB-streams. addModel now delegates
196
+ // to the canonical loadFile (one load path), so the acquireFileBuffer SAB
197
+ // streaming lives there — assert addModel routes through loadFile, and that
198
+ // loadFile keeps acquireFileBuffer for the STEP/IFC binary path. (IFCX is
199
+ // still guarded above: its federation entry points stay on file.arrayBuffer.)
197
200
  const addModelStart = source.indexOf('const addModel = useCallback');
198
201
  assert.ok(addModelStart >= 0, 'expected addModel declaration');
199
202
  const addModelEnd = source.indexOf('}, [', addModelStart);
200
203
  const addModelBody = source.slice(addModelStart, addModelEnd);
201
204
  assert.ok(
202
- addModelBody.includes('acquireFileBuffer'),
203
- 'addModel (IFC/STEP path) must keep using acquireFileBuffer() for SAB streaming',
205
+ addModelBody.includes('loadFile('),
206
+ 'addModel must delegate to the canonical loadFile (one load path)',
207
+ );
208
+ const loaderSource = readFileSync(join(here, '..', 'hooks', 'useIfcLoader.ts'), 'utf8');
209
+ assert.ok(
210
+ loaderSource.includes('acquireFileBuffer'),
211
+ 'loadFile (IFC/STEP path) must keep using acquireFileBuffer() for SAB streaming',
204
212
  );
205
213
  });
206
214
 
@@ -346,13 +346,14 @@ export async function restoreDesktopMetadataSnapshot(
346
346
  };
347
347
  dataStore.spatialHierarchy = deserializeSpatialHierarchy(metadata.spatialHierarchy);
348
348
 
349
- const { onDemandPropertyMap, onDemandQuantityMap } = rebuildOnDemandMaps(
349
+ const { onDemandPropertyMap, onDemandQuantityMap, onDemandMaterialMap } = rebuildOnDemandMaps(
350
350
  dataStore.entities,
351
351
  dataStore.relationships,
352
352
  dataStore.entityIndex,
353
353
  );
354
354
  dataStore.onDemandPropertyMap = onDemandPropertyMap;
355
355
  dataStore.onDemandQuantityMap = onDemandQuantityMap;
356
+ dataStore.onDemandMaterialMap = onDemandMaterialMap;
356
357
 
357
358
  return dataStore;
358
359
  }
@@ -44,3 +44,35 @@ export function buildSpatialIndexGuarded(
44
44
  console.warn('[loadingUtils] Failed to build spatial index:', err);
45
45
  });
46
46
  }
47
+
48
+ /**
49
+ * Build a spatial index for a specific (e.g. federated) model.
50
+ *
51
+ * Unlike {@link buildSpatialIndexGuarded}, this never touches the active-model
52
+ * slot: a federated model is usually not the active one, so guarding on / writing
53
+ * through `ifcDataStore` (`setIfcDataStore`) would either discard the index or
54
+ * mutate the wrong model. Instead it guards on the target model still holding the
55
+ * same store and publishes through `updateModel(modelId, ...)`.
56
+ *
57
+ * @param meshes - Final mesh array with correct IDs and world-space positions
58
+ * @param modelId - The federated model to attach the spatial index to
59
+ * @param dataStore - That model's IfcDataStore (mutated in place)
60
+ */
61
+ export function buildSpatialIndexForModel(
62
+ meshes: MeshData[],
63
+ modelId: string,
64
+ dataStore: IfcDataStore,
65
+ ): void {
66
+ if (meshes.length === 0) return;
67
+
68
+ buildSpatialIndexAsync(meshes).then(spatialIndex => {
69
+ const state = useViewerStore.getState();
70
+ const model = state.models.get(modelId);
71
+ // Model removed, or its store was replaced since this build started.
72
+ if (!model || model.ifcDataStore !== dataStore) return;
73
+ dataStore.spatialIndex = spatialIndex;
74
+ state.updateModel(modelId, { ifcDataStore: dataStore });
75
+ }).catch(err => {
76
+ console.warn('[loadingUtils] Failed to build spatial index for model:', err);
77
+ });
78
+ }
@@ -11,7 +11,7 @@ import {
11
11
  RelationshipType,
12
12
  StringTable,
13
13
  } from '@ifc-lite/data';
14
- import { rebuildSpatialHierarchy } from './spatialHierarchy';
14
+ import { rebuildSpatialHierarchy, rebuildOnDemandMaps } from './spatialHierarchy';
15
15
 
16
16
  describe('rebuildSpatialHierarchy', () => {
17
17
  it('preserves IFC4.3 facility-part trees during cache rebuilds', () => {
@@ -152,3 +152,55 @@ describe('rebuildSpatialHierarchy', () => {
152
152
  assert.equal(hierarchy.elementToStorey.get(6), 3);
153
153
  });
154
154
  });
155
+
156
+ describe('rebuildOnDemandMaps', () => {
157
+ const makeEntityIndex = (byType: Map<string, number[]>) => ({
158
+ byId: { get: () => undefined, has: () => false, size: 0 },
159
+ byType,
160
+ });
161
+
162
+ it('rebuilds onDemandMaterialMap from AssociatesMaterial edges (cache parity)', () => {
163
+ const strings = new StringTable();
164
+ const entities = new EntityTableBuilder(2, strings);
165
+ entities.add(5, 'IFCBEAM', 'b0', 'Beam', '', '', true);
166
+ entities.add(10, 'IFCMATERIAL', 'm0', 'Concrete', '', '');
167
+
168
+ const builder = new RelationshipGraphBuilder();
169
+ // material(10) -> element(5) forward, matching the columnar parser.
170
+ builder.addEdge(10, 5, RelationshipType.AssociatesMaterial, 100);
171
+ // pset(20) -> element(5), so the property map still rebuilds too.
172
+ builder.addEdge(20, 5, RelationshipType.DefinesByProperties, 101);
173
+
174
+ const entityIndex = makeEntityIndex(new Map<string, number[]>([
175
+ ['IFCMATERIAL', [10]],
176
+ ['IFCPROPERTYSET', [20]],
177
+ ]));
178
+
179
+ const { onDemandMaterialMap, onDemandPropertyMap } = rebuildOnDemandMaps(
180
+ entities.build(),
181
+ builder.build(),
182
+ entityIndex,
183
+ );
184
+
185
+ assert.equal(onDemandMaterialMap.size, 1);
186
+ assert.equal(onDemandMaterialMap.get(5), 10);
187
+ assert.deepEqual(onDemandPropertyMap.get(5), [20]);
188
+ });
189
+
190
+ it('matches material definitions case-insensitively (mixed-case byType keys)', () => {
191
+ const strings = new StringTable();
192
+ const entities = new EntityTableBuilder(2, strings);
193
+ entities.add(5, 'IFCWALL', 'w0', 'Wall', '', '', true);
194
+ entities.add(40, 'IFCMATERIALLAYERSET', 'ls0', 'Buildup', '', '');
195
+
196
+ const builder = new RelationshipGraphBuilder();
197
+ builder.addEdge(40, 5, RelationshipType.AssociatesMaterial, 100);
198
+
199
+ const entityIndex = makeEntityIndex(new Map<string, number[]>([
200
+ ['IfcMaterialLayerSet', [40]], // mixed-case, as some cache writers emit
201
+ ]));
202
+
203
+ const { onDemandMaterialMap } = rebuildOnDemandMaps(entities.build(), builder.build(), entityIndex);
204
+ assert.equal(onDemandMaterialMap.get(5), 40);
205
+ });
206
+ });
@@ -208,8 +208,22 @@ export interface EntityIndex {
208
208
  export interface OnDemandMaps {
209
209
  onDemandPropertyMap: Map<number, number[]>;
210
210
  onDemandQuantityMap: Map<number, number[]>;
211
+ /** element/type expressId -> associated material definition expressId. */
212
+ onDemandMaterialMap: Map<number, number>;
211
213
  }
212
214
 
215
+ /** IFC material *definition* classes that can be the RelatingMaterial of an
216
+ * IfcRelAssociatesMaterial — the source nodes of AssociatesMaterial edges. */
217
+ const MATERIAL_DEF_TYPES = new Set([
218
+ 'IFCMATERIAL',
219
+ 'IFCMATERIALLAYERSET',
220
+ 'IFCMATERIALLAYERSETUSAGE',
221
+ 'IFCMATERIALPROFILESET',
222
+ 'IFCMATERIALPROFILESETUSAGE',
223
+ 'IFCMATERIALCONSTITUENTSET',
224
+ 'IFCMATERIALLIST',
225
+ ]);
226
+
213
227
  /**
214
228
  * Rebuild on-demand property/quantity maps from relationships and entity types
215
229
  * Uses FORWARD direction: pset -> elements (more efficient than inverse lookup)
@@ -228,6 +242,7 @@ export function rebuildOnDemandMaps(
228
242
  ): OnDemandMaps {
229
243
  const onDemandPropertyMap = new Map<number, number[]>();
230
244
  const onDemandQuantityMap = new Map<number, number[]>();
245
+ const onDemandMaterialMap = new Map<number, number>();
231
246
 
232
247
  // Use entityIndex.byType if available (needed for cache loads where entity table
233
248
  // doesn't include IfcPropertySet/IfcElementQuantity entities)
@@ -288,8 +303,33 @@ export function rebuildOnDemandMaps(
288
303
  }
289
304
  }
290
305
 
306
+ // Process material associations (FORWARD: material definition -> elements),
307
+ // mirroring the columnar parser's onDemandMaterialMap. Needed so cache-loaded
308
+ // models populate the Materials tab + per-material totals, which read this map
309
+ // (the relationship-graph fallback only covers single-element lookups, not the
310
+ // model-wide usage index). Requires entityIndex.byType to enumerate material
311
+ // definitions — the cached graph preserves AssociatesMaterial edges.
312
+ let materialDefCount = 0;
313
+ if (entityIndex?.byType) {
314
+ for (const [typeKey, ids] of entityIndex.byType) {
315
+ if (!MATERIAL_DEF_TYPES.has(typeKey.toUpperCase())) continue;
316
+ for (const materialId of ids) {
317
+ materialDefCount += 1;
318
+ const associated = relationships.getRelated(
319
+ materialId,
320
+ RelationshipType.AssociatesMaterial,
321
+ 'forward'
322
+ );
323
+ for (const entityId of associated) {
324
+ // Last association wins, matching the columnar parser's `.set` build.
325
+ onDemandMaterialMap.set(entityId, materialId);
326
+ }
327
+ }
328
+ }
329
+ }
330
+
291
331
  console.log(
292
- `[spatialHierarchy] Rebuilt on-demand maps: ${propertySets.length} psets, ${quantitySets.length} qsets -> ${onDemandPropertyMap.size} entities with properties, ${onDemandQuantityMap.size} with quantities`
332
+ `[spatialHierarchy] Rebuilt on-demand maps: ${propertySets.length} psets, ${quantitySets.length} qsets, ${materialDefCount} material defs -> ${onDemandPropertyMap.size} entities with properties, ${onDemandQuantityMap.size} with quantities, ${onDemandMaterialMap.size} with materials`
293
333
  );
294
- return { onDemandPropertyMap, onDemandQuantityMap };
334
+ return { onDemandPropertyMap, onDemandQuantityMap, onDemandMaterialMap };
295
335
  }
package/src/vite-env.d.ts CHANGED
@@ -17,6 +17,8 @@ interface ImportMetaEnv {
17
17
  readonly VITE_LLM_IMAGE_MODELS?: string;
18
18
  /** Comma-separated model IDs that support file attachment context */
19
19
  readonly VITE_LLM_FILE_ATTACHMENT_MODELS?: string;
20
+ /** Build-time default Cesium ion access token */
21
+ readonly VITE_CESIUM_ION_TOKEN?: string;
20
22
  }
21
23
 
22
24
  interface ImportMeta {