@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
@@ -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;
@@ -3,16 +3,11 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  /**
6
- * Platform-agnostic cache service
7
- * Dynamically loads the appropriate cache implementation based on platform:
8
- * - Tauri (desktop): Uses native filesystem via desktop-cache.ts
9
- * - Web: Uses IndexedDB via ifc-cache.ts
6
+ * Cache service — IndexedDB-backed cache (ifc-cache.ts) for the web build.
10
7
  *
11
8
  * Extracted from useIfc.ts for reusability and testability
12
9
  */
13
10
 
14
- import { isTauri } from '../utils/ifcConfig.js';
15
-
16
11
  // ============================================================================
17
12
  // Types
18
13
  // ============================================================================
@@ -65,29 +60,18 @@ export interface ICacheService {
65
60
  let cacheService: ICacheService | null = null;
66
61
 
67
62
  /**
68
- * Get the cache service for the current platform
69
- * Lazily loads the appropriate implementation
63
+ * Get the cache service.
64
+ * Lazily loads the IndexedDB implementation.
70
65
  */
71
66
  export async function getCacheService(): Promise<ICacheService> {
72
67
  if (cacheService) return cacheService;
73
68
 
74
- if (isTauri()) {
75
- // Desktop: Use Tauri native filesystem
76
- const mod = await import('./desktop-cache.js');
77
- cacheService = {
78
- getCached: mod.getCached,
79
- setCached: mod.setCached,
80
- deleteCached: mod.deleteCached,
81
- };
82
- } else {
83
- // Web: Use IndexedDB
84
- const mod = await import('./ifc-cache.js');
85
- cacheService = {
86
- getCached: mod.getCached,
87
- setCached: mod.setCached,
88
- deleteCached: mod.deleteCached,
89
- };
90
- }
69
+ const mod = await import('./ifc-cache.js');
70
+ cacheService = {
71
+ getCached: mod.getCached,
72
+ setCached: mod.setCached,
73
+ deleteCached: mod.deleteCached,
74
+ };
91
75
 
92
76
  return cacheService;
93
77
  }
@@ -2,34 +2,8 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { IfcParser, type IfcDataStore } from '@ifc-lite/parser';
5
+ import { type IfcDataStore } from '@ifc-lite/parser';
6
6
  import { getViewerStoreApi } from '@/store';
7
- import { toast } from '@/components/ui/toast';
8
- import { readNativeFile } from '@/services/file-dialog';
9
-
10
- const exportHydrationByModel = new Map<string, Promise<IfcDataStore | null>>();
11
-
12
- function isDesktopRuntime(): boolean {
13
- // `globalThis` and `Window` aren't structurally compatible per TS, so
14
- // route through `unknown` first — the cast is intentional.
15
- const win = globalThis as unknown as Window & { __TAURI_INTERNALS__?: { invoke?: unknown } };
16
- return typeof win.__TAURI_INTERNALS__?.invoke === 'function';
17
- }
18
-
19
- function hasFullStepSource(dataStore: IfcDataStore | null | undefined): dataStore is IfcDataStore {
20
- return Boolean(dataStore?.source?.length && dataStore.entityIndex?.byId?.size);
21
- }
22
-
23
- function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
24
- if (
25
- bytes.buffer instanceof ArrayBuffer
26
- && bytes.byteOffset === 0
27
- && bytes.byteLength === bytes.buffer.byteLength
28
- ) {
29
- return bytes.buffer;
30
- }
31
- return bytes.slice().buffer;
32
- }
33
7
 
34
8
  export async function ensureModelExportReady(modelId: string): Promise<IfcDataStore | null> {
35
9
  const store = getViewerStoreApi();
@@ -44,36 +18,5 @@ export async function ensureModelExportReady(modelId: string): Promise<IfcDataSt
44
18
  return null;
45
19
  }
46
20
 
47
- if (hasFullStepSource(model.ifcDataStore)) {
48
- return model.ifcDataStore;
49
- }
50
-
51
- if (!isDesktopRuntime() || !model.nativeMetadata?.filePath) {
52
- return model.ifcDataStore;
53
- }
54
-
55
- const pending = exportHydrationByModel.get(modelId);
56
- if (pending) {
57
- return pending;
58
- }
59
-
60
- const hydrationPromise = (async () => {
61
- toast.info(`Preparing ${model.name} for IFC export...`);
62
- const bytes = await readNativeFile(model.nativeMetadata!.filePath);
63
- const parser = new IfcParser();
64
- const hydratedStore = await parser.parseColumnar(toExactArrayBuffer(bytes));
65
-
66
- store.getState().updateModel(modelId, {
67
- ifcDataStore: hydratedStore,
68
- schemaVersion: hydratedStore.schemaVersion,
69
- metadataLoadState: 'complete',
70
- });
71
-
72
- return hydratedStore;
73
- })().finally(() => {
74
- exportHydrationByModel.delete(modelId);
75
- });
76
-
77
- exportHydrationByModel.set(modelId, hydrationPromise);
78
- return hydrationPromise;
21
+ return model.ifcDataStore;
79
22
  }
@@ -2,151 +2,17 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- type InvokeFn = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
6
-
7
- export interface NativeFileHandle {
8
- path: string;
9
- name: string;
10
- size: number;
11
- modifiedMs?: number | null;
12
- }
13
-
14
5
  export interface GenericFileDialogOptions {
15
6
  title?: string;
16
7
  filters?: Array<{ name: string; extensions: string[] }>;
17
8
  }
18
9
 
19
- async function loadInvokeFromTauriModule(): Promise<InvokeFn | null> {
20
- try {
21
- const core = await import('@tauri-apps/api/core');
22
- return typeof core.invoke === 'function' ? core.invoke as InvokeFn : null;
23
- } catch {
24
- return null;
25
- }
26
- }
27
-
28
- async function loadDialogModule(): Promise<{
29
- open: (options?: {
30
- multiple?: boolean;
31
- directory?: boolean;
32
- filters?: Array<{ name: string; extensions: string[] }>;
33
- title?: string;
34
- }) => Promise<string | string[] | null>;
35
- } | null> {
36
- try {
37
- return await import('@tauri-apps/plugin-dialog');
38
- } catch {
39
- return null;
40
- }
41
- }
42
-
43
- async function loadFsModule(): Promise<{
44
- stat: (path: string) => Promise<{ size: number }>;
45
- } | null> {
46
- try {
47
- return await import('@tauri-apps/plugin-fs');
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
- async function getInvoke(): Promise<InvokeFn> {
54
- const win = globalThis as unknown as { __TAURI_INTERNALS__?: { invoke: InvokeFn } };
55
- if (win.__TAURI_INTERNALS__?.invoke) {
56
- return win.__TAURI_INTERNALS__.invoke;
57
- }
58
- const moduleInvoke = await loadInvokeFromTauriModule();
59
- if (moduleInvoke) {
60
- return moduleInvoke;
61
- }
62
- throw new Error('Tauri API not available');
63
- }
64
-
65
- export async function openIfcFileDialog(): Promise<NativeFileHandle | null> {
66
- try {
67
- const invoke = await getInvoke();
68
- return await invoke<NativeFileHandle | null>('open_ifc_file');
69
- } catch {
70
- // Expected in browser builds — fall through to plugin fallback, then browser file input.
71
- }
72
-
73
- try {
74
- const dialog = await loadDialogModule();
75
- const fs = await loadFsModule();
76
- if (!dialog || !fs) {
77
- return null;
78
- }
79
-
80
- const selected = await dialog.open({
81
- multiple: false,
82
- directory: false,
83
- title: 'Open IFC, Mesh or Point Cloud File',
84
- filters: [
85
- { name: 'IFC Files', extensions: ['ifc', 'ifczip', 'ifcxml', 'ifcx'] },
86
- { name: 'Mesh Files', extensions: ['glb'] },
87
- { name: 'Point Clouds', extensions: ['las', 'laz', 'ply', 'pcd', 'e57'] },
88
- { name: 'All Files', extensions: ['*'] },
89
- ],
90
- });
91
- if (!selected || Array.isArray(selected)) {
92
- return null;
93
- }
94
-
95
- const metadata = await fs.stat(selected);
96
- const normalizedPath = selected.toString();
97
- const pathSegments = normalizedPath.split(/[\\/]/);
98
- const name = pathSegments[pathSegments.length - 1] || 'unknown.ifc';
99
-
100
- return {
101
- path: normalizedPath,
102
- name,
103
- size: metadata.size,
104
- };
105
- } catch {
106
- // No Tauri plugin available — caller falls back to browser <input type="file">.
107
- return null;
108
- }
109
- }
110
-
111
- export async function readNativeFile(path: string): Promise<Uint8Array> {
112
- try {
113
- const invoke = await getInvoke();
114
- const bytes = await invoke<number[]>('read_native_file', { path });
115
- return Uint8Array.from(bytes);
116
- } catch (error) {
117
- console.warn('[FileDialog] Falling back to plugin-fs read for native file:', error);
118
- const fs = await import('@tauri-apps/plugin-fs');
119
- return fs.readFile(path);
120
- }
121
- }
122
-
123
- export async function openGenericFileDialog(options: GenericFileDialogOptions = {}): Promise<File | null> {
124
- try {
125
- const dialog = await loadDialogModule();
126
- if (!dialog) {
127
- return null;
128
- }
129
-
130
- const selected = await dialog.open({
131
- multiple: false,
132
- directory: false,
133
- title: options.title,
134
- filters: options.filters,
135
- });
136
- if (!selected || Array.isArray(selected)) {
137
- return null;
138
- }
139
-
140
- const normalizedPath = selected.toString();
141
- const bytes = await readNativeFile(normalizedPath);
142
- const pathSegments = normalizedPath.split(/[\\/]/);
143
- const name = pathSegments[pathSegments.length - 1] || 'document';
144
- // Slice to a fresh ArrayBuffer view — TS5+ narrows `Uint8Array` to
145
- // `Uint8Array<ArrayBufferLike>` which `BlobPart` doesn't accept.
146
- const blobPart = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength).slice();
147
- return new File([blobPart], name, { type: 'application/octet-stream' });
148
- } catch (error) {
149
- console.warn('[FileDialog] Failed to open generic native file dialog:', error);
150
- return null;
151
- }
10
+ /**
11
+ * Browser-only build: there is no native OS file dialog, so this resolves to
12
+ * `null` and callers fall back to a browser `<input type="file">`. (ifc-lite no
13
+ * longer ships a desktop app; third parties building their own desktop shell on
14
+ * the published packages supply native file access in their own host layer.)
15
+ */
16
+ export async function openGenericFileDialog(_options: GenericFileDialogOptions = {}): Promise<null> {
17
+ return null;
152
18
  }
@@ -222,6 +222,29 @@ export function getPersistedTypeVisibility(): TypeVisibility {
222
222
  };
223
223
  }
224
224
 
225
+ /**
226
+ * The 3D view mode for the Model/Types switch (#957 follow-up).
227
+ * 'model' — show placed occurrences (the default; the building as designed).
228
+ * 'types' — show the type-library shapes (each IfcTypeProduct's
229
+ * RepresentationMap at its MappingOrigin), hiding occurrences.
230
+ * Orphan type geometry (a type with no occurrence, e.g. annex-E showcase files)
231
+ * shows in BOTH modes since it is the only geometry the file has.
232
+ */
233
+ export type TypeViewMode = 'model' | 'types';
234
+
235
+ export const TYPE_VIEW_MODE_STORAGE_KEY = 'ifc-lite-type-view-mode';
236
+ export const TYPE_VIEW_MODE_DEFAULT: TypeViewMode = 'model';
237
+
238
+ /** Resolve the persisted Model/Types view mode (read fresh, like type visibility). */
239
+ export function getPersistedTypeViewMode(): TypeViewMode {
240
+ if (typeof window === 'undefined') return TYPE_VIEW_MODE_DEFAULT;
241
+ try {
242
+ return localStorage.getItem(TYPE_VIEW_MODE_STORAGE_KEY) === 'types' ? 'types' : 'model';
243
+ } catch {
244
+ return TYPE_VIEW_MODE_DEFAULT;
245
+ }
246
+ }
247
+
225
248
  // ============================================================================
226
249
  // Data Defaults
227
250
  // ============================================================================
@@ -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,10 +34,10 @@ 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';
40
- import { createDesktopEntitlementSlice, type DesktopEntitlementSlice } from './slices/desktopEntitlementSlice.js';
41
41
  import { createScheduleSlice, type ScheduleSlice } from './slices/scheduleSlice.js';
42
42
  import { createPlaybackSlice, type PlaybackSlice } from './slices/playbackSlice.js';
43
43
  import { createOverlaySlice, type OverlaySlice } from './slices/overlaySlice.js';
@@ -50,7 +50,7 @@ import { createPointCloudSlice, type PointCloudSlice, POINT_CLOUD_DEFAULTS } fro
50
50
  import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
51
51
 
52
52
  // Import constants for reset function
53
- import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, getPersistedTypeVisibility } from './constants.js';
53
+ import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, getPersistedTypeVisibility, getPersistedTypeViewMode } from './constants.js';
54
54
 
55
55
  // Re-export types for consumers
56
56
  export type * from './types.js';
@@ -85,13 +85,13 @@ export type { PinboardSlice } from './slices/pinboardSlice.js';
85
85
 
86
86
  // Re-export Lens types
87
87
  export type { LensSlice, Lens, LensRule, LensCriteria } from './slices/lensSlice.js';
88
+ export type { CompareSlice, CompareResult } from './slices/compareSlice.js';
88
89
 
89
90
  // Re-export Script types
90
91
  export type { ScriptSlice } from './slices/scriptSlice.js';
91
92
 
92
93
  // Re-export Chat types
93
94
  export type { ChatSlice } from './slices/chatSlice.js';
94
- export type { DesktopEntitlementSlice } from './slices/desktopEntitlementSlice.js';
95
95
 
96
96
  // Re-export Cesium types
97
97
  export type { CesiumSlice, CesiumDataSource, CesiumPlacementDraft } from './slices/cesiumSlice.js';
@@ -131,10 +131,10 @@ export type ViewerState = LoadingSlice &
131
131
  PinboardSlice &
132
132
  LensSlice &
133
133
  ClashSlice &
134
+ CompareSlice &
134
135
  ScriptSlice &
135
136
  ChatSlice &
136
137
  CesiumSlice &
137
- DesktopEntitlementSlice &
138
138
  ScheduleSlice &
139
139
  PlaybackSlice &
140
140
  OverlaySlice &
@@ -155,7 +155,7 @@ export type ViewerState = LoadingSlice &
155
155
  * the right panel. Routed through by the toolbar, command palette, and the
156
156
  * BCF overlay so every entry point behaves identically.
157
157
  */
158
- openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') => void;
158
+ openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions') => void;
159
159
  };
160
160
 
161
161
  /**
@@ -182,10 +182,10 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
182
182
  ...createPinboardSlice(...args),
183
183
  ...createLensSlice(...args),
184
184
  ...createClashSlice(...args),
185
+ ...createCompareSlice(...args),
185
186
  ...createScriptSlice(...args),
186
187
  ...createChatSlice(...args),
187
188
  ...createCesiumSlice(...args),
188
- ...createDesktopEntitlementSlice(...args),
189
189
  ...createScheduleSlice(...args),
190
190
  ...createPlaybackSlice(...args),
191
191
  ...createOverlaySlice(...args),
@@ -211,6 +211,8 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
211
211
  // Selection (multi-model)
212
212
  selectedEntity: null,
213
213
  selectedEntitiesSet: new Set(),
214
+ selectedEntities: [],
215
+ selectedModelId: null,
214
216
 
215
217
  // Visibility (legacy)
216
218
  hiddenEntities: new Set(),
@@ -219,6 +221,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
219
221
  // Re-read persisted toggles on every file load so a new model never
220
222
  // reverts the user's visibility choices (e.g. "Show Annotations").
221
223
  typeVisibility: getPersistedTypeVisibility(),
224
+ typeViewMode: getPersistedTypeViewMode(),
222
225
 
223
226
  // Visibility (multi-model)
224
227
  hiddenEntitiesByModel: new Map(),
@@ -235,6 +238,14 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
235
238
  pendingColorUpdates: null,
236
239
  pendingMeshColorUpdates: null,
237
240
 
241
+ // Compare (#924): drop any stale diff result — it references models by
242
+ // id and the loaded set is changing. Keep panel visibility + A/B/scope
243
+ // choices (UI prefs); the user re-runs against the new set.
244
+ compareResult: null,
245
+ compareSelectedKey: null,
246
+ compareRunning: false,
247
+ compareError: null,
248
+
238
249
  // Hover/Context
239
250
  hoverState: { entityId: null, screenX: 0, screenY: 0 },
240
251
  contextMenu: { isOpen: false, entityId: null, screenX: 0, screenY: 0 },
@@ -316,6 +327,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
316
327
  scale: 100,
317
328
  useSymbolicRepresentations: false,
318
329
  showIfcAnnotations: true,
330
+ showConstructionProjection: false,
319
331
  },
320
332
  // Graphic overrides (keep presets, reset active and custom)
321
333
  activePresetId: 'preset-3d-colors',
@@ -460,6 +472,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
460
472
  idsPanelVisible: panel === 'ids',
461
473
  lensPanelVisible: panel === 'lens',
462
474
  clashPanelVisible: panel === 'clash',
475
+ comparePanelVisible: panel === 'compare',
463
476
  extensionsPanelVisible: panel === 'extensions',
464
477
  rightPanelCollapsed: false,
465
478
  });
@@ -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
+ });