@ifc-lite/viewer 1.27.0 → 1.28.1

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 (162) hide show
  1. package/.turbo/turbo-build.log +35 -42
  2. package/CHANGELOG.md +74 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
  5. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  6. package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
  7. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
  10. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  14. package/dist/assets/index-E9wB0zWt.css +1 -0
  15. package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
  24. package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
  26. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
  28. package/dist/assets/server-client-DVZ2huNS.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/App.tsx +1 -3
  35. package/src/components/mcp/playground-dispatcher.ts +3 -0
  36. package/src/components/mcp/playground-files.ts +33 -1
  37. package/src/components/viewer/BCFPanel.tsx +1 -16
  38. package/src/components/viewer/ChatPanel.tsx +11 -46
  39. package/src/components/viewer/CommandPalette.tsx +6 -1
  40. package/src/components/viewer/ComparePanel.tsx +420 -0
  41. package/src/components/viewer/HierarchyPanel.tsx +48 -183
  42. package/src/components/viewer/IDSPanel.tsx +1 -26
  43. package/src/components/viewer/MainToolbar.tsx +94 -187
  44. package/src/components/viewer/MobileToolbar.tsx +1 -9
  45. package/src/components/viewer/PropertiesPanel.tsx +98 -127
  46. package/src/components/viewer/ScriptPanel.tsx +8 -34
  47. package/src/components/viewer/Section2DPanel.tsx +32 -1
  48. package/src/components/viewer/ViewerLayout.tsx +5 -2
  49. package/src/components/viewer/Viewport.tsx +3 -0
  50. package/src/components/viewer/ViewportContainer.tsx +24 -42
  51. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  52. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  53. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  54. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  55. package/src/components/viewer/hierarchy/types.ts +1 -0
  56. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  57. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  58. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  59. package/src/hooks/federationLoadGate.test.ts +12 -2
  60. package/src/hooks/federationLoadGate.ts +9 -2
  61. package/src/hooks/ingest/federationAlign.ts +488 -0
  62. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  63. package/src/hooks/useCompare.ts +0 -0
  64. package/src/hooks/useCompareOverlay.ts +119 -0
  65. package/src/hooks/useDrawingGeneration.ts +234 -14
  66. package/src/hooks/useIfc.ts +1 -1
  67. package/src/hooks/useIfcCache.ts +100 -24
  68. package/src/hooks/useIfcFederation.ts +42 -811
  69. package/src/hooks/useIfcLoader.ts +349 -1517
  70. package/src/hooks/useIfcServer.ts +3 -0
  71. package/src/hooks/useLens.ts +5 -1
  72. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  73. package/src/lib/compare/buildFingerprints.ts +173 -0
  74. package/src/lib/compare/describeChange.ts +0 -0
  75. package/src/lib/compare/geometricData.test.ts +54 -0
  76. package/src/lib/compare/geometricData.ts +37 -0
  77. package/src/lib/compare/overlay.test.ts +99 -0
  78. package/src/lib/compare/overlay.ts +91 -0
  79. package/src/lib/geo/cesium-placement.ts +1 -1
  80. package/src/lib/geo/reproject.ts +4 -1
  81. package/src/lib/llm/script-edit-ops.ts +23 -0
  82. package/src/lib/llm/stream-client.ts +8 -1
  83. package/src/lib/search/result-export.ts +7 -1
  84. package/src/sdk/adapters/export-adapter.ts +6 -1
  85. package/src/services/cacheService.ts +9 -25
  86. package/src/services/desktop-export.ts +2 -59
  87. package/src/services/file-dialog.ts +8 -142
  88. package/src/store/constants.ts +23 -0
  89. package/src/store/globalId.ts +15 -13
  90. package/src/store/index.ts +19 -6
  91. package/src/store/slices/cesiumSlice.ts +8 -1
  92. package/src/store/slices/compareSlice.ts +96 -0
  93. package/src/store/slices/drawing2DSlice.ts +8 -0
  94. package/src/store/slices/lensSlice.ts +8 -0
  95. package/src/store/slices/visibilitySlice.ts +22 -1
  96. package/src/store/types.ts +1 -71
  97. package/src/utils/acquireFileBuffer.test.ts +12 -4
  98. package/src/utils/ifcConfig.ts +0 -12
  99. package/src/utils/loadingUtils.ts +32 -0
  100. package/src/utils/spatialHierarchy.test.ts +53 -1
  101. package/src/utils/spatialHierarchy.ts +42 -2
  102. package/src/vite-env.d.ts +2 -0
  103. package/vite.config.ts +6 -3
  104. package/DESKTOP_CONTRACT_VERSION +0 -1
  105. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  106. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  107. package/dist/assets/event-B0kAzHa-.js +0 -1
  108. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  109. package/dist/assets/index-ajK6D32J.css +0 -1
  110. package/dist/assets/lens-PYsLu_MA.js +0 -1
  111. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  112. package/dist/assets/raw-D9iw0tmc.js +0 -1
  113. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  114. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  115. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  116. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  117. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  118. package/src/components/viewer/SettingsPage.tsx +0 -581
  119. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  120. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  121. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  122. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
  123. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  124. package/src/lib/desktop-entitlement.ts +0 -43
  125. package/src/lib/desktop-product.ts +0 -130
  126. package/src/lib/platform.ts +0 -23
  127. package/src/services/desktop-cache.ts +0 -186
  128. package/src/services/desktop-harness.ts +0 -196
  129. package/src/services/desktop-logger.ts +0 -20
  130. package/src/services/desktop-native-metadata.ts +0 -230
  131. package/src/services/desktop-panel-actions.ts +0 -43
  132. package/src/services/desktop-preferences.ts +0 -44
  133. package/src/services/fs-cache.ts +0 -212
  134. package/src/services/tauri-core-stub.ts +0 -7
  135. package/src/services/tauri-dialog-stub.ts +0 -7
  136. package/src/services/tauri-fs-stub.ts +0 -7
  137. package/src/services/tauri-modules.d.ts +0 -50
  138. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  139. package/src/utils/desktopModelSnapshot.ts +0 -358
  140. package/src/utils/nativeSpatialDataStore.ts +0 -277
  141. package/src-tauri/Cargo.toml +0 -29
  142. package/src-tauri/build.rs +0 -7
  143. package/src-tauri/capabilities/default.json +0 -18
  144. package/src-tauri/icons/128x128.png +0 -0
  145. package/src-tauri/icons/128x128@2x.png +0 -0
  146. package/src-tauri/icons/32x32.png +0 -0
  147. package/src-tauri/icons/Square107x107Logo.png +0 -0
  148. package/src-tauri/icons/Square142x142Logo.png +0 -0
  149. package/src-tauri/icons/Square150x150Logo.png +0 -0
  150. package/src-tauri/icons/Square284x284Logo.png +0 -0
  151. package/src-tauri/icons/Square30x30Logo.png +0 -0
  152. package/src-tauri/icons/Square310x310Logo.png +0 -0
  153. package/src-tauri/icons/Square44x44Logo.png +0 -0
  154. package/src-tauri/icons/Square71x71Logo.png +0 -0
  155. package/src-tauri/icons/Square89x89Logo.png +0 -0
  156. package/src-tauri/icons/StoreLogo.png +0 -0
  157. package/src-tauri/icons/icon.icns +0 -0
  158. package/src-tauri/icons/icon.ico +0 -0
  159. package/src-tauri/icons/icon.png +0 -0
  160. package/src-tauri/src/lib.rs +0 -21
  161. package/src-tauri/src/main.rs +0 -10
  162. package/src-tauri/tauri.conf.json +0 -39
@@ -18,8 +18,6 @@ import {
18
18
  parseFederatedIfcx,
19
19
  type IfcDataStore,
20
20
  type FederatedIfcxParseResult,
21
- type MapConversion,
22
- type ProjectedCRS,
23
21
  } from '@ifc-lite/parser';
24
22
  import type { CoordinateInfo, MeshData } from '@ifc-lite/geometry';
25
23
  import { IfcQuery } from '@ifc-lite/query';
@@ -28,493 +26,10 @@ import { getDynamicBatchConfig } from '../utils/ifcConfig.js';
28
26
  import { calculateMeshBounds, createCoordinateInfo } from '../utils/localParsingUtils.js';
29
27
  import {
30
28
  convertIfcxMeshes,
31
- getMaxExpressId,
32
- parseGlbViewerModel,
33
- parseIfcxViewerModel,
34
- parseStepBufferViewerModel,
35
29
  } from './ingest/viewerModelIngest.js';
36
- import {
37
- detectPointCloudFormat,
38
- ingestPointCloud,
39
- type PointCloudFormat,
40
- } from './ingest/pointCloudIngest.js';
41
- import { getGlobalRenderer } from './useBCF.js';
42
- import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
43
- import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
44
- import { resolveMapUnitToMetreScale } from '../lib/geo/geo-scale.js';
45
- import { resolveProjection } from '../lib/geo/reproject.js';
30
+ import { extractModelGeoref, alignGeometryToReference, findReferenceGeorefModel } from './ingest/federationAlign.js';
46
31
  import { toast } from '../components/ui/toast.js';
47
- import proj4 from 'proj4';
48
32
  import { acquireFederationLoadSlot, releaseFederationLoadSlot } from './federationLoadGate.js';
49
- import { acquireFileBuffer } from '../utils/acquireFileBuffer.js';
50
-
51
- function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
52
- return typeof (file as NativeFileHandle).path === 'string';
53
- }
54
-
55
- function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
56
- if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
57
- return bytes.buffer;
58
- }
59
- return bytes.slice().buffer;
60
- }
61
-
62
- type FederatedGeometryResult = NonNullable<FederatedModel['geometryResult']>;
63
-
64
- interface ModelGeoref {
65
- mapConversion: MapConversion;
66
- projectedCRS: ProjectedCRS;
67
- lengthUnitScale: number;
68
- coordinateInfo?: CoordinateInfo;
69
- }
70
-
71
- interface AffineTransform3D {
72
- m00: number;
73
- m01: number;
74
- m02: number;
75
- tx: number;
76
- m10: number;
77
- m11: number;
78
- m12: number;
79
- ty: number;
80
- m20: number;
81
- m21: number;
82
- m22: number;
83
- tz: number;
84
- }
85
-
86
- function getMapUnitScale(georef: ModelGeoref): number {
87
- return resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1);
88
- }
89
-
90
- function getAxis(georef: ModelGeoref): { a: number; o: number; scale: number; denom: number } {
91
- const conversion = georef.mapConversion;
92
- const a = conversion.xAxisAbscissa ?? 1;
93
- const o = conversion.xAxisOrdinate ?? 0;
94
- // Use the effective horizontal scale: viewer geometry is already in metres,
95
- // so applying IfcMapConversion.Scale raw would double-scale — see issue #595.
96
- const mapUnitScale = resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1);
97
- const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, georef.lengthUnitScale ?? 1);
98
- const denom = Math.max(a * a + o * o, 1e-12);
99
- return { a, o, scale, denom };
100
- }
101
-
102
- function extractModelGeoref(
103
- dataStore: IfcDataStore,
104
- coordinateInfo?: CoordinateInfo,
105
- mutations?: GeorefMutationDataLike,
106
- ): ModelGeoref | null {
107
- const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
108
- // Only TRUE georeferencing (real IfcMapConversion + IfcProjectedCRS) may drive
109
- // federation alignment. A file with no IfcMapConversion gets a synthesised
110
- // `source: 'siteLocation'` georef (EPSG:4326 from IfcSite RefLatitude/Longitude/
111
- // Elevation) so it can still be pinned on the location map — but those are
112
- // geographic degrees plus a raw, un-unit-scaled site elevation, not a projected
113
- // metric frame. buildGeorefAlignmentTransform assumes projected eastings/
114
- // northings/height in metres, so feeding it site data places the second model
115
- // kilometres away: the BIMcollab ARC/STR pair share a site GUID but carry
116
- // RefElevation 0 vs 20000 mm, and the height term lands ARC ~20 km below STR.
117
- // Such models have no real georef relationship, so leave them in their own local
118
- // frames where they overlay correctly. hasStandardGeoreferencing() excludes
119
- // 'siteLocation' (see effective-georef.test.ts). (Regression from #658.)
120
- if (!hasStandardGeoreferencing(georef) || !georef?.mapConversion || !georef.projectedCRS?.name) {
121
- return null;
122
- }
123
- return {
124
- mapConversion: georef.mapConversion,
125
- projectedCRS: georef.projectedCRS,
126
- lengthUnitScale: georef.lengthUnitScale,
127
- coordinateInfo,
128
- };
129
- }
130
-
131
- function crsKey(crs: ProjectedCRS): string {
132
- return `${crs.name ?? ''}|${crs.geodeticDatum ?? ''}|${crs.mapProjection ?? ''}|${crs.mapZone ?? ''}`.toUpperCase();
133
- }
134
-
135
- function canAlignInSameProjectedCrs(a: ModelGeoref, b: ModelGeoref): boolean {
136
- return crsKey(a.projectedCRS) === crsKey(b.projectedCRS);
137
- }
138
-
139
- function totalYupOffset(coordinateInfo?: CoordinateInfo): { x: number; y: number; z: number } {
140
- const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
141
- const rtc = coordinateInfo?.wasmRtcOffset;
142
- const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 };
143
- return {
144
- x: shift.x + rtcYup.x,
145
- y: shift.y + rtcYup.y,
146
- z: shift.z + rtcYup.z,
147
- };
148
- }
149
-
150
- function emptyBounds() {
151
- return {
152
- min: { x: Infinity, y: Infinity, z: Infinity },
153
- max: { x: -Infinity, y: -Infinity, z: -Infinity },
154
- };
155
- }
156
-
157
- function zeroBounds() {
158
- return {
159
- min: { x: 0, y: 0, z: 0 },
160
- max: { x: 0, y: 0, z: 0 },
161
- };
162
- }
163
-
164
- function updateBounds(bounds: ReturnType<typeof emptyBounds>, x: number, y: number, z: number): boolean {
165
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
166
- bounds.min.x = Math.min(bounds.min.x, x);
167
- bounds.min.y = Math.min(bounds.min.y, y);
168
- bounds.min.z = Math.min(bounds.min.z, z);
169
- bounds.max.x = Math.max(bounds.max.x, x);
170
- bounds.max.y = Math.max(bounds.max.y, y);
171
- bounds.max.z = Math.max(bounds.max.z, z);
172
- return true;
173
- }
174
-
175
- function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null {
176
- const sourceConv = source.mapConversion;
177
- const refConv = reference.mapConversion;
178
- const sourceAxis = getAxis(source);
179
- const refAxis = getAxis(reference);
180
- const refDenom = refAxis.scale * refAxis.denom;
181
- if (Math.abs(refDenom) < 1e-12) return null;
182
-
183
- const sourceMapUnitScale = getMapUnitScale(source);
184
- const refMapUnitScale = getMapUnitScale(reference);
185
- const sourceOffset = totalYupOffset(source.coordinateInfo);
186
- const refOffset = totalYupOffset(reference.coordinateInfo);
187
-
188
- const eVx = sourceAxis.scale * sourceAxis.a;
189
- const eVz = sourceAxis.scale * sourceAxis.o;
190
- const eC = sourceConv.eastings * sourceMapUnitScale
191
- + sourceAxis.scale * (sourceAxis.a * sourceOffset.x + sourceAxis.o * sourceOffset.z)
192
- - refConv.eastings * refMapUnitScale;
193
-
194
- const nVx = sourceAxis.scale * sourceAxis.o;
195
- const nVz = -sourceAxis.scale * sourceAxis.a;
196
- const nC = sourceConv.northings * sourceMapUnitScale
197
- + sourceAxis.scale * (sourceAxis.o * sourceOffset.x - sourceAxis.a * sourceOffset.z)
198
- - refConv.northings * refMapUnitScale;
199
-
200
- const hC = sourceConv.orthogonalHeight * sourceMapUnitScale
201
- + sourceOffset.y
202
- - refConv.orthogonalHeight * refMapUnitScale;
203
-
204
- const invRefDenom = 1 / refDenom;
205
- const xVx = (refAxis.a * eVx + refAxis.o * nVx) * invRefDenom;
206
- const xVz = (refAxis.a * eVz + refAxis.o * nVz) * invRefDenom;
207
- const xC = (refAxis.a * eC + refAxis.o * nC) * invRefDenom - refOffset.x;
208
-
209
- const yVx = (-refAxis.o * eVx + refAxis.a * nVx) * invRefDenom;
210
- const yVz = (-refAxis.o * eVz + refAxis.a * nVz) * invRefDenom;
211
- const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom;
212
-
213
- return {
214
- m00: xVx,
215
- m01: 0,
216
- m02: xVz,
217
- tx: xC,
218
- m10: 0,
219
- m11: 1,
220
- m12: 0,
221
- ty: hC - refOffset.y,
222
- m20: -yVx,
223
- m21: 0,
224
- m22: -yVz,
225
- tz: -yC - refOffset.z,
226
- };
227
- }
228
-
229
- function isIdentityTransform(transform: AffineTransform3D): boolean {
230
- const eps = 1e-7;
231
- return Math.abs(transform.m00 - 1) < eps
232
- && Math.abs(transform.m01) < eps
233
- && Math.abs(transform.m02) < eps
234
- && Math.abs(transform.tx) < eps
235
- && Math.abs(transform.m10) < eps
236
- && Math.abs(transform.m11 - 1) < eps
237
- && Math.abs(transform.m12) < eps
238
- && Math.abs(transform.ty) < eps
239
- && Math.abs(transform.m20) < eps
240
- && Math.abs(transform.m21) < eps
241
- && Math.abs(transform.m22 - 1) < eps
242
- && Math.abs(transform.tz) < eps;
243
- }
244
-
245
- function applyAlignmentTransformAndUpdateBounds(
246
- geometry: FederatedGeometryResult,
247
- transform: AffineTransform3D,
248
- referenceInfo?: CoordinateInfo,
249
- ): void {
250
- const bounds = emptyBounds();
251
- let found = false;
252
-
253
- for (const mesh of geometry.meshes) {
254
- const positions = mesh.positions;
255
- for (let i = 0; i < positions.length; i += 3) {
256
- const x = positions[i];
257
- const y = positions[i + 1];
258
- const z = positions[i + 2];
259
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
260
- continue;
261
- }
262
-
263
- const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx;
264
- const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty;
265
- const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz;
266
- positions[i] = alignedX;
267
- positions[i + 1] = alignedY;
268
- positions[i + 2] = alignedZ;
269
- found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
270
- }
271
-
272
- // Rotate normals by the transform's 3×3 linear part (translation omitted)
273
- // and renormalize. CRS alignment is a rigid rotation, so the linear part
274
- // itself is the correct transform for normals; degenerate results from
275
- // zero-length or non-finite inputs are left in place.
276
- const normals = mesh.normals;
277
- if (normals && normals.length >= 3) {
278
- for (let i = 0; i < normals.length; i += 3) {
279
- const nx = normals[i];
280
- const ny = normals[i + 1];
281
- const nz = normals[i + 2];
282
- if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) {
283
- continue;
284
- }
285
- const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz;
286
- const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz;
287
- const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz;
288
- const len = Math.sqrt(rx * rx + ry * ry + rz * rz);
289
- if (!Number.isFinite(len) || len < 1e-12) {
290
- continue;
291
- }
292
- normals[i] = rx / len;
293
- normals[i + 1] = ry / len;
294
- normals[i + 2] = rz / len;
295
- }
296
- }
297
- }
298
-
299
- geometry.coordinateInfo = {
300
- originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 },
301
- originalBounds: found ? bounds : zeroBounds(),
302
- shiftedBounds: found ? bounds : zeroBounds(),
303
- hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false,
304
- wasmRtcOffset: referenceInfo?.wasmRtcOffset,
305
- buildingRotation: referenceInfo?.buildingRotation,
306
- };
307
- }
308
-
309
- /**
310
- * Reproject every vertex from a source model's georeference into the reference
311
- * model's viewer-space frame using proj4 between the two projected CRSs.
312
- *
313
- * Used for federated loads where models declare different IfcProjectedCRSs
314
- * (e.g. EPSG:28992 + EPSG:7415 mixed RD/NAP Dutch sets, or EPSG:25831 UTM +
315
- * EPSG:28992 mixed). The pipeline per vertex:
316
- *
317
- * viewer(Yup) ──(source RTC/shift, axis swap)──▶ IFC(Zup, source)
318
- * IFC(source) ──(source MapConversion)──────────▶ source projected (eS,nS,hS)
319
- * projected ──(proj4: srcDef → refDef)────────▶ reference projected (eR,nR)
320
- * projected ──(reference MapConversion inverse)▶ IFC(Zup, reference)
321
- * IFC(ref) ──(axis swap, reference RTC/shift)─▶ viewer(Yup, reference frame)
322
- *
323
- * Vertical: height passes through unchanged. Browser-side proj4 has no vertical
324
- * datum transforms (no NTv2/gtx grids), so cross-CRS vertical mismatches are
325
- * left for the user to resolve via the per-model orthogonalHeight editor.
326
- *
327
- * Normals are NOT rotated. Cross-CRS rotations between projected systems in the
328
- * same locality are sub-degree, and recomputing per-vertex would require a
329
- * Jacobian per mesh — acceptable trade-off for now, document if it bites.
330
- */
331
- async function alignGeometryAcrossCrs(
332
- geometry: FederatedGeometryResult,
333
- source: ModelGeoref,
334
- reference: ModelGeoref,
335
- ): Promise<boolean> {
336
- const sourceProjDef = await resolveProjection(source.projectedCRS);
337
- const refProjDef = await resolveProjection(reference.projectedCRS);
338
- if (!sourceProjDef || !refProjDef) return false;
339
-
340
- const sourceMapUnitScale = getMapUnitScale(source);
341
- const refMapUnitScale = getMapUnitScale(reference);
342
- const sourceAxis = getAxis(source);
343
- const refAxis = getAxis(reference);
344
- const sourceOffset = totalYupOffset(source.coordinateInfo);
345
- const refOffset = totalYupOffset(reference.coordinateInfo);
346
-
347
- const refDenom = refAxis.scale * refAxis.denom;
348
- if (Math.abs(refDenom) < 1e-12) return false;
349
- const invRefDenom = 1 / refDenom;
350
-
351
- const sourceConv = source.mapConversion;
352
- const refConv = reference.mapConversion;
353
-
354
- const bounds = emptyBounds();
355
- let found = false;
356
- let projFailures = 0;
357
- let attempts = 0;
358
- let firstProjError: unknown = null;
359
-
360
- for (const mesh of geometry.meshes) {
361
- const positions = mesh.positions;
362
- for (let i = 0; i < positions.length; i += 3) {
363
- const vx = positions[i];
364
- const vy = positions[i + 1];
365
- const vz = positions[i + 2];
366
- if (!Number.isFinite(vx) || !Number.isFinite(vy) || !Number.isFinite(vz)) continue;
367
-
368
- // viewer(Y-up, source-local) → world(Y-up) → IFC(Z-up, source)
369
- const wx = vx + sourceOffset.x;
370
- const wy = vy + sourceOffset.y;
371
- const wz = vz + sourceOffset.z;
372
- const ifcXs = wx;
373
- const ifcYs = -wz;
374
- const ifcZs = wy;
375
-
376
- // IFC(source) → source projected (apply source MapConversion)
377
- const eS = sourceConv.eastings * sourceMapUnitScale
378
- + sourceAxis.scale * (sourceAxis.a * ifcXs - sourceAxis.o * ifcYs);
379
- const nS = sourceConv.northings * sourceMapUnitScale
380
- + sourceAxis.scale * (sourceAxis.o * ifcXs + sourceAxis.a * ifcYs);
381
- const hS = sourceConv.orthogonalHeight * sourceMapUnitScale + ifcZs;
382
-
383
- // source projected → reference projected via proj4
384
- attempts += 1;
385
- let eR: number;
386
- let nR: number;
387
- try {
388
- const projected = proj4(sourceProjDef, refProjDef, [eS, nS]);
389
- eR = projected[0];
390
- nR = projected[1];
391
- } catch (error) {
392
- projFailures += 1;
393
- if (firstProjError == null) firstProjError = error;
394
- continue;
395
- }
396
- if (!Number.isFinite(eR) || !Number.isFinite(nR)) {
397
- projFailures += 1;
398
- continue;
399
- }
400
- // Height transformed under identity (no vertical datum hop in browser).
401
- const hR = hS;
402
-
403
- // reference projected → IFC(reference): invert reference MapConversion
404
- const dE = eR - refConv.eastings * refMapUnitScale;
405
- const dN = nR - refConv.northings * refMapUnitScale;
406
- const ifcXr = invRefDenom * (refAxis.a * dE + refAxis.o * dN);
407
- const ifcYr = invRefDenom * (-refAxis.o * dE + refAxis.a * dN);
408
- const ifcZr = hR - refConv.orthogonalHeight * refMapUnitScale;
409
-
410
- // IFC(Z-up, reference) → world(Y-up) → viewer(Y-up, reference-local)
411
- const refWorldX = ifcXr;
412
- const refWorldY = ifcZr;
413
- const refWorldZ = -ifcYr;
414
- const alignedX = refWorldX - refOffset.x;
415
- const alignedY = refWorldY - refOffset.y;
416
- const alignedZ = refWorldZ - refOffset.z;
417
-
418
- positions[i] = alignedX;
419
- positions[i + 1] = alignedY;
420
- positions[i + 2] = alignedZ;
421
- found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
422
- }
423
- }
424
-
425
- if (!found) {
426
- console.warn(
427
- `[ifc-lite] Cross-CRS alignment failed: ${projFailures}/${attempts} `
428
- + `vertex transforms failed for ${source.projectedCRS.name} → ${reference.projectedCRS.name}; `
429
- + 'no vertices were successfully reprojected. Leaving geometry untouched.',
430
- firstProjError,
431
- );
432
- return false;
433
- }
434
-
435
- geometry.coordinateInfo = {
436
- originShift: reference.coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 },
437
- originalBounds: bounds,
438
- shiftedBounds: bounds,
439
- hasLargeCoordinates: reference.coordinateInfo?.hasLargeCoordinates ?? false,
440
- wasmRtcOffset: reference.coordinateInfo?.wasmRtcOffset,
441
- buildingRotation: reference.coordinateInfo?.buildingRotation,
442
- };
443
-
444
- if (projFailures > 0) {
445
- console.warn(
446
- `[ifc-lite] Cross-CRS alignment: ${projFailures}/${attempts} vertex transforms `
447
- + `failed from ${source.projectedCRS.name} to ${reference.projectedCRS.name}. `
448
- + 'Those vertices are left at their original positions.',
449
- firstProjError,
450
- );
451
- }
452
- return true;
453
- }
454
-
455
- export type FederationAlignmentStatus = 'same-crs' | 'reprojected' | 'identity' | 'failed';
456
-
457
- /**
458
- * Route alignment to the right strategy based on whether the source and
459
- * reference share a projected CRS. Returns a status describing how the model
460
- * was placed in the federation, suitable for surfacing in the UI.
461
- */
462
- async function alignGeometryToReference(
463
- geometry: FederatedGeometryResult,
464
- source: ModelGeoref,
465
- reference: ModelGeoref,
466
- ): Promise<FederationAlignmentStatus> {
467
- if (canAlignInSameProjectedCrs(source, reference)) {
468
- const transform = buildGeorefAlignmentTransform(source, reference);
469
- if (!transform) return 'failed';
470
- if (isIdentityTransform(transform)) return 'identity';
471
- applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo);
472
- return 'same-crs';
473
- }
474
- const ok = await alignGeometryAcrossCrs(geometry, source, reference);
475
- return ok ? 'reprojected' : 'failed';
476
- }
477
-
478
- /**
479
- * Select the federation anchor model.
480
- *
481
- * Resolution order:
482
- * 1. `anchorModelIdOverride` from the store, if it points to a loaded model
483
- * with a valid georeference.
484
- * 2. Earliest `loadedAt` model with a valid georeference (the default — gives
485
- * a stable anchor across loads while letting the user override when they
486
- * want a different model to drive the world frame).
487
- */
488
- function findReferenceGeorefModel(): { modelId: string; georef: ModelGeoref } | null {
489
- const state = useViewerStore.getState();
490
- const override = state.anchorModelIdOverride;
491
- if (override) {
492
- const model = state.models.get(override) as FederatedModel | undefined;
493
- if (model?.ifcDataStore && model.geometryResult) {
494
- const georef = extractModelGeoref(
495
- model.ifcDataStore,
496
- model.geometryResult.coordinateInfo,
497
- state.georefMutations.get(override),
498
- );
499
- if (georef) return { modelId: override, georef };
500
- }
501
- // Fall through if the override no longer resolves — keeps loads
502
- // recoverable even if the user removed the anchor they had pinned.
503
- }
504
-
505
- const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>;
506
- const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
507
- for (const [modelId, model] of sorted) {
508
- if (!model.ifcDataStore || !model.geometryResult) continue;
509
- const georef = extractModelGeoref(
510
- model.ifcDataStore,
511
- model.geometryResult.coordinateInfo,
512
- state.georefMutations.get(modelId),
513
- );
514
- if (georef) return { modelId, georef };
515
- }
516
- return null;
517
- }
518
33
 
519
34
  /**
520
35
  * Extended data store type for IFCX (IFC5) files.
@@ -537,7 +52,11 @@ export interface IfcxDataStore extends IfcDataStore {
537
52
  * Includes addModel, removeModel, federated IFCX loading, overlay management,
538
53
  * and ID resolution helpers
539
54
  */
540
- export function useIfcFederation() {
55
+ export function useIfcFederation(
56
+ // The ONE canonical loader. Federated adds route through it (target
57
+ // 'federated') so model #1 and model #N share an identical pipeline.
58
+ loadFile: (file: File, target?: import('./useIfcLoader.js').LoadTarget) => Promise<void>,
59
+ ) {
541
60
  const {
542
61
  setLoading,
543
62
  setError,
@@ -583,7 +102,7 @@ export function useIfcFederation() {
583
102
  * Returns the model ID on success, null on failure
584
103
  */
585
104
  const addModel = useCallback(async (
586
- file: File | NativeFileHandle,
105
+ file: File,
587
106
  options?: {
588
107
  name?: string;
589
108
  modelId?: string;
@@ -605,336 +124,48 @@ export function useIfcFederation() {
605
124
  const fileSizeForGateMB = (typeof (file as File).size === 'number' ? (file as File).size : 0) / (1024 * 1024);
606
125
  const gateSlot = await acquireFederationLoadSlot(fileSizeForGateMB);
607
126
  try {
608
- // IMPORTANT: Before adding a new model, check if there's a legacy model
609
- // (loaded via loadFile) that's not in the Map yet. If so, migrate it first.
610
- const currentModels = useViewerStore.getState().models;
611
- const currentIfcDataStore = useViewerStore.getState().ifcDataStore;
612
- const currentGeometryResult = useViewerStore.getState().geometryResult;
613
-
614
- if (currentModels.size === 0 && currentIfcDataStore && currentGeometryResult) {
615
- // Migrate the legacy model to the Map
616
- // Legacy model has offset 0 (IDs are unchanged)
617
- const legacyModelId = crypto.randomUUID();
618
- const legacyName = currentIfcDataStore.spatialHierarchy?.project?.name || 'Model 1';
619
-
620
- // Find max expressId in legacy model for registry
621
- // IMPORTANT: Include ALL entities, not just meshes, for proper globalId resolution
622
- const legacyMeshes = currentGeometryResult.meshes || [];
623
- const legacyMaxExpressIdFromMeshes = legacyMeshes.reduce((max: number, m: MeshData) => Math.max(max, m.expressId), 0);
624
- // FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
625
- let legacyMaxExpressIdFromEntities = 0;
626
- if (currentIfcDataStore.entityIndex?.byId) {
627
- for (const key of currentIfcDataStore.entityIndex.byId.keys()) {
628
- if (key > legacyMaxExpressIdFromEntities) legacyMaxExpressIdFromEntities = key;
629
- }
630
- }
631
- const legacyMaxExpressId = Math.max(legacyMaxExpressIdFromMeshes, legacyMaxExpressIdFromEntities);
632
-
633
- // Register legacy model with offset 0 (IDs already in use as-is)
634
- const legacyOffset = registerModelOffset(legacyModelId, legacyMaxExpressId);
635
-
636
- const legacyModel: FederatedModel = {
637
- id: legacyModelId,
638
- name: legacyName,
639
- ifcDataStore: currentIfcDataStore,
640
- geometryResult: currentGeometryResult,
641
- visible: true,
642
- collapsed: false,
643
- schemaVersion: 'IFC4',
644
- loadedAt: Date.now() - 1000,
645
- fileSize: 0,
646
- sourceFile: undefined,
647
- idOffset: legacyOffset,
648
- maxExpressId: legacyMaxExpressId,
649
- };
650
- storeAddModel(legacyModel);
651
- }
652
-
127
+ // (Removed the legacy→Map migration: every model including model #1
128
+ // now registers in the FederationRegistry + models Map via loadFile's
129
+ // upsertModel/finalizeModel, so a top-level-only "legacy" model can no
130
+ // longer exist. See PR description for the audit.)
653
131
  setLoading(true);
654
132
  setError(null);
655
133
  setProgress({ phase: 'Loading file', percent: 0 });
656
134
 
657
- // Read file from disk. The browser path streams files above
658
- // `STREAM_SAB_THRESHOLD` directly into a SharedArrayBuffer, eliminating
659
- // the doubled peak (ArrayBuffer + SAB) of `await file.arrayBuffer()`
660
- // when the geometry pipeline copies into its own SAB. The native path
661
- // still reads via Tauri's Rust IPC because it bounds memory differently.
662
- // (#600)
663
- let buffer: ArrayBuffer;
664
- if (isNativeFileHandle(file)) {
665
- buffer = toExactArrayBuffer(await readNativeFile(file.path));
666
- } else {
667
- // The cast preserves the previous ArrayBuffer-shaped contract for
668
- // every downstream consumer. When the underlying store is a SAB,
669
- // downstream code only ever reads bytes via `new Uint8Array(buffer)`
670
- // / `new DataView(buffer)`, both of which work on either backing
671
- // store. The cast is purely type-system; runtime is identical.
672
- const acquired = await acquireFileBuffer(file as File);
673
- buffer = acquired.buffer as ArrayBuffer;
674
- }
675
- const fileSizeMB = buffer.byteLength / (1024 * 1024);
676
-
677
- // Detect point cloud formats first — we never run them through
678
- // detectFormat() (which is IFC-shaped) because they have their own
679
- // streaming pipeline that bypasses geometryResult.meshes.
680
- const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
681
-
682
- // Detect file format
683
- const format: ReturnType<typeof detectFormat> | PointCloudFormat =
684
- pointCloudFormat ?? detectFormat(buffer);
685
-
686
- let parsedDataStore: IfcDataStore | null = null;
687
- let parsedGeometry: FederatedModel['geometryResult'] = null;
688
- let schemaVersion: SchemaVersion = 'IFC4';
689
- // Renderer handle for streamed point clouds; surviving model lifecycle
690
- // depends on persisting it onto the FederatedModel record.
691
- let pointCloudHandleId: number | undefined;
692
-
693
- if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57' || format === 'pts' || format === 'xyz') {
694
- const renderer = getGlobalRenderer();
695
- if (!renderer) {
696
- setError('Renderer not initialised — try again after the viewer mounts.');
697
- setLoading(false);
698
- return null;
699
- }
700
- setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
701
- const blob = isNativeFileHandle(file)
702
- ? new Blob([buffer])
703
- : (file as File);
704
- const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
705
- const ingest = ingestPointCloud({
706
- format,
707
- blob,
708
- fileName: file.name,
709
- buffer,
710
- renderer,
711
- onProgress: setProgress,
712
- onAssetCountDelta: incCount,
713
- });
714
- // Expose cancellation while the stream is in-flight. Capture
715
- // the canceller as a named ref so the cleanup can verify the
716
- // store still points at us before clearing — a second
717
- // addModel() that began before this one settles must not lose
718
- // its Cancel button to our finally block.
719
- const { setActiveStreamCanceller } = useViewerStore.getState();
720
- const cancelStream = () => ingest.streamHandle.cancel();
721
- setActiveStreamCanceller(cancelStream);
722
- // ingest.done rejects on stream errors; ingestPointCloud's onError
723
- // callback already calls removePointCloudAsset + incCount(-1), so
724
- // the outer catch must NOT repeat that cleanup or the count goes
725
- // negative when other point clouds are still loaded.
726
- try {
727
- await ingest.done;
728
- } finally {
729
- if (useViewerStore.getState().activeStreamCanceller === cancelStream) {
730
- setActiveStreamCanceller(null);
731
- }
732
- }
733
- parsedDataStore = ingest.dataStore;
734
- parsedGeometry = ingest.geometryResult;
735
- schemaVersion = ingest.schemaVersion;
736
- pointCloudHandleId = ingest.rendererHandle.id;
737
- } else if (format === 'ifcx') {
738
- setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
739
- try {
740
- const result = await parseIfcxViewerModel(buffer, setProgress);
741
- parsedDataStore = result.dataStore;
742
- parsedGeometry = result.geometryResult;
743
- schemaVersion = result.schemaVersion;
744
- } catch (error) {
745
- if (error instanceof Error && error.message === 'overlay-only-ifcx') {
746
- console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this is an overlay file.`);
747
- setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once for federated loading).`);
748
- setLoading(false);
749
- return null;
750
- }
751
- throw error;
752
- }
753
- } else if (format === 'glb') {
754
- setProgress({ phase: 'Parsing GLB', percent: 10 });
755
- const result = await parseGlbViewerModel(buffer);
756
- parsedDataStore = result.dataStore;
757
- parsedGeometry = result.geometryResult;
758
- schemaVersion = result.schemaVersion;
759
- } else {
760
- setProgress({ phase: 'Starting geometry streaming', percent: 10 });
761
-
762
- // For federated models: use the first model's RTC offset so all models
763
- // share the same coordinate origin. This ensures pixel-perfect alignment
764
- // without error-prone delta adjustments.
765
- let sharedRtcOffset: { x: number; y: number; z: number } | undefined;
766
- const existingModelsForRtc = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
767
- if (existingModelsForRtc.length > 0) {
768
- const sorted = [...existingModelsForRtc].sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
769
- sharedRtcOffset = sorted.find(
770
- (model) => model.geometryResult?.coordinateInfo?.wasmRtcOffset != null,
771
- )?.geometryResult?.coordinateInfo?.wasmRtcOffset;
772
- }
773
-
774
- const result = await parseStepBufferViewerModel({
775
- fileName: file.name,
776
- buffer,
777
- fileSizeMB,
778
- getDynamicBatchSize: getDynamicBatchConfig,
779
- onProgress: setProgress,
780
- sharedRtcOffset,
781
- });
782
- parsedDataStore = result.dataStore;
783
- parsedGeometry = result.geometryResult;
784
- schemaVersion = result.schemaVersion;
785
- }
786
-
787
- if (!parsedDataStore || !parsedGeometry) {
788
- throw new Error('Failed to parse file');
135
+ // Pick the shared RTC origin from the earliest existing model so every
136
+ // federated model lands in one coordinate space (pixel-perfect alignment,
137
+ // no post-shift). Threaded into the canonical loader below.
138
+ let sharedRtcOffset: { x: number; y: number; z: number } | undefined;
139
+ const existingModelsForRtc = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
140
+ if (existingModelsForRtc.length > 0) {
141
+ const sorted = [...existingModelsForRtc].sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
142
+ sharedRtcOffset = sorted.find(
143
+ (model) => model.geometryResult?.coordinateInfo?.wasmRtcOffset != null,
144
+ )?.geometryResult?.coordinateInfo?.wasmRtcOffset;
789
145
  }
790
146
 
791
- const referenceSelection = findReferenceGeorefModel();
792
- const referenceGeoref = referenceSelection?.georef ?? null;
793
- // Include any georef edits the user has already saved for this model so
794
- // that a reload after editing reflects the new placement. Without this,
795
- // extractModelGeoref reads only the raw parsed metadata and mutations
796
- // are silently ignored.
797
- const parsedGeorefMutations = useViewerStore.getState().georefMutations.get(modelId);
798
- const parsedGeoref = extractModelGeoref(
799
- parsedDataStore,
800
- parsedGeometry.coordinateInfo,
801
- parsedGeorefMutations,
802
- );
803
- // Cache of pre-alignment vertex positions/normals for realignFederation().
804
- // Only populated when alignment actually runs, so single-model loads pay
805
- // no memory cost. See FederatedModel.preAlignmentPositions for rationale.
806
- let preAlignmentPositions: Float32Array[] | undefined;
807
- let preAlignmentNormals: (Float32Array | undefined)[] | undefined;
808
- let preAlignmentCoordinateInfo: CoordinateInfo | undefined;
809
- let federationAlignmentStatus: FederatedModel['federationAlignmentStatus'] = 'none';
810
-
811
- if (referenceGeoref && parsedGeoref) {
812
- // referenceSelection.modelId !== modelId always holds — the anchor was
813
- // already in the store before this addModel call.
814
- setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
815
- preAlignmentPositions = parsedGeometry.meshes.map((mesh) => new Float32Array(mesh.positions));
816
- preAlignmentNormals = parsedGeometry.meshes.map((mesh) =>
817
- mesh.normals && mesh.normals.length > 0 ? new Float32Array(mesh.normals) : undefined,
818
- );
819
- preAlignmentCoordinateInfo = parsedGeometry.coordinateInfo;
820
- const status = await alignGeometryToReference(parsedGeometry, parsedGeoref, referenceGeoref);
821
- federationAlignmentStatus = status;
822
- if (status === 'reprojected') {
823
- toast.info(
824
- `Reprojected "${file.name}" from ${parsedGeoref.projectedCRS.name} `
825
- + `to ${referenceGeoref.projectedCRS.name} for federation alignment.`,
826
- );
827
- } else if (status === 'failed') {
828
- toast.error(
829
- `Could not align "${file.name}" with the federation anchor — `
830
- + `${parsedGeoref.projectedCRS.name} → ${referenceGeoref.projectedCRS.name} `
831
- + 'reprojection failed. The model is shown in its own local frame and may '
832
- + 'appear at the wrong real-world position.',
833
- );
834
- }
835
- } else if (parsedGeoref) {
836
- // This load is itself the federation anchor (first georeferenced model
837
- // in the federation, or the only one). Surface that to the UI.
838
- federationAlignmentStatus = 'anchor';
839
- }
840
-
841
- // =========================================================================
842
- // FEDERATION REGISTRY: Transform expressIds to globally unique IDs
843
- // This is the BULLETPROOF fix for multi-model ID collisions
844
- // =========================================================================
845
-
846
- // Step 1: Find max expressId in this model
847
- // IMPORTANT: Use ALL entities from data store, not just meshes
848
- // Spatial containers (IfcProject, IfcSite, etc.) don't have geometry but need valid globalId resolution
849
- const maxExpressId = getMaxExpressId(parsedDataStore, parsedGeometry.meshes);
850
-
851
- // Step 2: Register with federation registry to get unique offset
852
- const idOffset = registerModelOffset(modelId, maxExpressId);
147
+ // THE canonical load path. loadFile acquires bytes, detects format
148
+ // (IFC / IFCX / GLB / point cloud), produces geometry through the single
149
+ // GeometryProcessor pipeline, parses the data store, and because the
150
+ // target is federated finalizeModel aligns to the anchor, offsets ids,
151
+ // builds the spatial index, and registers the model via addModel. loadFile
152
+ // awaits that finalize, so on return the model is already in the map.
153
+ await loadFile(file, {
154
+ kind: 'federated',
155
+ modelId,
156
+ name: options?.name,
157
+ visible: options?.visible,
158
+ collapsed: options?.collapsed,
159
+ loadedAt: options?.loadedAt,
160
+ sharedRtcOffset,
161
+ });
853
162
 
854
- // Step 3: Transform ALL mesh expressIds to globalIds
855
- // globalId = originalExpressId + offset
856
- // This ensures no two models can have the same ID
857
- if (idOffset > 0) {
858
- for (const mesh of parsedGeometry.meshes) {
859
- mesh.expressId = mesh.expressId + idOffset;
860
- }
861
- // Point clouds need the same offset so picking / isolation /
862
- // property lookup resolve through the FederationRegistry's
863
- // global ID space — otherwise two pointcloud models with the
864
- // same local expressId collide.
865
- for (const asset of parsedGeometry.pointClouds ?? []) {
866
- asset.expressId = asset.expressId + idOffset;
867
- }
163
+ if (loadSessionRef.current !== currentSession) return null;
164
+ const registered = useViewerStore.getState().models.has(modelId);
165
+ if (registered) {
166
+ console.log(`[ifc-lite] Added model ${file.name} (${fileSizeForGateMB.toFixed(1)}MB) in ${(performance.now() - addStart).toFixed(0)}ms`);
868
167
  }
869
- // Streamed point cloud: the GPU asset was opened with a synthetic
870
- // local expressId. After registerModelOffset() hands us an
871
- // idOffset, the renderer needs to emit the post-offset globalId
872
- // in picking + selection outputs — otherwise picks resolve to
873
- // the local id and collide across federated models. The shader
874
- // reads expressId from a per-asset uniform (`flags.x`) so this
875
- // is just a metadata update; no GPU buffer rewrite.
876
- if (idOffset > 0 && pointCloudHandleId !== undefined) {
877
- const renderer = getGlobalRenderer();
878
- if (renderer && parsedGeometry.pointClouds && parsedGeometry.pointClouds.length > 0) {
879
- // Use the asset that's already had idOffset folded in above
880
- // as the source of truth for the global id.
881
- const asset = parsedGeometry.pointClouds[0];
882
- renderer.relabelPointCloudAsset({ id: pointCloudHandleId }, asset.expressId);
883
- }
884
- }
885
-
886
- // =========================================================================
887
- // COORDINATE ALIGNMENT: All federated models use the same shared RTC offset
888
- // (passed to WASM during parsing above), so no post-processing vertex
889
- // adjustment is needed. All models are already in the same coordinate space.
890
- // =========================================================================
891
-
892
- // Build spatial index AFTER ID offset + RTC alignment so it stores
893
- // correct globalIds and final world-space positions.
894
- buildSpatialIndexGuarded(parsedGeometry.meshes, parsedDataStore, setIfcDataStore);
895
-
896
- // Create the federated model with offset info
897
- const federatedModel: FederatedModel = {
898
- id: modelId,
899
- name: options?.name ?? file.name,
900
- ifcDataStore: parsedDataStore,
901
- geometryResult: parsedGeometry,
902
- visible: options?.visible ?? true,
903
- collapsed: options?.collapsed ?? hasModels(), // Collapse if not first model
904
- schemaVersion,
905
- loadedAt: options?.loadedAt ?? Date.now(),
906
- fileSize: buffer.byteLength,
907
- sourceFile: file,
908
- idOffset,
909
- maxExpressId,
910
- pointCloudHandleId,
911
- preAlignmentPositions,
912
- preAlignmentNormals,
913
- preAlignmentCoordinateInfo,
914
- federationAlignmentStatus,
915
- };
916
-
917
- // Add to store
918
- storeAddModel(federatedModel);
919
-
920
- // Don't touch the legacy top-level setters for added models. When this
921
- // is the first model, modelSlice.addModel already mirrored it into the
922
- // top-level fields. When subsequent models are added, activeModelId
923
- // stays on the first model — writing here would alias the new model's
924
- // data into the active (first) model's per-model entry and cause both
925
- // viewport slots to render the same mesh (issue #661, PR #792).
926
- //
927
- // An earlier draft of this branch called `setActiveModel(modelId)`
928
- // here, which also fixed #661 but had the side-effect of stealing
929
- // focus to every added model — confusing UX. The main-branch fix
930
- // (drop the legacy calls; keep activeModelId on the first model)
931
- // is preferred and was kept on merge.
932
-
933
- setProgress({ phase: 'Complete', percent: 100 });
934
- setLoading(false);
935
- console.log(`[ifc-lite] Added model ${file.name} (${fileSizeMB.toFixed(1)}MB) in ${(performance.now() - addStart).toFixed(0)}ms`);
936
-
937
- return modelId;
168
+ return registered ? modelId : null;
938
169
 
939
170
  } catch (err) {
940
171
  // Only mutate shared loading/error/progress state if our session
@@ -963,7 +194,7 @@ export function useIfcFederation() {
963
194
  } finally {
964
195
  releaseFederationLoadSlot(gateSlot);
965
196
  }
966
- }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, storeAddModel, hasModels, registerModelOffset]);
197
+ }, [loadFile, setLoading, setError, setProgress]);
967
198
 
968
199
  /**
969
200
  * Re-apply federation alignment using the currently selected anchor