@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
@@ -0,0 +1,69 @@
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
+ * useBimHost — React hook that initializes the SDK and BimHost.
7
+ *
8
+ * This hook:
9
+ * 1. Creates a LocalBackend backed by the Zustand store
10
+ * 2. Creates a BimContext (the `bim` object)
11
+ * 3. Starts a BimHost listening on BroadcastChannel 'ifc-lite'
12
+ * 4. External tools (ifc-scripts, ifc-flow) can connect to control the viewer
13
+ *
14
+ * Usage:
15
+ * function App() {
16
+ * const bim = useBimHost();
17
+ * // bim is available for internal use
18
+ * // External tools can connect via BroadcastChannel 'ifc-lite'
19
+ * }
20
+ */
21
+
22
+ import { useRef, useEffect, useMemo } from 'react';
23
+ import { createBimContext, BimHost, type BimContext } from '@ifc-lite/sdk';
24
+ import { useViewerStore } from '../store/index.js';
25
+ import { LocalBackend } from './local-backend.js';
26
+
27
+ const BROADCAST_CHANNEL = 'ifc-lite';
28
+
29
+ /**
30
+ * Initialize the SDK with a local backend and start the BimHost.
31
+ * Returns the BimContext for internal use.
32
+ */
33
+ export function useBimHost(): BimContext {
34
+ const hostRef = useRef<BimHost | null>(null);
35
+ const backendRef = useRef<LocalBackend | null>(null);
36
+
37
+ // Create local backend and BimContext once — single shared backend
38
+ const bim = useMemo(() => {
39
+ const storeApi = {
40
+ getState: useViewerStore.getState,
41
+ subscribe: useViewerStore.subscribe,
42
+ };
43
+ const backend = new LocalBackend(storeApi);
44
+ backendRef.current = backend;
45
+ return createBimContext({ backend });
46
+ }, []);
47
+
48
+ // Start BimHost for external connections — reuse the same backend
49
+ useEffect(() => {
50
+ const backend = backendRef.current;
51
+ if (!backend) return;
52
+ const host = new BimHost(backend);
53
+
54
+ try {
55
+ host.listenBroadcast(BROADCAST_CHANNEL);
56
+ } catch {
57
+ // BroadcastChannel not available (e.g., in some test environments)
58
+ }
59
+
60
+ hostRef.current = host;
61
+
62
+ return () => {
63
+ host.close();
64
+ hostRef.current = null;
65
+ };
66
+ }, []);
67
+
68
+ return bim;
69
+ }
@@ -51,11 +51,19 @@ export const EDGE_LOCK_DEFAULTS = {
51
51
  // UI Defaults
52
52
  // ============================================================================
53
53
 
54
+ /** Resolve the initial theme: localStorage override > system preference > dark fallback */
55
+ function getInitialTheme(): 'light' | 'dark' {
56
+ if (typeof window === 'undefined') return 'dark';
57
+ const saved = localStorage.getItem('ifc-lite-theme');
58
+ if (saved === 'light' || saved === 'dark') return saved;
59
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
60
+ }
61
+
54
62
  export const UI_DEFAULTS = {
55
63
  /** Default active tool */
56
64
  ACTIVE_TOOL: 'select',
57
- /** Default theme */
58
- THEME: 'dark' as const,
65
+ /** Default theme – respects user's OS colour-scheme preference */
66
+ THEME: getInitialTheme(),
59
67
  /** Default hover tooltips state */
60
68
  HOVER_TOOLTIPS_ENABLED: false,
61
69
  } as const;
@@ -30,6 +30,7 @@ import { createIdsSlice, type IDSSlice } from './slices/idsSlice.js';
30
30
  import { createListSlice, type ListSlice } from './slices/listSlice.js';
31
31
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
32
32
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
33
+ import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
33
34
 
34
35
  // Import constants for reset function
35
36
  import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, TYPE_VISIBILITY_DEFAULTS } from './constants.js';
@@ -43,8 +44,11 @@ export type { EntityRef, SchemaVersion, FederatedModel, MeasurementConstraintEdg
43
44
  // Re-export utility functions for entity references
44
45
  export { entityRefToString, stringToEntityRef, entityRefEquals, isIfcxDataStore } from './types.js';
45
46
 
47
+ // Re-export single source of truth for globalId → EntityRef resolution
48
+ export { resolveEntityRef } from './resolveEntityRef.js';
49
+
46
50
  // Re-export Drawing2D types
47
- export type { Drawing2DState, Drawing2DStatus } from './slices/drawing2DSlice.js';
51
+ export type { Drawing2DState, Drawing2DStatus, Annotation2DTool, PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, SelectedAnnotation2D } from './slices/drawing2DSlice.js';
48
52
 
49
53
  // Re-export Sheet types
50
54
  export type { SheetState } from './slices/sheetSlice.js';
@@ -64,6 +68,9 @@ export type { PinboardSlice } from './slices/pinboardSlice.js';
64
68
  // Re-export Lens types
65
69
  export type { LensSlice, Lens, LensRule, LensCriteria } from './slices/lensSlice.js';
66
70
 
71
+ // Re-export Script types
72
+ export type { ScriptSlice } from './slices/scriptSlice.js';
73
+
67
74
  // Combined store type
68
75
  export type ViewerState = LoadingSlice &
69
76
  SelectionSlice &
@@ -82,7 +89,8 @@ export type ViewerState = LoadingSlice &
82
89
  IDSSlice &
83
90
  ListSlice &
84
91
  PinboardSlice &
85
- LensSlice & {
92
+ LensSlice &
93
+ ScriptSlice & {
86
94
  resetViewerState: () => void;
87
95
  };
88
96
 
@@ -109,6 +117,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
109
117
  ...createListSlice(...args),
110
118
  ...createPinboardSlice(...args),
111
119
  ...createLensSlice(...args),
120
+ ...createScriptSlice(...args),
112
121
 
113
122
  // Reset all viewer state when loading new file
114
123
  // Note: Does NOT clear models - use clearAllModels() for that
@@ -205,6 +214,16 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
205
214
  measure2DLockedAxis: null,
206
215
  measure2DResults: [],
207
216
  measure2DSnapPoint: null,
217
+ // Annotation tools
218
+ annotation2DActiveTool: 'none' as const,
219
+ annotation2DCursorPos: null,
220
+ polygonArea2DPoints: [],
221
+ polygonArea2DResults: [],
222
+ textAnnotations2D: [],
223
+ textAnnotation2DEditing: null,
224
+ cloudAnnotation2DPoints: [],
225
+ cloudAnnotations2D: [],
226
+ selectedAnnotation2D: null,
208
227
  // Drawing Sheet
209
228
  activeSheet: null,
210
229
  sheetEnabled: false,
@@ -238,6 +257,13 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
238
257
  // Pinboard - clear pinned entities on new file
239
258
  pinboardEntities: new Set<string>(),
240
259
 
260
+ // Script - reset execution state but keep saved scripts and editor content
261
+ scriptPanelVisible: false,
262
+ scriptExecutionState: 'idle' as const,
263
+ scriptLastResult: null,
264
+ scriptLastError: null,
265
+ scriptDeleteConfirmId: null,
266
+
241
267
  // Lens - deactivate but keep saved lenses
242
268
  activeLensId: null,
243
269
  lensPanelVisible: false,
@@ -0,0 +1,44 @@
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
+ * Single source of truth for resolving a globalId to an EntityRef.
7
+ *
8
+ * Every code path that needs an EntityRef from a globalId MUST use this
9
+ * function. It guarantees consistent modelId values so that basket
10
+ * add/remove keys always match, regardless of which UI surface triggered
11
+ * the selection.
12
+ */
13
+
14
+ import type { EntityRef } from './types.js';
15
+ import { useViewerStore } from './index.js';
16
+
17
+ /**
18
+ * Resolve a globalId (renderer-space) to an EntityRef (model-space).
19
+ *
20
+ * Resolution order:
21
+ * 1. resolveGlobalIdFromModels (offset-based range check — the canonical path)
22
+ * 2. First loaded model as fallback (single-model, offset 0)
23
+ * 3. 'legacy' sentinel for truly legacy single-model mode (no federation map)
24
+ *
25
+ * ALWAYS returns an EntityRef — never null. This ensures all callers
26
+ * (multi-select, basket, context menu) can proceed without null-guards.
27
+ */
28
+ export function resolveEntityRef(globalId: number): EntityRef {
29
+ const state = useViewerStore.getState();
30
+ const resolved = state.resolveGlobalIdFromModels(globalId);
31
+ if (resolved) {
32
+ return { modelId: resolved.modelId, expressId: resolved.expressId };
33
+ }
34
+
35
+ // Fallback: single-model mode where offset is 0 → globalId === expressId
36
+ if (state.models.size > 0) {
37
+ const firstModelId = state.models.keys().next().value as string;
38
+ return { modelId: firstModelId, expressId: globalId };
39
+ }
40
+
41
+ // Legacy single-model mode: no models in federation map yet.
42
+ // 'legacy' is recognized by PropertiesPanel for fallback to legacy ifcDataStore.
43
+ return { modelId: 'legacy', expressId: globalId };
44
+ }
@@ -15,6 +15,9 @@ import { BUILT_IN_PRESETS } from '@ifc-lite/drawing-2d';
15
15
 
16
16
  export type Drawing2DStatus = 'idle' | 'generating' | 'ready' | 'error';
17
17
 
18
+ /** Active 2D annotation tool */
19
+ export type Annotation2DTool = 'none' | 'measure' | 'polygon-area' | 'text' | 'cloud';
20
+
18
21
  /** Point in 2D drawing coordinates */
19
22
  export interface Point2D {
20
23
  x: number;
@@ -29,6 +32,39 @@ export interface Measure2DResult {
29
32
  distance: number; // in drawing units (typically meters)
30
33
  }
31
34
 
35
+ /** Polygon area measurement result */
36
+ export interface PolygonArea2DResult {
37
+ id: string;
38
+ points: Point2D[]; // Closed polygon vertices (drawing coords)
39
+ area: number; // Computed area in m²
40
+ perimeter: number; // Computed perimeter in m
41
+ }
42
+
43
+ /** Text box annotation */
44
+ export interface TextAnnotation2D {
45
+ id: string;
46
+ position: Point2D; // Top-left corner (drawing coords)
47
+ text: string;
48
+ fontSize: number; // Font size in screen px (default 14)
49
+ color: string; // Text color (default '#000000')
50
+ backgroundColor: string; // Background fill
51
+ borderColor: string; // Border color
52
+ }
53
+
54
+ /** Cloud (revision cloud) annotation */
55
+ export interface CloudAnnotation2D {
56
+ id: string;
57
+ points: Point2D[]; // Rectangle corners (drawing coords, 2 points: topLeft, bottomRight)
58
+ color: string; // Cloud stroke color (default '#E53935')
59
+ label: string; // Optional label text inside cloud
60
+ }
61
+
62
+ /** Reference to a selected annotation */
63
+ export interface SelectedAnnotation2D {
64
+ type: 'measure' | 'polygon' | 'text' | 'cloud';
65
+ id: string;
66
+ }
67
+
32
68
  export interface Drawing2DState {
33
69
  /** Current drawing data (null when not generated) */
34
70
  drawing2D: Drawing2D | null;
@@ -80,6 +116,34 @@ export interface Drawing2DState {
80
116
  measure2DResults: Measure2DResult[];
81
117
  /** Current snap point (if snapping to geometry) */
82
118
  measure2DSnapPoint: Point2D | null;
119
+
120
+ // Annotation Tool System
121
+ /** Active annotation tool (none = pan mode) */
122
+ annotation2DActiveTool: Annotation2DTool;
123
+ /** Current cursor position in drawing coords for preview rendering */
124
+ annotation2DCursorPos: Point2D | null;
125
+
126
+ // Polygon Area Measurement
127
+ /** Points being placed for in-progress polygon */
128
+ polygonArea2DPoints: Point2D[];
129
+ /** Completed polygon area measurements */
130
+ polygonArea2DResults: PolygonArea2DResult[];
131
+
132
+ // Text Annotations
133
+ /** Placed text annotations */
134
+ textAnnotations2D: TextAnnotation2D[];
135
+ /** ID of text annotation currently being edited (null = none) */
136
+ textAnnotation2DEditing: string | null;
137
+
138
+ // Cloud Annotations
139
+ /** Rectangle corners being placed for in-progress cloud (0-2 points) */
140
+ cloudAnnotation2DPoints: Point2D[];
141
+ /** Completed cloud annotations */
142
+ cloudAnnotations2D: CloudAnnotation2D[];
143
+
144
+ // Selection
145
+ /** Currently selected annotation (null = none) */
146
+ selectedAnnotation2D: SelectedAnnotation2D | null;
83
147
  }
84
148
 
85
149
  export interface Drawing2DSlice extends Drawing2DState {
@@ -121,6 +185,45 @@ export interface Drawing2DSlice extends Drawing2DState {
121
185
  completeMeasure2D: () => void;
122
186
  /** Cancel current measurement */
123
187
  cancelMeasure2D: () => void;
188
+
189
+ // Annotation Tool Actions
190
+ /** Set active annotation tool (also manages measure2DMode for backward compat) */
191
+ setAnnotation2DActiveTool: (tool: Annotation2DTool) => void;
192
+ /** Update cursor position for annotation previews */
193
+ setAnnotation2DCursorPos: (pos: Point2D | null) => void;
194
+
195
+ // Polygon Area Actions
196
+ addPolygonArea2DPoint: (point: Point2D) => void;
197
+ completePolygonArea2D: (area: number, perimeter: number) => void;
198
+ cancelPolygonArea2D: () => void;
199
+ removePolygonArea2DResult: (id: string) => void;
200
+ clearPolygonArea2DResults: () => void;
201
+
202
+ // Text Annotation Actions
203
+ addTextAnnotation2D: (annotation: TextAnnotation2D) => void;
204
+ updateTextAnnotation2D: (id: string, updates: Partial<TextAnnotation2D>) => void;
205
+ removeTextAnnotation2D: (id: string) => void;
206
+ setTextAnnotation2DEditing: (id: string | null) => void;
207
+ clearTextAnnotations2D: () => void;
208
+
209
+ // Cloud Annotation Actions
210
+ addCloudAnnotation2DPoint: (point: Point2D) => void;
211
+ completeCloudAnnotation2D: (label?: string) => void;
212
+ cancelCloudAnnotation2D: () => void;
213
+ removeCloudAnnotation2D: (id: string) => void;
214
+ clearCloudAnnotations2D: () => void;
215
+
216
+ // Selection Actions
217
+ /** Set the selected annotation (null to deselect) */
218
+ setSelectedAnnotation2D: (sel: SelectedAnnotation2D | null) => void;
219
+ /** Delete the currently selected annotation */
220
+ deleteSelectedAnnotation2D: () => void;
221
+ /** Move an annotation to a new origin position (used during drag) */
222
+ moveAnnotation2D: (sel: SelectedAnnotation2D, newOrigin: Point2D) => void;
223
+
224
+ // Bulk Actions
225
+ /** Clear all annotations (measurements, polygons, text, clouds) */
226
+ clearAllAnnotations2D: () => void;
124
227
  }
125
228
 
126
229
  const getDefaultDisplayOptions = (): Drawing2DState['drawing2DDisplayOptions'] => ({
@@ -155,6 +258,17 @@ const getDefaultState = (): Drawing2DState => ({
155
258
  measure2DLockedAxis: null,
156
259
  measure2DResults: [],
157
260
  measure2DSnapPoint: null,
261
+ // Annotation tools
262
+ annotation2DActiveTool: 'none',
263
+ annotation2DCursorPos: null,
264
+ polygonArea2DPoints: [],
265
+ polygonArea2DResults: [],
266
+ textAnnotations2D: [],
267
+ textAnnotation2DEditing: null,
268
+ cloudAnnotation2DPoints: [],
269
+ cloudAnnotations2D: [],
270
+ // Selection
271
+ selectedAnnotation2D: null,
158
272
  });
159
273
 
160
274
  export const createDrawing2DSlice: StateCreator<Drawing2DSlice, [], [], Drawing2DSlice> = (set, get) => ({
@@ -337,4 +451,211 @@ export const createDrawing2DSlice: StateCreator<Drawing2DSlice, [], [], Drawing2
337
451
  measure2DLockedAxis: null,
338
452
  measure2DSnapPoint: null,
339
453
  }),
454
+
455
+ // ═══════════════════════════════════════════════════════════════════════
456
+ // ANNOTATION TOOL ACTIONS
457
+ // ═══════════════════════════════════════════════════════════════════════
458
+
459
+ setAnnotation2DActiveTool: (tool) => {
460
+ const state = get();
461
+ // Cancel any in-progress work from previous tool
462
+ const resetState: Partial<Drawing2DState> = {
463
+ annotation2DActiveTool: tool,
464
+ annotation2DCursorPos: null,
465
+ // Keep measure2DMode in sync for backward compatibility
466
+ measure2DMode: tool === 'measure',
467
+ // Clear in-progress state from all tools
468
+ measure2DStart: null,
469
+ measure2DCurrent: null,
470
+ measure2DShiftLocked: false,
471
+ measure2DLockedAxis: null,
472
+ measure2DSnapPoint: null,
473
+ polygonArea2DPoints: [],
474
+ cloudAnnotation2DPoints: [],
475
+ textAnnotation2DEditing: null,
476
+ selectedAnnotation2D: null,
477
+ };
478
+ set(resetState);
479
+ },
480
+
481
+ setAnnotation2DCursorPos: (pos) => set({ annotation2DCursorPos: pos }),
482
+
483
+ // Polygon Area Actions
484
+ addPolygonArea2DPoint: (point) => set((state) => ({
485
+ polygonArea2DPoints: [...state.polygonArea2DPoints, point],
486
+ })),
487
+
488
+ completePolygonArea2D: (area, perimeter) => {
489
+ const state = get();
490
+ if (state.polygonArea2DPoints.length < 3) return;
491
+
492
+ const result: PolygonArea2DResult = {
493
+ id: `poly-area-${Date.now()}`,
494
+ points: [...state.polygonArea2DPoints],
495
+ area,
496
+ perimeter,
497
+ };
498
+
499
+ set({
500
+ polygonArea2DResults: [...state.polygonArea2DResults, result],
501
+ polygonArea2DPoints: [],
502
+ annotation2DCursorPos: null,
503
+ });
504
+ },
505
+
506
+ cancelPolygonArea2D: () => set({
507
+ polygonArea2DPoints: [],
508
+ annotation2DCursorPos: null,
509
+ }),
510
+
511
+ removePolygonArea2DResult: (id) => set((state) => ({
512
+ polygonArea2DResults: state.polygonArea2DResults.filter((r) => r.id !== id),
513
+ })),
514
+
515
+ clearPolygonArea2DResults: () => set({ polygonArea2DResults: [] }),
516
+
517
+ // Text Annotation Actions
518
+ addTextAnnotation2D: (annotation) => set((state) => ({
519
+ textAnnotations2D: [...state.textAnnotations2D, annotation],
520
+ })),
521
+
522
+ updateTextAnnotation2D: (id, updates) => set((state) => ({
523
+ textAnnotations2D: state.textAnnotations2D.map((a) =>
524
+ a.id === id ? { ...a, ...updates } : a
525
+ ),
526
+ })),
527
+
528
+ removeTextAnnotation2D: (id) => set((state) => ({
529
+ textAnnotations2D: state.textAnnotations2D.filter((a) => a.id !== id),
530
+ textAnnotation2DEditing: state.textAnnotation2DEditing === id ? null : state.textAnnotation2DEditing,
531
+ })),
532
+
533
+ setTextAnnotation2DEditing: (id) => set({ textAnnotation2DEditing: id }),
534
+
535
+ clearTextAnnotations2D: () => set({
536
+ textAnnotations2D: [],
537
+ textAnnotation2DEditing: null,
538
+ }),
539
+
540
+ // Cloud Annotation Actions
541
+ addCloudAnnotation2DPoint: (point) => set((state) => ({
542
+ cloudAnnotation2DPoints: [...state.cloudAnnotation2DPoints, point],
543
+ })),
544
+
545
+ completeCloudAnnotation2D: (label = '') => {
546
+ const state = get();
547
+ if (state.cloudAnnotation2DPoints.length < 2) return;
548
+
549
+ const result: CloudAnnotation2D = {
550
+ id: `cloud-${Date.now()}`,
551
+ points: [...state.cloudAnnotation2DPoints],
552
+ color: '#E53935',
553
+ label,
554
+ };
555
+
556
+ set({
557
+ cloudAnnotations2D: [...state.cloudAnnotations2D, result],
558
+ cloudAnnotation2DPoints: [],
559
+ annotation2DCursorPos: null,
560
+ });
561
+ },
562
+
563
+ cancelCloudAnnotation2D: () => set({
564
+ cloudAnnotation2DPoints: [],
565
+ annotation2DCursorPos: null,
566
+ }),
567
+
568
+ removeCloudAnnotation2D: (id) => set((state) => ({
569
+ cloudAnnotations2D: state.cloudAnnotations2D.filter((a) => a.id !== id),
570
+ })),
571
+
572
+ clearCloudAnnotations2D: () => set({ cloudAnnotations2D: [] }),
573
+
574
+ // Selection Actions
575
+ setSelectedAnnotation2D: (sel) => set({ selectedAnnotation2D: sel }),
576
+
577
+ deleteSelectedAnnotation2D: () => {
578
+ const state = get();
579
+ const sel = state.selectedAnnotation2D;
580
+ if (!sel) return;
581
+
582
+ switch (sel.type) {
583
+ case 'measure':
584
+ set({ measure2DResults: state.measure2DResults.filter((r) => r.id !== sel.id), selectedAnnotation2D: null });
585
+ break;
586
+ case 'polygon':
587
+ set({ polygonArea2DResults: state.polygonArea2DResults.filter((r) => r.id !== sel.id), selectedAnnotation2D: null });
588
+ break;
589
+ case 'text':
590
+ set({
591
+ textAnnotations2D: state.textAnnotations2D.filter((a) => a.id !== sel.id),
592
+ selectedAnnotation2D: null,
593
+ textAnnotation2DEditing: state.textAnnotation2DEditing === sel.id ? null : state.textAnnotation2DEditing,
594
+ });
595
+ break;
596
+ case 'cloud':
597
+ set({ cloudAnnotations2D: state.cloudAnnotations2D.filter((a) => a.id !== sel.id), selectedAnnotation2D: null });
598
+ break;
599
+ }
600
+ },
601
+
602
+ moveAnnotation2D: (sel, newOrigin) => {
603
+ const state = get();
604
+ switch (sel.type) {
605
+ case 'measure': {
606
+ const result = state.measure2DResults.find((r) => r.id === sel.id);
607
+ if (!result) return;
608
+ const dx = newOrigin.x - result.start.x;
609
+ const dy = newOrigin.y - result.start.y;
610
+ set({ measure2DResults: state.measure2DResults.map((r) =>
611
+ r.id === sel.id ? { ...r, start: { x: r.start.x + dx, y: r.start.y + dy }, end: { x: r.end.x + dx, y: r.end.y + dy } } : r
612
+ ) });
613
+ break;
614
+ }
615
+ case 'polygon': {
616
+ const result = state.polygonArea2DResults.find((r) => r.id === sel.id);
617
+ if (!result) return;
618
+ const dx = newOrigin.x - result.points[0].x;
619
+ const dy = newOrigin.y - result.points[0].y;
620
+ set({ polygonArea2DResults: state.polygonArea2DResults.map((r) =>
621
+ r.id === sel.id ? { ...r, points: r.points.map((p) => ({ x: p.x + dx, y: p.y + dy })) } : r
622
+ ) });
623
+ break;
624
+ }
625
+ case 'text': {
626
+ set({ textAnnotations2D: state.textAnnotations2D.map((a) =>
627
+ a.id === sel.id ? { ...a, position: newOrigin } : a
628
+ ) });
629
+ break;
630
+ }
631
+ case 'cloud': {
632
+ const cloud = state.cloudAnnotations2D.find((a) => a.id === sel.id);
633
+ if (!cloud || cloud.points.length < 2) return;
634
+ const dx = newOrigin.x - cloud.points[0].x;
635
+ const dy = newOrigin.y - cloud.points[0].y;
636
+ set({ cloudAnnotations2D: state.cloudAnnotations2D.map((a) =>
637
+ a.id === sel.id ? { ...a, points: a.points.map((p) => ({ x: p.x + dx, y: p.y + dy })) } : a
638
+ ) });
639
+ break;
640
+ }
641
+ }
642
+ },
643
+
644
+ // Bulk Actions
645
+ clearAllAnnotations2D: () => set({
646
+ measure2DResults: [],
647
+ measure2DStart: null,
648
+ measure2DCurrent: null,
649
+ measure2DShiftLocked: false,
650
+ measure2DLockedAxis: null,
651
+ measure2DSnapPoint: null,
652
+ polygonArea2DPoints: [],
653
+ polygonArea2DResults: [],
654
+ textAnnotations2D: [],
655
+ textAnnotation2DEditing: null,
656
+ cloudAnnotation2DPoints: [],
657
+ cloudAnnotations2D: [],
658
+ annotation2DCursorPos: null,
659
+ selectedAnnotation2D: null,
660
+ }),
340
661
  });
@@ -11,18 +11,24 @@
11
11
  */
12
12
 
13
13
  import type { StateCreator } from 'zustand';
14
- import type { Lens, LensRule, LensCriteria } from '@ifc-lite/lens';
14
+ import type { Lens, LensRule, LensCriteria, AutoColorSpec, AutoColorLegendEntry, DiscoveredLensData } from '@ifc-lite/lens';
15
15
  import { BUILTIN_LENSES } from '@ifc-lite/lens';
16
16
 
17
17
  // Re-export types so existing consumer imports from this file still work
18
- export type { Lens, LensRule, LensCriteria };
18
+ export type { Lens, LensRule, LensCriteria, AutoColorSpec, AutoColorLegendEntry, DiscoveredLensData };
19
19
 
20
20
  // Re-export constants for consumers that import from this file
21
- export { COMMON_IFC_CLASSES, COMMON_IFC_TYPES, LENS_PALETTE } from '@ifc-lite/lens';
21
+ export {
22
+ COMMON_IFC_CLASSES, COMMON_IFC_TYPES, LENS_PALETTE,
23
+ LENS_CRITERIA_TYPES, AUTO_COLOR_SOURCES, ENTITY_ATTRIBUTE_NAMES,
24
+ } from '@ifc-lite/lens';
22
25
 
23
26
  /** localStorage key for persisting custom lenses */
24
27
  const STORAGE_KEY = 'ifc-lite-custom-lenses';
25
28
 
29
+ /** Ephemeral lens ID created when coloring from list column headers */
30
+ export const AUTO_COLOR_FROM_LIST_ID = 'auto-color-from-list';
31
+
26
32
  /** Built-in lens IDs — used to detect overrides */
27
33
  const BUILTIN_IDS = new Set(BUILTIN_LENSES.map(l => l.id));
28
34
 
@@ -98,6 +104,10 @@ export interface LensSlice {
98
104
  lensRuleCounts: Map<string, number>;
99
105
  /** Computed: ruleId → matched entity global IDs for the active lens */
100
106
  lensRuleEntityIds: Map<string, number[]>;
107
+ /** Auto-color legend entries (one per distinct value) for UI display */
108
+ lensAutoColorLegend: AutoColorLegendEntry[];
109
+ /** Discovered data from loaded models (classes instant, rest lazy) */
110
+ discoveredLensData: DiscoveredLensData | null;
101
111
 
102
112
  // Actions
103
113
  createLens: (lens: Lens) => void;
@@ -110,12 +120,18 @@ export interface LensSlice {
110
120
  setLensHiddenIds: (ids: Set<number>) => void;
111
121
  setLensRuleCounts: (counts: Map<string, number>) => void;
112
122
  setLensRuleEntityIds: (ids: Map<string, number[]>) => void;
123
+ setLensAutoColorLegend: (legend: AutoColorLegendEntry[]) => void;
124
+ setDiscoveredLensData: (data: DiscoveredLensData | null) => void;
125
+ /** Merge lazy-discovered data sources (psets, quantities, etc.) into existing discovered data */
126
+ mergeDiscoveredData: (patch: Partial<DiscoveredLensData>) => void;
113
127
  /** Get the active lens configuration */
114
128
  getActiveLens: () => Lens | null;
115
129
  /** Import lenses from parsed JSON array */
116
130
  importLenses: (lenses: Lens[]) => void;
117
131
  /** Export all lenses (builtins + custom) as serializable array */
118
132
  exportLenses: () => Lens[];
133
+ /** Create and activate an auto-color lens from a data column spec */
134
+ activateAutoColorFromColumn: (spec: AutoColorSpec, label: string) => void;
119
135
  }
120
136
 
121
137
  export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set, get) => ({
@@ -127,6 +143,8 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
127
143
  lensHiddenIds: new Set(),
128
144
  lensRuleCounts: new Map(),
129
145
  lensRuleEntityIds: new Map(),
146
+ lensAutoColorLegend: [],
147
+ discoveredLensData: null,
130
148
 
131
149
  // Actions
132
150
  createLens: (lens) => set((state) => {
@@ -161,6 +179,12 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
161
179
  setLensHiddenIds: (lensHiddenIds) => set({ lensHiddenIds }),
162
180
  setLensRuleCounts: (lensRuleCounts) => set({ lensRuleCounts }),
163
181
  setLensRuleEntityIds: (lensRuleEntityIds) => set({ lensRuleEntityIds }),
182
+ setLensAutoColorLegend: (lensAutoColorLegend) => set({ lensAutoColorLegend }),
183
+ setDiscoveredLensData: (discoveredLensData) => set({ discoveredLensData }),
184
+ mergeDiscoveredData: (patch) => set((state) => {
185
+ if (!state.discoveredLensData) return {};
186
+ return { discoveredLensData: { ...state.discoveredLensData, ...patch } };
187
+ }),
164
188
 
165
189
  getActiveLens: () => {
166
190
  const { savedLenses, activeLensId } = get();
@@ -179,6 +203,24 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
179
203
  }),
180
204
 
181
205
  exportLenses: () => {
182
- return get().savedLenses.map(({ id, name, rules }) => ({ id, name, rules }));
206
+ return get().savedLenses.map(({ id, name, rules, autoColor }) => {
207
+ const out: Lens = { id, name, rules };
208
+ if (autoColor) out.autoColor = autoColor;
209
+ return out;
210
+ });
183
211
  },
212
+
213
+ activateAutoColorFromColumn: (spec, label) => set((state) => {
214
+ const lensId = AUTO_COLOR_FROM_LIST_ID;
215
+ const lens: Lens = {
216
+ id: lensId,
217
+ name: `Color by ${label}`,
218
+ rules: [],
219
+ autoColor: spec,
220
+ };
221
+ // Replace existing ephemeral lens or add new
222
+ const filtered = state.savedLenses.filter(l => l.id !== lensId);
223
+ const next = [...filtered, lens];
224
+ return { savedLenses: next, activeLensId: lensId, lensPanelVisible: true };
225
+ }),
184
226
  });