@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
@@ -59,11 +59,21 @@ describe('federationLoadGate', () => {
59
59
  const bPromise = acquireFederationLoadSlot(50).then((id) => { order.push('b'); return id; });
60
60
 
61
61
  await new Promise((r) => setTimeout(r, 10));
62
+ // Releasing the blocker frees the budget for the head of the FIFO queue.
63
+ // A 2048 MB load costs more than the whole budget, so the first-queued load
64
+ // is admitted alone (single-file exception) and the 50 MB load stays queued
65
+ // until it releases. Awaiting them together would deadlock — and asserting
66
+ // that B does NOT wake alongside A is exactly what proves the gate respects
67
+ // the budget during the drain (the regression this guards against).
62
68
  releaseFederationLoadSlot(blocker);
63
69
 
64
- const [a, b] = await Promise.all([aPromise, bPromise]);
65
- assert.strictEqual(order[0], 'a');
70
+ const a = await aPromise;
71
+ assert.deepStrictEqual(order, ['a']);
72
+ assert.strictEqual(__getFederationLoadGateStats().queuedCount, 1);
73
+
66
74
  releaseFederationLoadSlot(a);
75
+ const b = await bPromise;
76
+ assert.deepStrictEqual(order, ['a', 'b']);
67
77
  releaseFederationLoadSlot(b);
68
78
  });
69
79
 
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  interface PendingAcquire {
24
+ id: number;
24
25
  fileSizeMB: number;
25
26
  resolve: () => void;
26
27
  }
@@ -70,6 +71,11 @@ function tryAdmit(): void {
70
71
  // Always admit when nothing is active (single file should never wait).
71
72
  if (active.size === 0 || wouldCost <= available) {
72
73
  queue.shift();
74
+ // Reserve the budget synchronously so activeCostMB() reflects this
75
+ // admission for the rest of this pass. The awaited acquire resumes in a
76
+ // later microtask, so registering here (not after the await) prevents the
77
+ // freed budget from being counted in full against every queued item.
78
+ active.set(head.id, { id: head.id, fileSizeMB: head.fileSizeMB });
73
79
  head.resolve();
74
80
  // Loop continues — we may be able to admit several queued small loads
75
81
  // after a single large load releases.
@@ -95,9 +101,10 @@ export async function acquireFederationLoadSlot(fileSizeMB: number): Promise<num
95
101
  }
96
102
 
97
103
  await new Promise<void>((resolve) => {
98
- queue.push({ fileSizeMB, resolve });
104
+ queue.push({ id, fileSizeMB, resolve });
99
105
  });
100
- active.set(id, { id, fileSizeMB });
106
+ // The slot is registered into `active` by tryAdmit at the moment of
107
+ // admission, so no active.set is needed here.
101
108
  return id;
102
109
  }
103
110
 
@@ -0,0 +1,488 @@
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
+ // NOTE: the refOffset handling is intentionally asymmetric between X and Z and
176
+ // must NOT be "symmetrised". refOffset is subtracted from the FINAL viewer
177
+ // coordinate on every axis. X maps positively (`tx = +xC`), so its offset is
178
+ // folded into xC above. Z maps to the NEGATED north axis (`tz = -yC`), so its
179
+ // offset is applied after the negation, leaving yC offset-free here. This
180
+ // matches alignGeometryAcrossCrs: alignedZ = refWorldZ - refOffset.z with
181
+ // refWorldZ = -ifcYr. Folding -refOffset.z into yC would flip its sign.
182
+ const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom;
183
+
184
+ return {
185
+ m00: xVx,
186
+ m01: 0,
187
+ m02: xVz,
188
+ tx: xC,
189
+ m10: 0,
190
+ m11: 1,
191
+ m12: 0,
192
+ ty: hC - refOffset.y,
193
+ m20: -yVx,
194
+ m21: 0,
195
+ m22: -yVz,
196
+ tz: -yC - refOffset.z,
197
+ };
198
+ }
199
+
200
+ function isIdentityTransform(transform: AffineTransform3D): boolean {
201
+ const eps = 1e-7;
202
+ return Math.abs(transform.m00 - 1) < eps
203
+ && Math.abs(transform.m01) < eps
204
+ && Math.abs(transform.m02) < eps
205
+ && Math.abs(transform.tx) < eps
206
+ && Math.abs(transform.m10) < eps
207
+ && Math.abs(transform.m11 - 1) < eps
208
+ && Math.abs(transform.m12) < eps
209
+ && Math.abs(transform.ty) < eps
210
+ && Math.abs(transform.m20) < eps
211
+ && Math.abs(transform.m21) < eps
212
+ && Math.abs(transform.m22 - 1) < eps
213
+ && Math.abs(transform.tz) < eps;
214
+ }
215
+
216
+ function applyAlignmentTransformAndUpdateBounds(
217
+ geometry: FederatedGeometryResult,
218
+ transform: AffineTransform3D,
219
+ referenceInfo?: CoordinateInfo,
220
+ ): void {
221
+ const bounds = emptyBounds();
222
+ let found = false;
223
+
224
+ for (const mesh of geometry.meshes) {
225
+ const positions = mesh.positions;
226
+ for (let i = 0; i < positions.length; i += 3) {
227
+ const x = positions[i];
228
+ const y = positions[i + 1];
229
+ const z = positions[i + 2];
230
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
231
+ continue;
232
+ }
233
+
234
+ const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx;
235
+ const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty;
236
+ const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz;
237
+ positions[i] = alignedX;
238
+ positions[i + 1] = alignedY;
239
+ positions[i + 2] = alignedZ;
240
+ found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
241
+ }
242
+
243
+ // Rotate normals by the transform's 3×3 linear part (translation omitted)
244
+ // and renormalize. CRS alignment is a rigid rotation, so the linear part
245
+ // itself is the correct transform for normals; degenerate results from
246
+ // zero-length or non-finite inputs are left in place.
247
+ const normals = mesh.normals;
248
+ if (normals && normals.length >= 3) {
249
+ for (let i = 0; i < normals.length; i += 3) {
250
+ const nx = normals[i];
251
+ const ny = normals[i + 1];
252
+ const nz = normals[i + 2];
253
+ if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) {
254
+ continue;
255
+ }
256
+ const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz;
257
+ const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz;
258
+ const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz;
259
+ const len = Math.sqrt(rx * rx + ry * ry + rz * rz);
260
+ if (!Number.isFinite(len) || len < 1e-12) {
261
+ continue;
262
+ }
263
+ normals[i] = rx / len;
264
+ normals[i + 1] = ry / len;
265
+ normals[i + 2] = rz / len;
266
+ }
267
+ }
268
+ }
269
+
270
+ geometry.coordinateInfo = {
271
+ originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 },
272
+ originalBounds: found ? bounds : zeroBounds(),
273
+ shiftedBounds: found ? bounds : zeroBounds(),
274
+ hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false,
275
+ wasmRtcOffset: referenceInfo?.wasmRtcOffset,
276
+ buildingRotation: referenceInfo?.buildingRotation,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Reproject every vertex from a source model's georeference into the reference
282
+ * model's viewer-space frame using proj4 between the two projected CRSs.
283
+ *
284
+ * Used for federated loads where models declare different IfcProjectedCRSs
285
+ * (e.g. EPSG:28992 + EPSG:7415 mixed RD/NAP Dutch sets, or EPSG:25831 UTM +
286
+ * EPSG:28992 mixed). The pipeline per vertex:
287
+ *
288
+ * viewer(Yup) ──(source RTC/shift, axis swap)──▶ IFC(Zup, source)
289
+ * IFC(source) ──(source MapConversion)──────────▶ source projected (eS,nS,hS)
290
+ * projected ──(proj4: srcDef → refDef)────────▶ reference projected (eR,nR)
291
+ * projected ──(reference MapConversion inverse)▶ IFC(Zup, reference)
292
+ * IFC(ref) ──(axis swap, reference RTC/shift)─▶ viewer(Yup, reference frame)
293
+ *
294
+ * Vertical: height passes through unchanged. Browser-side proj4 has no vertical
295
+ * datum transforms (no NTv2/gtx grids), so cross-CRS vertical mismatches are
296
+ * left for the user to resolve via the per-model orthogonalHeight editor.
297
+ *
298
+ * Normals are NOT rotated. Cross-CRS rotations between projected systems in the
299
+ * same locality are sub-degree, and recomputing per-vertex would require a
300
+ * Jacobian per mesh — acceptable trade-off for now, document if it bites.
301
+ */
302
+ async function alignGeometryAcrossCrs(
303
+ geometry: FederatedGeometryResult,
304
+ source: ModelGeoref,
305
+ reference: ModelGeoref,
306
+ ): Promise<boolean> {
307
+ const sourceProjDef = await resolveProjection(source.projectedCRS);
308
+ const refProjDef = await resolveProjection(reference.projectedCRS);
309
+ if (!sourceProjDef || !refProjDef) return false;
310
+
311
+ const sourceMapUnitScale = getMapUnitScale(source);
312
+ const refMapUnitScale = getMapUnitScale(reference);
313
+ const sourceAxis = getAxis(source);
314
+ const refAxis = getAxis(reference);
315
+ const sourceOffset = totalYupOffset(source.coordinateInfo);
316
+ const refOffset = totalYupOffset(reference.coordinateInfo);
317
+
318
+ const refDenom = refAxis.scale * refAxis.denom;
319
+ if (Math.abs(refDenom) < 1e-12) return false;
320
+ const invRefDenom = 1 / refDenom;
321
+
322
+ const sourceConv = source.mapConversion;
323
+ const refConv = reference.mapConversion;
324
+
325
+ const bounds = emptyBounds();
326
+ let found = false;
327
+ let projFailures = 0;
328
+ let attempts = 0;
329
+ let firstProjError: unknown = null;
330
+
331
+ for (const mesh of geometry.meshes) {
332
+ const positions = mesh.positions;
333
+ for (let i = 0; i < positions.length; i += 3) {
334
+ const vx = positions[i];
335
+ const vy = positions[i + 1];
336
+ const vz = positions[i + 2];
337
+ if (!Number.isFinite(vx) || !Number.isFinite(vy) || !Number.isFinite(vz)) continue;
338
+
339
+ // viewer(Y-up, source-local) → world(Y-up) → IFC(Z-up, source)
340
+ const wx = vx + sourceOffset.x;
341
+ const wy = vy + sourceOffset.y;
342
+ const wz = vz + sourceOffset.z;
343
+ const ifcXs = wx;
344
+ const ifcYs = -wz;
345
+ const ifcZs = wy;
346
+
347
+ // IFC(source) → source projected (apply source MapConversion)
348
+ const eS = sourceConv.eastings * sourceMapUnitScale
349
+ + sourceAxis.scale * (sourceAxis.a * ifcXs - sourceAxis.o * ifcYs);
350
+ const nS = sourceConv.northings * sourceMapUnitScale
351
+ + sourceAxis.scale * (sourceAxis.o * ifcXs + sourceAxis.a * ifcYs);
352
+ const hS = sourceConv.orthogonalHeight * sourceMapUnitScale + ifcZs;
353
+
354
+ // source projected → reference projected via proj4
355
+ attempts += 1;
356
+ let eR: number;
357
+ let nR: number;
358
+ try {
359
+ const projected = proj4(sourceProjDef, refProjDef, [eS, nS]);
360
+ eR = projected[0];
361
+ nR = projected[1];
362
+ } catch (error) {
363
+ projFailures += 1;
364
+ if (firstProjError == null) firstProjError = error;
365
+ continue;
366
+ }
367
+ if (!Number.isFinite(eR) || !Number.isFinite(nR)) {
368
+ projFailures += 1;
369
+ continue;
370
+ }
371
+ // Height transformed under identity (no vertical datum hop in browser).
372
+ const hR = hS;
373
+
374
+ // reference projected → IFC(reference): invert reference MapConversion
375
+ const dE = eR - refConv.eastings * refMapUnitScale;
376
+ const dN = nR - refConv.northings * refMapUnitScale;
377
+ const ifcXr = invRefDenom * (refAxis.a * dE + refAxis.o * dN);
378
+ const ifcYr = invRefDenom * (-refAxis.o * dE + refAxis.a * dN);
379
+ const ifcZr = hR - refConv.orthogonalHeight * refMapUnitScale;
380
+
381
+ // IFC(Z-up, reference) → world(Y-up) → viewer(Y-up, reference-local)
382
+ const refWorldX = ifcXr;
383
+ const refWorldY = ifcZr;
384
+ const refWorldZ = -ifcYr;
385
+ const alignedX = refWorldX - refOffset.x;
386
+ const alignedY = refWorldY - refOffset.y;
387
+ const alignedZ = refWorldZ - refOffset.z;
388
+
389
+ positions[i] = alignedX;
390
+ positions[i + 1] = alignedY;
391
+ positions[i + 2] = alignedZ;
392
+ found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
393
+ }
394
+ }
395
+
396
+ if (!found) {
397
+ console.warn(
398
+ `[ifc-lite] Cross-CRS alignment failed: ${projFailures}/${attempts} `
399
+ + `vertex transforms failed for ${source.projectedCRS.name} → ${reference.projectedCRS.name}; `
400
+ + 'no vertices were successfully reprojected. Leaving geometry untouched.',
401
+ firstProjError,
402
+ );
403
+ return false;
404
+ }
405
+
406
+ geometry.coordinateInfo = {
407
+ originShift: reference.coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 },
408
+ originalBounds: bounds,
409
+ shiftedBounds: bounds,
410
+ hasLargeCoordinates: reference.coordinateInfo?.hasLargeCoordinates ?? false,
411
+ wasmRtcOffset: reference.coordinateInfo?.wasmRtcOffset,
412
+ buildingRotation: reference.coordinateInfo?.buildingRotation,
413
+ };
414
+
415
+ if (projFailures > 0) {
416
+ console.warn(
417
+ `[ifc-lite] Cross-CRS alignment: ${projFailures}/${attempts} vertex transforms `
418
+ + `failed from ${source.projectedCRS.name} to ${reference.projectedCRS.name}. `
419
+ + 'Those vertices are left at their original positions.',
420
+ firstProjError,
421
+ );
422
+ }
423
+ return true;
424
+ }
425
+
426
+ export type FederationAlignmentStatus = 'same-crs' | 'reprojected' | 'identity' | 'failed';
427
+
428
+ /**
429
+ * Route alignment to the right strategy based on whether the source and
430
+ * reference share a projected CRS. Returns a status describing how the model
431
+ * was placed in the federation, suitable for surfacing in the UI.
432
+ */
433
+ export async function alignGeometryToReference(
434
+ geometry: FederatedGeometryResult,
435
+ source: ModelGeoref,
436
+ reference: ModelGeoref,
437
+ ): Promise<FederationAlignmentStatus> {
438
+ if (canAlignInSameProjectedCrs(source, reference)) {
439
+ const transform = buildGeorefAlignmentTransform(source, reference);
440
+ if (!transform) return 'failed';
441
+ if (isIdentityTransform(transform)) return 'identity';
442
+ applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo);
443
+ return 'same-crs';
444
+ }
445
+ const ok = await alignGeometryAcrossCrs(geometry, source, reference);
446
+ return ok ? 'reprojected' : 'failed';
447
+ }
448
+
449
+ /**
450
+ * Select the federation anchor model.
451
+ *
452
+ * Resolution order:
453
+ * 1. `anchorModelIdOverride` from the store, if it points to a loaded model
454
+ * with a valid georeference.
455
+ * 2. Earliest `loadedAt` model with a valid georeference (the default — gives
456
+ * a stable anchor across loads while letting the user override when they
457
+ * want a different model to drive the world frame).
458
+ */
459
+ export function findReferenceGeorefModel(): { modelId: string; georef: ModelGeoref } | null {
460
+ const state = useViewerStore.getState();
461
+ const override = state.anchorModelIdOverride;
462
+ if (override) {
463
+ const model = state.models.get(override) as FederatedModel | undefined;
464
+ if (model?.ifcDataStore && model.geometryResult) {
465
+ const georef = extractModelGeoref(
466
+ model.ifcDataStore,
467
+ model.geometryResult.coordinateInfo,
468
+ state.georefMutations.get(override),
469
+ );
470
+ if (georef) return { modelId: override, georef };
471
+ }
472
+ // Fall through if the override no longer resolves — keeps loads
473
+ // recoverable even if the user removed the anchor they had pinned.
474
+ }
475
+
476
+ const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>;
477
+ const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
478
+ for (const [modelId, model] of sorted) {
479
+ if (!model.ifcDataStore || !model.geometryResult) continue;
480
+ const georef = extractModelGeoref(
481
+ model.ifcDataStore,
482
+ model.geometryResult.coordinateInfo,
483
+ state.georefMutations.get(modelId),
484
+ );
485
+ if (georef) return { modelId, georef };
486
+ }
487
+ return null;
488
+ }