@ifc-lite/viewer 1.10.0 → 1.11.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 (44) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/assets/{Arrow.dom-Bw5JMdDs.js → Arrow.dom-p9ppgFLr.js} +1 -1
  3. package/dist/assets/{browser-DdRf3aWl.js → browser-lKzgHsnJ.js} +1 -1
  4. package/dist/assets/{ifc-lite_bg-C1-gLAHo.wasm → ifc-lite_bg-B6s-pcv0.wasm} +0 -0
  5. package/dist/assets/index-BoYyWYAu.css +1 -0
  6. package/dist/assets/{index-1ff6P0kc.js → index-CF854G-8.js} +42703 -41097
  7. package/dist/assets/{index-Bz7vHRxl.js → index-DQlpY6aJ.js} +4 -4
  8. package/dist/assets/{native-bridge-C5hD5vae.js → native-bridge-BgRWyawy.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CaNKXFGM.js → wasm-bridge-BZxGtE7z.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +20 -19
  12. package/src/components/viewer/BasketPresentationDock.tsx +422 -0
  13. package/src/components/viewer/CommandPalette.tsx +29 -32
  14. package/src/components/viewer/EntityContextMenu.tsx +37 -22
  15. package/src/components/viewer/HierarchyPanel.tsx +19 -1
  16. package/src/components/viewer/MainToolbar.tsx +56 -113
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/ThemeSwitch.tsx +55 -0
  19. package/src/components/viewer/Viewport.tsx +66 -105
  20. package/src/components/viewer/ViewportContainer.tsx +2 -0
  21. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  22. package/src/components/viewer/useGeometryStreaming.ts +25 -0
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +10 -3
  25. package/src/hooks/meshColorUpdates.test.ts +56 -0
  26. package/src/hooks/meshColorUpdates.ts +20 -0
  27. package/src/hooks/useIDS.ts +7 -8
  28. package/src/hooks/useIfcLoader.ts +25 -1
  29. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  30. package/src/hooks/useViewerSelectors.ts +4 -0
  31. package/src/store/basket/basketCommands.ts +81 -0
  32. package/src/store/basket/basketViewActivator.ts +54 -0
  33. package/src/store/basketSave.ts +122 -0
  34. package/src/store/basketVisibleSet.test.ts +161 -0
  35. package/src/store/basketVisibleSet.ts +487 -0
  36. package/src/store/homeView.ts +21 -0
  37. package/src/store/index.ts +8 -0
  38. package/src/store/slices/dataSlice.test.ts +53 -4
  39. package/src/store/slices/dataSlice.ts +13 -5
  40. package/src/store/slices/drawing2DSlice.ts +5 -0
  41. package/src/store/slices/pinboardSlice.test.ts +160 -0
  42. package/src/store/slices/pinboardSlice.ts +248 -18
  43. package/src/store/types.ts +11 -0
  44. package/dist/assets/index-mvbV6NHd.css +0 -1
@@ -0,0 +1,487 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
6
+ import type { IfcDataStore } from '@ifc-lite/parser';
7
+ import type { EntityRef } from './types.js';
8
+ import { entityRefToString, stringToEntityRef } from './types.js';
9
+ import { useViewerStore } from './index.js';
10
+
11
+ type ViewerStateSnapshot = ReturnType<typeof useViewerStore.getState>;
12
+
13
+ type VisibleCandidate = {
14
+ globalId: number;
15
+ modelId: string;
16
+ expressId: number;
17
+ ifcType?: string;
18
+ };
19
+
20
+ type BasketVisibleStats = {
21
+ visibleCount: number;
22
+ addCount: number;
23
+ removeCount: number;
24
+ basketCount: number;
25
+ };
26
+
27
+ export type BasketInputSource = 'selection' | 'hierarchy' | 'visible' | 'empty';
28
+
29
+ type CacheEntry = { key: string; refs: EntityRef[] };
30
+ let _visibleCache: CacheEntry | null = null;
31
+
32
+ function digestNumberSet(values: Iterable<number>): string {
33
+ let count = 0;
34
+ let xor = 0;
35
+ let sum = 0;
36
+ for (const v of values) {
37
+ const n = Number.isFinite(v) ? (v | 0) : 0;
38
+ count++;
39
+ xor ^= n;
40
+ sum = (sum + (n >>> 0)) >>> 0;
41
+ }
42
+ return `${count}:${xor >>> 0}:${sum >>> 0}`;
43
+ }
44
+
45
+ function digestModelEntityMap(map: Map<string, Set<number>>): string {
46
+ if (map.size === 0) return '0';
47
+ const parts: string[] = [];
48
+ for (const [modelId, ids] of map) {
49
+ parts.push(`${modelId}:${digestNumberSet(ids)}`);
50
+ }
51
+ parts.sort();
52
+ return parts.join('|');
53
+ }
54
+
55
+ function visibilityFingerprint(state: ViewerStateSnapshot): string {
56
+ const tv = state.typeVisibility;
57
+ return [
58
+ digestNumberSet(state.hiddenEntities),
59
+ state.isolatedEntities ? digestNumberSet(state.isolatedEntities) : 'none',
60
+ digestNumberSet(state.lensHiddenIds),
61
+ digestModelEntityMap(state.hiddenEntitiesByModel),
62
+ digestModelEntityMap(state.isolatedEntitiesByModel),
63
+ tv.spaces ? 1 : 0,
64
+ tv.openings ? 1 : 0,
65
+ tv.site ? 1 : 0,
66
+ state.models.size,
67
+ state.activeBasketViewId ?? 'none',
68
+ ].join(':');
69
+ }
70
+
71
+ export function invalidateVisibleBasketCache(): void {
72
+ _visibleCache = null;
73
+ }
74
+
75
+ const STOREY_TYPE = 'IfcBuildingStorey';
76
+ const SPATIAL_CONTAINER_TYPES = new Set(['IfcProject', 'IfcSite', 'IfcBuilding']);
77
+
78
+ function dedupeRefs(refs: EntityRef[]): EntityRef[] {
79
+ const out: EntityRef[] = [];
80
+ const seen = new Set<string>();
81
+ for (const ref of refs) {
82
+ const key = entityRefToString(ref);
83
+ if (seen.has(key)) continue;
84
+ seen.add(key);
85
+ out.push(ref);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function matchesTypeVisibility(ifcType: string | undefined, typeVisibility: ViewerStateSnapshot['typeVisibility']): boolean {
91
+ if (ifcType === 'IfcSpace' && !typeVisibility.spaces) return false;
92
+ if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) return false;
93
+ if (ifcType === 'IfcSite' && !typeVisibility.site) return false;
94
+ return true;
95
+ }
96
+
97
+ function getDataStoreForModel(state: ViewerStateSnapshot, modelId: string): IfcDataStore | null {
98
+ if (modelId === 'legacy') {
99
+ return state.ifcDataStore;
100
+ }
101
+ return state.models.get(modelId)?.ifcDataStore ?? null;
102
+ }
103
+
104
+ function getEntityTypeName(state: ViewerStateSnapshot, ref: EntityRef): string {
105
+ const dataStore = getDataStoreForModel(state, ref.modelId);
106
+ if (!dataStore) return '';
107
+ return dataStore.entities.getTypeName(ref.expressId) || '';
108
+ }
109
+
110
+ function findSpatialNode(root: SpatialNode, expressId: number): SpatialNode | null {
111
+ const stack: SpatialNode[] = [root];
112
+ while (stack.length > 0) {
113
+ const current = stack.pop()!;
114
+ if (current.expressId === expressId) {
115
+ return current;
116
+ }
117
+ for (const child of current.children || []) {
118
+ stack.push(child);
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function getContainerElementIds(dataStore: IfcDataStore, containerExpressId: number): number[] {
125
+ const hierarchy = dataStore.spatialHierarchy;
126
+ if (!hierarchy?.project) return [];
127
+
128
+ const startNode = findSpatialNode(hierarchy.project, containerExpressId);
129
+ if (!startNode) return [];
130
+
131
+ const elementIds: number[] = [];
132
+ const seen = new Set<number>();
133
+ const stack: SpatialNode[] = [startNode];
134
+
135
+ while (stack.length > 0) {
136
+ const current = stack.pop()!;
137
+ if (current.type === IfcTypeEnum.IfcBuildingStorey) {
138
+ const storeyElements = hierarchy.byStorey.get(current.expressId) as number[] | undefined;
139
+ if (storeyElements) {
140
+ for (const id of storeyElements) {
141
+ if (seen.has(id)) continue;
142
+ seen.add(id);
143
+ elementIds.push(id);
144
+ }
145
+ }
146
+ }
147
+ for (const child of current.children || []) {
148
+ stack.push(child);
149
+ }
150
+ }
151
+
152
+ return elementIds;
153
+ }
154
+
155
+ function expandRefToElements(state: ViewerStateSnapshot, ref: EntityRef): EntityRef[] {
156
+ const dataStore = getDataStoreForModel(state, ref.modelId);
157
+ if (!dataStore) return [ref];
158
+
159
+ const entityType = dataStore.entities.getTypeName(ref.expressId) || '';
160
+ if (entityType === STOREY_TYPE) {
161
+ const localIds = dataStore.spatialHierarchy?.byStorey.get(ref.expressId) as number[] | undefined;
162
+ if (!localIds || localIds.length === 0) return [];
163
+ return localIds.map((expressId) => ({ modelId: ref.modelId, expressId }));
164
+ }
165
+
166
+ if (SPATIAL_CONTAINER_TYPES.has(entityType)) {
167
+ const localIds = getContainerElementIds(dataStore, ref.expressId);
168
+ if (localIds.length === 0) return [];
169
+ return localIds.map((expressId) => ({ modelId: ref.modelId, expressId }));
170
+ }
171
+
172
+ return [ref];
173
+ }
174
+
175
+ function toGlobalId(modelId: string, expressId: number, state: ViewerStateSnapshot): number {
176
+ if (modelId === 'legacy') return expressId;
177
+ const model = state.models.get(modelId);
178
+ return expressId + (model?.idOffset ?? 0);
179
+ }
180
+
181
+ function globalIdToRef(state: ViewerStateSnapshot, globalId: number): EntityRef | null {
182
+ const resolved = state.resolveGlobalIdFromModels(globalId);
183
+ if (resolved) {
184
+ return { modelId: resolved.modelId, expressId: resolved.expressId };
185
+ }
186
+
187
+ if (state.models.size > 0) return null;
188
+
189
+ if (state.ifcDataStore) {
190
+ return { modelId: 'legacy', expressId: globalId };
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ function basketToGlobalIds(state: ViewerStateSnapshot): Set<number> {
197
+ const ids = new Set<number>();
198
+ for (const str of state.pinboardEntities) {
199
+ const ref = stringToEntityRef(str);
200
+ ids.add(toGlobalId(ref.modelId, ref.expressId, state));
201
+ }
202
+ return ids;
203
+ }
204
+
205
+ function getSelectedStoreyElementRefs(state: ViewerStateSnapshot): EntityRef[] {
206
+ if (state.selectedStoreys.size === 0) return [];
207
+
208
+ const refs: EntityRef[] = [];
209
+
210
+ if (state.models.size > 0) {
211
+ for (const [modelId, model] of state.models) {
212
+ const hierarchy = model.ifcDataStore?.spatialHierarchy;
213
+ if (!hierarchy) continue;
214
+ const offset = model.idOffset ?? 0;
215
+ for (const storeyId of state.selectedStoreys) {
216
+ const storeyElementIds = hierarchy.byStorey.get(storeyId) || hierarchy.byStorey.get(storeyId - offset);
217
+ if (!storeyElementIds) continue;
218
+ for (const localId of storeyElementIds) {
219
+ refs.push({ modelId, expressId: localId });
220
+ }
221
+ }
222
+ }
223
+ } else if (state.ifcDataStore?.spatialHierarchy) {
224
+ for (const storeyId of state.selectedStoreys) {
225
+ const storeyElementIds = state.ifcDataStore.spatialHierarchy.byStorey.get(storeyId);
226
+ if (!storeyElementIds) continue;
227
+ for (const id of storeyElementIds) {
228
+ refs.push({ modelId: 'legacy', expressId: id });
229
+ }
230
+ }
231
+ }
232
+
233
+ return dedupeRefs(refs);
234
+ }
235
+
236
+ function getSelectionBaseRefs(state: ViewerStateSnapshot): EntityRef[] {
237
+ const refs: EntityRef[] = [];
238
+
239
+ if (state.selectedEntitiesSet.size > 0) {
240
+ for (const str of state.selectedEntitiesSet) {
241
+ refs.push(stringToEntityRef(str));
242
+ }
243
+ return refs;
244
+ }
245
+
246
+ if (state.selectedEntityIds.size > 0) {
247
+ for (const globalId of state.selectedEntityIds) {
248
+ const resolved = globalIdToRef(state, globalId);
249
+ if (resolved) refs.push(resolved);
250
+ }
251
+ return refs;
252
+ }
253
+
254
+ if (state.selectedEntities.length > 0) {
255
+ return [...state.selectedEntities];
256
+ }
257
+
258
+ if (state.selectedEntity) {
259
+ return [state.selectedEntity];
260
+ }
261
+
262
+ if (state.selectedEntityId !== null) {
263
+ const resolved = globalIdToRef(state, state.selectedEntityId);
264
+ if (resolved) refs.push(resolved);
265
+ }
266
+
267
+ return refs;
268
+ }
269
+
270
+ function getExpandedSelectionRefs(state: ViewerStateSnapshot): EntityRef[] {
271
+ const baseRefs = getSelectionBaseRefs(state);
272
+ if (baseRefs.length === 0) return [];
273
+ return dedupeRefs(baseRefs.flatMap((ref) => expandRefToElements(state, ref)));
274
+ }
275
+
276
+ function computeStoreyIsolation(state: ViewerStateSnapshot): Set<number> | null {
277
+ if (state.selectedStoreys.size === 0) return null;
278
+
279
+ const ids = new Set<number>();
280
+
281
+ if (state.models.size > 0) {
282
+ for (const [, model] of state.models) {
283
+ const hierarchy = model.ifcDataStore?.spatialHierarchy;
284
+ if (!hierarchy) continue;
285
+ const offset = model.idOffset ?? 0;
286
+ for (const storeyId of state.selectedStoreys) {
287
+ const storeyElementIds = hierarchy.byStorey.get(storeyId) || hierarchy.byStorey.get(storeyId - offset);
288
+ if (!storeyElementIds) continue;
289
+ for (const localId of storeyElementIds) {
290
+ ids.add(localId + offset);
291
+ }
292
+ }
293
+ }
294
+ } else if (state.ifcDataStore?.spatialHierarchy) {
295
+ for (const storeyId of state.selectedStoreys) {
296
+ const storeyElementIds = state.ifcDataStore.spatialHierarchy.byStorey.get(storeyId);
297
+ if (!storeyElementIds) continue;
298
+ for (const id of storeyElementIds) {
299
+ ids.add(id);
300
+ }
301
+ }
302
+ }
303
+
304
+ return ids.size > 0 ? ids : null;
305
+ }
306
+
307
+ function collectVisibleCandidates(state: ViewerStateSnapshot): VisibleCandidate[] {
308
+ const candidates: VisibleCandidate[] = [];
309
+
310
+ if (state.models.size > 0) {
311
+ for (const [modelId, model] of state.models) {
312
+ if (!model.visible) continue;
313
+ const offset = model.idOffset ?? 0;
314
+ for (const mesh of model.geometryResult.meshes) {
315
+ if (!matchesTypeVisibility(mesh.ifcType, state.typeVisibility)) continue;
316
+ const globalId = mesh.expressId;
317
+ candidates.push({
318
+ globalId,
319
+ modelId,
320
+ expressId: globalId - offset,
321
+ ifcType: mesh.ifcType,
322
+ });
323
+ }
324
+ }
325
+ } else if (state.geometryResult) {
326
+ for (const mesh of state.geometryResult.meshes) {
327
+ if (!matchesTypeVisibility(mesh.ifcType, state.typeVisibility)) continue;
328
+ candidates.push({
329
+ globalId: mesh.expressId,
330
+ modelId: 'legacy',
331
+ expressId: mesh.expressId,
332
+ ifcType: mesh.ifcType,
333
+ });
334
+ }
335
+ }
336
+
337
+ return candidates;
338
+ }
339
+
340
+ function getVisibleGlobalIds(state: ViewerStateSnapshot): Set<number> {
341
+ const candidates = collectVisibleCandidates(state);
342
+
343
+ const globalHidden = new Set<number>(state.hiddenEntities);
344
+ for (const id of state.lensHiddenIds) {
345
+ globalHidden.add(id);
346
+ }
347
+
348
+ const globalIsolation = state.isolatedEntities ?? computeStoreyIsolation(state);
349
+
350
+ const visible = new Set<number>();
351
+ for (const candidate of candidates) {
352
+ if (globalIsolation !== null && !globalIsolation.has(candidate.globalId)) continue;
353
+ if (globalHidden.has(candidate.globalId)) continue;
354
+
355
+ const modelHidden = state.hiddenEntitiesByModel.get(candidate.modelId);
356
+ if (modelHidden?.has(candidate.expressId)) continue;
357
+
358
+ const modelIsolated = state.isolatedEntitiesByModel.get(candidate.modelId);
359
+ if (modelIsolated && !modelIsolated.has(candidate.expressId)) continue;
360
+
361
+ visible.add(candidate.globalId);
362
+ }
363
+
364
+ return visible;
365
+ }
366
+
367
+ export function getVisibleBasketEntityRefsFromStore(): EntityRef[] {
368
+ const state = useViewerStore.getState();
369
+ const key = visibilityFingerprint(state);
370
+ if (_visibleCache?.key === key) return _visibleCache.refs;
371
+
372
+ const visibleIds = getVisibleGlobalIds(state);
373
+ if (visibleIds.size === 0) {
374
+ _visibleCache = { key, refs: [] };
375
+ return [];
376
+ }
377
+
378
+ const refs: EntityRef[] = [];
379
+ for (const globalId of visibleIds) {
380
+ const resolved = state.resolveGlobalIdFromModels(globalId);
381
+ if (resolved) {
382
+ refs.push({ modelId: resolved.modelId, expressId: resolved.expressId });
383
+ } else {
384
+ refs.push({ modelId: 'legacy', expressId: globalId });
385
+ }
386
+ }
387
+ const result = dedupeRefs(refs);
388
+ _visibleCache = { key, refs: result };
389
+ return result;
390
+ }
391
+
392
+ /**
393
+ * Resolve active entity selection into basket refs.
394
+ * Explicit selected entities are preferred; if empty, selected storeys are expanded.
395
+ */
396
+ export function getBasketSelectionRefsFromStore(): EntityRef[] {
397
+ const state = useViewerStore.getState();
398
+
399
+ const expandedSelection = getExpandedSelectionRefs(state);
400
+ if (expandedSelection.length > 0) {
401
+ return expandedSelection;
402
+ }
403
+
404
+ return getSelectedStoreyElementRefs(state);
405
+ }
406
+
407
+ /**
408
+ * Resolve hierarchy-derived basket source.
409
+ * Priority: explicit hierarchy source snapshot -> selected storeys -> selected hierarchy container/entity.
410
+ */
411
+ export function getHierarchyBasketEntityRefsFromStore(): EntityRef[] {
412
+ const state = useViewerStore.getState();
413
+
414
+ if (state.hierarchyBasketSelection.size > 0) {
415
+ const hierarchyRefs = Array.from(state.hierarchyBasketSelection).map((key) => stringToEntityRef(key));
416
+ const expandedHierarchy = dedupeRefs(hierarchyRefs.flatMap((ref) => expandRefToElements(state, ref)));
417
+ if (expandedHierarchy.length > 0) {
418
+ return expandedHierarchy;
419
+ }
420
+ }
421
+
422
+ const storeyRefs = getSelectedStoreyElementRefs(state);
423
+ if (storeyRefs.length > 0) {
424
+ return storeyRefs;
425
+ }
426
+
427
+ const selectionRefs = getExpandedSelectionRefs(state);
428
+ if (selectionRefs.length > 0) {
429
+ const hasContainer = selectionRefs.some((ref) => {
430
+ const typeName = getEntityTypeName(state, ref);
431
+ return typeName === STOREY_TYPE || SPATIAL_CONTAINER_TYPES.has(typeName);
432
+ });
433
+ if (hasContainer || getSelectionBaseRefs(state).length > 0) {
434
+ return selectionRefs;
435
+ }
436
+ }
437
+
438
+ return [];
439
+ }
440
+
441
+ export function getSmartBasketInputFromStore(): { refs: EntityRef[]; source: BasketInputSource } {
442
+ const selectionRefs = getBasketSelectionRefsFromStore();
443
+ if (selectionRefs.length > 0) {
444
+ return { refs: selectionRefs, source: 'selection' };
445
+ }
446
+
447
+ const hierarchyRefs = getHierarchyBasketEntityRefsFromStore();
448
+ if (hierarchyRefs.length > 0) {
449
+ return { refs: hierarchyRefs, source: 'hierarchy' };
450
+ }
451
+
452
+ const visibleRefs = getVisibleBasketEntityRefsFromStore();
453
+ if (visibleRefs.length > 0) {
454
+ return { refs: visibleRefs, source: 'visible' };
455
+ }
456
+
457
+ return { refs: [], source: 'empty' };
458
+ }
459
+
460
+ export function isBasketIsolationActiveFromStore(): boolean {
461
+ const state = useViewerStore.getState();
462
+ if (state.pinboardEntities.size === 0 || state.isolatedEntities === null) return false;
463
+
464
+ const basketIds = basketToGlobalIds(state);
465
+ if (basketIds.size !== state.isolatedEntities.size) return false;
466
+ for (const id of basketIds) {
467
+ if (!state.isolatedEntities.has(id)) return false;
468
+ }
469
+ return true;
470
+ }
471
+
472
+ export function getVisibleBasketStatsFromStore(): BasketVisibleStats {
473
+ const state = useViewerStore.getState();
474
+ const visibleRefs = getVisibleBasketEntityRefsFromStore();
475
+ const visibleKeys = new Set<string>(visibleRefs.map(entityRefToString));
476
+ let removeCount = 0;
477
+ for (const key of state.pinboardEntities) {
478
+ if (visibleKeys.has(key)) removeCount++;
479
+ }
480
+
481
+ return {
482
+ visibleCount: visibleKeys.size,
483
+ addCount: Math.max(0, visibleKeys.size - removeCount),
484
+ removeCount,
485
+ basketCount: state.pinboardEntities.size,
486
+ };
487
+ }
@@ -0,0 +1,21 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { useViewerStore } from './index.js';
6
+
7
+ export function resetVisibilityForHomeFromStore(): void {
8
+ const state = useViewerStore.getState();
9
+ state.showAllInAllModels();
10
+ state.clearStoreySelection();
11
+ state.clearHierarchyBasketSelection();
12
+ state.clearEntitySelection();
13
+ state.clearBasket();
14
+ useViewerStore.setState({ activeBasketViewId: null });
15
+ }
16
+
17
+ export function goHomeFromStore(): void {
18
+ resetVisibilityForHomeFromStore();
19
+ const state = useViewerStore.getState();
20
+ state.cameraCallbacks.home?.();
21
+ }
@@ -31,6 +31,7 @@ import { createListSlice, type ListSlice } from './slices/listSlice.js';
31
31
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
32
32
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
33
33
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
34
+ import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
34
35
 
35
36
  // Import constants for reset function
36
37
  import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, TYPE_VISIBILITY_DEFAULTS } from './constants.js';
@@ -122,6 +123,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
122
123
  // Reset all viewer state when loading new file
123
124
  // Note: Does NOT clear models - use clearAllModels() for that
124
125
  resetViewerState: () => {
126
+ invalidateVisibleBasketCache();
125
127
  const [set] = args;
126
128
  set({
127
129
  // Selection (legacy)
@@ -148,6 +150,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
148
150
 
149
151
  // Data
150
152
  pendingColorUpdates: null,
153
+ pendingMeshColorUpdates: null,
151
154
 
152
155
  // Hover/Context
153
156
  hoverState: { entityId: null, screenX: 0, screenY: 0 },
@@ -202,6 +205,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
202
205
  drawing2DPhase: '',
203
206
  drawing2DError: null,
204
207
  drawing2DPanelVisible: false,
208
+ suppressNextSection2DPanelAutoOpen: false,
205
209
  drawing2DSvgContent: null,
206
210
  drawing2DDisplayOptions: {
207
211
  showHiddenLines: true,
@@ -266,6 +270,10 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
266
270
 
267
271
  // Pinboard - clear pinned entities on new file
268
272
  pinboardEntities: new Set<string>(),
273
+ basketViews: [],
274
+ activeBasketViewId: null,
275
+ basketPresentationVisible: false,
276
+ hierarchyBasketSelection: new Set<string>(),
269
277
 
270
278
  // Script - reset execution state but keep saved scripts and editor content
271
279
  scriptPanelVisible: false,
@@ -46,6 +46,10 @@ describe('DataSlice', () => {
46
46
  it('should have null pendingColorUpdates', () => {
47
47
  assert.strictEqual(state.pendingColorUpdates, null);
48
48
  });
49
+
50
+ it('should have null pendingMeshColorUpdates', () => {
51
+ assert.strictEqual(state.pendingMeshColorUpdates, null);
52
+ });
49
53
  });
50
54
 
51
55
  describe('setIfcDataStore', () => {
@@ -136,9 +140,11 @@ describe('DataSlice', () => {
136
140
  state.updateMeshColors(updates);
137
141
 
138
142
  assert.deepStrictEqual(state.geometryResult?.meshes[0].color, [0, 1, 0, 1]);
143
+ assert.strictEqual(state.pendingColorUpdates, null);
144
+ assert.deepStrictEqual(state.pendingMeshColorUpdates?.get(1), [0, 1, 0, 1]);
139
145
  });
140
146
 
141
- it('should clone the updates Map to prevent external mutation', () => {
147
+ it('should clone updates and avoid mutating state from external map writes', () => {
142
148
  const mesh = createMockMesh(1);
143
149
  state.appendGeometryBatch([mesh] as any);
144
150
 
@@ -151,16 +157,20 @@ describe('DataSlice', () => {
151
157
  updates.set(1, [1, 1, 1, 1]);
152
158
 
153
159
  // State should not be affected
154
- assert.notStrictEqual(state.pendingColorUpdates, updates);
160
+ assert.deepStrictEqual(state.geometryResult?.meshes[0].color, [0, 1, 0, 1]);
161
+ assert.strictEqual(state.pendingColorUpdates, null);
162
+ assert.deepStrictEqual(state.pendingMeshColorUpdates?.get(1), [0, 1, 0, 1]);
155
163
  });
156
164
 
157
- it('should not update when no geometry result', () => {
165
+ it('should skip mesh mutation but still set pendingMeshColorUpdates when no geometry result', () => {
158
166
  const updates = new Map<number, [number, number, number, number]>();
159
167
  updates.set(1, [0, 1, 0, 1]);
160
168
 
161
169
  state.updateMeshColors(updates);
162
170
 
163
171
  assert.strictEqual(state.geometryResult, null);
172
+ assert.strictEqual(state.pendingColorUpdates, null);
173
+ assert.deepStrictEqual(state.pendingMeshColorUpdates?.get(1), [0, 1, 0, 1]);
164
174
  });
165
175
 
166
176
  it('should preserve unaffected meshes', () => {
@@ -185,7 +195,7 @@ describe('DataSlice', () => {
185
195
 
186
196
  const updates = new Map<number, [number, number, number, number]>();
187
197
  updates.set(1, [0, 1, 0, 1]);
188
- state.updateMeshColors(updates);
198
+ state.setPendingColorUpdates(updates);
189
199
 
190
200
  state.clearPendingColorUpdates();
191
201
 
@@ -193,6 +203,45 @@ describe('DataSlice', () => {
193
203
  });
194
204
  });
195
205
 
206
+ describe('clearPendingMeshColorUpdates', () => {
207
+ it('should clear pending mesh color updates', () => {
208
+ const mesh = createMockMesh(1);
209
+ state.appendGeometryBatch([mesh] as any);
210
+
211
+ const updates = new Map<number, [number, number, number, number]>();
212
+ updates.set(1, [0, 1, 0, 1]);
213
+ state.updateMeshColors(updates);
214
+
215
+ state.clearPendingMeshColorUpdates();
216
+
217
+ assert.strictEqual(state.pendingMeshColorUpdates, null);
218
+ });
219
+ });
220
+
221
+ describe('setPendingColorUpdates', () => {
222
+ it('should clone pending color updates map', () => {
223
+ const updates = new Map<number, [number, number, number, number]>();
224
+ updates.set(1, [0, 1, 0, 1]);
225
+ state.setPendingColorUpdates(updates);
226
+
227
+ updates.set(1, [1, 1, 1, 1]);
228
+ assert.notStrictEqual(state.pendingColorUpdates, updates);
229
+ assert.deepStrictEqual(state.pendingColorUpdates?.get(1), [0, 1, 0, 1]);
230
+ });
231
+
232
+ it('should not mutate persisted geometry colors', () => {
233
+ const mesh = createMockMesh(1, [0.2, 0.2, 0.2, 1]);
234
+ state.appendGeometryBatch([mesh] as any);
235
+ const updates = new Map<number, [number, number, number, number]>();
236
+ updates.set(1, [1, 0, 1, 0.5]);
237
+
238
+ state.setPendingColorUpdates(updates);
239
+
240
+ assert.deepStrictEqual(state.geometryResult?.meshes[0].color, [0.2, 0.2, 0.2, 1]);
241
+ assert.deepStrictEqual(state.pendingColorUpdates?.get(1), [1, 0, 1, 0.5]);
242
+ });
243
+ });
244
+
196
245
  describe('updateCoordinateInfo', () => {
197
246
  it('should update coordinate info', () => {
198
247
  const mesh = createMockMesh(1);