@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
@@ -215,12 +215,12 @@ export function BCFTopicDetail({
215
215
  <img
216
216
  src={vp.snapshot}
217
217
  alt="Viewpoint"
218
- className="w-full aspect-video object-cover cursor-pointer hover:opacity-90 transition-opacity"
218
+ className="w-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
219
219
  onClick={() => onActivateViewpoint(vp)}
220
220
  />
221
221
  ) : (
222
222
  <div
223
- className="w-full aspect-video bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors"
223
+ className="w-full aspect-video bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors min-h-[120px]"
224
224
  onClick={() => onActivateViewpoint(vp)}
225
225
  >
226
226
  <Camera className="h-6 w-6 text-muted-foreground" />
@@ -294,7 +294,7 @@ export function BCFTopicDetail({
294
294
  <img
295
295
  src={associatedViewpoint.snapshot}
296
296
  alt="Associated viewpoint"
297
- className="w-full h-16 object-cover"
297
+ className="w-full max-h-24 object-contain bg-muted"
298
298
  />
299
299
  </div>
300
300
  )}
@@ -327,7 +327,7 @@ export function BCFTopicDetail({
327
327
  <img
328
328
  src={selectedViewpoint.snapshot}
329
329
  alt="Selected viewpoint"
330
- className="w-10 h-10 object-cover rounded"
330
+ className="w-12 h-10 object-contain rounded bg-muted"
331
331
  />
332
332
  )}
333
333
  <div className="flex-1 min-w-0">
@@ -4,11 +4,14 @@
4
4
 
5
5
  /**
6
6
  * ModelSelector — dropdown to pick the LLM model.
7
- * Free models available to everyone. Pro models show cost indicator and lock icon.
7
+ * Free models available to everyone via server proxy.
8
+ * BYOK models selectable always — greyed out with lock icon when key is missing,
9
+ * fully styled when key is configured. Selecting a locked model triggers the
10
+ * inline key prompt in ChatPanel.
8
11
  */
9
12
 
10
- import { useCallback } from 'react';
11
- import { Lock } from 'lucide-react';
13
+ import { useCallback, useEffect, useState } from 'react';
14
+ import { Check, Key } from 'lucide-react';
12
15
  import {
13
16
  Select,
14
17
  SelectContent,
@@ -17,27 +20,43 @@ import {
17
20
  SelectValue,
18
21
  } from '@/components/ui/select';
19
22
  import { useViewerStore } from '@/store';
20
- import { FREE_MODELS, PRO_MODELS, getModelById } from '@/lib/llm/models';
21
-
22
- interface ModelSelectorProps {
23
- /** Whether the user has a pro subscription */
24
- hasPro?: boolean;
25
- }
23
+ import { FREE_MODELS, getModelById, getByokModelsForSource } from '@/lib/llm/models';
24
+ import type { LLMModel } from '@/lib/llm/types';
25
+ import { hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
26
26
 
27
27
  function formatContextWindow(tokens: number): string {
28
28
  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(0)}M`;
29
29
  return `${(tokens / 1_000).toFixed(0)}K`;
30
30
  }
31
31
 
32
- export function ModelSelector({ hasPro = false }: ModelSelectorProps) {
32
+ function CostBadge({ cost }: { cost?: LLMModel['cost'] }) {
33
+ if (!cost) return null;
34
+ const color = cost === '$$$' ? 'text-amber-500' : cost === '$$' ? 'text-blue-500' : 'text-emerald-500';
35
+ return <span className={`text-[10px] font-mono ${color}`}>{cost}</span>;
36
+ }
37
+
38
+ export function ModelSelector() {
33
39
  const activeModel = useViewerStore((s) => s.chatActiveModel);
34
40
  const setActiveModel = useViewerStore((s) => s.setChatActiveModel);
35
41
 
42
+ const [hasAnthropic, setHasAnthropic] = useState(hasAnthropicKey);
43
+ const [hasOpenai, setHasOpenai] = useState(hasOpenaiKey);
44
+
45
+ useEffect(() => {
46
+ const refresh = () => {
47
+ setHasAnthropic(hasAnthropicKey());
48
+ setHasOpenai(hasOpenaiKey());
49
+ };
50
+ return subscribeApiKeys(refresh);
51
+ }, []);
52
+
36
53
  const handleChange = useCallback((value: string) => {
37
54
  setActiveModel(value);
38
55
  }, [setActiveModel]);
39
56
 
40
57
  const current = getModelById(activeModel);
58
+ const anthropicModels = getByokModelsForSource('anthropic');
59
+ const openaiModels = getByokModelsForSource('openai');
41
60
 
42
61
  return (
43
62
  <Select value={activeModel} onValueChange={handleChange}>
@@ -45,57 +64,74 @@ export function ModelSelector({ hasPro = false }: ModelSelectorProps) {
45
64
  <SelectValue>
46
65
  <span className="truncate flex items-center gap-1">
47
66
  {current?.name ?? activeModel}
48
- {current?.cost && (
49
- <span className={`text-[10px] font-mono ${
50
- current.cost === '$$$' ? 'text-amber-500' : current.cost === '$$' ? 'text-blue-500' : 'text-emerald-500'
51
- }`}>
52
- {current.cost}
53
- </span>
54
- )}
67
+ <CostBadge cost={current?.cost} />
55
68
  </span>
56
69
  </SelectValue>
57
70
  </SelectTrigger>
58
71
  <SelectContent>
59
72
  {/* Free tier */}
60
- <div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
61
- Free
62
- </div>
63
- {FREE_MODELS.map((m) => (
64
- <SelectItem key={m.id} value={m.id} className="text-xs">
65
- <span className="flex items-center gap-1.5">
66
- <span>{m.name}</span>
67
- <span className="text-muted-foreground text-[10px]">{m.provider}</span>
68
- <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
69
- </span>
70
- </SelectItem>
71
- ))}
73
+ {FREE_MODELS.length > 0 && (
74
+ <>
75
+ <div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
76
+ Free
77
+ </div>
78
+ {FREE_MODELS.map((m) => (
79
+ <SelectItem key={m.id} value={m.id} className="text-xs">
80
+ <span className="flex items-center gap-1.5">
81
+ <span>{m.name}</span>
82
+ <span className="text-muted-foreground text-[10px]">{m.provider}</span>
83
+ <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
84
+ </span>
85
+ </SelectItem>
86
+ ))}
87
+ </>
88
+ )}
89
+
90
+ {/* Anthropic BYOK */}
91
+ {anthropicModels.length > 0 && (
92
+ <>
93
+ <div className="px-2 py-1 mt-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
94
+ Anthropic
95
+ {hasAnthropic
96
+ ? <Check className="h-2.5 w-2.5 text-emerald-500" />
97
+ : <Key className="h-2.5 w-2.5" />
98
+ }
99
+ </div>
100
+ {anthropicModels.map((m) => (
101
+ <SelectItem key={m.id} value={m.id} className={`text-xs ${!hasAnthropic ? 'opacity-50' : ''}`}>
102
+ <span className="flex items-center gap-1.5">
103
+ <span>{m.name}</span>
104
+ <CostBadge cost={m.cost} />
105
+ <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
106
+ {!hasAnthropic && <Key className="h-3 w-3 text-muted-foreground/50" />}
107
+ </span>
108
+ </SelectItem>
109
+ ))}
110
+ </>
111
+ )}
72
112
 
73
- {/* Pro tier */}
74
- <div className="px-2 py-1 mt-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
75
- Pro
76
- </div>
77
- {PRO_MODELS.map((m) => (
78
- <SelectItem
79
- key={m.id}
80
- value={m.id}
81
- disabled={!hasPro}
82
- className="text-xs"
83
- >
84
- <span className="flex items-center gap-1.5">
85
- <span>{m.name}</span>
86
- <span className="text-muted-foreground text-[10px]">{m.provider}</span>
87
- {m.cost && (
88
- <span className={`text-[10px] font-mono ${
89
- m.cost === '$$$' ? 'text-amber-500' : m.cost === '$$' ? 'text-blue-500' : 'text-emerald-500'
90
- }`}>
91
- {m.cost}
113
+ {/* OpenAI BYOK */}
114
+ {openaiModels.length > 0 && (
115
+ <>
116
+ <div className="px-2 py-1 mt-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
117
+ OpenAI
118
+ {hasOpenai
119
+ ? <Check className="h-2.5 w-2.5 text-emerald-500" />
120
+ : <Key className="h-2.5 w-2.5" />
121
+ }
122
+ </div>
123
+ {openaiModels.map((m) => (
124
+ <SelectItem key={m.id} value={m.id} className={`text-xs ${!hasOpenai ? 'opacity-50' : ''}`}>
125
+ <span className="flex items-center gap-1.5">
126
+ <span>{m.name}</span>
127
+ <CostBadge cost={m.cost} />
128
+ <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
129
+ {!hasOpenai && <Key className="h-3 w-3 text-muted-foreground/50" />}
92
130
  </span>
93
- )}
94
- <span className="text-muted-foreground/50 text-[10px]">{formatContextWindow(m.contextWindow)}</span>
95
- {!hasPro && <Lock className="h-3 w-3 text-muted-foreground/50" />}
96
- </span>
97
- </SelectItem>
98
- ))}
131
+ </SelectItem>
132
+ ))}
133
+ </>
134
+ )}
99
135
  </SelectContent>
100
136
  </Select>
101
137
  );
@@ -67,34 +67,27 @@ export function ListPanel({ onClose }: ListPanelProps) {
67
67
 
68
68
  const importInputRef = React.useRef<HTMLInputElement>(null);
69
69
 
70
- // Collect all available data providers for multi-model support
71
- const allProviders = useMemo(() => {
72
- const providers: ListDataProvider[] = [];
70
+ // Build the {modelId, provider} pairs in a single pass so the two
71
+ // arrays can never drift out of alignment (skipping a model without
72
+ // an ifcDataStore must not shift every later model's provider index).
73
+ const modelProviderPairs = useMemo(() => {
74
+ const pairs: Array<{ modelId: string; provider: ListDataProvider }> = [];
73
75
  if (models.size > 0) {
74
- for (const [, model] of models) {
75
- providers.push(createListDataProvider(model.ifcDataStore));
76
+ for (const [modelId, model] of models) {
77
+ // Skip native-metadata models — they don't have a parsed
78
+ // IfcDataStore, so the list provider can't query them.
79
+ if (!model.ifcDataStore) continue;
80
+ pairs.push({ modelId, provider: createListDataProvider(model.ifcDataStore) });
76
81
  }
77
82
  } else if (ifcDataStore) {
78
- providers.push(createListDataProvider(ifcDataStore));
83
+ pairs.push({ modelId: 'default', provider: createListDataProvider(ifcDataStore) });
79
84
  }
80
- return providers;
85
+ return pairs;
81
86
  }, [models, ifcDataStore]);
82
87
 
83
- const hasData = allProviders.length > 0;
88
+ const allProviders = useMemo(() => modelProviderPairs.map((p) => p.provider), [modelProviderPairs]);
84
89
 
85
- // Build a stable map of modelId → provider index for execution
86
- const modelProviderPairs = useMemo(() => {
87
- const pairs: Array<{ modelId: string; provider: ListDataProvider }> = [];
88
- if (models.size > 0) {
89
- let i = 0;
90
- for (const [modelId] of models) {
91
- pairs.push({ modelId, provider: allProviders[i++] });
92
- }
93
- } else if (allProviders.length > 0) {
94
- pairs.push({ modelId: 'default', provider: allProviders[0] });
95
- }
96
- return pairs;
97
- }, [models, allProviders]);
90
+ const hasData = allProviders.length > 0;
98
91
 
99
92
  const handleExecuteList = useCallback((definition: ListDefinition) => {
100
93
  if (!hasData) return;
@@ -16,6 +16,9 @@ import { useViewerStore } from '@/store';
16
16
  import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
17
17
  import { EpsgLookupDialog, type EpsgResult } from './EpsgLookupDialog';
18
18
  import { LocationMap, type PickedPosition } from './LocationMap';
19
+ import { mergeMapConversion, mergeProjectedCRS } from '@/lib/geo/effective-georef';
20
+ import { useIfc } from '@/hooks/useIfc';
21
+ import { toast } from '@/components/ui/toast';
19
22
 
20
23
  // ── Field-specific assistance data ─────────────────────────────────────
21
24
 
@@ -319,9 +322,11 @@ export interface GeoreferencingPanelProps {
319
322
  coordinateInfo?: CoordinateInfo;
320
323
  /** GeometryResult for KMZ export */
321
324
  geometryResult?: GeometryResult | null;
325
+ /** IFC project length unit → metres (e.g. 0.001 for mm models). Default 1. */
326
+ lengthUnitScale?: number;
322
327
  }
323
328
 
324
- export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVersion, coordinateInfo, geometryResult }: GeoreferencingPanelProps) {
329
+ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVersion, coordinateInfo, geometryResult, lengthUnitScale }: GeoreferencingPanelProps) {
325
330
  const georefMutations = useViewerStore(s => s.georefMutations);
326
331
  const setGeorefField = useViewerStore(s => s.setGeorefField);
327
332
  const setGeorefFields = useViewerStore(s => s.setGeorefFields);
@@ -330,10 +335,14 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
330
335
  const setCesiumTerrainClamp = useViewerStore(s => s.setCesiumTerrainClamp);
331
336
  const cesiumTerrainHeight = useViewerStore(s => s.cesiumTerrainHeight);
332
337
  const cesiumSourceModelId = useViewerStore(s => s.cesiumSourceModelId);
338
+ const models = useViewerStore(s => s.models);
339
+ const loading = useViewerStore(s => s.loading);
340
+ const { addModel, clearAllModels } = useIfc();
333
341
  // Only show terrain actions when this panel's model is the one backing the Cesium overlay
334
342
  const isActiveCesiumModel = !!modelId && modelId === cesiumSourceModelId;
335
343
  const [crsOpen, setCrsOpen] = useState(false);
336
344
  const [conversionOpen, setConversionOpen] = useState(false);
345
+ const [showReloadPrompt, setShowReloadPrompt] = useState(false);
337
346
 
338
347
  useViewerStore(s => s.mutationVersion);
339
348
 
@@ -341,37 +350,12 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
341
350
  const supportsStandardGeoreferencing = !schemaVersion?.toUpperCase().includes('2X3');
342
351
 
343
352
  const mergedCRS = useMemo((): ProjectedCRS | undefined => {
344
- const base = georef?.projectedCRS;
345
- const muts = mutations?.projectedCRS;
346
- if (!base && !muts) return undefined;
347
- return {
348
- id: base?.id ?? 0,
349
- name: muts?.name ?? base?.name ?? '',
350
- description: muts?.description ?? base?.description,
351
- geodeticDatum: muts?.geodeticDatum ?? base?.geodeticDatum,
352
- verticalDatum: muts?.verticalDatum ?? base?.verticalDatum,
353
- mapProjection: muts?.mapProjection ?? base?.mapProjection,
354
- mapZone: muts?.mapZone ?? base?.mapZone,
355
- mapUnit: muts?.mapUnit ?? base?.mapUnit,
356
- };
357
- }, [georef, mutations]);
353
+ return mergeProjectedCRS(georef?.projectedCRS, mutations?.projectedCRS, lengthUnitScale ?? 1);
354
+ }, [georef?.projectedCRS, mutations?.projectedCRS, lengthUnitScale]);
358
355
 
359
356
  const mergedConversion = useMemo((): MapConversion | undefined => {
360
- const base = georef?.mapConversion;
361
- const muts = mutations?.mapConversion;
362
- if (!base && !muts) return undefined;
363
- return {
364
- id: base?.id ?? 0,
365
- sourceCRS: base?.sourceCRS ?? 0,
366
- targetCRS: base?.targetCRS ?? 0,
367
- eastings: muts?.eastings ?? base?.eastings ?? 0,
368
- northings: muts?.northings ?? base?.northings ?? 0,
369
- orthogonalHeight: muts?.orthogonalHeight ?? base?.orthogonalHeight ?? 0,
370
- xAxisAbscissa: muts?.xAxisAbscissa ?? base?.xAxisAbscissa,
371
- xAxisOrdinate: muts?.xAxisOrdinate ?? base?.xAxisOrdinate,
372
- scale: muts?.scale ?? base?.scale,
373
- };
374
- }, [georef, mutations]);
357
+ return mergeMapConversion(georef?.mapConversion, mutations?.mapConversion);
358
+ }, [georef?.mapConversion, mutations?.mapConversion]);
375
359
 
376
360
  const angleToGridNorth = useMemo(() => {
377
361
  return computeAngleToGridNorth(mergedConversion?.xAxisAbscissa, mergedConversion?.xAxisOrdinate);
@@ -399,22 +383,69 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
399
383
  return field in entityMuts;
400
384
  }, [mutations]);
401
385
 
386
+ const requestAlignmentReload = useCallback(() => {
387
+ if (models.size > 1) {
388
+ setShowReloadPrompt(true);
389
+ }
390
+ }, [models.size]);
391
+
392
+ const reloadModelsForAlignment = useCallback(async () => {
393
+ const state = useViewerStore.getState();
394
+ const snapshot = Array.from(state.models.values()).sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
395
+ const missingSource = snapshot.find(model => !model.sourceFile);
396
+ if (snapshot.length < 2) {
397
+ setShowReloadPrompt(false);
398
+ return;
399
+ }
400
+ if (missingSource) {
401
+ toast.error(`Cannot reload ${missingSource.name}: source file is not available`);
402
+ return;
403
+ }
404
+
405
+ try {
406
+ clearAllModels();
407
+ for (const model of snapshot) {
408
+ const sourceFile = model.sourceFile;
409
+ if (!sourceFile) continue;
410
+ const reloadedModelId = await addModel(sourceFile, {
411
+ name: model.name,
412
+ modelId: model.id,
413
+ loadedAt: model.loadedAt,
414
+ visible: model.visible,
415
+ collapsed: model.collapsed,
416
+ });
417
+ if (!reloadedModelId) {
418
+ throw new Error(`Failed to reload ${model.name}`);
419
+ }
420
+ if (model.visible === false) {
421
+ useViewerStore.getState().setModelVisibility(model.id, false);
422
+ }
423
+ }
424
+ setShowReloadPrompt(false);
425
+ toast.success('Reloaded models for edited georeferencing');
426
+ } catch (error) {
427
+ toast.error(error instanceof Error ? error.message : 'Reload failed');
428
+ }
429
+ }, [addModel, clearAllModels]);
430
+
402
431
  const handleSave = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string, value: string | number) => {
403
432
  if (!modelId || !setGeorefField) return;
404
433
  const oldValue = entity === 'projectedCRS'
405
- ? georef?.projectedCRS?.[field as keyof ProjectedCRS]
406
- : georef?.mapConversion?.[field as keyof MapConversion];
434
+ ? mergedCRS?.[field as keyof ProjectedCRS]
435
+ : mergedConversion?.[field as keyof MapConversion];
407
436
  setGeorefField(modelId, entity, field, value, oldValue as string | number | undefined);
408
- }, [modelId, setGeorefField, georef]);
437
+ requestAlignmentReload();
438
+ }, [modelId, setGeorefField, mergedCRS, mergedConversion, requestAlignmentReload]);
409
439
 
410
440
  // Handle angle edit: compute and set both XAxisAbscissa and XAxisOrdinate
411
441
  const handleAngleChange = useCallback((abscissa: number, ordinate: number) => {
412
442
  if (!modelId || !setGeorefFields) return;
413
443
  setGeorefFields(modelId, 'mapConversion', [
414
- { field: 'xAxisAbscissa', value: abscissa, oldValue: georef?.mapConversion?.xAxisAbscissa },
415
- { field: 'xAxisOrdinate', value: ordinate, oldValue: georef?.mapConversion?.xAxisOrdinate },
444
+ { field: 'xAxisAbscissa', value: abscissa, oldValue: mergedConversion?.xAxisAbscissa },
445
+ { field: 'xAxisOrdinate', value: ordinate, oldValue: mergedConversion?.xAxisOrdinate },
416
446
  ]);
417
- }, [modelId, setGeorefFields, georef]);
447
+ requestAlignmentReload();
448
+ }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload]);
418
449
 
419
450
  // Handle position picked from the map (reverse-projected easting/northing + optional terrain height)
420
451
  const handleApplyPosition = useCallback((position: PickedPosition) => {
@@ -432,35 +463,37 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
432
463
  }
433
464
  setGeorefFields(modelId, 'mapConversion', fields);
434
465
  setConversionOpen(true);
435
- }, [modelId, setGeorefFields, mergedConversion]);
466
+ requestAlignmentReload();
467
+ }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload]);
436
468
 
437
469
  const initializeMapConversionDefaults = useCallback(() => {
438
470
  if (!modelId || !setGeorefFields) return;
439
471
  setGeorefFields(modelId, 'mapConversion', [
440
- { field: 'eastings', value: georef?.mapConversion?.eastings ?? 0, oldValue: georef?.mapConversion?.eastings },
441
- { field: 'northings', value: georef?.mapConversion?.northings ?? 0, oldValue: georef?.mapConversion?.northings },
442
- { field: 'orthogonalHeight', value: georef?.mapConversion?.orthogonalHeight ?? 0, oldValue: georef?.mapConversion?.orthogonalHeight },
443
- { field: 'xAxisAbscissa', value: georef?.mapConversion?.xAxisAbscissa ?? 1, oldValue: georef?.mapConversion?.xAxisAbscissa },
444
- { field: 'xAxisOrdinate', value: georef?.mapConversion?.xAxisOrdinate ?? 0, oldValue: georef?.mapConversion?.xAxisOrdinate },
445
- { field: 'scale', value: georef?.mapConversion?.scale ?? 1, oldValue: georef?.mapConversion?.scale },
472
+ { field: 'eastings', value: mergedConversion?.eastings ?? 0, oldValue: mergedConversion?.eastings },
473
+ { field: 'northings', value: mergedConversion?.northings ?? 0, oldValue: mergedConversion?.northings },
474
+ { field: 'orthogonalHeight', value: mergedConversion?.orthogonalHeight ?? 0, oldValue: mergedConversion?.orthogonalHeight },
475
+ { field: 'xAxisAbscissa', value: mergedConversion?.xAxisAbscissa ?? 1, oldValue: mergedConversion?.xAxisAbscissa },
476
+ { field: 'xAxisOrdinate', value: mergedConversion?.xAxisOrdinate ?? 0, oldValue: mergedConversion?.xAxisOrdinate },
477
+ { field: 'scale', value: mergedConversion?.scale ?? 1, oldValue: mergedConversion?.scale },
446
478
  ]);
447
479
  setConversionOpen(true);
448
- }, [modelId, setGeorefFields, georef]);
480
+ requestAlignmentReload();
481
+ }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload]);
449
482
 
450
483
  const handleEpsgSelect = useCallback((result: EpsgResult) => {
451
484
  if (!modelId || !setGeorefFields) return;
452
485
  const epsgName = `EPSG:${result.code}`;
453
486
  const fieldUpdates: Array<{ field: string; value: string | number; oldValue?: string | number }> = [
454
- { field: 'name', value: epsgName, oldValue: georef?.projectedCRS?.name },
487
+ { field: 'name', value: epsgName, oldValue: mergedCRS?.name },
455
488
  ];
456
489
  if (result.name) {
457
- fieldUpdates.push({ field: 'description', value: result.name, oldValue: georef?.projectedCRS?.description });
490
+ fieldUpdates.push({ field: 'description', value: result.name, oldValue: mergedCRS?.description });
458
491
  }
459
492
  if (result.datum) {
460
- fieldUpdates.push({ field: 'geodeticDatum', value: result.datum, oldValue: georef?.projectedCRS?.geodeticDatum });
493
+ fieldUpdates.push({ field: 'geodeticDatum', value: result.datum, oldValue: mergedCRS?.geodeticDatum });
461
494
  }
462
495
  if (result.projection) {
463
- fieldUpdates.push({ field: 'mapProjection', value: result.projection, oldValue: georef?.projectedCRS?.mapProjection });
496
+ fieldUpdates.push({ field: 'mapProjection', value: result.projection, oldValue: mergedCRS?.mapProjection });
464
497
  }
465
498
  if (result.unit) {
466
499
  const unitUpper = result.unit.toUpperCase();
@@ -471,14 +504,15 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
471
504
  : unitUpper.includes('FOOT') || unitUpper.includes('FEET')
472
505
  ? 'FOOT'
473
506
  : result.unit;
474
- fieldUpdates.push({ field: 'mapUnit', value: mapUnit, oldValue: georef?.projectedCRS?.mapUnit });
507
+ fieldUpdates.push({ field: 'mapUnit', value: mapUnit, oldValue: mergedCRS?.mapUnit });
475
508
  }
476
509
  setGeorefFields(modelId, 'projectedCRS', fieldUpdates);
477
- if (!georef?.mapConversion && !mutations?.mapConversion) {
510
+ if (!mergedConversion && !mutations?.mapConversion) {
478
511
  initializeMapConversionDefaults();
479
512
  }
480
513
  setCrsOpen(true);
481
- }, [modelId, setGeorefFields, georef, mutations, initializeMapConversionDefaults]);
514
+ requestAlignmentReload();
515
+ }, [modelId, setGeorefFields, mergedCRS, mergedConversion, mutations, initializeMapConversionDefaults, requestAlignmentReload]);
482
516
 
483
517
  const hasData = mergedCRS || mergedConversion;
484
518
  const editable = enableEditing && !!modelId && supportsStandardGeoreferencing;
@@ -513,6 +547,33 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
513
547
 
514
548
  return (
515
549
  <div>
550
+ {showReloadPrompt && (
551
+ <div className="mx-2 my-2 border border-teal-300 dark:border-teal-700 bg-teal-50 dark:bg-teal-950/40 px-2.5 py-2">
552
+ <div className="flex items-start gap-2">
553
+ <MapPin className="h-3.5 w-3.5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
554
+ <div className="min-w-0 flex-1">
555
+ <p className="text-[10px] text-zinc-700 dark:text-zinc-300">
556
+ Georeference saved. Reload loaded models to recompute 3D alignment?
557
+ </p>
558
+ <div className="mt-1.5 flex items-center gap-2">
559
+ <button
560
+ onClick={reloadModelsForAlignment}
561
+ disabled={loading}
562
+ className="px-2 py-0.5 text-[10px] font-medium text-white bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
563
+ >
564
+ Reload models
565
+ </button>
566
+ <button
567
+ onClick={() => setShowReloadPrompt(false)}
568
+ className="px-2 py-0.5 text-[10px] text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200/60 dark:hover:bg-zinc-800"
569
+ >
570
+ Later
571
+ </button>
572
+ </div>
573
+ </div>
574
+ </div>
575
+ </div>
576
+ )}
516
577
  {/* CRS summary — always visible */}
517
578
  <div className="px-2 py-1.5 flex items-center gap-2">
518
579
  <Globe className="h-3 w-3 text-teal-500 shrink-0" />
@@ -663,6 +724,7 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
663
724
  projectedCRS={mergedCRS}
664
725
  coordinateInfo={coordinateInfo}
665
726
  geometryResult={geometryResult}
727
+ lengthUnitScale={lengthUnitScale}
666
728
  editable={editable}
667
729
  onApplyPosition={editable ? handleApplyPosition : undefined}
668
730
  />
@@ -49,6 +49,8 @@ export interface LocationMapProps {
49
49
  coordinateInfo?: CoordinateInfo;
50
50
  /** Geometry result for KMZ export (optional — KMZ button hidden if not provided) */
51
51
  geometryResult?: GeometryResult | null;
52
+ /** IFC project length unit → metres (e.g. 0.001 for mm models). Default 1 (metres). */
53
+ lengthUnitScale?: number;
52
54
  /** Whether the map is in edit mode (allows repositioning) */
53
55
  editable?: boolean;
54
56
  /** Called when the user applies a new position from the map */
@@ -138,7 +140,7 @@ function removeFootprintFromMap(map: InstanceType<typeof import('maplibre-gl').M
138
140
 
139
141
  export function LocationMap({
140
142
  mapConversion, projectedCRS, coordinateInfo, geometryResult,
141
- editable, onApplyPosition,
143
+ lengthUnitScale = 1, editable, onApplyPosition,
142
144
  }: LocationMapProps) {
143
145
  const containerRef = useRef<HTMLDivElement>(null);
144
146
  const mapRef = useRef<InstanceType<typeof import('maplibre-gl').Map> | null>(null);
@@ -195,14 +197,14 @@ export function LocationMap({
195
197
 
196
198
  let cancelled = false;
197
199
 
198
- computeFootprintGeoJSON(mapConversion, projectedCRS, coordinateInfo).then(fp => {
200
+ computeFootprintGeoJSON(mapConversion, projectedCRS, coordinateInfo, lengthUnitScale).then(fp => {
199
201
  if (cancelled) return;
200
202
  setFootprint(fp);
201
203
  footprintRef.current = fp;
202
204
  });
203
205
 
204
206
  return () => { cancelled = true; };
205
- }, [mapConversion, projectedCRS, coordinateInfo]);
207
+ }, [mapConversion, projectedCRS, coordinateInfo, lengthUnitScale]);
206
208
 
207
209
  // Geocode search
208
210
  useEffect(() => {
@@ -234,7 +236,7 @@ export function LocationMap({
234
236
  setMapState('loading');
235
237
  setError(null);
236
238
 
237
- reprojectToLatLon(mapConversion, projectedCRS, coordinateInfo).then(result => {
239
+ reprojectToLatLon(mapConversion, projectedCRS, coordinateInfo, lengthUnitScale).then(result => {
238
240
  if (cancelled) return;
239
241
  if (result) {
240
242
  setLatLon(result);
@@ -247,7 +249,7 @@ export function LocationMap({
247
249
  });
248
250
 
249
251
  return () => { cancelled = true; };
250
- }, [mapConversion, projectedCRS, coordinateInfo]);
252
+ }, [mapConversion, projectedCRS, coordinateInfo, lengthUnitScale]);
251
253
 
252
254
  // When a picked position changes, reverse-project and query elevation
253
255
  useEffect(() => {
@@ -262,7 +264,7 @@ export function LocationMap({
262
264
 
263
265
  // Reverse-project to get IfcMapConversion eastings/northings
264
266
  // Accounts for model local geometry offset, rotation, and scale
265
- reprojectFromLatLon(pickedLatLon, projectedCRS, mapConversion, coordinateInfo).then(coords => {
267
+ reprojectFromLatLon(pickedLatLon, projectedCRS, mapConversion, coordinateInfo, lengthUnitScale).then(coords => {
266
268
  if (!cancelled) setProjectedCoords(coords);
267
269
  });
268
270
 
@@ -276,7 +278,7 @@ export function LocationMap({
276
278
  });
277
279
 
278
280
  return () => { cancelled = true; };
279
- }, [pickedLatLon, projectedCRS, mapConversion, coordinateInfo]);
281
+ }, [pickedLatLon, projectedCRS, mapConversion, coordinateInfo, lengthUnitScale]);
280
282
 
281
283
  // Place or move the picked marker on the map
282
284
  const updatePickedMarker = useCallback((pos: LatLon, maplibregl: typeof import('maplibre-gl')) => {
@@ -212,7 +212,7 @@ export function ModelMetadataPanel({ model }: { model: FederatedModel }) {
212
212
  </div>
213
213
 
214
214
  {/* Georeferencing */}
215
- <GeoreferencingPanel georef={georef} modelId={model.id} enableEditing schemaVersion={model.schemaVersion} coordinateInfo={model.geometryResult?.coordinateInfo} geometryResult={model.geometryResult} />
215
+ <GeoreferencingPanel georef={georef} modelId={model.id} enableEditing schemaVersion={model.schemaVersion} coordinateInfo={model.geometryResult?.coordinateInfo} geometryResult={model.geometryResult} lengthUnitScale={unitInfo?.scale} />
216
216
 
217
217
  {/* IfcProject Data */}
218
218
  {projectData && (