@ifc-lite/viewer 1.26.0 → 1.28.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 (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -0,0 +1,481 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Georeferencing / federation alignment helpers.
7
+ *
8
+ * Extracted verbatim from useIfcFederation.ts so the unified model-load path
9
+ * (useIfcLoader's finalizeModel) can reuse them without a circular dependency.
10
+ * Behaviour-preserving move — do not change the georef maths or the issue-#595 /
11
+ * issue-#658 comments, which encode subtle alignment behaviour.
12
+ */
13
+
14
+ import {
15
+ type IfcDataStore,
16
+ type MapConversion,
17
+ type ProjectedCRS,
18
+ } from '@ifc-lite/parser';
19
+ import type { CoordinateInfo } from '@ifc-lite/geometry';
20
+ import { useViewerStore, type FederatedModel } from '../../store.js';
21
+ import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../../lib/geo/effective-georef.js';
22
+ import { resolveMapUnitToMetreScale } from '../../lib/geo/geo-scale.js';
23
+ import { resolveProjection } from '../../lib/geo/reproject.js';
24
+ import proj4 from 'proj4';
25
+
26
+ type FederatedGeometryResult = NonNullable<FederatedModel['geometryResult']>;
27
+
28
+ export interface ModelGeoref {
29
+ mapConversion: MapConversion;
30
+ projectedCRS: ProjectedCRS;
31
+ lengthUnitScale: number;
32
+ coordinateInfo?: CoordinateInfo;
33
+ }
34
+
35
+ interface AffineTransform3D {
36
+ m00: number;
37
+ m01: number;
38
+ m02: number;
39
+ tx: number;
40
+ m10: number;
41
+ m11: number;
42
+ m12: number;
43
+ ty: number;
44
+ m20: number;
45
+ m21: number;
46
+ m22: number;
47
+ tz: number;
48
+ }
49
+
50
+ function getMapUnitScale(georef: ModelGeoref): number {
51
+ return resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1);
52
+ }
53
+
54
+ function getAxis(georef: ModelGeoref): { a: number; o: number; scale: number; denom: number } {
55
+ const conversion = georef.mapConversion;
56
+ const a = conversion.xAxisAbscissa ?? 1;
57
+ const o = conversion.xAxisOrdinate ?? 0;
58
+ // Use the effective horizontal scale: viewer geometry is already in metres,
59
+ // so applying IfcMapConversion.Scale raw would double-scale — see issue #595.
60
+ const mapUnitScale = resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1);
61
+ const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, georef.lengthUnitScale ?? 1);
62
+ const denom = Math.max(a * a + o * o, 1e-12);
63
+ return { a, o, scale, denom };
64
+ }
65
+
66
+ export function extractModelGeoref(
67
+ dataStore: IfcDataStore,
68
+ coordinateInfo?: CoordinateInfo,
69
+ mutations?: GeorefMutationDataLike,
70
+ ): ModelGeoref | null {
71
+ const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
72
+ // Only TRUE georeferencing (real IfcMapConversion + IfcProjectedCRS) may drive
73
+ // federation alignment. A file with no IfcMapConversion gets a synthesised
74
+ // `source: 'siteLocation'` georef (EPSG:4326 from IfcSite RefLatitude/Longitude/
75
+ // Elevation) so it can still be pinned on the location map — but those are
76
+ // geographic degrees plus a raw, un-unit-scaled site elevation, not a projected
77
+ // metric frame. buildGeorefAlignmentTransform assumes projected eastings/
78
+ // northings/height in metres, so feeding it site data places the second model
79
+ // kilometres away: the BIMcollab ARC/STR pair share a site GUID but carry
80
+ // RefElevation 0 vs 20000 mm, and the height term lands ARC ~20 km below STR.
81
+ // Such models have no real georef relationship, so leave them in their own local
82
+ // frames where they overlay correctly. hasStandardGeoreferencing() excludes
83
+ // 'siteLocation' (see effective-georef.test.ts). (Regression from #658.)
84
+ if (!hasStandardGeoreferencing(georef) || !georef?.mapConversion || !georef.projectedCRS?.name) {
85
+ return null;
86
+ }
87
+ return {
88
+ mapConversion: georef.mapConversion,
89
+ projectedCRS: georef.projectedCRS,
90
+ lengthUnitScale: georef.lengthUnitScale,
91
+ coordinateInfo,
92
+ };
93
+ }
94
+
95
+ function crsKey(crs: ProjectedCRS): string {
96
+ return `${crs.name ?? ''}|${crs.geodeticDatum ?? ''}|${crs.mapProjection ?? ''}|${crs.mapZone ?? ''}`.toUpperCase();
97
+ }
98
+
99
+ function canAlignInSameProjectedCrs(a: ModelGeoref, b: ModelGeoref): boolean {
100
+ return crsKey(a.projectedCRS) === crsKey(b.projectedCRS);
101
+ }
102
+
103
+ function totalYupOffset(coordinateInfo?: CoordinateInfo): { x: number; y: number; z: number } {
104
+ const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
105
+ const rtc = coordinateInfo?.wasmRtcOffset;
106
+ const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 };
107
+ return {
108
+ x: shift.x + rtcYup.x,
109
+ y: shift.y + rtcYup.y,
110
+ z: shift.z + rtcYup.z,
111
+ };
112
+ }
113
+
114
+ function emptyBounds() {
115
+ return {
116
+ min: { x: Infinity, y: Infinity, z: Infinity },
117
+ max: { x: -Infinity, y: -Infinity, z: -Infinity },
118
+ };
119
+ }
120
+
121
+ function zeroBounds() {
122
+ return {
123
+ min: { x: 0, y: 0, z: 0 },
124
+ max: { x: 0, y: 0, z: 0 },
125
+ };
126
+ }
127
+
128
+ function updateBounds(bounds: ReturnType<typeof emptyBounds>, x: number, y: number, z: number): boolean {
129
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
130
+ bounds.min.x = Math.min(bounds.min.x, x);
131
+ bounds.min.y = Math.min(bounds.min.y, y);
132
+ bounds.min.z = Math.min(bounds.min.z, z);
133
+ bounds.max.x = Math.max(bounds.max.x, x);
134
+ bounds.max.y = Math.max(bounds.max.y, y);
135
+ bounds.max.z = Math.max(bounds.max.z, z);
136
+ return true;
137
+ }
138
+
139
+ function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null {
140
+ const sourceConv = source.mapConversion;
141
+ const refConv = reference.mapConversion;
142
+ const sourceAxis = getAxis(source);
143
+ const refAxis = getAxis(reference);
144
+ const refDenom = refAxis.scale * refAxis.denom;
145
+ if (Math.abs(refDenom) < 1e-12) return null;
146
+
147
+ const sourceMapUnitScale = getMapUnitScale(source);
148
+ const refMapUnitScale = getMapUnitScale(reference);
149
+ const sourceOffset = totalYupOffset(source.coordinateInfo);
150
+ const refOffset = totalYupOffset(reference.coordinateInfo);
151
+
152
+ const eVx = sourceAxis.scale * sourceAxis.a;
153
+ const eVz = sourceAxis.scale * sourceAxis.o;
154
+ const eC = sourceConv.eastings * sourceMapUnitScale
155
+ + sourceAxis.scale * (sourceAxis.a * sourceOffset.x + sourceAxis.o * sourceOffset.z)
156
+ - refConv.eastings * refMapUnitScale;
157
+
158
+ const nVx = sourceAxis.scale * sourceAxis.o;
159
+ const nVz = -sourceAxis.scale * sourceAxis.a;
160
+ const nC = sourceConv.northings * sourceMapUnitScale
161
+ + sourceAxis.scale * (sourceAxis.o * sourceOffset.x - sourceAxis.a * sourceOffset.z)
162
+ - refConv.northings * refMapUnitScale;
163
+
164
+ const hC = sourceConv.orthogonalHeight * sourceMapUnitScale
165
+ + sourceOffset.y
166
+ - refConv.orthogonalHeight * refMapUnitScale;
167
+
168
+ const invRefDenom = 1 / refDenom;
169
+ const xVx = (refAxis.a * eVx + refAxis.o * nVx) * invRefDenom;
170
+ const xVz = (refAxis.a * eVz + refAxis.o * nVz) * invRefDenom;
171
+ const xC = (refAxis.a * eC + refAxis.o * nC) * invRefDenom - refOffset.x;
172
+
173
+ const yVx = (-refAxis.o * eVx + refAxis.a * nVx) * invRefDenom;
174
+ const yVz = (-refAxis.o * eVz + refAxis.a * nVz) * invRefDenom;
175
+ const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom;
176
+
177
+ return {
178
+ m00: xVx,
179
+ m01: 0,
180
+ m02: xVz,
181
+ tx: xC,
182
+ m10: 0,
183
+ m11: 1,
184
+ m12: 0,
185
+ ty: hC - refOffset.y,
186
+ m20: -yVx,
187
+ m21: 0,
188
+ m22: -yVz,
189
+ tz: -yC - refOffset.z,
190
+ };
191
+ }
192
+
193
+ function isIdentityTransform(transform: AffineTransform3D): boolean {
194
+ const eps = 1e-7;
195
+ return Math.abs(transform.m00 - 1) < eps
196
+ && Math.abs(transform.m01) < eps
197
+ && Math.abs(transform.m02) < eps
198
+ && Math.abs(transform.tx) < eps
199
+ && Math.abs(transform.m10) < eps
200
+ && Math.abs(transform.m11 - 1) < eps
201
+ && Math.abs(transform.m12) < eps
202
+ && Math.abs(transform.ty) < eps
203
+ && Math.abs(transform.m20) < eps
204
+ && Math.abs(transform.m21) < eps
205
+ && Math.abs(transform.m22 - 1) < eps
206
+ && Math.abs(transform.tz) < eps;
207
+ }
208
+
209
+ function applyAlignmentTransformAndUpdateBounds(
210
+ geometry: FederatedGeometryResult,
211
+ transform: AffineTransform3D,
212
+ referenceInfo?: CoordinateInfo,
213
+ ): void {
214
+ const bounds = emptyBounds();
215
+ let found = false;
216
+
217
+ for (const mesh of geometry.meshes) {
218
+ const positions = mesh.positions;
219
+ for (let i = 0; i < positions.length; i += 3) {
220
+ const x = positions[i];
221
+ const y = positions[i + 1];
222
+ const z = positions[i + 2];
223
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
224
+ continue;
225
+ }
226
+
227
+ const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx;
228
+ const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty;
229
+ const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz;
230
+ positions[i] = alignedX;
231
+ positions[i + 1] = alignedY;
232
+ positions[i + 2] = alignedZ;
233
+ found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
234
+ }
235
+
236
+ // Rotate normals by the transform's 3×3 linear part (translation omitted)
237
+ // and renormalize. CRS alignment is a rigid rotation, so the linear part
238
+ // itself is the correct transform for normals; degenerate results from
239
+ // zero-length or non-finite inputs are left in place.
240
+ const normals = mesh.normals;
241
+ if (normals && normals.length >= 3) {
242
+ for (let i = 0; i < normals.length; i += 3) {
243
+ const nx = normals[i];
244
+ const ny = normals[i + 1];
245
+ const nz = normals[i + 2];
246
+ if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) {
247
+ continue;
248
+ }
249
+ const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz;
250
+ const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz;
251
+ const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz;
252
+ const len = Math.sqrt(rx * rx + ry * ry + rz * rz);
253
+ if (!Number.isFinite(len) || len < 1e-12) {
254
+ continue;
255
+ }
256
+ normals[i] = rx / len;
257
+ normals[i + 1] = ry / len;
258
+ normals[i + 2] = rz / len;
259
+ }
260
+ }
261
+ }
262
+
263
+ geometry.coordinateInfo = {
264
+ originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 },
265
+ originalBounds: found ? bounds : zeroBounds(),
266
+ shiftedBounds: found ? bounds : zeroBounds(),
267
+ hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false,
268
+ wasmRtcOffset: referenceInfo?.wasmRtcOffset,
269
+ buildingRotation: referenceInfo?.buildingRotation,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Reproject every vertex from a source model's georeference into the reference
275
+ * model's viewer-space frame using proj4 between the two projected CRSs.
276
+ *
277
+ * Used for federated loads where models declare different IfcProjectedCRSs
278
+ * (e.g. EPSG:28992 + EPSG:7415 mixed RD/NAP Dutch sets, or EPSG:25831 UTM +
279
+ * EPSG:28992 mixed). The pipeline per vertex:
280
+ *
281
+ * viewer(Yup) ──(source RTC/shift, axis swap)──▶ IFC(Zup, source)
282
+ * IFC(source) ──(source MapConversion)──────────▶ source projected (eS,nS,hS)
283
+ * projected ──(proj4: srcDef → refDef)────────▶ reference projected (eR,nR)
284
+ * projected ──(reference MapConversion inverse)▶ IFC(Zup, reference)
285
+ * IFC(ref) ──(axis swap, reference RTC/shift)─▶ viewer(Yup, reference frame)
286
+ *
287
+ * Vertical: height passes through unchanged. Browser-side proj4 has no vertical
288
+ * datum transforms (no NTv2/gtx grids), so cross-CRS vertical mismatches are
289
+ * left for the user to resolve via the per-model orthogonalHeight editor.
290
+ *
291
+ * Normals are NOT rotated. Cross-CRS rotations between projected systems in the
292
+ * same locality are sub-degree, and recomputing per-vertex would require a
293
+ * Jacobian per mesh — acceptable trade-off for now, document if it bites.
294
+ */
295
+ async function alignGeometryAcrossCrs(
296
+ geometry: FederatedGeometryResult,
297
+ source: ModelGeoref,
298
+ reference: ModelGeoref,
299
+ ): Promise<boolean> {
300
+ const sourceProjDef = await resolveProjection(source.projectedCRS);
301
+ const refProjDef = await resolveProjection(reference.projectedCRS);
302
+ if (!sourceProjDef || !refProjDef) return false;
303
+
304
+ const sourceMapUnitScale = getMapUnitScale(source);
305
+ const refMapUnitScale = getMapUnitScale(reference);
306
+ const sourceAxis = getAxis(source);
307
+ const refAxis = getAxis(reference);
308
+ const sourceOffset = totalYupOffset(source.coordinateInfo);
309
+ const refOffset = totalYupOffset(reference.coordinateInfo);
310
+
311
+ const refDenom = refAxis.scale * refAxis.denom;
312
+ if (Math.abs(refDenom) < 1e-12) return false;
313
+ const invRefDenom = 1 / refDenom;
314
+
315
+ const sourceConv = source.mapConversion;
316
+ const refConv = reference.mapConversion;
317
+
318
+ const bounds = emptyBounds();
319
+ let found = false;
320
+ let projFailures = 0;
321
+ let attempts = 0;
322
+ let firstProjError: unknown = null;
323
+
324
+ for (const mesh of geometry.meshes) {
325
+ const positions = mesh.positions;
326
+ for (let i = 0; i < positions.length; i += 3) {
327
+ const vx = positions[i];
328
+ const vy = positions[i + 1];
329
+ const vz = positions[i + 2];
330
+ if (!Number.isFinite(vx) || !Number.isFinite(vy) || !Number.isFinite(vz)) continue;
331
+
332
+ // viewer(Y-up, source-local) → world(Y-up) → IFC(Z-up, source)
333
+ const wx = vx + sourceOffset.x;
334
+ const wy = vy + sourceOffset.y;
335
+ const wz = vz + sourceOffset.z;
336
+ const ifcXs = wx;
337
+ const ifcYs = -wz;
338
+ const ifcZs = wy;
339
+
340
+ // IFC(source) → source projected (apply source MapConversion)
341
+ const eS = sourceConv.eastings * sourceMapUnitScale
342
+ + sourceAxis.scale * (sourceAxis.a * ifcXs - sourceAxis.o * ifcYs);
343
+ const nS = sourceConv.northings * sourceMapUnitScale
344
+ + sourceAxis.scale * (sourceAxis.o * ifcXs + sourceAxis.a * ifcYs);
345
+ const hS = sourceConv.orthogonalHeight * sourceMapUnitScale + ifcZs;
346
+
347
+ // source projected → reference projected via proj4
348
+ attempts += 1;
349
+ let eR: number;
350
+ let nR: number;
351
+ try {
352
+ const projected = proj4(sourceProjDef, refProjDef, [eS, nS]);
353
+ eR = projected[0];
354
+ nR = projected[1];
355
+ } catch (error) {
356
+ projFailures += 1;
357
+ if (firstProjError == null) firstProjError = error;
358
+ continue;
359
+ }
360
+ if (!Number.isFinite(eR) || !Number.isFinite(nR)) {
361
+ projFailures += 1;
362
+ continue;
363
+ }
364
+ // Height transformed under identity (no vertical datum hop in browser).
365
+ const hR = hS;
366
+
367
+ // reference projected → IFC(reference): invert reference MapConversion
368
+ const dE = eR - refConv.eastings * refMapUnitScale;
369
+ const dN = nR - refConv.northings * refMapUnitScale;
370
+ const ifcXr = invRefDenom * (refAxis.a * dE + refAxis.o * dN);
371
+ const ifcYr = invRefDenom * (-refAxis.o * dE + refAxis.a * dN);
372
+ const ifcZr = hR - refConv.orthogonalHeight * refMapUnitScale;
373
+
374
+ // IFC(Z-up, reference) → world(Y-up) → viewer(Y-up, reference-local)
375
+ const refWorldX = ifcXr;
376
+ const refWorldY = ifcZr;
377
+ const refWorldZ = -ifcYr;
378
+ const alignedX = refWorldX - refOffset.x;
379
+ const alignedY = refWorldY - refOffset.y;
380
+ const alignedZ = refWorldZ - refOffset.z;
381
+
382
+ positions[i] = alignedX;
383
+ positions[i + 1] = alignedY;
384
+ positions[i + 2] = alignedZ;
385
+ found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
386
+ }
387
+ }
388
+
389
+ if (!found) {
390
+ console.warn(
391
+ `[ifc-lite] Cross-CRS alignment failed: ${projFailures}/${attempts} `
392
+ + `vertex transforms failed for ${source.projectedCRS.name} → ${reference.projectedCRS.name}; `
393
+ + 'no vertices were successfully reprojected. Leaving geometry untouched.',
394
+ firstProjError,
395
+ );
396
+ return false;
397
+ }
398
+
399
+ geometry.coordinateInfo = {
400
+ originShift: reference.coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 },
401
+ originalBounds: bounds,
402
+ shiftedBounds: bounds,
403
+ hasLargeCoordinates: reference.coordinateInfo?.hasLargeCoordinates ?? false,
404
+ wasmRtcOffset: reference.coordinateInfo?.wasmRtcOffset,
405
+ buildingRotation: reference.coordinateInfo?.buildingRotation,
406
+ };
407
+
408
+ if (projFailures > 0) {
409
+ console.warn(
410
+ `[ifc-lite] Cross-CRS alignment: ${projFailures}/${attempts} vertex transforms `
411
+ + `failed from ${source.projectedCRS.name} to ${reference.projectedCRS.name}. `
412
+ + 'Those vertices are left at their original positions.',
413
+ firstProjError,
414
+ );
415
+ }
416
+ return true;
417
+ }
418
+
419
+ export type FederationAlignmentStatus = 'same-crs' | 'reprojected' | 'identity' | 'failed';
420
+
421
+ /**
422
+ * Route alignment to the right strategy based on whether the source and
423
+ * reference share a projected CRS. Returns a status describing how the model
424
+ * was placed in the federation, suitable for surfacing in the UI.
425
+ */
426
+ export async function alignGeometryToReference(
427
+ geometry: FederatedGeometryResult,
428
+ source: ModelGeoref,
429
+ reference: ModelGeoref,
430
+ ): Promise<FederationAlignmentStatus> {
431
+ if (canAlignInSameProjectedCrs(source, reference)) {
432
+ const transform = buildGeorefAlignmentTransform(source, reference);
433
+ if (!transform) return 'failed';
434
+ if (isIdentityTransform(transform)) return 'identity';
435
+ applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo);
436
+ return 'same-crs';
437
+ }
438
+ const ok = await alignGeometryAcrossCrs(geometry, source, reference);
439
+ return ok ? 'reprojected' : 'failed';
440
+ }
441
+
442
+ /**
443
+ * Select the federation anchor model.
444
+ *
445
+ * Resolution order:
446
+ * 1. `anchorModelIdOverride` from the store, if it points to a loaded model
447
+ * with a valid georeference.
448
+ * 2. Earliest `loadedAt` model with a valid georeference (the default — gives
449
+ * a stable anchor across loads while letting the user override when they
450
+ * want a different model to drive the world frame).
451
+ */
452
+ export function findReferenceGeorefModel(): { modelId: string; georef: ModelGeoref } | null {
453
+ const state = useViewerStore.getState();
454
+ const override = state.anchorModelIdOverride;
455
+ if (override) {
456
+ const model = state.models.get(override) as FederatedModel | undefined;
457
+ if (model?.ifcDataStore && model.geometryResult) {
458
+ const georef = extractModelGeoref(
459
+ model.ifcDataStore,
460
+ model.geometryResult.coordinateInfo,
461
+ state.georefMutations.get(override),
462
+ );
463
+ if (georef) return { modelId: override, georef };
464
+ }
465
+ // Fall through if the override no longer resolves — keeps loads
466
+ // recoverable even if the user removed the anchor they had pinned.
467
+ }
468
+
469
+ const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>;
470
+ const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
471
+ for (const [modelId, model] of sorted) {
472
+ if (!model.ifcDataStore || !model.geometryResult) continue;
473
+ const georef = extractModelGeoref(
474
+ model.ifcDataStore,
475
+ model.geometryResult.coordinateInfo,
476
+ state.georefMutations.get(modelId),
477
+ );
478
+ if (georef) return { modelId, georef };
479
+ }
480
+ return null;
481
+ }