@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
@@ -17,6 +17,9 @@ export interface ListSlice {
17
17
  listResult: ListResult | null;
18
18
  listPanelVisible: boolean;
19
19
  listExecuting: boolean;
20
+ /** A list definition handed off from elsewhere (e.g. "Create list" in the
21
+ * search filter) for the ListPanel to open straight into the builder. */
22
+ pendingListDraft: ListDefinition | null;
20
23
 
21
24
  // Actions
22
25
  setListDefinitions: (definitions: ListDefinition[]) => void;
@@ -28,6 +31,7 @@ export interface ListSlice {
28
31
  setListPanelVisible: (visible: boolean) => void;
29
32
  toggleListPanel: () => void;
30
33
  setListExecuting: (executing: boolean) => void;
34
+ setPendingListDraft: (definition: ListDefinition | null) => void;
31
35
  }
32
36
 
33
37
  export const createListSlice: StateCreator<ListSlice, [], [], ListSlice> = (set, get) => ({
@@ -37,6 +41,7 @@ export const createListSlice: StateCreator<ListSlice, [], [], ListSlice> = (set,
37
41
  listResult: null,
38
42
  listPanelVisible: false,
39
43
  listExecuting: false,
44
+ pendingListDraft: null,
40
45
 
41
46
  // Actions
42
47
  setListDefinitions: (listDefinitions) => {
@@ -71,4 +76,5 @@ export const createListSlice: StateCreator<ListSlice, [], [], ListSlice> = (set,
71
76
  setListPanelVisible: (listPanelVisible) => set({ listPanelVisible }),
72
77
  toggleListPanel: () => set((state) => ({ listPanelVisible: !state.listPanelVisible })),
73
78
  setListExecuting: (listExecuting) => set({ listExecuting }),
79
+ setPendingListDraft: (pendingListDraft) => set({ pendingListDraft }),
74
80
  });
@@ -69,6 +69,7 @@ import {
69
69
  computeSlabSplitGeometry,
70
70
  type SlabLikeType,
71
71
  } from '@/lib/slab-edit.js';
72
+ import { getModelLengthUnitScale } from '@/lib/length-unit-scale.js';
72
73
  import type { Point2D } from '@/lib/polygon-clip.js';
73
74
 
74
75
  /**
@@ -676,7 +677,9 @@ function generateChangeSetId(): string {
676
677
  */
677
678
  function getOrCreateStoreEditor(
678
679
  get: () => ViewerState,
679
- set: (partial: Partial<ViewerState>) => void,
680
+ // Editors are cached in-place on the (non-reactive) `storeEditors`
681
+ // Map below, so the Zustand setter is intentionally unused here.
682
+ _set: (partial: Partial<ViewerState>) => void,
680
683
  modelId: string,
681
684
  ): StoreEditor | null {
682
685
  const state = get();
@@ -691,9 +694,14 @@ function getOrCreateStoreEditor(
691
694
  if (!dataStore) return null;
692
695
 
693
696
  const editor = new StoreEditor(dataStore, view);
694
- const next = new Map(state.storeEditors);
695
- next.set(modelId, editor);
696
- set({ storeEditors: next });
697
+ // `storeEditors` is an internal, non-reactive cache (no component
698
+ // subscribes to it). Mutate the existing Map in place rather than
699
+ // `set({...})` the read functions (readSlabFootprint, etc.) call
700
+ // this during render via GeometryEditCard's `splittable` memo, and a
701
+ // reactive `set()` there triggers React's "cannot update a component
702
+ // while rendering a different component" warning. In-place caching
703
+ // keeps the editor memoised without scheduling a render-phase update.
704
+ state.storeEditors.set(modelId, editor);
697
705
  return editor;
698
706
  }
699
707
 
@@ -1862,7 +1870,7 @@ export const createMutationSlice: StateCreator<
1862
1870
  if (!editor) return null;
1863
1871
  const dataStore = get().models.get(modelId)?.ifcDataStore;
1864
1872
  if (!dataStore) return null;
1865
- const chain = resolveSlabEditChain(dataStore, view, editor, expressId);
1873
+ const chain = resolveSlabEditChain(dataStore, view, editor, expressId, getModelLengthUnitScale(dataStore));
1866
1874
  if (!chain) return null;
1867
1875
  const storeyId = dataStore.spatialHierarchy?.elementToStorey.get(expressId);
1868
1876
  const storeyElevation =
@@ -1883,7 +1891,7 @@ export const createMutationSlice: StateCreator<
1883
1891
  const { view, editor, dataStore, storeyExpressId } = ctx;
1884
1892
  const state = get();
1885
1893
 
1886
- const chain = resolveSlabEditChain(dataStore, view, editor, expressId);
1894
+ const chain = resolveSlabEditChain(dataStore, view, editor, expressId, getModelLengthUnitScale(dataStore));
1887
1895
  if (!chain) {
1888
1896
  return {
1889
1897
  ok: false,
@@ -24,7 +24,7 @@ import type { StateCreator } from 'zustand';
24
24
  import type { Tier1Index } from '@/lib/search/tier1-index';
25
25
  import type { SearchResult, MatchField } from '@/lib/search/tier0-scan';
26
26
  import type { FilterRule, Combinator } from '@/lib/search/filter-rules';
27
- import type { FilterSchema, PsetQtoSchema } from '@/lib/search/filter-schema';
27
+ import type { FilterSchema, PsetQtoSchema, FilterValueSchema } from '@/lib/search/filter-schema';
28
28
 
29
29
  /** Index lifecycle state for a single model. */
30
30
  export type Tier1IndexStatus = 'pending' | 'building' | 'ready' | 'error';
@@ -60,6 +60,10 @@ export interface SearchVimCycleState {
60
60
  */
61
61
  export type SearchFieldFilter = MatchField | 'all';
62
62
 
63
+ /** Which tab the advanced modal renders. Lets callers (e.g. the inline
64
+ * filter button) open straight to the Filter builder. */
65
+ export type SearchModalTab = 'search' | 'filter';
66
+
63
67
  /**
64
68
  * Tabular result from a Filter run. Flat snapshot so the modal can
65
69
  * re-render rows without holding live evaluator state.
@@ -94,6 +98,9 @@ export interface FilterSchemaCacheEntry {
94
98
  basic: FilterSchema;
95
99
  /** Expensive pass — pset / qto names. Lazy; null until first request. */
96
100
  psetQto: PsetQtoSchema | null;
101
+ /** Expensive pass — distinct material / classification / property values
102
+ * for chip value suggestions. Lazy; null until first request. */
103
+ values: FilterValueSchema | null;
97
104
  }
98
105
 
99
106
  export interface SearchSlice {
@@ -109,6 +116,8 @@ export interface SearchSlice {
109
116
  searchVimCycle: SearchVimCycleState | null;
110
117
  /** Advanced search modal (⌘⇧F) is open. */
111
118
  searchModalOpen: boolean;
119
+ /** Which tab the advanced modal shows. Remembered across opens. */
120
+ searchModalTab: SearchModalTab;
112
121
  /** Field chip filter active inside the modal. Defaults to 'all'. */
113
122
  searchFieldFilter: SearchFieldFilter;
114
123
  /** Per-modelId include filter inside the modal. `null` means all models included. */
@@ -147,6 +156,7 @@ export interface SearchSlice {
147
156
 
148
157
  setSearchModalOpen: (open: boolean) => void;
149
158
  toggleSearchModal: () => void;
159
+ setSearchModalTab: (tab: SearchModalTab) => void;
150
160
  setSearchFieldFilter: (filter: SearchFieldFilter) => void;
151
161
  /** Toggle a model in/out of the include filter. If the filter is null,
152
162
  * the first toggle materialises it as "all models except this one". */
@@ -177,6 +187,7 @@ export interface SearchSlice {
177
187
  // ── Schema cache actions ──────────────────────────────────────────
178
188
  setFilterSchema: (modelId: string, basic: FilterSchema) => void;
179
189
  setFilterPsetQtoSchema: (modelId: string, psetQto: PsetQtoSchema) => void;
190
+ setFilterValueSchema: (modelId: string, values: FilterValueSchema) => void;
180
191
  removeFilterSchema: (modelId: string) => void;
181
192
  }
182
193
 
@@ -187,6 +198,7 @@ export const createSearchSlice: StateCreator<SearchSlice, [], [], SearchSlice> =
187
198
  searchIndexes: new Map(),
188
199
  searchVimCycle: null,
189
200
  searchModalOpen: false,
201
+ searchModalTab: 'search',
190
202
  searchFieldFilter: 'all',
191
203
  searchModelFilter: null,
192
204
  searchFilterResult: null,
@@ -239,6 +251,7 @@ export const createSearchSlice: StateCreator<SearchSlice, [], [], SearchSlice> =
239
251
 
240
252
  setSearchModalOpen: (searchModalOpen) => set({ searchModalOpen }),
241
253
  toggleSearchModal: () => set((state) => ({ searchModalOpen: !state.searchModalOpen })),
254
+ setSearchModalTab: (searchModalTab) => set({ searchModalTab }),
242
255
  setSearchFieldFilter: (searchFieldFilter) => set({ searchFieldFilter }),
243
256
 
244
257
  toggleSearchModelFilter: (modelId, availableModelIds) =>
@@ -316,7 +329,11 @@ export const createSearchSlice: StateCreator<SearchSlice, [], [], SearchSlice> =
316
329
  set((state) => {
317
330
  const next = new Map(state.searchFilterSchema);
318
331
  const existing = next.get(modelId);
319
- next.set(modelId, { basic, psetQto: existing?.psetQto ?? null });
332
+ next.set(modelId, {
333
+ basic,
334
+ psetQto: existing?.psetQto ?? null,
335
+ values: existing?.values ?? null,
336
+ });
320
337
  return { searchFilterSchema: next };
321
338
  }),
322
339
 
@@ -327,7 +344,16 @@ export const createSearchSlice: StateCreator<SearchSlice, [], [], SearchSlice> =
327
344
  // Only meaningful once a basic schema has been set — the modal
328
345
  // calls discoverFilterSchema first then queues the heavy pass.
329
346
  if (!existing) return {};
330
- next.set(modelId, { basic: existing.basic, psetQto });
347
+ next.set(modelId, { ...existing, psetQto });
348
+ return { searchFilterSchema: next };
349
+ }),
350
+
351
+ setFilterValueSchema: (modelId, values) =>
352
+ set((state) => {
353
+ const next = new Map(state.searchFilterSchema);
354
+ const existing = next.get(modelId);
355
+ if (!existing) return {};
356
+ next.set(modelId, { ...existing, values });
331
357
  return { searchFilterSchema: next };
332
358
  }),
333
359
 
@@ -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
+ }
@@ -267,5 +267,11 @@ export function buildIfcDataStoreFromNativeMetadata(snapshot: NativeMetadataSnap
267
267
  quantities: undefined as unknown as IfcDataStore['quantities'],
268
268
  relationships: undefined as unknown as IfcDataStore['relationships'],
269
269
  spatialHierarchy: hierarchy,
270
+ // Native spatial snapshots are metadata-only (no source buffer / property
271
+ // or quantity tables), so the IfcStoreBase accessors return empties.
272
+ getEntity: () => null,
273
+ getEntitiesByType: () => [],
274
+ getProperties: () => [],
275
+ getQuantities: () => [],
270
276
  } as IfcDataStore;
271
277
  }
@@ -38,6 +38,9 @@ describe('convertServerDataModel', () => {
38
38
  { rel_type: 'IFCRELAGGREGATES', relating_id: 2, related_id: 3 },
39
39
  { rel_type: 'IFCRELCONTAINEDINSPATIALSTRUCTURE', relating_id: 3, related_id: 4 },
40
40
  ],
41
+ classifications: [],
42
+ materials: [],
43
+ documents: [],
41
44
  spatialHierarchy: {
42
45
  nodes: [
43
46
  {
@@ -101,6 +104,9 @@ describe('convertServerDataModel', () => {
101
104
  { rel_type: 'IFCRELNESTS', relating_id: 1, related_id: 2 },
102
105
  { rel_type: 'IFCRELASSOCIATESDOCUMENT', relating_id: 3, related_id: 2 },
103
106
  ],
107
+ classifications: [],
108
+ materials: [],
109
+ documents: [],
104
110
  spatialHierarchy: {
105
111
  nodes: [
106
112
  {
@@ -711,5 +711,12 @@ export function convertServerDataModel(
711
711
  relationships,
712
712
  spatialHierarchy,
713
713
  spatialIndex,
714
+ // IfcStoreBase accessors: server-parsed models carry pre-built property/
715
+ // quantity tables but no source buffer, so entity extraction is unavailable
716
+ // (the `entities` table remains the primary path for basic attributes).
717
+ getEntity: () => null,
718
+ getEntitiesByType: () => [],
719
+ getProperties: (expressId: number) => properties.getForEntity(expressId),
720
+ getQuantities: (expressId: number) => quantities.getForEntity(expressId),
714
721
  };
715
722
  }
@@ -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 {
@@ -1 +0,0 @@
1
- import{i as r}from"./pako.esm-Cram60i4.js";import{B as o}from"./geotiff-A5UjhI6L.js";import"./sandbox-CsRXlgCO.js";import"./lens-PYsLu_MA.js";import"./__vite-browser-external-B1O5LaIO.js";class d extends o{decodeBlock(e){return r(new Uint8Array(e)).buffer}}export{d as default};