@ifc-lite/viewer 1.8.0 → 1.10.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 (75) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/assets/{Arrow.dom-CwcRxist.js → Arrow.dom-Bw5JMdDs.js} +1 -1
  3. package/dist/assets/browser-DdRf3aWl.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/ifc-lite_bg-C1-gLAHo.wasm +0 -0
  13. package/dist/assets/index-1ff6P0kc.js +100011 -0
  14. package/dist/assets/index-Bz7vHRxl.js +216 -0
  15. package/dist/assets/index-mvbV6NHd.css +1 -0
  16. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
  17. package/dist/assets/{native-bridge-5LbrYh3R.js → native-bridge-C5hD5vae.js} +1 -1
  18. package/dist/assets/{wasm-bridge-CgpLtj1h.js → wasm-bridge-CaNKXFGM.js} +1 -1
  19. package/dist/index.html +12 -3
  20. package/index.html +10 -1
  21. package/package.json +30 -21
  22. package/src/App.tsx +6 -1
  23. package/src/components/ui/dialog.tsx +8 -6
  24. package/src/components/viewer/CodeEditor.tsx +309 -0
  25. package/src/components/viewer/CommandPalette.tsx +597 -0
  26. package/src/components/viewer/MainToolbar.tsx +31 -3
  27. package/src/components/viewer/ScriptPanel.tsx +416 -0
  28. package/src/components/viewer/ViewerLayout.tsx +63 -11
  29. package/src/components/viewer/Viewport.tsx +58 -2
  30. package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
  31. package/src/components/viewer/useAnimationLoop.ts +4 -1
  32. package/src/components/viewer/useGeometryStreaming.ts +13 -1
  33. package/src/components/viewer/useRenderUpdates.ts +6 -1
  34. package/src/hooks/useKeyboardShortcuts.ts +1 -0
  35. package/src/hooks/useLens.ts +2 -1
  36. package/src/hooks/useSandbox.ts +113 -0
  37. package/src/hooks/useViewerSelectors.ts +22 -0
  38. package/src/index.css +6 -0
  39. package/src/lib/recent-files.ts +122 -0
  40. package/src/lib/scripts/persistence.ts +132 -0
  41. package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
  42. package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
  43. package/src/lib/scripts/templates/envelope-check.ts +164 -0
  44. package/src/lib/scripts/templates/federation-compare.ts +189 -0
  45. package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
  46. package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
  47. package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
  48. package/src/lib/scripts/templates/reset-view.ts +6 -0
  49. package/src/lib/scripts/templates/space-validation.ts +189 -0
  50. package/src/lib/scripts/templates/tsconfig.json +13 -0
  51. package/src/lib/scripts/templates.ts +86 -0
  52. package/src/sdk/BimProvider.tsx +50 -0
  53. package/src/sdk/adapters/export-adapter.ts +283 -0
  54. package/src/sdk/adapters/lens-adapter.ts +44 -0
  55. package/src/sdk/adapters/model-adapter.ts +32 -0
  56. package/src/sdk/adapters/model-compat.ts +80 -0
  57. package/src/sdk/adapters/mutate-adapter.ts +45 -0
  58. package/src/sdk/adapters/query-adapter.ts +241 -0
  59. package/src/sdk/adapters/selection-adapter.ts +29 -0
  60. package/src/sdk/adapters/spatial-adapter.ts +37 -0
  61. package/src/sdk/adapters/types.ts +11 -0
  62. package/src/sdk/adapters/viewer-adapter.ts +103 -0
  63. package/src/sdk/adapters/visibility-adapter.ts +61 -0
  64. package/src/sdk/local-backend.ts +144 -0
  65. package/src/sdk/useBimHost.ts +69 -0
  66. package/src/store/constants.ts +30 -2
  67. package/src/store/index.ts +24 -1
  68. package/src/store/slices/pinboardSlice.ts +37 -41
  69. package/src/store/slices/scriptSlice.ts +218 -0
  70. package/src/store/slices/uiSlice.ts +43 -0
  71. package/tsconfig.json +5 -2
  72. package/vite.config.ts +8 -0
  73. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  74. package/dist/assets/index-7WoQ-qVC.css +0 -1
  75. package/dist/assets/index-BSANf7-H.js +0 -78795
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
- import { Renderer } from '@ifc-lite/renderer';
10
+ import { Renderer, type VisualEnhancementOptions } from '@ifc-lite/renderer';
11
11
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
12
12
  import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
13
13
  import {
@@ -158,7 +158,20 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
158
158
  const { updateCameraRotationRealtime, updateScaleRealtime, setCameraCallbacks } = useCameraState();
159
159
 
160
160
  // Theme state
161
- const { theme } = useThemeState();
161
+ const {
162
+ theme,
163
+ isMobile,
164
+ visualEnhancementsEnabled,
165
+ edgeContrastEnabled,
166
+ edgeContrastIntensity,
167
+ contactShadingQuality,
168
+ contactShadingIntensity,
169
+ contactShadingRadius,
170
+ separationLinesEnabled,
171
+ separationLinesQuality,
172
+ separationLinesIntensity,
173
+ separationLinesRadius,
174
+ } = useThemeState();
162
175
 
163
176
  // Hover state
164
177
  const { hoverTooltipsEnabled, setHoverState, clearHover } = useHoverState();
@@ -215,6 +228,37 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
215
228
  // Theme-aware clear color ref (updated when theme changes)
216
229
  // Tokyo Night storm: #1a1b26 = rgb(26, 27, 38)
217
230
  const clearColorRef = useRef<[number, number, number, number]>([0.102, 0.106, 0.149, 1]);
231
+ const visualEnhancement = useMemo<VisualEnhancementOptions>(() => ({
232
+ enabled: visualEnhancementsEnabled,
233
+ edgeContrast: {
234
+ enabled: edgeContrastEnabled,
235
+ intensity: edgeContrastIntensity,
236
+ },
237
+ contactShading: {
238
+ quality: isMobile ? 'off' : contactShadingQuality,
239
+ intensity: contactShadingIntensity,
240
+ radius: contactShadingRadius,
241
+ },
242
+ separationLines: {
243
+ enabled: separationLinesEnabled,
244
+ quality: isMobile ? 'low' : separationLinesQuality,
245
+ intensity: isMobile ? Math.min(0.4, separationLinesIntensity) : separationLinesIntensity,
246
+ radius: isMobile ? 1.0 : separationLinesRadius,
247
+ },
248
+ }), [
249
+ visualEnhancementsEnabled,
250
+ edgeContrastEnabled,
251
+ edgeContrastIntensity,
252
+ isMobile,
253
+ contactShadingQuality,
254
+ contactShadingIntensity,
255
+ contactShadingRadius,
256
+ separationLinesEnabled,
257
+ separationLinesQuality,
258
+ separationLinesIntensity,
259
+ separationLinesRadius,
260
+ ]);
261
+ const visualEnhancementRef = useRef<VisualEnhancementOptions>(visualEnhancement);
218
262
 
219
263
  // Animation frame ref
220
264
  const animationFrameRef = useRef<number | null>(null);
@@ -330,6 +374,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
330
374
  useEffect(() => { measurementConstraintEdgeRef.current = measurementConstraintEdge; }, [measurementConstraintEdge]);
331
375
  useEffect(() => { sectionPlaneRef.current = sectionPlane; }, [sectionPlane]);
332
376
  useEffect(() => { sectionRangeRef.current = sectionRange; }, [sectionRange]);
377
+ useEffect(() => { visualEnhancementRef.current = visualEnhancement; }, [visualEnhancement]);
333
378
  useEffect(() => {
334
379
  geometryRef.current = geometry;
335
380
  }, [geometry]);
@@ -459,6 +504,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
459
504
  selectedId: selectedEntityIdRef.current,
460
505
  selectedModelIndex: selectedModelIndexRef.current,
461
506
  clearColor: clearColorRef.current,
507
+ visualEnhancement: visualEnhancementRef.current,
462
508
  sectionPlane: activeToolRef.current === 'section' ? {
463
509
  ...sectionPlaneRef.current,
464
510
  min: sectionRangeRef.current?.min,
@@ -485,6 +531,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
485
531
  selectedId: selectedEntityIdRef.current,
486
532
  selectedModelIndex: selectedModelIndexRef.current,
487
533
  clearColor: clearColorRef.current,
534
+ visualEnhancement: visualEnhancementRef.current,
488
535
  sectionPlane: activeToolRef.current === 'section' ? {
489
536
  ...sectionPlaneRef.current,
490
537
  min: sectionRangeRef.current?.min,
@@ -501,6 +548,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
501
548
  selectedId: selectedEntityIdRef.current,
502
549
  selectedModelIndex: selectedModelIndexRef.current,
503
550
  clearColor: clearColorRef.current,
551
+ visualEnhancement: visualEnhancementRef.current,
504
552
  sectionPlane: activeToolRef.current === 'section' ? {
505
553
  ...sectionPlaneRef.current,
506
554
  min: sectionRangeRef.current?.min,
@@ -534,6 +582,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
534
582
  selectedId: selectedEntityIdRef.current,
535
583
  selectedModelIndex: selectedModelIndexRef.current,
536
584
  clearColor: clearColorRef.current,
585
+ visualEnhancement: visualEnhancementRef.current,
537
586
  sectionPlane: activeToolRef.current === 'section' ? {
538
587
  ...sectionPlaneRef.current,
539
588
  min: sectionRangeRef.current?.min,
@@ -557,6 +606,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
557
606
  selectedId: selectedEntityIdRef.current,
558
607
  selectedModelIndex: selectedModelIndexRef.current,
559
608
  clearColor: clearColorRef.current,
609
+ visualEnhancement: visualEnhancementRef.current,
560
610
  sectionPlane: activeToolRef.current === 'section' ? {
561
611
  ...sectionPlaneRef.current,
562
612
  min: sectionRangeRef.current?.min,
@@ -573,6 +623,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
573
623
  selectedId: selectedEntityIdRef.current,
574
624
  selectedModelIndex: selectedModelIndexRef.current,
575
625
  clearColor: clearColorRef.current,
626
+ visualEnhancement: visualEnhancementRef.current,
576
627
  sectionPlane: activeToolRef.current === 'section' ? {
577
628
  ...sectionPlaneRef.current,
578
629
  min: sectionRangeRef.current?.min,
@@ -598,6 +649,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
598
649
  selectedId: selectedEntityIdRef.current,
599
650
  selectedModelIndex: selectedModelIndexRef.current,
600
651
  clearColor: clearColorRef.current,
652
+ visualEnhancement: visualEnhancementRef.current,
601
653
  sectionPlane: activeToolRef.current === 'section' ? {
602
654
  ...sectionPlaneRef.current,
603
655
  min: sectionRangeRef.current?.min,
@@ -614,6 +666,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
614
666
  selectedId: selectedEntityIdRef.current,
615
667
  selectedModelIndex: selectedModelIndexRef.current,
616
668
  clearColor: clearColorRef.current,
669
+ visualEnhancement: visualEnhancementRef.current,
617
670
  sectionPlane: activeToolRef.current === 'section' ? {
618
671
  ...sectionPlaneRef.current,
619
672
  min: sectionRangeRef.current?.min,
@@ -767,6 +820,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
767
820
  clearColorRef,
768
821
  sectionPlaneRef,
769
822
  sectionRangeRef,
823
+ visualEnhancementRef,
770
824
  lastCameraStateRef,
771
825
  updateCameraRotationRealtime,
772
826
  calculateScale,
@@ -783,6 +837,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
783
837
  geometryBoundsRef,
784
838
  pendingColorUpdates,
785
839
  clearPendingColorUpdates,
840
+ clearColorRef,
786
841
  });
787
842
 
788
843
  useRenderUpdates({
@@ -790,6 +845,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
790
845
  isInitialized,
791
846
  theme,
792
847
  clearColorRef,
848
+ visualEnhancementRef,
793
849
  hiddenEntities,
794
850
  isolatedEntities,
795
851
  selectedEntityId,
@@ -132,7 +132,9 @@ function buildSpatialNodes(
132
132
  id: nodeId,
133
133
  expressIds: [spatialNode.expressId],
134
134
  modelIds: [modelId],
135
- name: spatialNode.name || `${nodeType} #${spatialNode.expressId}`,
135
+ name: (spatialNode.name && spatialNode.name.toLowerCase() !== 'unknown')
136
+ ? spatialNode.name
137
+ : nodeType,
136
138
  type: nodeType,
137
139
  depth,
138
140
  hasChildren,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useEffect, type MutableRefObject, type RefObject } from 'react';
11
- import type { Renderer } from '@ifc-lite/renderer';
11
+ import type { Renderer, VisualEnhancementOptions } from '@ifc-lite/renderer';
12
12
  import type { SectionPlane } from '@/store';
13
13
 
14
14
  export interface UseAnimationLoopParams {
@@ -24,6 +24,7 @@ export interface UseAnimationLoopParams {
24
24
  selectedEntityIdRef: MutableRefObject<number | null>;
25
25
  selectedModelIndexRef: MutableRefObject<number | undefined>;
26
26
  clearColorRef: MutableRefObject<[number, number, number, number]>;
27
+ visualEnhancementRef: MutableRefObject<VisualEnhancementOptions>;
27
28
  sectionPlaneRef: MutableRefObject<SectionPlane>;
28
29
  sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
29
30
  lastCameraStateRef: MutableRefObject<{
@@ -53,6 +54,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
53
54
  selectedEntityIdRef,
54
55
  selectedModelIndexRef,
55
56
  clearColorRef,
57
+ visualEnhancementRef,
56
58
  sectionPlaneRef,
57
59
  sectionRangeRef,
58
60
  lastCameraStateRef,
@@ -87,6 +89,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
87
89
  selectedId: selectedEntityIdRef.current,
88
90
  selectedModelIndex: selectedModelIndexRef.current,
89
91
  clearColor: clearColorRef.current,
92
+ visualEnhancement: visualEnhancementRef.current,
90
93
  sectionPlane: activeToolRef.current === 'section' ? {
91
94
  ...sectionPlaneRef.current,
92
95
  min: sectionRangeRef.current?.min,
@@ -20,6 +20,8 @@ export interface UseGeometryStreamingParams {
20
20
  geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
21
21
  pendingColorUpdates: Map<number, [number, number, number, number]> | null;
22
22
  clearPendingColorUpdates: () => void;
23
+ // Clear color ref — color update renders must preserve theme background
24
+ clearColorRef: MutableRefObject<[number, number, number, number]>;
23
25
  }
24
26
 
25
27
  export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
@@ -32,6 +34,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
32
34
  geometryBoundsRef,
33
35
  pendingColorUpdates,
34
36
  clearPendingColorUpdates,
37
+ clearColorRef,
35
38
  } = params;
36
39
 
37
40
  // Track processed meshes for incremental updates
@@ -397,7 +400,16 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
397
400
  // Non-empty map = set color overrides
398
401
  scene.setColorOverrides(pendingColorUpdates, device, pipeline);
399
402
  }
400
- renderer.render();
403
+ // Re-render with current theme background — render() without options
404
+ // defaults to black background. Do NOT pass hiddenIds/isolatedIds here:
405
+ // visibility filtering causes partial batches which write depth only for
406
+ // visible elements, but overlay batches cover all geometry. Without
407
+ // filtering, all original batches write depth for every entity, ensuring
408
+ // depthCompare 'equal' matches exactly for the overlay pass.
409
+ // The next render from useRenderUpdates will apply the correct visibility.
410
+ renderer.render({
411
+ clearColor: clearColorRef.current,
412
+ });
401
413
  clearPendingColorUpdates();
402
414
  }
403
415
  }, [pendingColorUpdates, isInitialized, clearPendingColorUpdates]);
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useEffect, type MutableRefObject } from 'react';
11
- import type { Renderer, CutPolygon2D, DrawingLine2D } from '@ifc-lite/renderer';
11
+ import type { Renderer, CutPolygon2D, DrawingLine2D, VisualEnhancementOptions } from '@ifc-lite/renderer';
12
12
  import type { CoordinateInfo } from '@ifc-lite/geometry';
13
13
  import type { Drawing2D } from '@ifc-lite/drawing-2d';
14
14
  import type { SectionPlane } from '@/store';
@@ -21,6 +21,7 @@ export interface UseRenderUpdatesParams {
21
21
  // Theme
22
22
  theme: string;
23
23
  clearColorRef: MutableRefObject<[number, number, number, number]>;
24
+ visualEnhancementRef: MutableRefObject<VisualEnhancementOptions>;
24
25
 
25
26
  // Visibility/selection state (reactive values, not refs)
26
27
  hiddenEntities: Set<number>;
@@ -54,6 +55,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
54
55
  isInitialized,
55
56
  theme,
56
57
  clearColorRef,
58
+ visualEnhancementRef,
57
59
  hiddenEntities,
58
60
  isolatedEntities,
59
61
  selectedEntityId,
@@ -88,6 +90,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
88
90
  selectedId: selectedEntityIdRef.current,
89
91
  selectedModelIndex: selectedModelIndexRef.current,
90
92
  clearColor: clearColorRef.current,
93
+ visualEnhancement: visualEnhancementRef.current,
91
94
  });
92
95
  }
93
96
  }, [theme, isInitialized]);
@@ -132,6 +135,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
132
135
  selectedIds: selectedEntityIdsRef.current,
133
136
  selectedModelIndex: selectedModelIndexRef.current,
134
137
  clearColor: clearColorRef.current,
138
+ visualEnhancement: visualEnhancementRef.current,
135
139
  sectionPlane: activeTool === 'section' ? {
136
140
  ...sectionPlane,
137
141
  min: sectionRangeRef.current?.min,
@@ -152,6 +156,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
152
156
  selectedIds: selectedEntityIds,
153
157
  selectedModelIndex,
154
158
  clearColor: clearColorRef.current,
159
+ visualEnhancement: visualEnhancementRef.current,
155
160
  sectionPlane: activeTool === 'section' ? {
156
161
  ...sectionPlane,
157
162
  min: sectionRange?.min,
@@ -279,5 +279,6 @@ export const KEYBOARD_SHORTCUTS = [
279
279
  { key: '1-6', description: 'Preset views', category: 'Camera' },
280
280
  { key: 'T', description: 'Toggle theme', category: 'UI' },
281
281
  { key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
282
+ { key: 'Ctrl+K', description: 'Command palette', category: 'UI' },
282
283
  { key: '?', description: 'Show info panel', category: 'Help' },
283
284
  ] as const;
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { useEffect, useRef, useMemo } from 'react';
26
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';
29
30
  import { useLensDiscovery } from './useLensDiscovery';
@@ -95,7 +96,7 @@ export function useLens() {
95
96
 
96
97
  // Store auto-color legend entries for UI display
97
98
  if (isAutoColor && 'legend' in result) {
98
- useViewerStore.getState().setLensAutoColorLegend(result.legend);
99
+ useViewerStore.getState().setLensAutoColorLegend((result as AutoColorEvaluationResult).legend);
99
100
  } else {
100
101
  useViewerStore.getState().setLensAutoColorLegend([]);
101
102
  }
@@ -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
+ }
@@ -157,9 +157,31 @@ export function useHoverState() {
157
157
  */
158
158
  export function useThemeState() {
159
159
  const theme = useViewerStore((state) => state.theme);
160
+ const isMobile = useViewerStore((state) => state.isMobile);
161
+ const visualEnhancementsEnabled = useViewerStore((state) => state.visualEnhancementsEnabled);
162
+ const edgeContrastEnabled = useViewerStore((state) => state.edgeContrastEnabled);
163
+ const edgeContrastIntensity = useViewerStore((state) => state.edgeContrastIntensity);
164
+ const contactShadingQuality = useViewerStore((state) => state.contactShadingQuality);
165
+ const contactShadingIntensity = useViewerStore((state) => state.contactShadingIntensity);
166
+ const contactShadingRadius = useViewerStore((state) => state.contactShadingRadius);
167
+ const separationLinesEnabled = useViewerStore((state) => state.separationLinesEnabled);
168
+ const separationLinesQuality = useViewerStore((state) => state.separationLinesQuality);
169
+ const separationLinesIntensity = useViewerStore((state) => state.separationLinesIntensity);
170
+ const separationLinesRadius = useViewerStore((state) => state.separationLinesRadius);
160
171
 
161
172
  return {
162
173
  theme,
174
+ isMobile,
175
+ visualEnhancementsEnabled,
176
+ edgeContrastEnabled,
177
+ edgeContrastIntensity,
178
+ contactShadingQuality,
179
+ contactShadingIntensity,
180
+ contactShadingRadius,
181
+ separationLinesEnabled,
182
+ separationLinesQuality,
183
+ separationLinesIntensity,
184
+ separationLinesRadius,
163
185
  };
164
186
  }
165
187
 
package/src/index.css CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  @import "tailwindcss";
6
6
 
7
+ /* Override Tailwind v4's default media-query dark variant so all dark: utilities
8
+ respond to the .dark class on <html> instead of prefers-color-scheme.
9
+ Without this, toggling light mode while the OS is in dark mode leaves all
10
+ dark: Tailwind utilities active, creating a mixed dark/light UI. */
11
+ @custom-variant dark (&:where(.dark, .dark *));
12
+
7
13
  /* ═══════════════════════════════════════════════════════════════════════════
8
14
  TOKYO NIGHT THEME - Dark Stormy Cyberpunk Vibes
9
15
  ═══════════════════════════════════════════════════════════════════════════ */
@@ -0,0 +1,122 @@
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
+ * Track recently opened model files via localStorage + IndexedDB.
7
+ *
8
+ * localStorage: metadata (name, size, timestamp) — for palette display.
9
+ * IndexedDB: actual file blobs — so recent files can be loaded instantly
10
+ * without the user re-selecting them from the file picker.
11
+ *
12
+ * Shared between MainToolbar (writes) and CommandPalette (reads).
13
+ */
14
+
15
+ const KEY = 'ifc-lite:recent-files';
16
+ const DB_NAME = 'ifc-lite-file-cache';
17
+ const DB_VERSION = 1;
18
+ const STORE_NAME = 'files';
19
+ const MAX_CACHED_FILES = 5;
20
+ /** Max file size to cache (150 MB) — avoids filling IndexedDB quota */
21
+ const MAX_CACHE_SIZE = 150 * 1024 * 1024;
22
+
23
+ export interface RecentFileEntry {
24
+ name: string;
25
+ size: number;
26
+ timestamp: number;
27
+ }
28
+
29
+ // ── localStorage (metadata) ─────────────────────────────────────────────
30
+
31
+ export function getRecentFiles(): RecentFileEntry[] {
32
+ try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); }
33
+ catch { return []; }
34
+ }
35
+
36
+ export function recordRecentFiles(files: { name: string; size: number }[]) {
37
+ try {
38
+ const names = new Set(files.map(f => f.name));
39
+ const existing = getRecentFiles().filter(f => !names.has(f.name));
40
+ const entries: RecentFileEntry[] = files.map(f => ({
41
+ name: f.name,
42
+ size: f.size,
43
+ timestamp: Date.now(),
44
+ }));
45
+ localStorage.setItem(KEY, JSON.stringify([...entries, ...existing].slice(0, 10)));
46
+ } catch { /* noop */ }
47
+ }
48
+
49
+ /** Format bytes into human-readable size */
50
+ export function formatFileSize(bytes: number): string {
51
+ if (bytes < 1024) return `${bytes} B`;
52
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
53
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
54
+ }
55
+
56
+ // ── IndexedDB (file blob cache) ─────────────────────────────────────────
57
+
58
+ function openDB(): Promise<IDBDatabase> {
59
+ return new Promise((resolve, reject) => {
60
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
61
+ req.onupgradeneeded = () => {
62
+ const db = req.result;
63
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
64
+ db.createObjectStore(STORE_NAME, { keyPath: 'name' });
65
+ }
66
+ };
67
+ req.onsuccess = () => resolve(req.result);
68
+ req.onerror = () => reject(req.error);
69
+ });
70
+ }
71
+
72
+ /** Cache file blobs in IndexedDB for instant reload from palette. */
73
+ export async function cacheFileBlobs(files: File[]): Promise<void> {
74
+ try {
75
+ const db = await openDB();
76
+ const tx = db.transaction(STORE_NAME, 'readwrite');
77
+ const store = tx.objectStore(STORE_NAME);
78
+
79
+ for (const file of files) {
80
+ if (file.size > MAX_CACHE_SIZE) continue; // skip oversized files
81
+ const blob = await file.arrayBuffer();
82
+ store.put({ name: file.name, blob, size: file.size, type: file.type, timestamp: Date.now() });
83
+ }
84
+
85
+ // Evict old entries beyond MAX_CACHED_FILES
86
+ const allReq = store.getAll();
87
+ allReq.onsuccess = () => {
88
+ const all = allReq.result as { name: string; timestamp: number }[];
89
+ if (all.length > MAX_CACHED_FILES) {
90
+ all.sort((a, b) => b.timestamp - a.timestamp);
91
+ for (let i = MAX_CACHED_FILES; i < all.length; i++) {
92
+ store.delete(all[i].name);
93
+ }
94
+ }
95
+ };
96
+
97
+ await new Promise<void>((resolve, reject) => {
98
+ tx.oncomplete = () => resolve();
99
+ tx.onerror = () => reject(tx.error);
100
+ });
101
+ db.close();
102
+ } catch { /* IndexedDB unavailable — degrade gracefully */ }
103
+ }
104
+
105
+ /** Retrieve a cached file blob and reconstruct a File object. */
106
+ export async function getCachedFile(name: string): Promise<File | null> {
107
+ try {
108
+ const db = await openDB();
109
+ const tx = db.transaction(STORE_NAME, 'readonly');
110
+ const store = tx.objectStore(STORE_NAME);
111
+ const req = store.get(name);
112
+ const result = await new Promise<{ name: string; blob: ArrayBuffer; size: number; type: string } | undefined>((resolve, reject) => {
113
+ req.onsuccess = () => resolve(req.result);
114
+ req.onerror = () => reject(req.error);
115
+ });
116
+ db.close();
117
+ if (!result) return null;
118
+ return new File([result.blob], result.name, { type: result.type || 'application/octet-stream' });
119
+ } catch {
120
+ return null;
121
+ }
122
+ }