@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.
- package/.turbo/turbo-build.log +38 -38
- package/CHANGELOG.md +64 -0
- package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-BNRDNuUJ.js} +8 -8
- package/dist/assets/{bcf-QeHK_Aud.js → bcf-DCwCuP7n.js} +56 -56
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/{deflate-B-d0SYQM.js → deflate-DNGgs8Ur.js} +1 -1
- package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/{exporters-B4LbZFeT.js → exporters-B9v81gi9.js} +1249 -1140
- package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
- package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-D-YCLS4g.js} +10 -10
- package/dist/assets/{ids-DjsGFN10.js → ids-CCpq-5d3.js} +952 -945
- package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-DbgS5EUA.wasm} +0 -0
- package/dist/assets/{index-COYokSKc.js → index-Bgb3_Pu_.js} +41073 -38715
- package/dist/assets/index-BtbXFKsX.css +1 -0
- package/dist/assets/{index.es-CY202jA3.js → index.es-CWfqZyyr.js} +9 -9
- package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-DGOAeUqU.js} +1 -1
- package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-XPLU2Wkq.js} +4 -4
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-DmW0_tgf.js → lerc-1PMSCHwX.js} +1 -1
- package/dist/assets/{lzw-oWetY-d6.js → lzw-C65U9lNM.js} +1 -1
- package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-XxXos6yI.js} +2 -2
- package/dist/assets/{packbits-F8Nkp4NY.js → packbits-BdMWXC3m.js} +1 -1
- package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
- package/dist/assets/{pdf-Dsh3HPZB.js → pdf-CRwaZf3s.js} +10 -10
- package/dist/assets/raw-CJgQdyuZ.js +1 -0
- package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-0sDo3g3m.js} +2960 -2552
- package/dist/assets/server-client-cTCJ-853.js +719 -0
- package/dist/assets/{webimage-BLV1dgmd.js → webimage-BtakWX7W.js} +1 -1
- package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-B1YOg2QB.js} +8 -8
- package/dist/assets/{zstd-C_1HxVrA.js → zstd-CmwsbxmM.js} +1 -1
- package/dist/index.html +9 -9
- package/package.json +24 -23
- package/src/components/mcp/playground-dispatcher.ts +3 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +46 -7
- package/src/components/viewer/MainToolbar.tsx +19 -2
- package/src/components/viewer/PropertiesPanel.tsx +71 -2
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +3 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +481 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +23 -1
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +32 -9
- package/src/hooks/useIfcFederation.ts +42 -810
- package/src/hooks/useIfcLoader.ts +361 -488
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- package/src/lib/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- package/src/lib/search/result-export.ts +7 -1
- package/src/sdk/adapters/export-adapter.ts +6 -1
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +16 -1
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/desktopModelSnapshot.ts +2 -1
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
- package/dist/assets/index-ajK6D32J.css +0 -1
- package/dist/assets/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-D591Zu_-.js +0 -182
- package/dist/assets/raw-D9iw0tmc.js +0 -1
- package/dist/assets/server-client-Cjwnm7il.js +0 -706
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- 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 {
|
|
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
|
-
|
|
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;
|
package/src/store/globalId.ts
CHANGED
|
@@ -44,23 +44,17 @@ export function fromGlobalIdFromModels(
|
|
|
44
44
|
models: ReverseModelMapLike,
|
|
45
45
|
globalId: number,
|
|
46
46
|
): EntityRef | undefined {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
package/src/store/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
196
|
-
// (
|
|
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('
|
|
203
|
-
'addModel
|
|
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 {
|