@ifc-lite/viewer 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-CusgkT03.js → Arrow.dom-IIkrrCZ0.js} +1 -1
  3. package/dist/assets/{browser-BXNIkE8a.js → browser-BoonPy8d.js} +1 -1
  4. package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
  5. package/dist/assets/{index-huvR-kGC.js → index-CQkEOlYf.js} +49090 -46453
  6. package/dist/assets/{index-6Mr3byM-.js → index-ClZCG7KA.js} +4 -4
  7. package/dist/assets/index-qxIHWl_B.css +1 -0
  8. package/dist/assets/{native-bridge-DsHOKdgD.js → native-bridge-Beg4Kf9O.js} +1 -1
  9. package/dist/assets/{wasm-bridge-Bd73HXn-.js → wasm-bridge-CY8jkr7u.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +19 -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 +32 -89
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/Viewport.tsx +107 -98
  19. package/src/components/viewer/ViewportContainer.tsx +2 -0
  20. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  21. package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
  22. package/src/components/viewer/useAnimationLoop.ts +4 -1
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +16 -4
  25. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  26. package/src/hooks/useViewerSelectors.ts +22 -0
  27. package/src/index.css +6 -0
  28. package/src/store/basket/basketCommands.ts +81 -0
  29. package/src/store/basket/basketViewActivator.ts +54 -0
  30. package/src/store/basketSave.ts +122 -0
  31. package/src/store/basketVisibleSet.test.ts +161 -0
  32. package/src/store/basketVisibleSet.ts +487 -0
  33. package/src/store/constants.ts +20 -0
  34. package/src/store/homeView.ts +21 -0
  35. package/src/store/index.ts +17 -0
  36. package/src/store/slices/drawing2DSlice.ts +5 -0
  37. package/src/store/slices/pinboardSlice.test.ts +160 -0
  38. package/src/store/slices/pinboardSlice.ts +248 -18
  39. package/src/store/slices/uiSlice.ts +41 -0
  40. package/src/store/types.ts +11 -0
  41. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  42. package/dist/assets/index-CGbokkQ9.css +0 -1
@@ -0,0 +1,161 @@
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 { describe, it, beforeEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { useViewerStore } from './index.js';
8
+ import {
9
+ getSmartBasketInputFromStore,
10
+ getBasketSelectionRefsFromStore,
11
+ getVisibleBasketEntityRefsFromStore,
12
+ isBasketIsolationActiveFromStore,
13
+ invalidateVisibleBasketCache,
14
+ } from './basketVisibleSet.js';
15
+ import { entityRefToString } from './types.js';
16
+
17
+ describe('basketVisibleSet', () => {
18
+ beforeEach(() => {
19
+ invalidateVisibleBasketCache();
20
+ useViewerStore.getState().resetViewerState();
21
+ });
22
+
23
+ describe('source priority', () => {
24
+ it('returns selection refs when selectedEntitiesSet has items', () => {
25
+ useViewerStore.setState({
26
+ selectedEntitiesSet: new Set(['legacy:100', 'legacy:200']),
27
+ });
28
+
29
+ const result = getSmartBasketInputFromStore();
30
+ assert.strictEqual(result.source, 'selection');
31
+ assert.strictEqual(result.refs.length, 2);
32
+ assert.ok(result.refs.some((r) => entityRefToString(r) === 'legacy:100'));
33
+ assert.ok(result.refs.some((r) => entityRefToString(r) === 'legacy:200'));
34
+ });
35
+
36
+ it('returns hierarchy refs when hierarchyBasketSelection has items and no selection', () => {
37
+ useViewerStore.setState({
38
+ selectedEntitiesSet: new Set(),
39
+ selectedEntity: null,
40
+ selectedEntityIds: new Set(),
41
+ hierarchyBasketSelection: new Set(['legacy:300']),
42
+ });
43
+
44
+ const result = getSmartBasketInputFromStore();
45
+ assert.strictEqual(result.source, 'hierarchy');
46
+ assert.ok(result.refs.length >= 1);
47
+ });
48
+
49
+ it('returns visible refs when only geometry is available', () => {
50
+ useViewerStore.setState({
51
+ selectedEntitiesSet: new Set(),
52
+ selectedEntity: null,
53
+ selectedEntityIds: new Set(),
54
+ hierarchyBasketSelection: new Set(),
55
+ geometryResult: {
56
+ meshes: [
57
+ { expressId: 1, ifcType: 'IfcWall' },
58
+ { expressId: 2, ifcType: 'IfcSlab' },
59
+ ],
60
+ } as any,
61
+ });
62
+
63
+ const result = getSmartBasketInputFromStore();
64
+ assert.ok(result.source === 'visible' || result.source === 'empty');
65
+ if (result.source === 'visible') {
66
+ assert.ok(result.refs.length >= 1);
67
+ }
68
+ });
69
+
70
+ it('returns empty when no source has refs', () => {
71
+ useViewerStore.setState({
72
+ selectedEntitiesSet: new Set(),
73
+ selectedEntity: null,
74
+ selectedEntityIds: new Set(),
75
+ hierarchyBasketSelection: new Set(),
76
+ geometryResult: null,
77
+ });
78
+
79
+ const result = getSmartBasketInputFromStore();
80
+ assert.strictEqual(result.source, 'empty');
81
+ assert.strictEqual(result.refs.length, 0);
82
+ });
83
+ });
84
+
85
+ describe('isBasketIsolationActiveFromStore', () => {
86
+ it('returns true when isolated equals basket', () => {
87
+ useViewerStore.setState({
88
+ pinboardEntities: new Set(['legacy:100', 'legacy:200']),
89
+ isolatedEntities: new Set([100, 200]),
90
+ models: new Map(),
91
+ });
92
+
93
+ assert.strictEqual(isBasketIsolationActiveFromStore(), true);
94
+ });
95
+
96
+ it('returns false when pinboard is empty', () => {
97
+ useViewerStore.setState({
98
+ pinboardEntities: new Set(),
99
+ isolatedEntities: new Set([100]),
100
+ });
101
+
102
+ assert.strictEqual(isBasketIsolationActiveFromStore(), false);
103
+ });
104
+
105
+ it('returns false when isolated is null', () => {
106
+ useViewerStore.setState({
107
+ pinboardEntities: new Set(['legacy:100']),
108
+ isolatedEntities: null,
109
+ });
110
+
111
+ assert.strictEqual(isBasketIsolationActiveFromStore(), false);
112
+ });
113
+
114
+ it('returns false when isolated size differs from basket', () => {
115
+ useViewerStore.setState({
116
+ pinboardEntities: new Set(['legacy:100', 'legacy:200']),
117
+ isolatedEntities: new Set([100]),
118
+ });
119
+
120
+ assert.strictEqual(isBasketIsolationActiveFromStore(), false);
121
+ });
122
+ });
123
+
124
+ describe('visibility cache', () => {
125
+ it('invalidateVisibleBasketCache clears cache', () => {
126
+ useViewerStore.setState({
127
+ geometryResult: { meshes: [{ expressId: 1, ifcType: 'IfcWall' }] } as any,
128
+ });
129
+
130
+ const first = getVisibleBasketEntityRefsFromStore();
131
+ invalidateVisibleBasketCache();
132
+ const second = getVisibleBasketEntityRefsFromStore();
133
+
134
+ assert.deepStrictEqual(first, second);
135
+ });
136
+
137
+ it('returns consistent result on repeated calls with same state', () => {
138
+ useViewerStore.setState({
139
+ geometryResult: { meshes: [{ expressId: 1, ifcType: 'IfcWall' }] } as any,
140
+ });
141
+
142
+ const a = getVisibleBasketEntityRefsFromStore();
143
+ const b = getVisibleBasketEntityRefsFromStore();
144
+
145
+ assert.deepStrictEqual(a, b);
146
+ });
147
+ });
148
+
149
+ describe('federation: unresolved globalId in multi-model', () => {
150
+ it('getBasketSelectionRefsFromStore returns array when models exist', () => {
151
+ useViewerStore.setState({
152
+ selectedEntityIds: new Set([99999]),
153
+ selectedEntitiesSet: new Set(),
154
+ selectedEntity: null,
155
+ });
156
+
157
+ const refs = getBasketSelectionRefsFromStore();
158
+ assert.ok(Array.isArray(refs));
159
+ });
160
+ });
161
+ });
@@ -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
+ }
@@ -66,6 +66,26 @@ export const UI_DEFAULTS = {
66
66
  THEME: getInitialTheme(),
67
67
  /** Default hover tooltips state */
68
68
  HOVER_TOOLTIPS_ENABLED: false,
69
+ /** Global visual enhancement kill switch */
70
+ VISUAL_ENHANCEMENTS_ENABLED: true,
71
+ /** Edge contrast enhancement default */
72
+ EDGE_CONTRAST_ENABLED: true,
73
+ /** Edge contrast intensity */
74
+ EDGE_CONTRAST_INTENSITY: 1.2,
75
+ /** Contact shading quality preset */
76
+ CONTACT_SHADING_QUALITY: 'low' as const,
77
+ /** Contact shading intensity */
78
+ CONTACT_SHADING_INTENSITY: 0.35,
79
+ /** Contact shading radius in pixels */
80
+ CONTACT_SHADING_RADIUS: 1.5,
81
+ /** Separation-line overlay default */
82
+ SEPARATION_LINES_ENABLED: true,
83
+ /** Separation-line quality preset */
84
+ SEPARATION_LINES_QUALITY: 'low' as const,
85
+ /** Separation-line intensity */
86
+ SEPARATION_LINES_INTENSITY: 0.38,
87
+ /** Separation-line radius in pixels */
88
+ SEPARATION_LINES_RADIUS: 1.0,
69
89
  } as const;
70
90
 
71
91
  // ============================================================================
@@ -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
+ }