@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
@@ -13,8 +13,15 @@
13
13
  import { useCallback } from 'react';
14
14
  import { useShallow } from 'zustand/react/shallow';
15
15
  import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
16
- import { detectFormat, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
17
- import type { MeshData } from '@ifc-lite/geometry';
16
+ import {
17
+ detectFormat,
18
+ parseFederatedIfcx,
19
+ type IfcDataStore,
20
+ type FederatedIfcxParseResult,
21
+ type MapConversion,
22
+ type ProjectedCRS,
23
+ } from '@ifc-lite/parser';
24
+ import type { CoordinateInfo, MeshData } from '@ifc-lite/geometry';
18
25
  import { IfcQuery } from '@ifc-lite/query';
19
26
  import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
20
27
  import { getDynamicBatchConfig } from '../utils/ifcConfig.js';
@@ -27,6 +34,7 @@ import {
27
34
  parseStepBufferViewerModel,
28
35
  } from './ingest/viewerModelIngest.js';
29
36
  import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
37
+ import { getEffectiveGeoreference, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
30
38
 
31
39
  function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
32
40
  return typeof (file as NativeFileHandle).path === 'string';
@@ -39,6 +47,271 @@ function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
39
47
  return bytes.slice().buffer;
40
48
  }
41
49
 
50
+ type FederatedGeometryResult = NonNullable<FederatedModel['geometryResult']>;
51
+
52
+ interface ModelGeoref {
53
+ mapConversion: MapConversion;
54
+ projectedCRS: ProjectedCRS;
55
+ lengthUnitScale: number;
56
+ coordinateInfo?: CoordinateInfo;
57
+ }
58
+
59
+ interface AffineTransform3D {
60
+ m00: number;
61
+ m01: number;
62
+ m02: number;
63
+ tx: number;
64
+ m10: number;
65
+ m11: number;
66
+ m12: number;
67
+ ty: number;
68
+ m20: number;
69
+ m21: number;
70
+ m22: number;
71
+ tz: number;
72
+ }
73
+
74
+ function getMapUnitScale(georef: ModelGeoref): number {
75
+ return georef.projectedCRS.mapUnitScale ?? georef.lengthUnitScale ?? 1;
76
+ }
77
+
78
+ function getAxis(conversion: MapConversion): { a: number; o: number; scale: number; denom: number } {
79
+ const a = conversion.xAxisAbscissa ?? 1;
80
+ const o = conversion.xAxisOrdinate ?? 0;
81
+ const scale = conversion.scale ?? 1;
82
+ const denom = Math.max(a * a + o * o, 1e-12);
83
+ return { a, o, scale, denom };
84
+ }
85
+
86
+ function extractModelGeoref(
87
+ dataStore: IfcDataStore,
88
+ coordinateInfo?: CoordinateInfo,
89
+ mutations?: GeorefMutationDataLike,
90
+ ): ModelGeoref | null {
91
+ const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
92
+ if (!georef?.mapConversion || !georef.projectedCRS?.name) return null;
93
+ return {
94
+ mapConversion: georef.mapConversion,
95
+ projectedCRS: georef.projectedCRS,
96
+ lengthUnitScale: georef.lengthUnitScale,
97
+ coordinateInfo,
98
+ };
99
+ }
100
+
101
+ function crsKey(crs: ProjectedCRS): string {
102
+ return `${crs.name ?? ''}|${crs.geodeticDatum ?? ''}|${crs.mapProjection ?? ''}|${crs.mapZone ?? ''}`.toUpperCase();
103
+ }
104
+
105
+ function canAlignInSameProjectedCrs(a: ModelGeoref, b: ModelGeoref): boolean {
106
+ return crsKey(a.projectedCRS) === crsKey(b.projectedCRS);
107
+ }
108
+
109
+ function totalYupOffset(coordinateInfo?: CoordinateInfo): { x: number; y: number; z: number } {
110
+ const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
111
+ const rtc = coordinateInfo?.wasmRtcOffset;
112
+ const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 };
113
+ return {
114
+ x: shift.x + rtcYup.x,
115
+ y: shift.y + rtcYup.y,
116
+ z: shift.z + rtcYup.z,
117
+ };
118
+ }
119
+
120
+ function emptyBounds() {
121
+ return {
122
+ min: { x: Infinity, y: Infinity, z: Infinity },
123
+ max: { x: -Infinity, y: -Infinity, z: -Infinity },
124
+ };
125
+ }
126
+
127
+ function zeroBounds() {
128
+ return {
129
+ min: { x: 0, y: 0, z: 0 },
130
+ max: { x: 0, y: 0, z: 0 },
131
+ };
132
+ }
133
+
134
+ function updateBounds(bounds: ReturnType<typeof emptyBounds>, x: number, y: number, z: number): boolean {
135
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
136
+ bounds.min.x = Math.min(bounds.min.x, x);
137
+ bounds.min.y = Math.min(bounds.min.y, y);
138
+ bounds.min.z = Math.min(bounds.min.z, z);
139
+ bounds.max.x = Math.max(bounds.max.x, x);
140
+ bounds.max.y = Math.max(bounds.max.y, y);
141
+ bounds.max.z = Math.max(bounds.max.z, z);
142
+ return true;
143
+ }
144
+
145
+ function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null {
146
+ const sourceConv = source.mapConversion;
147
+ const refConv = reference.mapConversion;
148
+ const sourceAxis = getAxis(sourceConv);
149
+ const refAxis = getAxis(refConv);
150
+ const refDenom = refAxis.scale * refAxis.denom;
151
+ if (Math.abs(refDenom) < 1e-12) return null;
152
+
153
+ const sourceMapUnitScale = getMapUnitScale(source);
154
+ const refMapUnitScale = getMapUnitScale(reference);
155
+ const sourceOffset = totalYupOffset(source.coordinateInfo);
156
+ const refOffset = totalYupOffset(reference.coordinateInfo);
157
+
158
+ const eVx = sourceAxis.scale * sourceAxis.a;
159
+ const eVz = sourceAxis.scale * sourceAxis.o;
160
+ const eC = sourceConv.eastings * sourceMapUnitScale
161
+ + sourceAxis.scale * (sourceAxis.a * sourceOffset.x + sourceAxis.o * sourceOffset.z)
162
+ - refConv.eastings * refMapUnitScale;
163
+
164
+ const nVx = sourceAxis.scale * sourceAxis.o;
165
+ const nVz = -sourceAxis.scale * sourceAxis.a;
166
+ const nC = sourceConv.northings * sourceMapUnitScale
167
+ + sourceAxis.scale * (sourceAxis.o * sourceOffset.x - sourceAxis.a * sourceOffset.z)
168
+ - refConv.northings * refMapUnitScale;
169
+
170
+ const hC = sourceConv.orthogonalHeight * sourceMapUnitScale
171
+ + sourceOffset.y
172
+ - refConv.orthogonalHeight * refMapUnitScale;
173
+
174
+ const invRefDenom = 1 / refDenom;
175
+ const xVx = (refAxis.a * eVx + refAxis.o * nVx) * invRefDenom;
176
+ const xVz = (refAxis.a * eVz + refAxis.o * nVz) * invRefDenom;
177
+ const xC = (refAxis.a * eC + refAxis.o * nC) * invRefDenom - refOffset.x;
178
+
179
+ const yVx = (-refAxis.o * eVx + refAxis.a * nVx) * invRefDenom;
180
+ const yVz = (-refAxis.o * eVz + refAxis.a * nVz) * invRefDenom;
181
+ const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom;
182
+
183
+ return {
184
+ m00: xVx,
185
+ m01: 0,
186
+ m02: xVz,
187
+ tx: xC,
188
+ m10: 0,
189
+ m11: 1,
190
+ m12: 0,
191
+ ty: hC - refOffset.y,
192
+ m20: -yVx,
193
+ m21: 0,
194
+ m22: -yVz,
195
+ tz: -yC - refOffset.z,
196
+ };
197
+ }
198
+
199
+ function isIdentityTransform(transform: AffineTransform3D): boolean {
200
+ const eps = 1e-7;
201
+ return Math.abs(transform.m00 - 1) < eps
202
+ && Math.abs(transform.m01) < eps
203
+ && Math.abs(transform.m02) < eps
204
+ && Math.abs(transform.tx) < eps
205
+ && Math.abs(transform.m10) < eps
206
+ && Math.abs(transform.m11 - 1) < eps
207
+ && Math.abs(transform.m12) < eps
208
+ && Math.abs(transform.ty) < eps
209
+ && Math.abs(transform.m20) < eps
210
+ && Math.abs(transform.m21) < eps
211
+ && Math.abs(transform.m22 - 1) < eps
212
+ && Math.abs(transform.tz) < eps;
213
+ }
214
+
215
+ function applyAlignmentTransformAndUpdateBounds(
216
+ geometry: FederatedGeometryResult,
217
+ transform: AffineTransform3D,
218
+ referenceInfo?: CoordinateInfo,
219
+ ): void {
220
+ const bounds = emptyBounds();
221
+ let found = false;
222
+
223
+ for (const mesh of geometry.meshes) {
224
+ const positions = mesh.positions;
225
+ for (let i = 0; i < positions.length; i += 3) {
226
+ const x = positions[i];
227
+ const y = positions[i + 1];
228
+ const z = positions[i + 2];
229
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
230
+ continue;
231
+ }
232
+
233
+ const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx;
234
+ const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty;
235
+ const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz;
236
+ positions[i] = alignedX;
237
+ positions[i + 1] = alignedY;
238
+ positions[i + 2] = alignedZ;
239
+ found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
240
+ }
241
+
242
+ // Rotate normals by the transform's 3×3 linear part (translation omitted)
243
+ // and renormalize. CRS alignment is a rigid rotation, so the linear part
244
+ // itself is the correct transform for normals; degenerate results from
245
+ // zero-length or non-finite inputs are left in place.
246
+ const normals = mesh.normals;
247
+ if (normals && normals.length >= 3) {
248
+ for (let i = 0; i < normals.length; i += 3) {
249
+ const nx = normals[i];
250
+ const ny = normals[i + 1];
251
+ const nz = normals[i + 2];
252
+ if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) {
253
+ continue;
254
+ }
255
+ const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz;
256
+ const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz;
257
+ const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz;
258
+ const len = Math.sqrt(rx * rx + ry * ry + rz * rz);
259
+ if (!Number.isFinite(len) || len < 1e-12) {
260
+ continue;
261
+ }
262
+ normals[i] = rx / len;
263
+ normals[i + 1] = ry / len;
264
+ normals[i + 2] = rz / len;
265
+ }
266
+ }
267
+ }
268
+
269
+ geometry.coordinateInfo = {
270
+ originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 },
271
+ originalBounds: found ? bounds : zeroBounds(),
272
+ shiftedBounds: found ? bounds : zeroBounds(),
273
+ hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false,
274
+ wasmRtcOffset: referenceInfo?.wasmRtcOffset,
275
+ buildingRotation: referenceInfo?.buildingRotation,
276
+ };
277
+ }
278
+
279
+ function alignGeometryToReferenceGeoref(
280
+ geometry: FederatedGeometryResult,
281
+ source: ModelGeoref,
282
+ reference: ModelGeoref,
283
+ ): boolean {
284
+ if (!canAlignInSameProjectedCrs(source, reference)) {
285
+ return false;
286
+ }
287
+
288
+ const transform = buildGeorefAlignmentTransform(source, reference);
289
+ if (!transform) {
290
+ return false;
291
+ }
292
+
293
+ if (!isIdentityTransform(transform)) {
294
+ applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo);
295
+ }
296
+ return true;
297
+ }
298
+
299
+ function findReferenceGeorefModel(): ModelGeoref | null {
300
+ const state = useViewerStore.getState();
301
+ const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>;
302
+ const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
303
+ for (const [modelId, model] of sorted) {
304
+ if (!model.ifcDataStore || !model.geometryResult) continue;
305
+ const georef = extractModelGeoref(
306
+ model.ifcDataStore,
307
+ model.geometryResult.coordinateInfo,
308
+ state.georefMutations.get(modelId),
309
+ );
310
+ if (georef) return georef;
311
+ }
312
+ return null;
313
+ }
314
+
42
315
  /**
43
316
  * Extended data store type for IFCX (IFC5) files.
44
317
  * IFCX uses schemaVersion 'IFC5' and may include federated composition metadata.
@@ -100,9 +373,15 @@ export function useIfcFederation() {
100
373
  */
101
374
  const addModel = useCallback(async (
102
375
  file: File | NativeFileHandle,
103
- options?: { name?: string }
376
+ options?: {
377
+ name?: string;
378
+ modelId?: string;
379
+ loadedAt?: number;
380
+ visible?: boolean;
381
+ collapsed?: boolean;
382
+ }
104
383
  ): Promise<string | null> => {
105
- const modelId = crypto.randomUUID();
384
+ const modelId = options?.modelId ?? crypto.randomUUID();
106
385
  const addStart = performance.now();
107
386
  try {
108
387
  // IMPORTANT: Before adding a new model, check if there's a legacy model
@@ -143,6 +422,7 @@ export function useIfcFederation() {
143
422
  schemaVersion: 'IFC4',
144
423
  loadedAt: Date.now() - 1000,
145
424
  fileSize: 0,
425
+ sourceFile: undefined,
146
426
  idOffset: legacyOffset,
147
427
  maxExpressId: legacyMaxExpressId,
148
428
  };
@@ -190,12 +470,26 @@ export function useIfcFederation() {
190
470
  schemaVersion = result.schemaVersion;
191
471
  } else {
192
472
  setProgress({ phase: 'Starting geometry streaming', percent: 10 });
473
+
474
+ // For federated models: use the first model's RTC offset so all models
475
+ // share the same coordinate origin. This ensures pixel-perfect alignment
476
+ // without error-prone delta adjustments.
477
+ let sharedRtcOffset: { x: number; y: number; z: number } | undefined;
478
+ const existingModelsForRtc = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
479
+ if (existingModelsForRtc.length > 0) {
480
+ const sorted = [...existingModelsForRtc].sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
481
+ sharedRtcOffset = sorted.find(
482
+ (model) => model.geometryResult?.coordinateInfo?.wasmRtcOffset != null,
483
+ )?.geometryResult?.coordinateInfo?.wasmRtcOffset;
484
+ }
485
+
193
486
  const result = await parseStepBufferViewerModel({
194
487
  fileName: file.name,
195
488
  buffer,
196
489
  fileSizeMB,
197
490
  getDynamicBatchSize: getDynamicBatchConfig,
198
491
  onProgress: setProgress,
492
+ sharedRtcOffset,
199
493
  });
200
494
  parsedDataStore = result.dataStore;
201
495
  parsedGeometry = result.geometryResult;
@@ -206,6 +500,27 @@ export function useIfcFederation() {
206
500
  throw new Error('Failed to parse file');
207
501
  }
208
502
 
503
+ const referenceGeoref = findReferenceGeorefModel();
504
+ // Include any georef edits the user has already saved for this model so
505
+ // that a reload after editing reflects the new placement. Without this,
506
+ // extractModelGeoref reads only the raw parsed metadata and mutations
507
+ // are silently ignored.
508
+ const parsedGeorefMutations = useViewerStore.getState().georefMutations.get(modelId);
509
+ const parsedGeoref = extractModelGeoref(
510
+ parsedDataStore,
511
+ parsedGeometry.coordinateInfo,
512
+ parsedGeorefMutations,
513
+ );
514
+ if (referenceGeoref && parsedGeoref) {
515
+ setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
516
+ const aligned = alignGeometryToReferenceGeoref(parsedGeometry, parsedGeoref, referenceGeoref);
517
+ if (!aligned) {
518
+ console.warn(
519
+ `[ifc-lite] Skipped georeferenced federation alignment for "${file.name}" because CRS differs from the reference model.`,
520
+ );
521
+ }
522
+ }
523
+
209
524
  // =========================================================================
210
525
  // FEDERATION REGISTRY: Transform expressIds to globally unique IDs
211
526
  // This is the BULLETPROOF fix for multi-model ID collisions
@@ -229,71 +544,10 @@ export function useIfcFederation() {
229
544
  }
230
545
 
231
546
  // =========================================================================
232
- // COORDINATE ALIGNMENT: Align new model with existing models using RTC delta
233
- // WASM applies per-model RTC offsets. To align models from the same project,
234
- // we calculate the difference in RTC offsets and apply it to the new model.
235
- //
236
- // RTC offset is in IFC coordinates (Z-up). After Z-up to Y-up conversion:
237
- // - IFC X → WebGL X
238
- // - IFC Y → WebGL -Z
239
- // - IFC Z → WebGL Y (vertical)
547
+ // COORDINATE ALIGNMENT: All federated models use the same shared RTC offset
548
+ // (passed to WASM during parsing above), so no post-processing vertex
549
+ // adjustment is needed. All models are already in the same coordinate space.
240
550
  // =========================================================================
241
- const existingModels = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
242
- if (existingModels.length > 0) {
243
- const firstModel = existingModels[0];
244
- const firstRtc = firstModel.geometryResult?.coordinateInfo?.wasmRtcOffset;
245
- const newRtc = parsedGeometry.coordinateInfo?.wasmRtcOffset;
246
-
247
- // If both models have RTC offsets, use RTC delta for precise alignment
248
- if (firstRtc && newRtc) {
249
- // Calculate what adjustment is needed to align new model with first model
250
- // First model: pos = original - firstRtc
251
- // New model: pos = original - newRtc
252
- // To align: newPos + adjustment = firstPos (assuming same original)
253
- // adjustment = firstRtc - newRtc (add back new's RTC, subtract first's RTC)
254
- const adjustX = firstRtc.x - newRtc.x; // IFC X adjustment
255
- const adjustY = firstRtc.y - newRtc.y; // IFC Y adjustment
256
- const adjustZ = firstRtc.z - newRtc.z; // IFC Z adjustment (vertical)
257
-
258
- // Convert to WebGL coordinates:
259
- // IFC X → WebGL X (no change)
260
- // IFC Y → WebGL -Z (swap and negate)
261
- // IFC Z → WebGL Y (vertical)
262
- const webglAdjustX = adjustX;
263
- const webglAdjustY = adjustZ; // IFC Z is WebGL Y (vertical)
264
- const webglAdjustZ = -adjustY; // IFC Y is WebGL -Z
265
-
266
- const hasSignificantAdjust = Math.abs(webglAdjustX) > 0.01 ||
267
- Math.abs(webglAdjustY) > 0.01 ||
268
- Math.abs(webglAdjustZ) > 0.01;
269
-
270
- if (hasSignificantAdjust) {
271
- // Apply adjustment to all mesh vertices
272
- // SUBTRACT adjustment: if firstRtc > newRtc, first was shifted MORE,
273
- // so new model needs to be shifted in same direction (subtract more)
274
- for (const mesh of parsedGeometry.meshes) {
275
- const positions = mesh.positions;
276
- for (let i = 0; i < positions.length; i += 3) {
277
- positions[i] -= webglAdjustX;
278
- positions[i + 1] -= webglAdjustY;
279
- positions[i + 2] -= webglAdjustZ;
280
- }
281
- }
282
-
283
- // Update coordinate info bounds
284
- if (parsedGeometry.coordinateInfo) {
285
- parsedGeometry.coordinateInfo.shiftedBounds.min.x -= webglAdjustX;
286
- parsedGeometry.coordinateInfo.shiftedBounds.max.x -= webglAdjustX;
287
- parsedGeometry.coordinateInfo.shiftedBounds.min.y -= webglAdjustY;
288
- parsedGeometry.coordinateInfo.shiftedBounds.max.y -= webglAdjustY;
289
- parsedGeometry.coordinateInfo.shiftedBounds.min.z -= webglAdjustZ;
290
- parsedGeometry.coordinateInfo.shiftedBounds.max.z -= webglAdjustZ;
291
- }
292
- }
293
- } else {
294
- // No RTC info - can't align reliably. This happens with old cache entries.
295
- }
296
- }
297
551
 
298
552
  // Build spatial index AFTER ID offset + RTC alignment so it stores
299
553
  // correct globalIds and final world-space positions.
@@ -305,11 +559,12 @@ export function useIfcFederation() {
305
559
  name: options?.name ?? file.name,
306
560
  ifcDataStore: parsedDataStore,
307
561
  geometryResult: parsedGeometry,
308
- visible: true,
309
- collapsed: hasModels(), // Collapse if not first model
562
+ visible: options?.visible ?? true,
563
+ collapsed: options?.collapsed ?? hasModels(), // Collapse if not first model
310
564
  schemaVersion,
311
- loadedAt: Date.now(),
565
+ loadedAt: options?.loadedAt ?? Date.now(),
312
566
  fileSize: buffer.byteLength,
567
+ sourceFile: file,
313
568
  idOffset,
314
569
  maxExpressId,
315
570
  };
@@ -231,6 +231,7 @@ export function useIfcLoader() {
231
231
  schemaVersion: 'IFC4',
232
232
  loadedAt: Date.now(),
233
233
  fileSize,
234
+ sourceFile: file,
234
235
  idOffset: 0,
235
236
  maxExpressId: 0,
236
237
  loadState: 'pending',
@@ -280,15 +281,23 @@ export function useIfcLoader() {
280
281
  return 'IFC2X3';
281
282
  };
282
283
 
284
+ // Native renderer streaming path is currently disabled — the
285
+ // `huge native file` block further down handles real desktop
286
+ // streaming. This branch is retained as a scaffold for the future
287
+ // always-on native renderer integration.
288
+ const NATIVE_RENDERER_PATH_ENABLED = false as boolean;
283
289
  if (
290
+ NATIVE_RENDERER_PATH_ENABLED &&
284
291
  isNativeFileHandle(file) &&
285
- fileName.toLowerCase().endsWith('.ifc') &&
286
- false
292
+ fileName.toLowerCase().endsWith('.ifc')
287
293
  ) {
294
+ // Re-narrow `file` for the body — TS occasionally drops the
295
+ // type-predicate result inside a dead branch.
296
+ const nativeFile: NativeFileHandle = file;
288
297
  const harnessRequest = getActiveHarnessRequest();
289
- const nativeCacheKey = computeNativeCacheKey(file);
290
- const shouldUseNativeCache = file.size >= CACHE_SIZE_THRESHOLD;
291
- const hugeNativeMode = file.size >= HUGE_NATIVE_FILE_THRESHOLD;
298
+ const nativeCacheKey = computeNativeCacheKey(nativeFile);
299
+ const shouldUseNativeCache = nativeFile.size >= CACHE_SIZE_THRESHOLD;
300
+ const hugeNativeMode = nativeFile.size >= HUGE_NATIVE_FILE_THRESHOLD;
292
301
  let firstBatchWaitMs: number | null = null;
293
302
  let firstVisibleGeometryMs: number | null = null;
294
303
  let modelOpenMs: number | null = null;
@@ -311,7 +320,7 @@ export function useIfcLoader() {
311
320
  let nativeGeometryCacheHit = false;
312
321
  let nativeMetadataSnapshotHit = false;
313
322
  let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
314
- let nativeMetadataStartGate: 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete' = 'immediate';
323
+ let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
315
324
  let finalCoordinateInfo: CoordinateInfo | null = null;
316
325
 
317
326
  console.log(`[useIfc] Native renderer load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
@@ -726,7 +735,7 @@ export function useIfcLoader() {
726
735
  let fullNativeDataStore: IfcDataStore | null = null;
727
736
  let nativeLoadStage: 'open' | 'streamGeometry' | 'finalizeGeometry' | 'hydrateMetadata' | 'complete' = 'open';
728
737
  let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
729
- let nativeMetadataStartGate: 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete' = 'immediate';
738
+ let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
730
739
 
731
740
  setGeometryResult(null);
732
741
 
@@ -1637,8 +1646,10 @@ export function useIfcLoader() {
1637
1646
  }
1638
1647
 
1639
1648
  // Try server parsing first (enabled by default for multi-core performance)
1640
- // Only for IFC4 STEP files (server doesn't support IFCX)
1641
- if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
1649
+ // Only for IFC4 STEP files (server doesn't support IFCX). Native
1650
+ // file handles (Tauri) don't have an HTTP-uploadable body, so skip
1651
+ // the server path and fall through to the WASM loader.
1652
+ if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
1642
1653
  // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
1643
1654
  const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
1644
1655
  if (serverSuccess) {
@@ -1791,7 +1802,9 @@ export function useIfcLoader() {
1791
1802
  if (geometryIteratorClosed || typeof geometryIterator.return !== 'function') return;
1792
1803
  geometryIteratorClosed = true;
1793
1804
  try {
1794
- await geometryIterator.return();
1805
+ // `AsyncIterator.return()` is signed as taking a value in
1806
+ // current TS libs; callers conventionally pass `undefined`.
1807
+ await geometryIterator.return(undefined);
1795
1808
  } catch {
1796
1809
  // Ignore iterator shutdown failures during recovery.
1797
1810
  }
@@ -88,6 +88,10 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
88
88
  e.preventDefault();
89
89
  setActiveTool('section');
90
90
  }
91
+ if (key === 'p' && !ctrl && !shift) {
92
+ e.preventDefault();
93
+ setActiveTool('annotate');
94
+ }
91
95
 
92
96
  // Basket controls (automatic context source)
93
97
  // I = Isolate from current context
@@ -150,6 +154,26 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
150
154
  resetVisibilityForHomeFromStore();
151
155
  }
152
156
 
157
+ // Add-element tool shortcuts — Enter commits an in-progress slab
158
+ // polygon; Esc clears any pending points before falling through to
159
+ // the global Esc handler (which exits the tool).
160
+ if (activeTool === 'addElement') {
161
+ const state = useViewerStore.getState();
162
+ const polygonable = ['slab', 'roof', 'plate', 'space'].includes(state.addElementType);
163
+ if (key === 'enter' && polygonable && state.addElementSlabMode === 'polygon') {
164
+ e.preventDefault();
165
+ // Lazy import keeps this module out of the keyboard hook's
166
+ // synchronous bundle (the close handler pulls in toast).
167
+ import('@/components/viewer/selectionHandlers').then((mod) => mod.commitAddElementSlabPolygon());
168
+ return;
169
+ }
170
+ if (key === 'escape' && state.addElementPendingPoints.length > 0) {
171
+ e.preventDefault();
172
+ state.clearAddElementPending();
173
+ return;
174
+ }
175
+ }
176
+
153
177
  // Measure tool shortcuts
154
178
  if (activeTool === 'measure') {
155
179
  // Cancel active measurement with ESC
@@ -243,6 +267,7 @@ export const KEYBOARD_SHORTCUTS = [
243
267
  { key: 'V', description: 'Select tool', category: 'Tools' },
244
268
  { key: 'C', description: 'Walk mode', category: 'Tools' },
245
269
  { key: 'M', description: 'Measure tool', category: 'Tools' },
270
+ { key: 'P', description: 'Annotate tool — drop a pin with a note', category: 'Tools' },
246
271
  { key: 'X', description: 'Section tool', category: 'Tools' },
247
272
  { key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
248
273
  { key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
@@ -165,7 +165,7 @@ export function useSandbox(config?: SandboxConfig) {
165
165
  // Create a fresh sandbox for every execution — full isolation
166
166
  const { createSandbox } = await import('@ifc-lite/sandbox');
167
167
  sandbox = await createSandbox(bim, {
168
- permissions: { model: true, query: true, viewer: true, mutate: true, lens: true, export: true, files: true, ...config?.permissions },
168
+ permissions: { model: true, query: true, viewer: true, mutate: true, store: true, lens: true, export: true, files: true, ...config?.permissions },
169
169
  limits: { timeoutMs: 30_000, ...config?.limits },
170
170
  });
171
171
  activeSandboxRef.current = sandbox;