@ifc-lite/viewer 1.17.4 → 1.18.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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -9,19 +9,23 @@ import { TooltipProvider } from '@/components/ui/tooltip';
9
9
  import { MainToolbar } from './MainToolbar';
10
10
  import { HierarchyPanel } from './HierarchyPanel';
11
11
  import { PropertiesPanel } from './PropertiesPanel';
12
+ import { AddElementPanel } from './AddElementPanel';
12
13
  import { StatusBar } from './StatusBar';
13
14
  import { ViewportContainer } from './ViewportContainer';
14
15
  import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
15
16
  import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
16
17
  import { useViewerStore } from '@/store';
17
18
  import { EntityContextMenu } from './EntityContextMenu';
19
+ import { useDuplicateShortcut } from './useDuplicateShortcut';
18
20
  import { HoverTooltip } from './HoverTooltip';
19
21
  import { BCFPanel } from './BCFPanel';
20
22
  import { IDSPanel } from './IDSPanel';
21
23
  import { LensPanel } from './LensPanel';
22
24
  import { ListPanel } from './lists/ListPanel';
23
25
  import { ScriptPanel } from './ScriptPanel';
26
+ import { GanttPanel } from './schedule/GanttPanel';
24
27
  import { CommandPalette } from './CommandPalette';
28
+ import { SearchModal } from './SearchModal';
25
29
  import { DesktopEntitlementBanner } from './DesktopEntitlementBanner';
26
30
  import {
27
31
  closeActiveAnalysisExtension,
@@ -37,6 +41,8 @@ const BOTTOM_PANEL_MAX_RATIO = 0.7; // max 70% of container
37
41
  export function ViewerLayout() {
38
42
  // Initialize keyboard shortcuts
39
43
  useKeyboardShortcuts();
44
+ // ⌘D / Ctrl+D to duplicate the current selection.
45
+ useDuplicateShortcut();
40
46
  const shortcutsDialog = useKeyboardShortcutsDialog();
41
47
 
42
48
  // Command palette state
@@ -76,6 +82,8 @@ export function ViewerLayout() {
76
82
  const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed);
77
83
  const bcfPanelVisible = useViewerStore((s) => s.bcfPanelVisible);
78
84
  const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
85
+ const activeTool = useViewerStore((s) => s.activeTool);
86
+ const setActiveTool = useViewerStore((s) => s.setActiveTool);
79
87
  const idsPanelVisible = useViewerStore((s) => s.idsPanelVisible);
80
88
  const setIdsPanelVisible = useViewerStore((s) => s.setIdsPanelVisible);
81
89
  const listPanelVisible = useViewerStore((s) => s.listPanelVisible);
@@ -84,6 +92,8 @@ export function ViewerLayout() {
84
92
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
85
93
  const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
86
94
  const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
95
+ const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
96
+ const setGanttPanelVisible = useViewerStore((s) => s.setGanttPanelVisible);
87
97
  const analysisExtensionState = useSyncExternalStore(
88
98
  subscribeAnalysisExtensions,
89
99
  getAnalysisExtensionsSnapshot,
@@ -186,6 +196,7 @@ export function ViewerLayout() {
186
196
  // Keep DOM class in sync when theme changes (initial class is set by inline script in index.html)
187
197
  useEffect(() => {
188
198
  document.documentElement.classList.toggle('dark', theme === 'dark');
199
+ document.documentElement.classList.toggle('colorful', theme === 'colorful');
189
200
  }, [theme]);
190
201
 
191
202
 
@@ -199,6 +210,7 @@ export function ViewerLayout() {
199
210
  <EntityContextMenu />
200
211
  <HoverTooltip />
201
212
  <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
213
+ <SearchModal />
202
214
 
203
215
  {/* Main Toolbar */}
204
216
  <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
@@ -255,6 +267,8 @@ export function ViewerLayout() {
255
267
  <div className="h-full w-full overflow-hidden panel-container">
256
268
  {activeRightAnalysisExtension ? (
257
269
  activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
270
+ ) : activeTool === 'addElement' ? (
271
+ <AddElementPanel onClose={() => setActiveTool('select')} />
258
272
  ) : lensPanelVisible ? (
259
273
  <LensPanel onClose={() => setLensPanelVisible(false)} />
260
274
  ) : idsPanelVisible ? (
@@ -269,8 +283,8 @@ export function ViewerLayout() {
269
283
  </PanelGroup>
270
284
  </div>
271
285
 
272
- {/* Bottom Panel - Lists or Script (custom resizable, outside PanelGroup) */}
273
- {(listPanelVisible || scriptPanelVisible || !!activeBottomAnalysisExtension) && (
286
+ {/* Bottom Panel - Lists / Script / Gantt / analysis ext (custom resizable) */}
287
+ {(listPanelVisible || scriptPanelVisible || ganttPanelVisible || !!activeBottomAnalysisExtension) && (
274
288
  <div style={{ height: bottomHeight, flexShrink: 0 }} className="relative">
275
289
  {/* Drag handle */}
276
290
  <div
@@ -280,6 +294,8 @@ export function ViewerLayout() {
280
294
  <div className="h-full w-full overflow-hidden border-t pt-1.5">
281
295
  {activeBottomAnalysisExtension ? (
282
296
  activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
297
+ ) : ganttPanelVisible ? (
298
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
283
299
  ) : scriptPanelVisible ? (
284
300
  <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
285
301
  ) : (
@@ -325,7 +341,7 @@ export function ViewerLayout() {
325
341
  <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
326
342
  <div className="flex items-center justify-between p-2 border-b">
327
343
  <span className="font-medium text-sm">
328
- {activeAnalysisExtension ? activeAnalysisExtension.label : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
344
+ {activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Inspector'}
329
345
  </span>
330
346
  <button
331
347
  className="p-1 hover:bg-muted rounded"
@@ -333,6 +349,7 @@ export function ViewerLayout() {
333
349
  setRightPanelCollapsed(true);
334
350
  if (scriptPanelVisible) setScriptPanelVisible(false);
335
351
  if (listPanelVisible) setListPanelVisible(false);
352
+ if (ganttPanelVisible) setGanttPanelVisible(false);
336
353
  if (bcfPanelVisible) setBcfPanelVisible(false);
337
354
  if (lensPanelVisible) setLensPanelVisible(false);
338
355
  if (idsPanelVisible) setIdsPanelVisible(false);
@@ -350,10 +367,14 @@ export function ViewerLayout() {
350
367
  activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
351
368
  ) : activeRightAnalysisExtension ? (
352
369
  activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
370
+ ) : ganttPanelVisible ? (
371
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
353
372
  ) : scriptPanelVisible ? (
354
373
  <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
355
374
  ) : listPanelVisible ? (
356
375
  <ListPanel onClose={() => setListPanelVisible(false)} />
376
+ ) : activeTool === 'addElement' ? (
377
+ <AddElementPanel onClose={() => setActiveTool('select')} />
357
378
  ) : lensPanelVisible ? (
358
379
  <LensPanel onClose={() => setLensPanelVisible(false)} />
359
380
  ) : idsPanelVisible ? (
@@ -385,7 +406,7 @@ export function ViewerLayout() {
385
406
  setRightPanelCollapsed(!rightPanelCollapsed);
386
407
  }}
387
408
  >
388
- Properties
409
+ Inspector
389
410
  </button>
390
411
  </div>
391
412
  </div>
@@ -312,7 +312,7 @@ export function Viewport({
312
312
  if (cesiumActive) {
313
313
  clearColorRef.current = [0, 0, 0, 0]; // fully transparent
314
314
  } else {
315
- clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
315
+ clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark' | 'colorful');
316
316
  }
317
317
  rendererRef.current?.requestRender();
318
318
  }, [cesiumActive, theme]);
@@ -456,11 +456,21 @@ export function Viewport({
456
456
  }
457
457
 
458
458
  // Set cursor based on active tool
459
- if (activeTool === 'measure') {
459
+ if (activeTool === 'measure' || activeTool === 'annotate' || activeTool === 'addElement') {
460
460
  canvas.style.cursor = 'crosshair';
461
461
  } else {
462
462
  canvas.style.cursor = 'default';
463
463
  }
464
+
465
+ // Clear add-element pending state + hover point when leaving the
466
+ // tool so the SVG overlay doesn't paint stale geometry from a
467
+ // previous session.
468
+ if (activeTool !== 'addElement') {
469
+ const state = useViewerStore.getState();
470
+ if (state.addElementPendingPoints.length > 0 || state.addElementHoverPoint !== null) {
471
+ state.clearAddElementPending();
472
+ }
473
+ }
464
474
  }, [activeTool, activeMeasurement, cancelMeasurement]);
465
475
 
466
476
  // Helper: calculate scale bar value (world-space size for 96px scale bar)
@@ -878,13 +888,25 @@ export function Viewport({
878
888
  // The model will be rendered by Cesium (as GLB) for correct positioning.
879
889
  // Canvas stays in the DOM for picking/interaction.
880
890
 
891
+ // Colorful mode: transparent WebGPU clear colour + CSS gradient on the
892
+ // canvas element. The gradient is the *CSS background* of the <canvas>;
893
+ // premultiplied-alpha compositing shows it through transparent clear-colour
894
+ // regions while opaque model fragments (alpha=1) stay fully visible.
895
+ const canvasStyle = cesiumActive
896
+ ? { opacity: 0 }
897
+ : theme === 'colorful'
898
+ ? {
899
+ background: 'linear-gradient(180deg, #4a5a8a 0%, #6272a8 10%, #7e8dba 20%, #9aa3c8 32%, #b5b8d1 44%, #cdc3d4 56%, #dcccc8 68%, #e8d5be 80%, #f0ddb8 92%, #f5e2b6 100%)',
900
+ }
901
+ : undefined;
902
+
881
903
  return (
882
904
  <canvas
883
905
  ref={canvasRef}
884
906
  data-viewport="main"
885
907
  tabIndex={-1}
886
908
  className={`w-full h-full block ${cesiumActive ? 'relative z-[1]' : ''}`}
887
- style={cesiumActive ? { opacity: 0 } : undefined}
909
+ style={canvasStyle}
888
910
  onPointerDown={focusViewportForKeyboardShortcuts}
889
911
  />
890
912
  );
@@ -6,6 +6,7 @@ import { useMemo, useRef, useState, useCallback, useEffect, useSyncExternalStore
6
6
  import { Viewport } from './Viewport';
7
7
  import { ViewportOverlays } from './ViewportOverlays';
8
8
  import { ToolOverlays } from './ToolOverlays';
9
+ import { AnnotationLayer } from './annotations/AnnotationLayer';
9
10
  import { Section2DPanel } from './Section2DPanel';
10
11
  import { BasketPresentationDock } from './BasketPresentationDock';
11
12
  import { BCFOverlay } from './bcf/BCFOverlay';
@@ -18,10 +19,11 @@ import { useWebGPU } from '@/hooks/useWebGPU';
18
19
  import { openIfcFileDialog } from '@/services/file-dialog';
19
20
  import { logToDesktopTerminal } from '@/services/desktop-logger';
20
21
  import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
21
- import { isTauri } from '@/utils/ifcConfig';
22
+ import { isTauri } from '@/lib/platform';
22
23
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3 } from 'lucide-react';
23
24
  import type { MeshData, CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
24
- import { extractGeoreferencingOnDemand, type IfcDataStore, type MapConversion, type ProjectedCRS } from '@ifc-lite/parser';
25
+ import { type IfcDataStore } from '@ifc-lite/parser';
26
+ import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
25
27
 
26
28
  const ZERO_VEC3 = { x: 0, y: 0, z: 0 };
27
29
  const DEFAULT_COORDINATE_INFO: CoordinateInfo = {
@@ -43,6 +45,7 @@ export function ViewportContainer() {
43
45
  const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
44
46
  const georefMutations = useViewerStore((s) => s.georefMutations);
45
47
  const setCesiumSourceModelId = useViewerStore((s) => s.setCesiumSourceModelId);
48
+ const setCesiumAvailable = useViewerStore((s) => s.setCesiumAvailable);
46
49
  // Subscribe to mutationVersion so Cesium reacts to georef edits
47
50
  const mutationVersion = useViewerStore((s) => s.mutationVersion);
48
51
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -176,82 +179,64 @@ export function ViewportContainer() {
176
179
  const georef = useMemo(() => {
177
180
  if (!cesiumEnabled) return null;
178
181
 
179
- // Helper: merge original georef with mutations for a model
180
- function mergeGeoref(
181
- originalCRS: ProjectedCRS | undefined,
182
- originalConv: MapConversion | undefined,
183
- modelId: string,
184
- ): { mapConversion: MapConversion; projectedCRS: ProjectedCRS } | null {
185
- const muts = georefMutations.get(modelId);
186
- const mutCRS = muts?.projectedCRS;
187
- const mutConv = muts?.mapConversion;
188
-
189
- // Build merged ProjectedCRS — mutation fields override originals
190
- const hasCRS = originalCRS || mutCRS;
191
- if (!hasCRS) return null;
192
- const projectedCRS: ProjectedCRS = {
193
- id: originalCRS?.id ?? 0,
194
- name: (mutCRS?.name ?? originalCRS?.name ?? '') as string,
195
- description: mutCRS?.description ?? originalCRS?.description,
196
- geodeticDatum: mutCRS?.geodeticDatum ?? originalCRS?.geodeticDatum,
197
- verticalDatum: mutCRS?.verticalDatum ?? originalCRS?.verticalDatum,
198
- mapProjection: mutCRS?.mapProjection ?? originalCRS?.mapProjection,
199
- mapZone: mutCRS?.mapZone ?? originalCRS?.mapZone,
200
- mapUnit: mutCRS?.mapUnit ?? originalCRS?.mapUnit,
201
- };
202
-
203
- // Need at least an EPSG name to resolve projection
204
- if (!projectedCRS.name) return null;
205
-
206
- // Build merged MapConversion
207
- const mapConversion: MapConversion = {
208
- id: originalConv?.id ?? 0,
209
- sourceCRS: originalConv?.sourceCRS ?? 0,
210
- targetCRS: originalConv?.targetCRS ?? 0,
211
- eastings: (mutConv?.eastings ?? originalConv?.eastings ?? 0) as number,
212
- northings: (mutConv?.northings ?? originalConv?.northings ?? 0) as number,
213
- orthogonalHeight: (mutConv?.orthogonalHeight ?? originalConv?.orthogonalHeight ?? 0) as number,
214
- xAxisAbscissa: mutConv?.xAxisAbscissa ?? originalConv?.xAxisAbscissa,
215
- xAxisOrdinate: mutConv?.xAxisOrdinate ?? originalConv?.xAxisOrdinate,
216
- scale: mutConv?.scale ?? originalConv?.scale,
217
- };
218
-
219
- return { mapConversion, projectedCRS };
220
- }
221
-
222
182
  // Check federated models first
223
183
  for (const [modelId, model] of storeModels) {
224
184
  const ds = model.ifcDataStore;
225
185
  if (!ds) continue;
226
- const original = extractGeoreferencingOnDemand(ds as IfcDataStore);
227
- const merged = mergeGeoref(
228
- original?.projectedCRS,
229
- original?.mapConversion,
230
- modelId,
186
+ const effective = getEffectiveGeoreference(
187
+ ds as IfcDataStore,
188
+ model.geometryResult?.coordinateInfo,
189
+ georefMutations.get(modelId),
231
190
  );
232
- if (merged) {
233
- // Return coordinateInfo from the SAME model to avoid mismatched transforms
234
- const coordInfo = model.geometryResult?.coordinateInfo;
235
- return { hasGeoreference: true, ...merged, sourceModelId: modelId, coordinateInfo: coordInfo };
191
+ if (effective?.projectedCRS?.name && effective.mapConversion) {
192
+ return { ...effective, sourceModelId: modelId };
236
193
  }
237
194
  }
238
195
 
239
196
  // Fallback to legacy single-model
240
197
  if (ifcDataStore) {
241
- const original = extractGeoreferencingOnDemand(ifcDataStore as IfcDataStore);
242
- const merged = mergeGeoref(
243
- original?.projectedCRS,
244
- original?.mapConversion,
245
- '__legacy__',
198
+ const effective = getEffectiveGeoreference(
199
+ ifcDataStore as IfcDataStore,
200
+ mergedGeometryResult?.coordinateInfo,
201
+ georefMutations.get('__legacy__'),
246
202
  );
247
- if (merged) {
248
- return { hasGeoreference: true, ...merged, sourceModelId: '__legacy__', coordinateInfo: mergedGeometryResult?.coordinateInfo };
203
+ if (effective?.projectedCRS?.name && effective.mapConversion) {
204
+ return { ...effective, sourceModelId: '__legacy__' };
249
205
  }
250
206
  }
251
207
 
252
208
  return null;
253
209
  }, [cesiumEnabled, storeModels, ifcDataStore, georefMutations, mutationVersion, mergedGeometryResult]);
254
210
 
211
+ // Determine whether Cesium button should be visible (model has georef or user added it via mutations).
212
+ // Runs independently of cesiumEnabled so the button appears/disappears reactively.
213
+ useEffect(() => {
214
+ function hasGeoref(): boolean {
215
+ // Check federated models
216
+ for (const [modelId, model] of storeModels) {
217
+ const ds = model.ifcDataStore;
218
+ if (!ds) continue;
219
+ const effective = getEffectiveGeoreference(
220
+ ds as IfcDataStore,
221
+ model.geometryResult?.coordinateInfo,
222
+ georefMutations.get(modelId),
223
+ );
224
+ if (effective?.projectedCRS?.name) return true;
225
+ }
226
+ // Fallback to legacy single-model
227
+ if (ifcDataStore) {
228
+ const effective = getEffectiveGeoreference(
229
+ ifcDataStore as IfcDataStore,
230
+ mergedGeometryResult?.coordinateInfo,
231
+ georefMutations.get('__legacy__'),
232
+ );
233
+ if (effective?.projectedCRS?.name) return true;
234
+ }
235
+ return false;
236
+ }
237
+ setCesiumAvailable(hasGeoref());
238
+ }, [storeModels, ifcDataStore, georefMutations, mutationVersion, setCesiumAvailable, mergedGeometryResult]);
239
+
255
240
  // Sync the active Cesium source model ID so terrain actions are scoped correctly
256
241
  useEffect(() => {
257
242
  setCesiumSourceModelId(georef?.sourceModelId ?? null);
@@ -847,13 +832,14 @@ export function ViewportContainer() {
847
832
  </div>
848
833
  )}
849
834
 
850
- {/* Cesium 3D world context overlay — rendered behind the WebGPU canvas */}
851
- {cesiumEnabled && georef && (
835
+ {/* Cesium 3D world context overlay — rendered behind the WebGPU canvas (web only) */}
836
+ {cesiumEnabled && georef && !isTauri() && (
852
837
  <CesiumOverlay
853
838
  mapConversion={georef.mapConversion}
854
839
  projectedCRS={georef.projectedCRS}
855
840
  coordinateInfo={georef.coordinateInfo}
856
841
  geometryResult={mergedGeometryResult}
842
+ lengthUnitScale={georef.lengthUnitScale}
857
843
  />
858
844
  )}
859
845
  <Viewport
@@ -862,10 +848,11 @@ export function ViewportContainer() {
862
848
  coordinateInfo={mergedGeometryResult?.coordinateInfo}
863
849
  computedIsolatedIds={computedIsolatedIds}
864
850
  modelIdToIndex={modelIdToIndex}
865
- cesiumActive={cesiumEnabled && georef !== null}
851
+ cesiumActive={cesiumEnabled && georef !== null && !isTauri()}
866
852
  releaseGeometryAfterStream={false}
867
853
  onGeometryReleased={releaseGeometryMemory}
868
854
  />
855
+ <AnnotationLayer />
869
856
  {bcfOverlayVisible && <BCFOverlay />}
870
857
  <ViewportOverlays />
871
858
  <ToolOverlays />
@@ -21,6 +21,9 @@ import type { CesiumDataSource } from '@/store/slices/cesiumSlice';
21
21
  import { goHomeFromStore } from '@/store/homeView';
22
22
  import { useIfc } from '@/hooks/useIfc';
23
23
  import { cn } from '@/lib/utils';
24
+ import { isTauri } from '@/lib/platform';
25
+
26
+ const isDesktop = isTauri();
24
27
  import { ViewCube, type ViewCubeRef } from './ViewCube';
25
28
  import { AxisHelper, type AxisHelperRef } from './AxisHelper';
26
29
 
@@ -146,8 +149,8 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
146
149
 
147
150
  return (
148
151
  <>
149
- {/* Bottom-right: Cesium settings overlay OR Navigation controls */}
150
- {cesiumEnabled ? (
152
+ {/* Bottom-right: Cesium settings overlay OR Navigation controls (Cesium is web-only) */}
153
+ {cesiumEnabled && !isDesktop ? (
151
154
  <CesiumSettingsOverlay
152
155
  dataSource={cesiumDataSource}
153
156
  onDataSourceChange={setCesiumDataSource}
@@ -0,0 +1,203 @@
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
+ * The inline note input that appears at the click site when the user
7
+ * drops a fresh pin with the Annotate tool. Shape mirrors the popover's
8
+ * edit mode so muscle memory carries over, but the chrome is lighter
9
+ * (a guiding label, no entity-context header) since this is a
10
+ * commit-or-cancel surface.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import { Check, X } from 'lucide-react';
15
+ import { Button } from '@/components/ui/button';
16
+ import { cn } from '@/lib/utils';
17
+
18
+ const MAX_NOTE_LEN = 2000;
19
+ const SOFT_NOTE_LIMIT = 200;
20
+ const INPUT_WIDTH = 280;
21
+ const INPUT_OFFSET_X = 16;
22
+
23
+ export interface AnnotationDropInputProps {
24
+ anchorX: number;
25
+ anchorY: number;
26
+ canvasWidth: number;
27
+ canvasHeight: number;
28
+ /** Resolved entity type when the drop landed on a known mesh. */
29
+ entityType?: string | null;
30
+ entityExpressId?: number | null;
31
+ onSave: (note: string) => void;
32
+ onCancel: () => void;
33
+ }
34
+
35
+ export function AnnotationDropInput({
36
+ anchorX,
37
+ anchorY,
38
+ canvasWidth,
39
+ canvasHeight,
40
+ entityType,
41
+ entityExpressId,
42
+ onSave,
43
+ onCancel,
44
+ }: AnnotationDropInputProps) {
45
+ const [draft, setDraft] = useState('');
46
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+
49
+ useEffect(() => {
50
+ textareaRef.current?.focus();
51
+ }, []);
52
+
53
+ // Cancel on outside click, but defer registration so the click that
54
+ // dropped the pin doesn't immediately close the input.
55
+ useEffect(() => {
56
+ const handler = (e: MouseEvent) => {
57
+ const node = containerRef.current;
58
+ if (!node) return;
59
+ if (node.contains(e.target as Node)) return;
60
+ // Empty draft on outside-click → silent cancel; non-empty
61
+ // → commit the draft (matches "blur to save" feel without
62
+ // destroying typed content). An over-limit draft is rejected
63
+ // consistently with the disabled save button.
64
+ if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
65
+ onCancel();
66
+ } else {
67
+ onSave(draft);
68
+ }
69
+ };
70
+ const id = window.setTimeout(() => {
71
+ document.addEventListener('mousedown', handler);
72
+ }, 0);
73
+ return () => {
74
+ window.clearTimeout(id);
75
+ document.removeEventListener('mousedown', handler);
76
+ };
77
+ }, [draft, onSave, onCancel]);
78
+
79
+ const handleKeyDown = useCallback(
80
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
81
+ if (e.key === 'Enter' && !e.shiftKey) {
82
+ e.preventDefault();
83
+ if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
84
+ // Over-limit Enter does nothing — match the disabled button.
85
+ if (draft.trim().length === 0) onCancel();
86
+ } else {
87
+ onSave(draft);
88
+ }
89
+ } else if (e.key === 'Escape') {
90
+ e.preventDefault();
91
+ onCancel();
92
+ }
93
+ },
94
+ [draft, onSave, onCancel],
95
+ );
96
+
97
+ const wantsLeft = anchorX + INPUT_OFFSET_X + INPUT_WIDTH > canvasWidth;
98
+ const left = wantsLeft
99
+ ? Math.max(8, anchorX - INPUT_OFFSET_X - INPUT_WIDTH)
100
+ : Math.min(anchorX + INPUT_OFFSET_X, canvasWidth - INPUT_WIDTH - 8);
101
+ const top = Math.min(Math.max(8, anchorY - 8), canvasHeight - 140);
102
+
103
+ const charCountVisible = draft.length >= SOFT_NOTE_LIMIT;
104
+ const overSoftLimit = draft.length > SOFT_NOTE_LIMIT;
105
+ const overHardLimit = draft.length > MAX_NOTE_LEN;
106
+
107
+ return (
108
+ <div
109
+ ref={containerRef}
110
+ role="dialog"
111
+ aria-label="New annotation"
112
+ style={{ left, top, width: INPUT_WIDTH }}
113
+ className={cn(
114
+ 'absolute z-[60] pointer-events-auto',
115
+ 'rounded-md border border-amber-400/70 dark:border-amber-600/40',
116
+ 'bg-white/95 dark:bg-zinc-950/95 backdrop-blur-md',
117
+ 'shadow-[0_8px_32px_rgba(0,0,0,0.18)]',
118
+ 'overflow-hidden',
119
+ 'animate-in fade-in-0 zoom-in-95 duration-150',
120
+ )}
121
+ >
122
+ {/* Guiding label — explicit so the user knows what to type and
123
+ establishes "this is for capturing intent, not chat". */}
124
+ <div className="px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20">
125
+ <span className="font-mono text-[10px] uppercase tracking-wider text-amber-700 dark:text-amber-300">
126
+ What's worth noting?
127
+ {entityType && (
128
+ <span className="ml-1.5 text-zinc-500 dark:text-zinc-400">
129
+ · {entityType}
130
+ {entityExpressId !== null && entityExpressId !== undefined && ` #${entityExpressId}`}
131
+ </span>
132
+ )}
133
+ </span>
134
+ </div>
135
+
136
+ <div className="px-3 py-2.5">
137
+ <textarea
138
+ ref={textareaRef}
139
+ value={draft}
140
+ onChange={(e) => setDraft(e.target.value)}
141
+ onKeyDown={handleKeyDown}
142
+ placeholder="A short note — flag a defect, ask a question, leave context…"
143
+ rows={3}
144
+ maxLength={MAX_NOTE_LEN + 100}
145
+ className={cn(
146
+ 'w-full resize-none font-mono text-[11px] leading-relaxed',
147
+ 'bg-zinc-50 dark:bg-zinc-900/60 text-zinc-800 dark:text-zinc-200',
148
+ 'border border-zinc-200 dark:border-zinc-800 rounded-sm',
149
+ 'px-2 py-1.5 outline-none focus:ring-1',
150
+ overHardLimit
151
+ ? 'focus:ring-red-400 border-red-300 dark:border-red-700/60'
152
+ : 'focus:ring-amber-400/50 focus:border-amber-300/60',
153
+ )}
154
+ spellCheck
155
+ autoCorrect="on"
156
+ />
157
+ <div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-mono">
158
+ <span className="text-zinc-400 dark:text-zinc-500">
159
+ ⏎ save · ⇧⏎ newline · esc cancel
160
+ </span>
161
+ {charCountVisible && (
162
+ <span
163
+ className={cn(
164
+ 'tabular-nums',
165
+ overHardLimit
166
+ ? 'text-red-500'
167
+ : overSoftLimit
168
+ ? 'text-amber-600 dark:text-amber-400'
169
+ : 'text-zinc-400',
170
+ )}
171
+ >
172
+ {draft.length}/{MAX_NOTE_LEN}
173
+ </span>
174
+ )}
175
+ </div>
176
+ <div className="mt-2 flex items-center justify-end gap-1">
177
+ <Button
178
+ variant="ghost"
179
+ size="sm"
180
+ className="h-7 px-2 text-[11px]"
181
+ onClick={onCancel}
182
+ >
183
+ <X className="h-3 w-3 mr-1" />
184
+ Cancel
185
+ </Button>
186
+ <Button
187
+ size="sm"
188
+ className="h-7 px-2 text-[11px] bg-amber-500 hover:bg-amber-500/90 text-white"
189
+ onClick={() => {
190
+ if (overHardLimit) return;
191
+ if (draft.trim().length === 0) onCancel();
192
+ else onSave(draft);
193
+ }}
194
+ disabled={overHardLimit}
195
+ >
196
+ <Check className="h-3 w-3 mr-1" />
197
+ Drop pin
198
+ </Button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }