@ifc-lite/viewer 1.7.0 → 1.9.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 (95) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
  3. package/dist/assets/browser-BXNIkE8a.js +694 -0
  4. package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
  5. package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
  6. package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
  7. package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
  8. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
  9. package/dist/assets/esbuild-COv63sf-.js +1 -0
  10. package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
  11. package/dist/assets/ffi-DlhRHxHv.js +1 -0
  12. package/dist/assets/index-6Mr3byM-.js +216 -0
  13. package/dist/assets/index-CGbokkQ9.css +1 -0
  14. package/dist/assets/index-huvR-kGC.js +98305 -0
  15. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
  16. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
  17. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
  18. package/dist/index.html +12 -3
  19. package/index.html +10 -1
  20. package/package.json +30 -21
  21. package/src/App.tsx +6 -1
  22. package/src/components/ui/dialog.tsx +8 -6
  23. package/src/components/viewer/CodeEditor.tsx +309 -0
  24. package/src/components/viewer/CommandPalette.tsx +597 -0
  25. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  26. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  27. package/src/components/viewer/ExportDialog.tsx +166 -17
  28. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  29. package/src/components/viewer/LensPanel.tsx +848 -85
  30. package/src/components/viewer/MainToolbar.tsx +145 -84
  31. package/src/components/viewer/ScriptPanel.tsx +416 -0
  32. package/src/components/viewer/Section2DPanel.tsx +269 -29
  33. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  34. package/src/components/viewer/ViewerLayout.tsx +63 -11
  35. package/src/components/viewer/Viewport.tsx +58 -23
  36. package/src/components/viewer/ViewportContainer.tsx +2 -0
  37. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  38. package/src/components/viewer/hierarchy/types.ts +1 -1
  39. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  40. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  41. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  42. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  43. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  44. package/src/components/viewer/useGeometryStreaming.ts +25 -5
  45. package/src/hooks/ids/idsExportService.ts +1 -1
  46. package/src/hooks/useAnnotation2D.ts +551 -0
  47. package/src/hooks/useDrawingExport.ts +83 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +114 -14
  49. package/src/hooks/useLens.ts +40 -55
  50. package/src/hooks/useLensDiscovery.ts +46 -0
  51. package/src/hooks/useModelSelection.ts +5 -22
  52. package/src/hooks/useSandbox.ts +113 -0
  53. package/src/index.css +7 -1
  54. package/src/lib/lens/adapter.ts +127 -1
  55. package/src/lib/lists/columnToAutoColor.ts +33 -0
  56. package/src/lib/recent-files.ts +122 -0
  57. package/src/lib/scripts/persistence.ts +132 -0
  58. package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
  59. package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
  60. package/src/lib/scripts/templates/envelope-check.ts +164 -0
  61. package/src/lib/scripts/templates/federation-compare.ts +189 -0
  62. package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
  63. package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
  64. package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
  65. package/src/lib/scripts/templates/reset-view.ts +6 -0
  66. package/src/lib/scripts/templates/space-validation.ts +189 -0
  67. package/src/lib/scripts/templates/tsconfig.json +13 -0
  68. package/src/lib/scripts/templates.ts +86 -0
  69. package/src/sdk/BimProvider.tsx +50 -0
  70. package/src/sdk/adapters/export-adapter.ts +283 -0
  71. package/src/sdk/adapters/lens-adapter.ts +44 -0
  72. package/src/sdk/adapters/model-adapter.ts +32 -0
  73. package/src/sdk/adapters/model-compat.ts +80 -0
  74. package/src/sdk/adapters/mutate-adapter.ts +45 -0
  75. package/src/sdk/adapters/query-adapter.ts +241 -0
  76. package/src/sdk/adapters/selection-adapter.ts +29 -0
  77. package/src/sdk/adapters/spatial-adapter.ts +37 -0
  78. package/src/sdk/adapters/types.ts +11 -0
  79. package/src/sdk/adapters/viewer-adapter.ts +103 -0
  80. package/src/sdk/adapters/visibility-adapter.ts +61 -0
  81. package/src/sdk/local-backend.ts +144 -0
  82. package/src/sdk/useBimHost.ts +69 -0
  83. package/src/store/constants.ts +10 -2
  84. package/src/store/index.ts +28 -2
  85. package/src/store/resolveEntityRef.ts +44 -0
  86. package/src/store/slices/drawing2DSlice.ts +321 -0
  87. package/src/store/slices/lensSlice.ts +46 -4
  88. package/src/store/slices/pinboardSlice.ts +171 -42
  89. package/src/store/slices/scriptSlice.ts +218 -0
  90. package/src/store/slices/uiSlice.ts +2 -0
  91. package/src/store.ts +3 -0
  92. package/tsconfig.json +5 -2
  93. package/vite.config.ts +8 -0
  94. package/dist/assets/index-dgdgiQ9p.js +0 -75456
  95. 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,17 @@ 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' },
282
+ { key: 'Ctrl+K', description: 'Command palette', category: 'UI' },
183
283
  { key: '?', description: 'Show info panel', category: 'Help' },
184
284
  ] as const;
@@ -10,78 +10,55 @@
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
+ import type { AutoColorEvaluationResult } from '@ifc-lite/lens';
27
28
  import { useViewerStore } from '@/store';
28
29
  import { createLensDataProvider } from '@/lib/lens';
30
+ import { useLensDiscovery } from './useLensDiscovery';
29
31
 
30
32
  export function useLens() {
31
33
  const activeLensId = useViewerStore((s) => s.activeLensId);
32
34
  const savedLenses = useViewerStore((s) => s.savedLenses);
33
35
 
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
- }
36
+ // Derive the active lens object only re-evaluates when activeLensId or
37
+ // the active lens entry itself changes, not when unrelated lenses are edited.
38
+ const activeLens = useMemo(
39
+ () => savedLenses.find(l => l.id === activeLensId) ?? null,
40
+ [activeLensId, savedLenses],
41
+ );
56
42
 
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
- }
43
+ // Run data discovery when models change (populates discoveredLensData in store)
44
+ useLensDiscovery();
65
45
 
66
- return originals;
67
- }, []);
46
+ // Track the previously active lens to detect deactivation
47
+ const prevLensIdRef = useRef<string | null>(null);
68
48
 
69
49
  useEffect(() => {
70
- const activeLens = savedLenses.find(l => l.id === activeLensId) ?? null;
71
50
 
72
- // Lens deactivated — restore original colors
51
+ // Lens deactivated — clear overlay (instant, no batch rebuild)
73
52
  if (!activeLens && prevLensIdRef.current !== null) {
74
53
  prevLensIdRef.current = null;
75
54
  useViewerStore.getState().setLensColorMap(new Map());
76
55
  useViewerStore.getState().setLensHiddenIds(new Set());
77
56
  useViewerStore.getState().setLensRuleCounts(new Map());
78
57
  useViewerStore.getState().setLensRuleEntityIds(new Map());
58
+ useViewerStore.getState().setLensAutoColorLegend([]);
79
59
 
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;
60
+ // Send empty map to signal "clear overlays" to useGeometryStreaming
61
+ useViewerStore.getState().setPendingColorUpdates(new Map());
85
62
  return;
86
63
  }
87
64
 
@@ -92,16 +69,18 @@ export function useLens() {
92
69
  const { models, ifcDataStore } = useViewerStore.getState();
93
70
  if (models.size === 0 && !ifcDataStore) return;
94
71
 
95
- // Save original colors before first lens application
96
- if (prevLensIdRef.current === null) {
97
- originalColorsRef.current = captureOriginalColors();
98
- }
99
-
100
72
  prevLensIdRef.current = activeLensId;
101
73
 
102
74
  // Create data provider and evaluate lens using @ifc-lite/lens package
103
75
  const provider = createLensDataProvider(models, ifcDataStore);
104
- const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = evaluateLens(activeLens, provider);
76
+
77
+ // Dispatch: auto-color mode vs. rule-based mode
78
+ const isAutoColor = !!activeLens.autoColor;
79
+ const result = isAutoColor
80
+ ? evaluateAutoColorLens(activeLens.autoColor!, provider)
81
+ : evaluateLens(activeLens, provider);
82
+
83
+ const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = result;
105
84
 
106
85
  // Build hex color map for UI legend (exclude ghost entries)
107
86
  const hexColorMap = new Map<number, string>();
@@ -115,12 +94,18 @@ export function useLens() {
115
94
  useViewerStore.getState().setLensRuleCounts(ruleCounts);
116
95
  useViewerStore.getState().setLensRuleEntityIds(ruleEntityIds);
117
96
 
118
- // Apply ALL colors to renderer via pendingColorUpdates only —
119
- // no mesh cloning needed, the renderer picks these up directly
97
+ // Store auto-color legend entries for UI display
98
+ if (isAutoColor && 'legend' in result) {
99
+ useViewerStore.getState().setLensAutoColorLegend((result as AutoColorEvaluationResult).legend);
100
+ } else {
101
+ useViewerStore.getState().setLensAutoColorLegend([]);
102
+ }
103
+
104
+ // Apply colors via overlay system — original batches are never modified
120
105
  if (colorMap.size > 0) {
121
106
  useViewerStore.getState().setPendingColorUpdates(colorMap);
122
107
  }
123
- }, [activeLensId, savedLenses, captureOriginalColors]);
108
+ }, [activeLensId, activeLens]);
124
109
 
125
110
  return {
126
111
  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
  }
@@ -0,0 +1,113 @@
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
+ * useSandbox — React hook for executing scripts in a QuickJS sandbox.
7
+ *
8
+ * Creates a fresh sandbox context per execution for full isolation.
9
+ * The WASM module is cached across the session (cheap to reuse),
10
+ * but each script runs in a clean context with no leaked state.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef } from 'react';
14
+ import { useBim } from '../sdk/BimProvider.js';
15
+ import { useViewerStore } from '../store/index.js';
16
+ import type { Sandbox, ScriptResult, SandboxConfig } from '@ifc-lite/sandbox';
17
+
18
+ /** Type guard for ScriptError shape (has logs + durationMs) */
19
+ function isScriptError(err: unknown): err is { message: string; logs: Array<{ level: string; args: unknown[]; timestamp: number }>; durationMs: number } {
20
+ return (
21
+ err !== null &&
22
+ typeof err === 'object' &&
23
+ 'logs' in err &&
24
+ Array.isArray((err as Record<string, unknown>).logs) &&
25
+ 'durationMs' in err &&
26
+ typeof (err as Record<string, unknown>).durationMs === 'number'
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Hook that provides a sandbox execution interface.
32
+ *
33
+ * Each execute() call creates a fresh QuickJS context for full isolation —
34
+ * scripts cannot leak global state between runs. The WASM module itself
35
+ * is cached (loaded once per app lifetime, ~1ms context creation overhead).
36
+ */
37
+ export function useSandbox(config?: SandboxConfig) {
38
+ const bim = useBim();
39
+ const activeSandboxRef = useRef<Sandbox | null>(null);
40
+
41
+ const setExecutionState = useViewerStore((s) => s.setScriptExecutionState);
42
+ const setResult = useViewerStore((s) => s.setScriptResult);
43
+ const setError = useViewerStore((s) => s.setScriptError);
44
+
45
+ /** Execute a script in an isolated sandbox context */
46
+ const execute = useCallback(async (code: string): Promise<ScriptResult | null> => {
47
+ setExecutionState('running');
48
+ setError(null);
49
+
50
+ let sandbox: Sandbox | null = null;
51
+ try {
52
+ // Create a fresh sandbox for every execution — full isolation
53
+ const { createSandbox } = await import('@ifc-lite/sandbox');
54
+ sandbox = await createSandbox(bim, {
55
+ permissions: { model: true, query: true, viewer: true, mutate: true, lens: true, export: true, ...config?.permissions },
56
+ limits: { timeoutMs: 30_000, ...config?.limits },
57
+ });
58
+ activeSandboxRef.current = sandbox;
59
+
60
+ const result = await sandbox.eval(code);
61
+ setResult({
62
+ value: result.value,
63
+ logs: result.logs,
64
+ durationMs: result.durationMs,
65
+ });
66
+ return result;
67
+ } catch (err: unknown) {
68
+ const message = err instanceof Error ? err.message : String(err);
69
+ setError(message);
70
+
71
+ // If the error is a ScriptError with captured logs, preserve them
72
+ if (isScriptError(err)) {
73
+ setResult({
74
+ value: undefined,
75
+ logs: err.logs as ScriptResult['logs'],
76
+ durationMs: err.durationMs,
77
+ });
78
+ }
79
+ return null;
80
+ } finally {
81
+ // Always dispose the sandbox after execution
82
+ if (sandbox) {
83
+ sandbox.dispose();
84
+ }
85
+ if (activeSandboxRef.current === sandbox) {
86
+ activeSandboxRef.current = null;
87
+ }
88
+ }
89
+ }, [bim, config?.permissions, config?.limits, setExecutionState, setResult, setError]);
90
+
91
+ /** Reset clears any active sandbox (no-op if none running) */
92
+ const reset = useCallback(() => {
93
+ if (activeSandboxRef.current) {
94
+ activeSandboxRef.current.dispose();
95
+ activeSandboxRef.current = null;
96
+ }
97
+ setExecutionState('idle');
98
+ setResult(null);
99
+ setError(null);
100
+ }, [setExecutionState, setResult, setError]);
101
+
102
+ // Cleanup on unmount
103
+ useEffect(() => {
104
+ return () => {
105
+ if (activeSandboxRef.current) {
106
+ activeSandboxRef.current.dispose();
107
+ activeSandboxRef.current = null;
108
+ }
109
+ };
110
+ }, []);
111
+
112
+ return { execute, reset };
113
+ }
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;