@ifc-lite/viewer 1.1.6 → 1.5.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 (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-Dgd6vzw_.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
  15. package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
@@ -0,0 +1,504 @@
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
+ * BCF (BIM Collaboration Format) hook
7
+ *
8
+ * Provides functions to create and apply BCF viewpoints, including:
9
+ * - Capturing snapshots from the WebGPU canvas
10
+ * - Converting between viewer camera state and BCF viewpoint format
11
+ * - Applying viewpoints to the viewer (camera, selection, visibility)
12
+ */
13
+
14
+ import { useCallback, useRef } from 'react';
15
+ import { useViewerStore } from '@/store';
16
+ import type { BCFViewpoint } from '@ifc-lite/bcf';
17
+ import {
18
+ createViewpoint,
19
+ extractViewpointState,
20
+ type ViewerCameraState,
21
+ type ViewerSectionPlane,
22
+ type ViewerBounds,
23
+ } from '@ifc-lite/bcf';
24
+ import type { Renderer } from '@ifc-lite/renderer';
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ interface UseBCFOptions {
31
+ /** Ref to the WebGPU canvas for snapshot capture */
32
+ canvasRef?: React.RefObject<HTMLCanvasElement>;
33
+ /** Ref to the renderer for camera access */
34
+ rendererRef?: React.RefObject<Renderer | null>;
35
+ }
36
+
37
+ interface CreateViewpointOptions {
38
+ /** Include a snapshot image */
39
+ includeSnapshot?: boolean;
40
+ /** Include selected entities */
41
+ includeSelection?: boolean;
42
+ /** Include hidden entities */
43
+ includeHidden?: boolean;
44
+ }
45
+
46
+ interface UseBCFResult {
47
+ /** Create a viewpoint from current viewer state */
48
+ createViewpointFromState: (options?: CreateViewpointOptions) => Promise<BCFViewpoint | null>;
49
+ /** Apply a viewpoint to the viewer */
50
+ applyViewpoint: (viewpoint: BCFViewpoint, animate?: boolean) => void;
51
+ /** Capture a snapshot from the canvas */
52
+ captureSnapshot: () => Promise<string | null>;
53
+ /** Set the canvas ref for snapshot capture */
54
+ setCanvasRef: (ref: React.RefObject<HTMLCanvasElement>) => void;
55
+ /** Set the renderer ref for camera access */
56
+ setRendererRef: (ref: React.RefObject<Renderer | null>) => void;
57
+ }
58
+
59
+ // ============================================================================
60
+ // Canvas Reference Store (module-level for cross-component access)
61
+ // ============================================================================
62
+
63
+ let globalCanvasRef: React.RefObject<HTMLCanvasElement> | null = null;
64
+ let globalRendererRef: React.RefObject<Renderer | null> | null = null;
65
+
66
+ /**
67
+ * Set the global canvas reference (called by ViewportContainer)
68
+ */
69
+ export function setGlobalCanvasRef(ref: React.RefObject<HTMLCanvasElement>): void {
70
+ globalCanvasRef = ref;
71
+ }
72
+
73
+ /**
74
+ * Set the global renderer reference (called by ViewportContainer)
75
+ */
76
+ export function setGlobalRendererRef(ref: React.RefObject<Renderer | null>): void {
77
+ globalRendererRef = ref;
78
+ }
79
+
80
+ /**
81
+ * Clear the global references (called on unmount to prevent memory leaks)
82
+ */
83
+ export function clearGlobalRefs(): void {
84
+ globalCanvasRef = null;
85
+ globalRendererRef = null;
86
+ }
87
+
88
+ // ============================================================================
89
+ // Hook
90
+ // ============================================================================
91
+
92
+ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
93
+ const localCanvasRef = useRef<React.RefObject<HTMLCanvasElement> | null>(
94
+ options.canvasRef ?? null
95
+ );
96
+ const localRendererRef = useRef<React.RefObject<Renderer | null> | null>(
97
+ options.rendererRef ?? null
98
+ );
99
+
100
+ // Store selectors
101
+ const sectionPlane = useViewerStore((s) => s.sectionPlane);
102
+ const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
103
+ const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
104
+ const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
105
+ const selectedEntityIds = useViewerStore((s) => s.selectedEntityIds);
106
+ const setSectionPlaneAxis = useViewerStore((s) => s.setSectionPlaneAxis);
107
+ const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition);
108
+ const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane);
109
+ const flipSectionPlane = useViewerStore((s) => s.flipSectionPlane);
110
+
111
+ // Selection and visibility actions
112
+ const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
113
+ const setHiddenEntities = useViewerStore((s) => s.setHiddenEntities);
114
+ const setIsolatedEntities = useViewerStore((s) => s.setIsolatedEntities);
115
+
116
+ // Get coordinate info for bounds
117
+ const models = useViewerStore((s) => s.models);
118
+
119
+ /**
120
+ * Get the canvas element (local ref or global)
121
+ */
122
+ const getCanvas = useCallback((): HTMLCanvasElement | null => {
123
+ return localCanvasRef.current?.current ?? globalCanvasRef?.current ?? null;
124
+ }, []);
125
+
126
+ /**
127
+ * Get the renderer instance (local ref or global)
128
+ */
129
+ const getRenderer = useCallback((): Renderer | null => {
130
+ return localRendererRef.current?.current ?? globalRendererRef?.current ?? null;
131
+ }, []);
132
+
133
+ /**
134
+ * Set the canvas ref for snapshot capture
135
+ */
136
+ const setCanvasRef = useCallback((ref: React.RefObject<HTMLCanvasElement>) => {
137
+ localCanvasRef.current = ref;
138
+ }, []);
139
+
140
+ /**
141
+ * Set the renderer ref for camera access
142
+ */
143
+ const setRendererRef = useCallback((ref: React.RefObject<Renderer | null>) => {
144
+ localRendererRef.current = ref;
145
+ }, []);
146
+
147
+ /**
148
+ * Capture a snapshot from the WebGPU canvas
149
+ * Captures exactly what the user sees - no re-rendering
150
+ */
151
+ const captureSnapshot = useCallback(async (): Promise<string | null> => {
152
+ const canvas = getCanvas();
153
+ const renderer = getRenderer();
154
+ if (!canvas) {
155
+ console.warn('[useBCF] No canvas available for snapshot capture');
156
+ return null;
157
+ }
158
+
159
+ try {
160
+ // Wait for any pending GPU work to complete before capturing
161
+ // This ensures we capture the fully rendered frame
162
+ if (renderer) {
163
+ const device = renderer.getGPUDevice();
164
+ if (device) {
165
+ await device.queue.onSubmittedWorkDone();
166
+ }
167
+ }
168
+
169
+ // Capture exactly what's displayed on the canvas
170
+ const dataUrl = canvas.toDataURL('image/png');
171
+ return dataUrl;
172
+ } catch (error) {
173
+ console.error('[useBCF] Failed to capture snapshot:', error);
174
+ return null;
175
+ }
176
+ }, [getCanvas, getRenderer]);
177
+
178
+ /**
179
+ * Get current camera state from renderer
180
+ */
181
+ const getCameraState = useCallback((): ViewerCameraState | null => {
182
+ const renderer = getRenderer();
183
+ if (!renderer) {
184
+ console.warn('[useBCF] No renderer available for camera state');
185
+ return null;
186
+ }
187
+
188
+ const camera = renderer.getCamera();
189
+ const position = camera.getPosition();
190
+ const target = camera.getTarget();
191
+ const up = camera.getUp();
192
+ const fov = camera.getFOV();
193
+
194
+ return {
195
+ position,
196
+ target,
197
+ up, // Use actual camera up vector
198
+ fov,
199
+ isOrthographic: false,
200
+ };
201
+ }, [getRenderer]);
202
+
203
+ /**
204
+ * Get model bounds from loaded models
205
+ */
206
+ const getBounds = useCallback((): ViewerBounds | null => {
207
+ // Get bounds from first loaded model's geometry result
208
+ for (const model of models.values()) {
209
+ if (model.geometryResult?.coordinateInfo?.shiftedBounds) {
210
+ return model.geometryResult.coordinateInfo.shiftedBounds;
211
+ }
212
+ }
213
+ return null;
214
+ }, [models]);
215
+
216
+ /**
217
+ * Convert expressId (with model offset) to IFC GlobalId string
218
+ * Handles multi-model federation by finding the correct model and subtracting offset
219
+ */
220
+ const expressIdToGlobalId = useCallback(
221
+ (expressId: number): string | null => {
222
+ for (const model of models.values()) {
223
+ const offset = model.idOffset ?? 0;
224
+ const localExpressId = expressId - offset;
225
+
226
+ // Check if this expressId belongs to this model's range
227
+ if (localExpressId > 0 && localExpressId <= (model.maxExpressId ?? Infinity)) {
228
+ const globalIdString = model.ifcDataStore?.entities?.getGlobalId(localExpressId);
229
+ if (globalIdString) {
230
+ return globalIdString;
231
+ }
232
+ }
233
+ }
234
+ return null;
235
+ },
236
+ [models]
237
+ );
238
+
239
+ /**
240
+ * Convert IFC GlobalId string to expressId (with model offset for federation)
241
+ * Returns { expressId, modelId } or null if not found
242
+ */
243
+ const globalIdToExpressId = useCallback(
244
+ (globalIdString: string): { expressId: number; modelId: string } | null => {
245
+ for (const [modelId, model] of models.entries()) {
246
+ const localExpressId = model.ifcDataStore?.entities?.getExpressIdByGlobalId(globalIdString);
247
+ if (localExpressId !== undefined && localExpressId > 0) {
248
+ // Add model offset for federation
249
+ const offset = model.idOffset ?? 0;
250
+ return {
251
+ expressId: localExpressId + offset,
252
+ modelId,
253
+ };
254
+ }
255
+ }
256
+ return null;
257
+ },
258
+ [models]
259
+ );
260
+
261
+ /**
262
+ * Create a viewpoint from current viewer state
263
+ */
264
+ const createViewpointFromState = useCallback(
265
+ async (opts: CreateViewpointOptions = {}): Promise<BCFViewpoint | null> => {
266
+ const {
267
+ includeSnapshot = true,
268
+ includeSelection = true,
269
+ includeHidden = true,
270
+ } = opts;
271
+
272
+ const cameraState = getCameraState();
273
+ if (!cameraState) {
274
+ console.warn('[useBCF] Cannot create viewpoint: no camera state');
275
+ return null;
276
+ }
277
+
278
+ // Get snapshot if requested
279
+ let snapshot: string | undefined;
280
+ if (includeSnapshot) {
281
+ const captured = await captureSnapshot();
282
+ if (captured) {
283
+ snapshot = captured;
284
+ }
285
+ }
286
+
287
+ // Convert section plane state
288
+ const viewerSectionPlane: ViewerSectionPlane | undefined = sectionPlane.enabled
289
+ ? {
290
+ axis: sectionPlane.axis,
291
+ position: sectionPlane.position,
292
+ enabled: true,
293
+ flipped: sectionPlane.flipped,
294
+ }
295
+ : undefined;
296
+
297
+ // Get bounds for section plane conversion
298
+ const bounds = getBounds() ?? undefined;
299
+
300
+ // Get selected GUIDs - convert expressIds to IFC GlobalId strings
301
+ const selectedGuids: string[] | undefined = includeSelection
302
+ ? (() => {
303
+ const guids: string[] = [];
304
+ if (selectedEntityId !== null) {
305
+ const guid = expressIdToGlobalId(selectedEntityId);
306
+ if (guid) guids.push(guid);
307
+ }
308
+ for (const id of selectedEntityIds) {
309
+ if (id !== selectedEntityId) {
310
+ const guid = expressIdToGlobalId(id);
311
+ if (guid) guids.push(guid);
312
+ }
313
+ }
314
+ return guids.length > 0 ? guids : undefined;
315
+ })()
316
+ : undefined;
317
+
318
+ // Get visibility GUIDs - either hidden (normal mode) or visible (isolation mode)
319
+ let hiddenGuids: string[] | undefined;
320
+ let visibleGuids: string[] | undefined;
321
+
322
+ if (includeHidden) {
323
+ if (isolatedEntities !== null && isolatedEntities.size > 0) {
324
+ // Isolation mode: capture visible entities (defaultVisibility=false)
325
+ const guids: string[] = [];
326
+ for (const id of isolatedEntities) {
327
+ const guid = expressIdToGlobalId(id);
328
+ if (guid) guids.push(guid);
329
+ }
330
+ visibleGuids = guids.length > 0 ? guids : undefined;
331
+ } else if (hiddenEntities.size > 0) {
332
+ // Normal mode: capture hidden entities (defaultVisibility=true)
333
+ const guids: string[] = [];
334
+ for (const id of hiddenEntities) {
335
+ const guid = expressIdToGlobalId(id);
336
+ if (guid) guids.push(guid);
337
+ }
338
+ hiddenGuids = guids.length > 0 ? guids : undefined;
339
+ }
340
+ }
341
+
342
+ // Create viewpoint
343
+ return createViewpoint({
344
+ camera: cameraState,
345
+ sectionPlane: viewerSectionPlane,
346
+ bounds,
347
+ snapshot,
348
+ selectedGuids,
349
+ hiddenGuids,
350
+ visibleGuids,
351
+ });
352
+ },
353
+ [
354
+ getCameraState,
355
+ captureSnapshot,
356
+ sectionPlane,
357
+ getBounds,
358
+ selectedEntityId,
359
+ selectedEntityIds,
360
+ hiddenEntities,
361
+ isolatedEntities,
362
+ expressIdToGlobalId,
363
+ ]
364
+ );
365
+
366
+ /**
367
+ * Apply a viewpoint to the viewer
368
+ */
369
+ const applyViewpoint = useCallback(
370
+ (viewpoint: BCFViewpoint, animate = true) => {
371
+ const renderer = getRenderer();
372
+ if (!renderer) {
373
+ console.warn('[useBCF] Cannot apply viewpoint: no renderer');
374
+ return;
375
+ }
376
+
377
+ const bounds = getBounds() ?? undefined;
378
+
379
+ // Extract state from viewpoint (once, reused for camera, section plane, and selection)
380
+ const state = extractViewpointState(
381
+ viewpoint,
382
+ bounds,
383
+ renderer.getCamera().getDistance() // Use current distance as reference
384
+ );
385
+ const { camera, sectionPlane: viewpointSectionPlane } = state;
386
+
387
+ // Apply camera
388
+ if (camera) {
389
+ const rendererCamera = renderer.getCamera();
390
+
391
+ if (animate) {
392
+ // Animate to new position
393
+ rendererCamera.animateTo(
394
+ camera.position,
395
+ camera.target,
396
+ 300 // 300ms animation
397
+ );
398
+ } else {
399
+ // Set immediately
400
+ rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
401
+ rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
402
+ }
403
+ }
404
+
405
+ // Apply section plane
406
+ if (viewpointSectionPlane) {
407
+ // Set axis and position
408
+ setSectionPlaneAxis(viewpointSectionPlane.axis);
409
+ setSectionPlanePosition(viewpointSectionPlane.position);
410
+
411
+ // Toggle enabled state if needed
412
+ const currentEnabled = sectionPlane.enabled;
413
+ if (viewpointSectionPlane.enabled !== currentEnabled) {
414
+ toggleSectionPlane();
415
+ }
416
+
417
+ // Toggle flip state if needed
418
+ const currentFlipped = sectionPlane.flipped;
419
+ if (viewpointSectionPlane.flipped !== currentFlipped) {
420
+ flipSectionPlane();
421
+ }
422
+ }
423
+
424
+ // Apply selection from BCF components
425
+ if (state.selectedGuids.length > 0) {
426
+ // Convert GlobalId strings to expressIds
427
+ const selectedExpressIds: number[] = [];
428
+ for (const guid of state.selectedGuids) {
429
+ const result = globalIdToExpressId(guid);
430
+ if (result) {
431
+ selectedExpressIds.push(result.expressId);
432
+ }
433
+ }
434
+
435
+ if (selectedExpressIds.length > 0) {
436
+ // Select the first entity (primary selection)
437
+ // The expressId here already includes the federation offset
438
+ setSelectedEntityId(selectedExpressIds[0]);
439
+ // Note: Multi-selection would require additional store support
440
+ }
441
+ } else {
442
+ // Clear selection if viewpoint has no selection
443
+ setSelectedEntityId(null);
444
+ }
445
+
446
+ // Apply visibility from BCF components
447
+ // Either isolation mode (visibleGuids with defaultVisibility=false)
448
+ // or normal mode (hiddenGuids with defaultVisibility=true)
449
+ if (state.visibleGuids.length > 0) {
450
+ // Isolation mode: only specified entities are visible
451
+ const isolatedExpressIds = new Set<number>();
452
+ for (const guid of state.visibleGuids) {
453
+ const result = globalIdToExpressId(guid);
454
+ if (result) {
455
+ isolatedExpressIds.add(result.expressId);
456
+ }
457
+ }
458
+
459
+ if (isolatedExpressIds.size > 0) {
460
+ setIsolatedEntities(isolatedExpressIds);
461
+ } else {
462
+ setIsolatedEntities(null);
463
+ }
464
+ } else if (state.hiddenGuids.length > 0) {
465
+ // Normal mode: specified entities are hidden
466
+ const hiddenExpressIds = new Set<number>();
467
+ for (const guid of state.hiddenGuids) {
468
+ const result = globalIdToExpressId(guid);
469
+ if (result) {
470
+ hiddenExpressIds.add(result.expressId);
471
+ }
472
+ }
473
+
474
+ if (hiddenExpressIds.size > 0) {
475
+ setHiddenEntities(hiddenExpressIds);
476
+ }
477
+ } else {
478
+ // Clear all visibility state if viewpoint has none
479
+ setHiddenEntities(new Set());
480
+ }
481
+ },
482
+ [
483
+ getRenderer,
484
+ getBounds,
485
+ sectionPlane,
486
+ setSectionPlaneAxis,
487
+ setSectionPlanePosition,
488
+ toggleSectionPlane,
489
+ flipSectionPlane,
490
+ globalIdToExpressId,
491
+ setSelectedEntityId,
492
+ setHiddenEntities,
493
+ setIsolatedEntities,
494
+ ]
495
+ );
496
+
497
+ return {
498
+ createViewpointFromState,
499
+ applyViewpoint,
500
+ captureSnapshot,
501
+ setCanvasRef,
502
+ setRendererRef,
503
+ };
504
+ }