@ifc-lite/viewer 1.7.0 → 1.8.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 (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/index-7WoQ-qVC.css +1 -0
  4. package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
  5. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
  6. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
  7. package/dist/index.html +2 -2
  8. package/package.json +18 -18
  9. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  10. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  11. package/src/components/viewer/ExportDialog.tsx +166 -17
  12. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  13. package/src/components/viewer/LensPanel.tsx +848 -85
  14. package/src/components/viewer/MainToolbar.tsx +114 -81
  15. package/src/components/viewer/Section2DPanel.tsx +269 -29
  16. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  17. package/src/components/viewer/Viewport.tsx +57 -23
  18. package/src/components/viewer/ViewportContainer.tsx +2 -0
  19. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  20. package/src/components/viewer/hierarchy/types.ts +1 -1
  21. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  22. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  23. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  24. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  25. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  26. package/src/components/viewer/useGeometryStreaming.ts +12 -4
  27. package/src/hooks/ids/idsExportService.ts +1 -1
  28. package/src/hooks/useAnnotation2D.ts +551 -0
  29. package/src/hooks/useDrawingExport.ts +83 -1
  30. package/src/hooks/useKeyboardShortcuts.ts +113 -14
  31. package/src/hooks/useLens.ts +39 -55
  32. package/src/hooks/useLensDiscovery.ts +46 -0
  33. package/src/hooks/useModelSelection.ts +5 -22
  34. package/src/index.css +7 -1
  35. package/src/lib/lens/adapter.ts +127 -1
  36. package/src/lib/lists/columnToAutoColor.ts +33 -0
  37. package/src/store/index.ts +14 -1
  38. package/src/store/resolveEntityRef.ts +44 -0
  39. package/src/store/slices/drawing2DSlice.ts +321 -0
  40. package/src/store/slices/lensSlice.ts +46 -4
  41. package/src/store/slices/pinboardSlice.ts +171 -38
  42. package/src/store.ts +3 -0
  43. package/dist/assets/index-yTqs8kgX.css +0 -1
@@ -7,12 +7,49 @@
7
7
  */
8
8
 
9
9
  import { useEffect, useCallback } from 'react';
10
- import { useViewerStore } from '@/store';
10
+ import { useViewerStore, stringToEntityRef } from '@/store';
11
+ import type { EntityRef } from '@/store';
11
12
 
12
13
  interface KeyboardShortcutsOptions {
13
14
  enabled?: boolean;
14
15
  }
15
16
 
17
+ /** Clear multi-select state so subsequent operations use single-entity selectedEntity */
18
+ function clearMultiSelect(): void {
19
+ const state = useViewerStore.getState();
20
+ if (state.selectedEntitiesSet.size > 0) {
21
+ useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
22
+ }
23
+ }
24
+
25
+ /** Get all selected global IDs — multi-select if available, else single selectedEntityId */
26
+ function getAllSelectedGlobalIds(): number[] {
27
+ const state = useViewerStore.getState();
28
+ if (state.selectedEntityIds.size > 0) {
29
+ return Array.from(state.selectedEntityIds);
30
+ }
31
+ if (state.selectedEntityId !== null) {
32
+ return [state.selectedEntityId];
33
+ }
34
+ return [];
35
+ }
36
+
37
+ /** Get current selection as EntityRef[] — multi-select if available, else single */
38
+ function getSelectionRefsFromStore(): EntityRef[] {
39
+ const state = useViewerStore.getState();
40
+ if (state.selectedEntitiesSet.size > 0) {
41
+ const refs: EntityRef[] = [];
42
+ for (const str of state.selectedEntitiesSet) {
43
+ refs.push(stringToEntityRef(str));
44
+ }
45
+ return refs;
46
+ }
47
+ if (state.selectedEntity) {
48
+ return [state.selectedEntity];
49
+ }
50
+ return [];
51
+ }
52
+
16
53
  export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
17
54
  const { enabled = true } = options;
18
55
 
@@ -20,12 +57,18 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
20
57
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
21
58
  const activeTool = useViewerStore((s) => s.activeTool);
22
59
  const setActiveTool = useViewerStore((s) => s.setActiveTool);
23
- const isolateEntity = useViewerStore((s) => s.isolateEntity);
24
- const hideEntity = useViewerStore((s) => s.hideEntity);
60
+ const hideEntities = useViewerStore((s) => s.hideEntities);
25
61
  const showAll = useViewerStore((s) => s.showAll);
26
62
  const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
27
63
  const toggleTheme = useViewerStore((s) => s.toggleTheme);
28
64
 
65
+ // Basket actions
66
+ const setBasket = useViewerStore((s) => s.setBasket);
67
+ const addToBasket = useViewerStore((s) => s.addToBasket);
68
+ const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
69
+ const clearBasket = useViewerStore((s) => s.clearBasket);
70
+ const showPinboard = useViewerStore((s) => s.showPinboard);
71
+
29
72
  // Measure tool specific actions
30
73
  const activeMeasurement = useViewerStore((s) => s.activeMeasurement);
31
74
  const cancelMeasurement = useViewerStore((s) => s.cancelMeasurement);
@@ -74,18 +117,67 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
74
117
  setActiveTool('section');
75
118
  }
76
119
 
77
- // Visibility controls
78
- if (key === 'i' && !ctrl && !shift && selectedEntityId) {
120
+ // Basket / Visibility controls
121
+ // I = Set basket (isolate selection as basket), or re-apply basket if no selection
122
+ if (key === 'i' && !ctrl && !shift) {
123
+ const state = useViewerStore.getState();
124
+ // If basket already exists and user hasn't explicitly multi-selected,
125
+ // re-apply the basket instead of replacing it with a stale single selection.
126
+ if (state.pinboardEntities.size > 0 && state.selectedEntitiesSet.size === 0) {
127
+ e.preventDefault();
128
+ showPinboard();
129
+ } else {
130
+ const refs = getSelectionRefsFromStore();
131
+ if (refs.length > 0) {
132
+ e.preventDefault();
133
+ setBasket(refs);
134
+ // Consume multi-select so subsequent − removes a single entity
135
+ clearMultiSelect();
136
+ }
137
+ }
138
+ }
139
+
140
+ // + or = (with shift) = Add to basket
141
+ if ((e.key === '+' || (e.key === '=' && shift)) && !ctrl) {
142
+ e.preventDefault();
143
+ const refs = getSelectionRefsFromStore();
144
+ if (refs.length > 0) {
145
+ addToBasket(refs);
146
+ // Consume multi-select so subsequent − removes a single entity
147
+ clearMultiSelect();
148
+ }
149
+ }
150
+
151
+ // - or _ = Remove from basket
152
+ if ((e.key === '-' || e.key === '_') && !ctrl) {
79
153
  e.preventDefault();
80
- isolateEntity(selectedEntityId);
154
+ const refs = getSelectionRefsFromStore();
155
+ if (refs.length > 0) {
156
+ removeFromBasket(refs);
157
+ // Consume multi-select after removal
158
+ clearMultiSelect();
159
+ }
81
160
  }
161
+
82
162
  if ((key === 'delete' || key === 'backspace') && !ctrl && !shift && selectedEntityId) {
83
163
  e.preventDefault();
84
- hideEntity(selectedEntityId);
164
+ const ids = getAllSelectedGlobalIds();
165
+ hideEntities(ids);
166
+ clearMultiSelect();
167
+ }
168
+ // Space to hide — skip when focused on buttons/selects/links where Space has native behavior
169
+ if (key === ' ' && !ctrl && !shift && selectedEntityId) {
170
+ const tag = document.activeElement?.tagName;
171
+ if (tag !== 'BUTTON' && tag !== 'SELECT' && tag !== 'A') {
172
+ e.preventDefault();
173
+ const ids = getAllSelectedGlobalIds();
174
+ hideEntities(ids);
175
+ clearMultiSelect();
176
+ }
85
177
  }
86
178
  if (key === 'a' && !ctrl && !shift) {
87
179
  e.preventDefault();
88
- showAll();
180
+ showAll(); // Clear hiddenEntities + isolatedEntities (basket preserved)
89
181
  clearStoreySelection(); // Also clear storey filtering
90
182
  }
91
183
 
@@ -121,6 +213,7 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
121
213
  if (key === 'escape') {
122
214
  e.preventDefault();
123
215
  setSelectedEntityId(null);
216
+ clearBasket();
124
217
  showAll();
125
218
  clearStoreySelection(); // Also clear storey filtering
126
219
  setActiveTool('select');
@@ -139,8 +232,12 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
139
232
  setSelectedEntityId,
140
233
  activeTool,
141
234
  setActiveTool,
142
- isolateEntity,
143
- hideEntity,
235
+ setBasket,
236
+ addToBasket,
237
+ removeFromBasket,
238
+ clearBasket,
239
+ showPinboard,
240
+ hideEntities,
144
241
  showAll,
145
242
  clearStoreySelection,
146
243
  toggleTheme,
@@ -171,14 +268,16 @@ export const KEYBOARD_SHORTCUTS = [
171
268
  { key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
172
269
  { key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
173
270
  { key: 'Ctrl+C', description: 'Clear measurements (Measure tool)', category: 'Tools' },
174
- { key: 'I', description: 'Isolate selection', category: 'Visibility' },
175
- { key: 'Del', description: 'Hide selection', category: 'Visibility' },
176
- { key: 'A', description: 'Show all (reset filters)', category: 'Visibility' },
271
+ { key: 'I', description: 'Set basket (isolate selection)', category: 'Visibility' },
272
+ { key: '+', description: 'Add selection to basket', category: 'Visibility' },
273
+ { key: '', description: 'Remove selection from basket', category: 'Visibility' },
274
+ { key: 'Del / Space', description: 'Hide selection', category: 'Visibility' },
275
+ { key: 'A', description: 'Show all (clear filters, keep basket)', category: 'Visibility' },
177
276
  { key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
178
277
  { key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
179
278
  { key: 'F', description: 'Frame selection', category: 'Camera' },
180
279
  { key: '1-6', description: 'Preset views', category: 'Camera' },
181
280
  { key: 'T', description: 'Toggle theme', category: 'UI' },
182
- { key: 'Esc', description: 'Reset all (clear selection, filters, isolation)', category: 'Selection' },
281
+ { key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
183
282
  { key: '?', description: 'Show info panel', category: 'Help' },
184
283
  ] as const;
@@ -10,78 +10,54 @@
10
10
  * Unmatched entities with geometry are ghosted (semi-transparent).
11
11
  *
12
12
  * The pure evaluation logic lives in @ifc-lite/lens — this hook handles
13
- * React lifecycle, original-color capture/restore, and Zustand integration.
13
+ * React lifecycle and Zustand integration.
14
14
  *
15
15
  * Performance notes:
16
16
  * - Does NOT subscribe to `models` or `ifcDataStore` — reads them from
17
17
  * getState() only when the active lens changes. This prevents re-evaluation
18
18
  * during model loading.
19
- * - Uses `setPendingColorUpdates` instead of `updateMeshColors` to avoid
20
- * cloning the entire mesh array (O(n) mesh copies) on every lens switch.
21
- * - Original mesh colors are captured once and restored on deactivation.
19
+ * - Uses color overlay system: pendingColorUpdates triggers
20
+ * scene.setColorOverrides() which builds overlay batches rendered on top
21
+ * of original geometry. Original batches are NEVER modified clearing
22
+ * lens is instant (no batch rebuild).
22
23
  */
23
24
 
24
- import { useEffect, useRef, useCallback } from 'react';
25
- import { evaluateLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
26
- import type { RGBAColor } from '@ifc-lite/lens';
25
+ import { useEffect, useRef, useMemo } from 'react';
26
+ import { evaluateLens, evaluateAutoColorLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
27
27
  import { useViewerStore } from '@/store';
28
28
  import { createLensDataProvider } from '@/lib/lens';
29
+ import { useLensDiscovery } from './useLensDiscovery';
29
30
 
30
31
  export function useLens() {
31
32
  const activeLensId = useViewerStore((s) => s.activeLensId);
32
33
  const savedLenses = useViewerStore((s) => s.savedLenses);
33
34
 
34
- // Track the previously active lens to detect deactivation
35
- const prevLensIdRef = useRef<string | null>(null);
36
- // Track original colors to restore when lens is deactivated
37
- const originalColorsRef = useRef<Map<number, RGBAColor> | null>(null);
38
-
39
- /** Collect original mesh colors from all geometry sources (federation + legacy) */
40
- const captureOriginalColors = useCallback(() => {
41
- const state = useViewerStore.getState();
42
- const originals = new Map<number, RGBAColor>();
43
-
44
- // Federation mode: collect from all model geometries
45
- if (state.models.size > 0) {
46
- for (const [, model] of state.models) {
47
- if (model.geometryResult?.meshes) {
48
- for (const mesh of model.geometryResult.meshes) {
49
- if (mesh.color) {
50
- originals.set(mesh.expressId, mesh.color as RGBAColor);
51
- }
52
- }
53
- }
54
- }
55
- }
35
+ // Derive the active lens object only re-evaluates when activeLensId or
36
+ // the active lens entry itself changes, not when unrelated lenses are edited.
37
+ const activeLens = useMemo(
38
+ () => savedLenses.find(l => l.id === activeLensId) ?? null,
39
+ [activeLensId, savedLenses],
40
+ );
56
41
 
57
- // Legacy mode: collect from store geometryResult
58
- if (state.geometryResult?.meshes) {
59
- for (const mesh of state.geometryResult.meshes) {
60
- if (mesh.color) {
61
- originals.set(mesh.expressId, mesh.color as RGBAColor);
62
- }
63
- }
64
- }
42
+ // Run data discovery when models change (populates discoveredLensData in store)
43
+ useLensDiscovery();
65
44
 
66
- return originals;
67
- }, []);
45
+ // Track the previously active lens to detect deactivation
46
+ const prevLensIdRef = useRef<string | null>(null);
68
47
 
69
48
  useEffect(() => {
70
- const activeLens = savedLenses.find(l => l.id === activeLensId) ?? null;
71
49
 
72
- // Lens deactivated — restore original colors
50
+ // Lens deactivated — clear overlay (instant, no batch rebuild)
73
51
  if (!activeLens && prevLensIdRef.current !== null) {
74
52
  prevLensIdRef.current = null;
75
53
  useViewerStore.getState().setLensColorMap(new Map());
76
54
  useViewerStore.getState().setLensHiddenIds(new Set());
77
55
  useViewerStore.getState().setLensRuleCounts(new Map());
78
56
  useViewerStore.getState().setLensRuleEntityIds(new Map());
57
+ useViewerStore.getState().setLensAutoColorLegend([]);
79
58
 
80
- // Restore original mesh colors via lightweight pending path
81
- if (originalColorsRef.current && originalColorsRef.current.size > 0) {
82
- useViewerStore.getState().setPendingColorUpdates(originalColorsRef.current);
83
- }
84
- originalColorsRef.current = null;
59
+ // Send empty map to signal "clear overlays" to useGeometryStreaming
60
+ useViewerStore.getState().setPendingColorUpdates(new Map());
85
61
  return;
86
62
  }
87
63
 
@@ -92,16 +68,18 @@ export function useLens() {
92
68
  const { models, ifcDataStore } = useViewerStore.getState();
93
69
  if (models.size === 0 && !ifcDataStore) return;
94
70
 
95
- // Save original colors before first lens application
96
- if (prevLensIdRef.current === null) {
97
- originalColorsRef.current = captureOriginalColors();
98
- }
99
-
100
71
  prevLensIdRef.current = activeLensId;
101
72
 
102
73
  // Create data provider and evaluate lens using @ifc-lite/lens package
103
74
  const provider = createLensDataProvider(models, ifcDataStore);
104
- const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = evaluateLens(activeLens, provider);
75
+
76
+ // Dispatch: auto-color mode vs. rule-based mode
77
+ const isAutoColor = !!activeLens.autoColor;
78
+ const result = isAutoColor
79
+ ? evaluateAutoColorLens(activeLens.autoColor!, provider)
80
+ : evaluateLens(activeLens, provider);
81
+
82
+ const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = result;
105
83
 
106
84
  // Build hex color map for UI legend (exclude ghost entries)
107
85
  const hexColorMap = new Map<number, string>();
@@ -115,12 +93,18 @@ export function useLens() {
115
93
  useViewerStore.getState().setLensRuleCounts(ruleCounts);
116
94
  useViewerStore.getState().setLensRuleEntityIds(ruleEntityIds);
117
95
 
118
- // Apply ALL colors to renderer via pendingColorUpdates only —
119
- // no mesh cloning needed, the renderer picks these up directly
96
+ // Store auto-color legend entries for UI display
97
+ if (isAutoColor && 'legend' in result) {
98
+ useViewerStore.getState().setLensAutoColorLegend(result.legend);
99
+ } else {
100
+ useViewerStore.getState().setLensAutoColorLegend([]);
101
+ }
102
+
103
+ // Apply colors via overlay system — original batches are never modified
120
104
  if (colorMap.size > 0) {
121
105
  useViewerStore.getState().setPendingColorUpdates(colorMap);
122
106
  }
123
- }, [activeLensId, savedLenses, captureOriginalColors]);
107
+ }, [activeLensId, activeLens]);
124
108
 
125
109
  return {
126
110
  activeLensId,
@@ -0,0 +1,46 @@
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
+ * Lens class discovery hook — INSTANT, zero loading impact.
7
+ *
8
+ * Only discovers IFC class names from the entity table (O(n) array scan,
9
+ * no STEP buffer parsing). Property sets, quantities, materials, and
10
+ * classifications are discovered lazily on-demand when the user opens
11
+ * a dropdown that needs them — see `useLazyDiscovery` in LensPanel.
12
+ */
13
+
14
+ import { useEffect } from 'react';
15
+ import { discoverClasses } from '@ifc-lite/lens';
16
+ import { useViewerStore } from '@/store';
17
+ import { createLensDataProvider } from '@/lib/lens';
18
+
19
+ /**
20
+ * Discover IFC classes when models change (instant).
21
+ * Stores result in `discoveredLensData.classes` on the lens slice.
22
+ */
23
+ export function useLensDiscovery(): void {
24
+ const modelCount = useViewerStore((s) => s.models.size);
25
+ const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
26
+ const setDiscoveredLensData = useViewerStore((s) => s.setDiscoveredLensData);
27
+
28
+ useEffect(() => {
29
+ const { models, ifcDataStore: ds } = useViewerStore.getState();
30
+ if (models.size === 0 && !ds) {
31
+ setDiscoveredLensData(null);
32
+ return;
33
+ }
34
+
35
+ // Instant: just reads type names from entity arrays, no STEP parsing
36
+ const provider = createLensDataProvider(models, ds);
37
+ const classes = discoverClasses(provider);
38
+ setDiscoveredLensData({
39
+ classes,
40
+ propertySets: null, // lazy — discovered on-demand
41
+ quantitySets: null, // lazy — discovered on-demand
42
+ classificationSystems: null, // lazy — discovered on-demand
43
+ materials: null, // lazy — discovered on-demand
44
+ });
45
+ }, [modelCount, ifcDataStore, setDiscoveredLensData]);
46
+ }
@@ -20,14 +20,13 @@
20
20
 
21
21
  import { useEffect } from 'react';
22
22
  import { useViewerStore } from '../store.js';
23
+ import { resolveEntityRef } from '../store/resolveEntityRef.js';
23
24
 
24
25
  export function useModelSelection() {
25
26
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
26
27
  const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
27
28
  // Subscribe to models for reactivity (when models are added/removed)
28
29
  const models = useViewerStore((s) => s.models);
29
- // Use the bulletproof store-based resolver
30
- const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
31
30
 
32
31
  useEffect(() => {
33
32
  if (selectedEntityId === null) {
@@ -38,24 +37,8 @@ export function useModelSelection() {
38
37
  return;
39
38
  }
40
39
 
41
- // selectedEntityId is now a globalId
42
- // Resolve it back to (modelId, originalExpressId) using the store-based resolver
43
- // This is more reliable than the singleton registry which might have bundling issues
44
- const resolved = resolveGlobalIdFromModels(selectedEntityId);
45
- if (resolved) {
46
- // Set EntityRef with ORIGINAL expressId (for property lookup in IfcDataStore)
47
- setSelectedEntity({ modelId: resolved.modelId, expressId: resolved.expressId });
48
- } else {
49
- // Fallback for single-model mode (offset = 0, globalId = expressId)
50
- // In this case, try to find the first model and use the globalId as expressId
51
- if (models.size > 0) {
52
- const firstModelId = Array.from(models.keys())[0];
53
- setSelectedEntity({ modelId: firstModelId, expressId: selectedEntityId });
54
- } else {
55
- // Legacy single-model mode: use 'legacy' as modelId
56
- // This allows PropertiesPanel to fall back to the legacy query
57
- setSelectedEntity({ modelId: 'legacy', expressId: selectedEntityId });
58
- }
59
- }
60
- }, [selectedEntityId, setSelectedEntity, models, resolveGlobalIdFromModels]);
40
+ // Single source of truth: resolveEntityRef handles globalId → EntityRef
41
+ // including fallback for single-model mode (offset 0). Always returns an EntityRef.
42
+ setSelectedEntity(resolveEntityRef(selectedEntityId));
43
+ }, [selectedEntityId, setSelectedEntity, models]);
61
44
  }
package/src/index.css CHANGED
@@ -187,10 +187,16 @@ body {
187
187
  color: var(--tokyo-comment) !important;
188
188
  }
189
189
 
190
- .dark .text-zinc-200 {
190
+ .dark .text-zinc-200,
191
+ .dark .text-zinc-300 {
191
192
  color: var(--tokyo-fg-dark) !important;
192
193
  }
193
194
 
195
+ .dark .text-zinc-700,
196
+ .dark .text-zinc-800 {
197
+ color: var(--tokyo-fg) !important;
198
+ }
199
+
194
200
  /* Primary accent */
195
201
  .dark .bg-primary {
196
202
  background-color: var(--tokyo-blue) !important;
@@ -10,8 +10,14 @@
10
10
  * - Legacy single-model: uses offset = 0
11
11
  */
12
12
 
13
- import type { LensDataProvider, PropertySetInfo } from '@ifc-lite/lens';
13
+ import type { LensDataProvider, PropertySetInfo, ClassificationInfo } from '@ifc-lite/lens';
14
14
  import type { IfcDataStore } from '@ifc-lite/parser';
15
+ import {
16
+ extractEntityAttributesOnDemand,
17
+ extractQuantitiesOnDemand,
18
+ extractClassificationsOnDemand,
19
+ extractMaterialsOnDemand,
20
+ } from '@ifc-lite/parser';
15
21
  import type { FederatedModel } from '@/store/types';
16
22
 
17
23
  interface ModelEntry {
@@ -111,6 +117,126 @@ export function createLensDataProvider(
111
117
  if (!psets) return [];
112
118
  return psets as PropertySetInfo[];
113
119
  },
120
+
121
+ getEntityAttribute(globalId: number, attrName: string): string | undefined {
122
+ const resolved = resolveGlobalId(globalId, entries);
123
+ if (!resolved) return undefined;
124
+ const store = resolved.entry.ifcDataStore;
125
+ const id = resolved.expressId;
126
+
127
+ // Fast path: columnar attributes stored during initial parse
128
+ switch (attrName) {
129
+ case 'Name':
130
+ return store.entities.getName(id) || undefined;
131
+ case 'Description': {
132
+ const desc = store.entities.getDescription?.(id);
133
+ if (desc) return desc;
134
+ break;
135
+ }
136
+ case 'ObjectType': {
137
+ const ot = store.entities.getObjectType?.(id);
138
+ if (ot) return ot;
139
+ break;
140
+ }
141
+ case 'Tag':
142
+ // Tag is not stored in columnar — always on-demand
143
+ break;
144
+ case 'GlobalId':
145
+ return store.entities.getGlobalId(id) || undefined;
146
+ case 'Type':
147
+ return store.entities.getTypeName?.(id) || undefined;
148
+ }
149
+
150
+ // Slow path: on-demand extraction from source buffer
151
+ if (store.source?.length > 0 && store.entityIndex) {
152
+ const attrs = extractEntityAttributesOnDemand(store, id);
153
+ switch (attrName) {
154
+ case 'Name': return attrs.name || undefined;
155
+ case 'Description': return attrs.description || undefined;
156
+ case 'ObjectType': return attrs.objectType || undefined;
157
+ case 'Tag': return attrs.tag || undefined;
158
+ }
159
+ }
160
+ return undefined;
161
+ },
162
+
163
+ getQuantityValue(
164
+ globalId: number,
165
+ qsetName: string,
166
+ quantName: string,
167
+ ): number | string | undefined {
168
+ const resolved = resolveGlobalId(globalId, entries);
169
+ if (!resolved) return undefined;
170
+ const store = resolved.entry.ifcDataStore;
171
+ const id = resolved.expressId;
172
+
173
+ // On-demand quantity extraction
174
+ if (store.onDemandQuantityMap && store.source?.length > 0) {
175
+ const qsets = extractQuantitiesOnDemand(store, id);
176
+ for (const qset of qsets) {
177
+ if (qset.name === qsetName) {
178
+ for (const q of qset.quantities) {
179
+ if (q.name === quantName) return q.value;
180
+ }
181
+ }
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ // Fallback: pre-built quantity tables
187
+ const qsets = store.quantities?.getForEntity?.(id);
188
+ if (!qsets) return undefined;
189
+ for (const qset of qsets) {
190
+ if (qset.name === qsetName) {
191
+ for (const q of qset.quantities) {
192
+ if (q.name === quantName) return q.value;
193
+ }
194
+ }
195
+ }
196
+ return undefined;
197
+ },
198
+
199
+ getClassifications(globalId: number): ClassificationInfo[] {
200
+ const resolved = resolveGlobalId(globalId, entries);
201
+ if (!resolved) return [];
202
+ const store = resolved.entry.ifcDataStore;
203
+ return extractClassificationsOnDemand(store, resolved.expressId);
204
+ },
205
+
206
+ getQuantitySets(globalId: number): ReadonlyArray<{
207
+ name: string;
208
+ quantities: ReadonlyArray<{ name: string }>;
209
+ }> {
210
+ const resolved = resolveGlobalId(globalId, entries);
211
+ if (!resolved) return [];
212
+ const store = resolved.entry.ifcDataStore;
213
+ const id = resolved.expressId;
214
+
215
+ // On-demand quantity extraction
216
+ if (store.onDemandQuantityMap && store.source?.length > 0) {
217
+ return extractQuantitiesOnDemand(store, id);
218
+ }
219
+
220
+ // Fallback: pre-built quantity tables
221
+ const qsets = store.quantities?.getForEntity?.(id);
222
+ if (!qsets) return [];
223
+ return qsets as ReadonlyArray<{ name: string; quantities: ReadonlyArray<{ name: string }> }>;
224
+ },
225
+
226
+ getMaterialName(globalId: number): string | undefined {
227
+ const resolved = resolveGlobalId(globalId, entries);
228
+ if (!resolved) return undefined;
229
+ const store = resolved.entry.ifcDataStore;
230
+ const info = extractMaterialsOnDemand(store, resolved.expressId);
231
+ if (!info) return undefined;
232
+ // Return the top-level material name, or first layer/constituent name
233
+ if (info.name) return info.name;
234
+ if (info.layers?.length) return info.layers[0].materialName;
235
+ if (info.constituents?.length) return info.constituents[0].materialName;
236
+ if (info.profiles?.length) return info.profiles[0].materialName;
237
+ if (info.materials?.length) return info.materials[0];
238
+ return undefined;
239
+ },
114
240
  };
115
241
  }
116
242
 
@@ -0,0 +1,33 @@
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
+ * Maps a list ColumnDefinition to a lens AutoColorSpec.
7
+ * This bridges the lists feature (column-based data tables) with
8
+ * the lens feature (3D coloring) by converting column metadata
9
+ * into the auto-color specification used by the lens engine.
10
+ */
11
+
12
+ import type { ColumnDefinition } from '@ifc-lite/lists';
13
+ import type { AutoColorSpec } from '@ifc-lite/lens';
14
+
15
+ /**
16
+ * Convert a list column definition to an auto-color spec.
17
+ *
18
+ * @param col - Column definition from a list configuration
19
+ * @returns AutoColorSpec for the lens engine
20
+ */
21
+ export function columnToAutoColor(col: ColumnDefinition): AutoColorSpec {
22
+ switch (col.source) {
23
+ case 'attribute':
24
+ if (col.propertyName === 'Class') return { source: 'ifcType' };
25
+ return { source: 'attribute', propertyName: col.propertyName };
26
+ case 'property':
27
+ return { source: 'property', psetName: col.psetName, propertyName: col.propertyName };
28
+ case 'quantity':
29
+ return { source: 'quantity', psetName: col.psetName, propertyName: col.propertyName };
30
+ default:
31
+ return { source: 'ifcType' };
32
+ }
33
+ }
@@ -43,8 +43,11 @@ export type { EntityRef, SchemaVersion, FederatedModel, MeasurementConstraintEdg
43
43
  // Re-export utility functions for entity references
44
44
  export { entityRefToString, stringToEntityRef, entityRefEquals, isIfcxDataStore } from './types.js';
45
45
 
46
+ // Re-export single source of truth for globalId → EntityRef resolution
47
+ export { resolveEntityRef } from './resolveEntityRef.js';
48
+
46
49
  // Re-export Drawing2D types
47
- export type { Drawing2DState, Drawing2DStatus } from './slices/drawing2DSlice.js';
50
+ export type { Drawing2DState, Drawing2DStatus, Annotation2DTool, PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, SelectedAnnotation2D } from './slices/drawing2DSlice.js';
48
51
 
49
52
  // Re-export Sheet types
50
53
  export type { SheetState } from './slices/sheetSlice.js';
@@ -205,6 +208,16 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
205
208
  measure2DLockedAxis: null,
206
209
  measure2DResults: [],
207
210
  measure2DSnapPoint: null,
211
+ // Annotation tools
212
+ annotation2DActiveTool: 'none' as const,
213
+ annotation2DCursorPos: null,
214
+ polygonArea2DPoints: [],
215
+ polygonArea2DResults: [],
216
+ textAnnotations2D: [],
217
+ textAnnotation2DEditing: null,
218
+ cloudAnnotation2DPoints: [],
219
+ cloudAnnotations2D: [],
220
+ selectedAnnotation2D: null,
208
221
  // Drawing Sheet
209
222
  activeSheet: null,
210
223
  sheetEnabled: false,