@ifc-lite/viewer 1.19.0 → 1.21.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 (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -29,6 +29,8 @@ import proj4 from 'proj4';
29
29
  import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
30
30
  import type { CoordinateInfo } from '@ifc-lite/geometry';
31
31
  import { resolveProjection } from './reproject';
32
+ import { resolveTerrainElevation } from './terrain-elevation';
33
+ import { getEffectiveHorizontalScale } from './effective-georef';
32
34
 
33
35
  export interface GeodesicPosition {
34
36
  longitude: number;
@@ -64,18 +66,23 @@ export interface CesiumBridge {
64
66
  viewerToGeodetic(vx: number, vy: number, vz: number): GeodesicPosition | null;
65
67
  }
66
68
 
67
- const TERRAIN_QUERY_RETRY_DELAY_MS = 3000;
68
-
69
69
  export async function createCesiumBridge(
70
70
  mapConversion: MapConversion,
71
71
  projectedCRS: ProjectedCRS,
72
72
  coordinateInfo?: CoordinateInfo,
73
73
  lengthUnitScale = 1,
74
+ /**
75
+ * If provided, replaces the IFC-derived origin altitude (mapConversion's
76
+ * OrthogonalHeight + viewer-space Z) for the enuToEcef origin used by both
77
+ * the camera frame and the model matrix. Pass the terrain-clamped placement
78
+ * here to bake "model on terrain" into the bridge from creation, so the
79
+ * model never has to be moved after loading into Cesium.
80
+ */
81
+ placementHeightOverride?: number,
74
82
  ): Promise<CesiumBridge | null> {
75
83
  const projDef = await resolveProjection(projectedCRS);
76
84
  if (!projDef) return null;
77
85
 
78
- const hScale = mapConversion.scale ?? 1.0;
79
86
  const absc = mapConversion.xAxisAbscissa ?? 1.0;
80
87
  const ordi = mapConversion.xAxisOrdinate ?? 0.0;
81
88
  const rotAngle = Math.atan2(ordi, absc);
@@ -103,9 +110,18 @@ export async function createCesiumBridge(
103
110
  // converts from the IFC file's native unit during extraction). MapConversion
104
111
  // values use the unit from IfcProjectedCRS.MapUnit; fall back to project unit.
105
112
  const mapScale = projectedCRS.mapUnitScale ?? lengthUnitScale;
113
+ // IfcMapConversion.Scale bridges project length unit → map unit (e.g. 0.001
114
+ // for mm→m). Geometry is already in metres, so the effective horizontal
115
+ // scale is (Scale * mapUnitScale) / lengthUnitScale — see issue #595.
116
+ const hScale = getEffectiveHorizontalScale(mapConversion.scale, mapScale, lengthUnitScale);
106
117
  const oEasting = mapConversion.eastings * mapScale + hScale * (absc * oIfcX - ordi * oIfcY);
107
118
  const oNorthing = mapConversion.northings * mapScale + hScale * (ordi * oIfcX + absc * oIfcY);
108
- const oHeight = mapConversion.orthogonalHeight * mapScale + oIfcZ;
119
+ const ifcOHeight = mapConversion.orthogonalHeight * mapScale + oIfcZ;
120
+ // The actual altitude used for the enuToEcef origin. When the caller
121
+ // pre-computes a terrain-clamped placement, we honour it so the bridge,
122
+ // model matrix, and camera frame are all built around the SAME altitude
123
+ // from the start — no post-load shifting required.
124
+ const oHeight = placementHeightOverride ?? ifcOHeight;
109
125
 
110
126
  let originLon: number, originLat: number;
111
127
  try {
@@ -192,6 +208,21 @@ export async function createCesiumBridge(
192
208
  );
193
209
  }
194
210
 
211
+ /**
212
+ * Sync the Cesium camera from the IFC viewer's camera state.
213
+ *
214
+ * Best practice for an externally-driven camera: keep Cesium's screen-space
215
+ * controller fully disabled (Effect 1) and write camera state directly in
216
+ * ECEF coordinates. We previously called `lookAtTransform` so we could set
217
+ * position/direction/up in viewer-space, but that locks Cesium's reference
218
+ * frame and constrains certain operations (rotate, tilt, zoom) to the local
219
+ * frame — which manifested as "can't orbit upward, camera stuck to terrain"
220
+ * even though our overlay is supposed to be input-passive.
221
+ *
222
+ * Instead, transform the IFC camera's viewer-space pose to ECEF here and
223
+ * write it. Cesium handles RTC for primitives (Models, 3D Tilesets, terrain)
224
+ * internally so we don't need a local-frame trick for shader precision.
225
+ */
195
226
  function syncCamera(
196
227
  Cesium: typeof import('cesium'),
197
228
  viewer: InstanceType<typeof import('cesium').Viewer>,
@@ -205,48 +236,60 @@ export async function createCesiumBridge(
205
236
  ensureEcefCache(Cesium, clampUp);
206
237
  if (!viewerToEcefMatrix) return;
207
238
 
208
- // Set the camera's reference frame to our viewer→ECEF transform.
209
- // After this call, all camera properties (position, direction, up)
210
- // are interpreted in IFC VIEWER coordinates, not ECEF.
211
- viewer.camera.lookAtTransform(viewerToEcefMatrix);
212
-
213
- // Now set camera in VIEWER coordinates — Cesium applies the transform
214
- viewer.camera.position = new Cesium.Cartesian3(camPos.x, camPos.y, camPos.z);
215
-
216
- const dirX = camTarget.x - camPos.x;
217
- const dirY = camTarget.y - camPos.y;
218
- const dirZ = camTarget.z - camPos.z;
219
- const dirLen = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ);
220
- if (dirLen > 1e-8) {
221
- viewer.camera.direction = new Cesium.Cartesian3(
222
- dirX / dirLen, dirY / dirLen, dirZ / dirLen,
223
- );
224
- }
225
-
226
- viewer.camera.up = new Cesium.Cartesian3(camUp.x, camUp.y, camUp.z);
239
+ // Make sure no prior lookAtTransform is still in effect — if the
240
+ // overlay was activated from a previous bridge that called it, the
241
+ // camera could still be locked to that frame.
242
+ viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
243
+
244
+ // Transform IFC viewer-space pose ECEF.
245
+ // Position uses full matrix (rotation + translation).
246
+ const posECEF = Cesium.Matrix4.multiplyByPoint(
247
+ viewerToEcefMatrix,
248
+ new Cesium.Cartesian3(camPos.x, camPos.y, camPos.z),
249
+ new Cesium.Cartesian3(),
250
+ );
251
+ const targetECEF = Cesium.Matrix4.multiplyByPoint(
252
+ viewerToEcefMatrix,
253
+ new Cesium.Cartesian3(camTarget.x, camTarget.y, camTarget.z),
254
+ new Cesium.Cartesian3(),
255
+ );
227
256
 
228
- // Recompute right = direction × up (maintain orthonormality)
229
- const right = Cesium.Cartesian3.cross(
230
- viewer.camera.direction, viewer.camera.up, new Cesium.Cartesian3(),
257
+ // Direction = (target position) normalised, in ECEF.
258
+ const dirECEF = Cesium.Cartesian3.subtract(targetECEF, posECEF, new Cesium.Cartesian3());
259
+ const dirLen = Cesium.Cartesian3.magnitude(dirECEF);
260
+ if (dirLen < 1e-8) return; // degenerate: target ≡ position
261
+ Cesium.Cartesian3.normalize(dirECEF, dirECEF);
262
+
263
+ // Up: rotate the viewer-space up vector to ECEF (rotation only, no
264
+ // translation — multiplyByPointAsVector ignores the translation column).
265
+ const upECEF = Cesium.Matrix4.multiplyByPointAsVector(
266
+ viewerToEcefMatrix,
267
+ new Cesium.Cartesian3(camUp.x, camUp.y, camUp.z),
268
+ new Cesium.Cartesian3(),
231
269
  );
232
- Cesium.Cartesian3.normalize(right, right);
233
- viewer.camera.right = right;
234
-
235
- // Sync FOV CRITICAL for preventing model drift.
236
- // IFC renderer uses `fov` as VERTICAL FOV always.
237
- // Cesium's PerspectiveFrustum.fov is HORIZONTAL when aspect > 1 (landscape).
238
- // If we set Cesium's fov = IFC's vertical fov, Cesium treats it as horizontal,
239
- // producing a completely different projection — the model slides during orbit.
240
- // Fix: convert vertical FOV → horizontal FOV.
270
+ Cesium.Cartesian3.normalize(upECEF, upECEF);
271
+
272
+ // Right = direction × up — recompute fresh each frame so the orthonormal
273
+ // basis stays clean. (The "drift" the previous implementation worried
274
+ // about only matters if we read Cesium's camera state back into our
275
+ // calculations; we always recompute from the IFC source of truth.)
276
+ const rightECEF = Cesium.Cartesian3.cross(dirECEF, upECEF, new Cesium.Cartesian3());
277
+ Cesium.Cartesian3.normalize(rightECEF, rightECEF);
278
+
279
+ viewer.camera.position = posECEF;
280
+ viewer.camera.direction = dirECEF;
281
+ viewer.camera.up = upECEF;
282
+ viewer.camera.right = rightECEF;
283
+
284
+ // Sync FOV — IFC renderer reports VERTICAL FOV; Cesium's
285
+ // PerspectiveFrustum.fov is HORIZONTAL when aspect > 1 (landscape).
286
+ // Convert vertical → horizontal so the projection matches.
241
287
  const frustum = viewer.camera.frustum;
242
288
  if (frustum instanceof Cesium.PerspectiveFrustum) {
243
289
  const aspect = frustum.aspectRatio || (viewer.canvas.width / viewer.canvas.height);
244
290
  if (aspect > 1) {
245
- // Landscape: Cesium expects horizontal FOV
246
- // horizontal_fov = 2 * atan(aspect * tan(vertical_fov / 2))
247
291
  frustum.fov = 2 * Math.atan(aspect * Math.tan(fov / 2));
248
292
  } else {
249
- // Portrait: Cesium uses fov as vertical — pass through
250
293
  frustum.fov = fov;
251
294
  }
252
295
  }
@@ -254,38 +297,12 @@ export async function createCesiumBridge(
254
297
  viewer.scene.requestRender();
255
298
  }
256
299
 
257
- async function queryTerrainHeight(
300
+ /** Resolve terrain elevation at the model origin via the shared pipeline. */
301
+ function queryTerrainHeight(
258
302
  Cesium: typeof import('cesium'),
259
303
  viewer: InstanceType<typeof import('cesium').Viewer>,
260
304
  ): Promise<number | null> {
261
- const position = Cesium.Cartographic.fromDegrees(originLon, originLat);
262
-
263
- try {
264
- const globeHeight = viewer.scene.globe.getHeight(position);
265
- if (globeHeight !== undefined && Number.isFinite(globeHeight)) {
266
- return globeHeight;
267
- }
268
- } catch { /* not available yet */ }
269
-
270
- try {
271
- const terrainProvider = viewer.terrainProvider;
272
- if (terrainProvider) {
273
- const results = await Cesium.sampleTerrainMostDetailed(terrainProvider, [position]);
274
- if (results && results.length > 0 && Number.isFinite(results[0].height)) {
275
- return results[0].height;
276
- }
277
- }
278
- } catch { /* terrain sampling failed */ }
279
-
280
- await new Promise(r => setTimeout(r, TERRAIN_QUERY_RETRY_DELAY_MS));
281
- try {
282
- const globeHeight = viewer.scene.globe.getHeight(position);
283
- if (globeHeight !== undefined && Number.isFinite(globeHeight)) {
284
- return globeHeight;
285
- }
286
- } catch { /* still not available */ }
287
-
288
- return null;
305
+ return resolveTerrainElevation(Cesium, viewer, originLat, originLon);
289
306
  }
290
307
 
291
308
  function viewerToGeodetic(vx: number, vy: number, vz: number): GeodesicPosition | null {
@@ -0,0 +1,80 @@
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
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ import { findClampAnchorY } from './clamp-anchor.js';
9
+
10
+ describe('findClampAnchorY', () => {
11
+ const buildingBounds = { min: { y: -3.5 }, max: { y: 50 } }; // basement at -3.5m, top at 50m
12
+
13
+ it('falls back to bounds.min.y when no storeys are present', () => {
14
+ assert.strictEqual(findClampAnchorY(buildingBounds, undefined), -3.5);
15
+ assert.strictEqual(findClampAnchorY(buildingBounds, new Map()), -3.5);
16
+ });
17
+
18
+ it('falls back to bounds.min.y when bounds are missing', () => {
19
+ const storeys = new Map([[1, 0], [2, 3]]);
20
+ assert.strictEqual(findClampAnchorY(undefined, storeys), 0);
21
+ });
22
+
23
+ it('picks the storey closest to elevation 0 (ground floor)', () => {
24
+ const storeys = new Map([
25
+ [1, -3.0], // basement
26
+ [2, 0.0], // ground floor — closest to 0
27
+ [3, 3.5], // 1st floor
28
+ [4, 7.0], // 2nd floor
29
+ ]);
30
+ assert.strictEqual(findClampAnchorY(buildingBounds, storeys), 0.0);
31
+ });
32
+
33
+ it('handles a model authored with a non-zero ground (e.g. 0.15m above origin)', () => {
34
+ const storeys = new Map([
35
+ [1, -3.0],
36
+ [2, 0.15], // ground floor — closest to 0
37
+ [3, 3.65],
38
+ ]);
39
+ assert.strictEqual(findClampAnchorY(buildingBounds, storeys), 0.15);
40
+ });
41
+
42
+ it('ignores storeys outside the model bounds (avoid stray site-only markers)', () => {
43
+ // A "Site" or "External" storey with elevation way outside the actual
44
+ // building extents shouldn't lift the clamp out into mid-air.
45
+ const storeys = new Map([
46
+ [1, -3.0],
47
+ [2, 200], // out of bounds — site marker, must be skipped
48
+ [3, 5.0],
49
+ ]);
50
+ // Within bounds: -3.0 (|3|) and 5.0 (|5|). −3.0 is closer to 0.
51
+ assert.strictEqual(findClampAnchorY(buildingBounds, storeys), -3.0);
52
+ });
53
+
54
+ it('uses the lowest storey if all are below 0 (basement-level model)', () => {
55
+ const bounds = { min: { y: -10 }, max: { y: -1 } };
56
+ const storeys = new Map([[1, -8], [2, -3]]);
57
+ // -3 is closer to 0 than -8
58
+ assert.strictEqual(findClampAnchorY(bounds, storeys), -3);
59
+ });
60
+
61
+ it('handles negative-only storeys without crashing', () => {
62
+ const bounds = { min: { y: -100 }, max: { y: 0 } };
63
+ const storeys = new Map([[1, -50], [2, -20]]);
64
+ assert.strictEqual(findClampAnchorY(bounds, storeys), -20);
65
+ });
66
+
67
+ it('skips non-finite elevations defensively', () => {
68
+ const storeys = new Map([
69
+ [1, NaN],
70
+ [2, Infinity],
71
+ [3, 2.0],
72
+ ]);
73
+ assert.strictEqual(findClampAnchorY(buildingBounds, storeys), 2.0);
74
+ });
75
+
76
+ it('falls back to bounds.min.y when every storey is out of range', () => {
77
+ const storeys = new Map([[1, 1000], [2, -1000]]);
78
+ assert.strictEqual(findClampAnchorY(buildingBounds, storeys), -3.5);
79
+ });
80
+ });
@@ -0,0 +1,57 @@
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
+ * Clamp anchor selection.
7
+ *
8
+ * "Auto-clamp to terrain" needs to decide WHICH viewer-Y of the model
9
+ * should be pinned to the terrain surface. The naïve choice — `bounds.min.y`
10
+ * — anchors the lowest geometry vertex (typically the bottom of the
11
+ * basement / foundation) to terrain, which buries the building's
12
+ * actual ground floor below the surface.
13
+ *
14
+ * Better: pick the IfcBuildingStorey whose elevation is closest to 0
15
+ * (the conventional "ground floor"). Fall back to `bounds.min.y` when
16
+ * no storeys are present or none lie within the model's vertical
17
+ * extent — that preserves the previous behaviour for non-architectural
18
+ * IFCs (structural-only models, civil works, point clouds, etc.).
19
+ */
20
+
21
+ interface BoundsLike {
22
+ min: { y: number };
23
+ max: { y: number };
24
+ }
25
+
26
+ /**
27
+ * @param bounds Model bounds in viewer-space (Y-up, metres).
28
+ * @param storeyElevations IFC storey elevations (metres, viewer-Y aligned).
29
+ * The geometry pipeline already converts IFC Z-up
30
+ * values to viewer-Y, so values here can be
31
+ * compared against `bounds.min.y` / `max.y`.
32
+ *
33
+ * @returns viewer-Y altitude that should land at terrain when clamping.
34
+ */
35
+ export function findClampAnchorY(
36
+ bounds: BoundsLike | undefined,
37
+ storeyElevations: Map<number, number> | undefined,
38
+ ): number {
39
+ const minY = bounds?.min.y ?? 0;
40
+ if (!storeyElevations || storeyElevations.size === 0) return minY;
41
+
42
+ const maxY = bounds?.max.y ?? minY;
43
+ const slack = 1; // metre — tolerate storey markers slightly outside the AABB
44
+ let bestElevation: number | null = null;
45
+ let bestDistanceFromZero = Infinity;
46
+ for (const elevation of storeyElevations.values()) {
47
+ if (!Number.isFinite(elevation)) continue;
48
+ if (elevation < minY - slack || elevation > maxY + slack) continue;
49
+ const distance = Math.abs(elevation);
50
+ if (distance < bestDistanceFromZero) {
51
+ bestDistanceFromZero = distance;
52
+ bestElevation = elevation;
53
+ }
54
+ }
55
+
56
+ return bestElevation ?? minY;
57
+ }
@@ -5,7 +5,7 @@
5
5
  import { describe, it } from 'node:test';
6
6
  import assert from 'node:assert';
7
7
 
8
- import { inferMapUnitScale, mergeMapConversion, mergeProjectedCRS } from './effective-georef.js';
8
+ import { detectScaleUnitMismatch, getEffectiveHorizontalScale, inferMapUnitScale, mergeMapConversion, mergeProjectedCRS } from './effective-georef.js';
9
9
  import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
10
10
 
11
11
  describe('effective georeferencing', () => {
@@ -70,4 +70,82 @@ describe('effective georeferencing', () => {
70
70
  assert.strictEqual(inferMapUnitScale('METRE'), 1);
71
71
  assert.strictEqual(inferMapUnitScale('MILLIMETRE'), 0.001);
72
72
  });
73
+
74
+ describe('getEffectiveHorizontalScale (issue #595)', () => {
75
+ it('returns 1 when project mm and map m, with Scale=0.001 (Bonsai-style)', () => {
76
+ // mm project (lengthUnitScale=0.001), m map (mapUnitScale=1), Scale=0.001
77
+ assert.strictEqual(getEffectiveHorizontalScale(0.001, 1, 0.001), 1);
78
+ });
79
+
80
+ it('returns 1 when project m and map m, with Scale=1', () => {
81
+ assert.strictEqual(getEffectiveHorizontalScale(1, 1, 1), 1);
82
+ });
83
+
84
+ it('returns 1 when project ft and map m, with Scale=0.3048', () => {
85
+ assert.strictEqual(getEffectiveHorizontalScale(0.3048, 1, 0.3048), 1);
86
+ });
87
+
88
+ it('returns 1 when project mm and map mm, with Scale=1 (consistent units)', () => {
89
+ assert.strictEqual(getEffectiveHorizontalScale(1, 0.001, 0.001), 1);
90
+ });
91
+
92
+ it('preserves a deliberate non-unit scaling (Scale=2 with metres throughout)', () => {
93
+ assert.strictEqual(getEffectiveHorizontalScale(2, 1, 1), 2);
94
+ });
95
+
96
+ it('defaults Scale to 1 when undefined', () => {
97
+ // Project mm, map m, Scale undefined (treated as 1):
98
+ // effective = 1 * 1 / 0.001 = 1000 → model would appear 1000x too large.
99
+ // This matches the IFC spec; users who omit Scale in such files have an
100
+ // inconsistent file. Issue #595 reports that this happens to "work"
101
+ // because the workaround offsets a different bug — fixed here.
102
+ assert.strictEqual(getEffectiveHorizontalScale(undefined, 1, 0.001), 1000);
103
+ });
104
+
105
+ it('falls back to 1 for non-positive lengthUnitScale or mapUnitScale', () => {
106
+ assert.strictEqual(getEffectiveHorizontalScale(1, 0, 1), 1);
107
+ assert.strictEqual(getEffectiveHorizontalScale(1, 1, 0), 1);
108
+ assert.strictEqual(getEffectiveHorizontalScale(1, -1, 1), 1);
109
+ });
110
+ });
111
+
112
+ describe('detectScaleUnitMismatch', () => {
113
+ it('returns null for spec-compliant Scale (mm/m with Scale=0.001)', () => {
114
+ assert.strictEqual(detectScaleUnitMismatch(0.001, 1, 0.001), null);
115
+ });
116
+
117
+ it('returns null when project=map=metres and Scale=1', () => {
118
+ assert.strictEqual(detectScaleUnitMismatch(1, 1, 1), null);
119
+ });
120
+
121
+ it('returns null when project=map=metres and Scale is undefined', () => {
122
+ assert.strictEqual(detectScaleUnitMismatch(undefined, 1, 1), null);
123
+ });
124
+
125
+ it('flags the common Scale=1 + mm-project + m-map error', () => {
126
+ const m = detectScaleUnitMismatch(1, 1, 0.001);
127
+ assert.ok(m, 'expected a mismatch report');
128
+ assert.strictEqual(m!.rawScale, 1);
129
+ assert.strictEqual(m!.effectiveScale, 1000);
130
+ assert.strictEqual(m!.expectedScale, 0.001);
131
+ });
132
+
133
+ it('flags Scale omitted when units differ', () => {
134
+ const m = detectScaleUnitMismatch(undefined, 1, 0.001);
135
+ assert.ok(m);
136
+ assert.strictEqual(m!.effectiveScale, 1000);
137
+ });
138
+
139
+ it('tolerates tiny floating-point noise around 1.0', () => {
140
+ // Scale = 1.0 ± 0.4% should still be considered consistent.
141
+ assert.strictEqual(detectScaleUnitMismatch(1.004, 1, 1), null);
142
+ assert.strictEqual(detectScaleUnitMismatch(0.996, 1, 1), null);
143
+ });
144
+
145
+ it('flags a deliberate non-unit scaling (Scale=2 with metres)', () => {
146
+ const m = detectScaleUnitMismatch(2, 1, 1);
147
+ assert.ok(m);
148
+ assert.strictEqual(m!.effectiveScale, 2);
149
+ });
150
+ });
73
151
  });
@@ -23,6 +23,89 @@ export interface EffectiveGeoreference extends GeoreferenceInfo {
23
23
  lengthUnitScale: number;
24
24
  }
25
25
 
26
+ /**
27
+ * Compute the effective horizontal scale to apply to viewer-space coordinates
28
+ * (which are already in metres) when transforming through IfcMapConversion.
29
+ *
30
+ * Per the IFC schema, IfcMapConversion.Scale converts LOCAL ENGINEERING
31
+ * coordinates (in the project's length unit) to MAP coordinates (in the map
32
+ * CRS unit). For a typical file with mm project units and m map units, the
33
+ * Scale attribute is 0.001.
34
+ *
35
+ * The IFC formula is:
36
+ * E_map_units = Eastings + (X_local * absc - Y_local * ordi) * Scale
37
+ *
38
+ * To produce metres for proj4, we multiply by mapUnitScale; and X_local can be
39
+ * recovered from the metre-converted geometry as X_metres / lengthUnitScale.
40
+ * Substituting:
41
+ * E_metres = mapUnitScale * Eastings
42
+ * + (mapUnitScale * Scale / lengthUnitScale)
43
+ * * (X_metres * absc - Y_metres * ordi)
44
+ *
45
+ * So when geometry has already been converted to metres (as ifc-lite does),
46
+ * the effective horizontal scale is (Scale * mapUnitScale) / lengthUnitScale.
47
+ * For files where Scale is set per IFC spec to bridge the unit difference
48
+ * (Scale = lengthUnitScale / mapUnitScale), this evaluates to 1.0 and the
49
+ * geometry passes through unchanged. Applying the raw Scale would otherwise
50
+ * double-scale and shrink/expand the model — see issue #595.
51
+ */
52
+ export function getEffectiveHorizontalScale(
53
+ ifcMapConversionScale: number | undefined,
54
+ mapUnitScale: number,
55
+ lengthUnitScale: number,
56
+ ): number {
57
+ const scale = ifcMapConversionScale ?? 1.0;
58
+ const lus = lengthUnitScale > 0 ? lengthUnitScale : 1;
59
+ const mus = mapUnitScale > 0 ? mapUnitScale : 1;
60
+ return (scale * mus) / lus;
61
+ }
62
+
63
+ export interface ScaleUnitMismatch {
64
+ /** Effective horizontal scale applied to viewer-space (metre) geometry. */
65
+ effectiveScale: number;
66
+ /** Raw IfcMapConversion.Scale (or 1 if absent). */
67
+ rawScale: number;
68
+ /** Map unit → metres factor (e.g. 1 for METRE, 0.001 for MILLIMETRE). */
69
+ mapUnitScale: number;
70
+ /** Project length unit → metres factor. */
71
+ lengthUnitScale: number;
72
+ /**
73
+ * Scale value the file would need for the IFC formula to map local→map
74
+ * coordinates without any extra scaling (i.e. lengthUnitScale / mapUnitScale).
75
+ */
76
+ expectedScale: number;
77
+ }
78
+
79
+ /**
80
+ * Detect when IfcMapConversion.Scale is inconsistent with the project and map
81
+ * units. Per the IFC schema, Scale × mapUnitScale should equal lengthUnitScale
82
+ * (i.e. effectiveScale = 1.0). A deviation usually means the authoring tool
83
+ * forgot to set Scale to bridge a unit difference (e.g. mm project + m map
84
+ * with Scale=1.0). Files like this render at the wrong size in any tool that
85
+ * follows the schema strictly — see issue #595.
86
+ *
87
+ * Returns null when the values are consistent (within 0.5% of 1.0); otherwise
88
+ * returns the diagnostic data so callers can surface a warning.
89
+ */
90
+ export function detectScaleUnitMismatch(
91
+ ifcMapConversionScale: number | undefined,
92
+ mapUnitScale: number | undefined,
93
+ lengthUnitScale: number | undefined,
94
+ ): ScaleUnitMismatch | null {
95
+ const lus = lengthUnitScale && lengthUnitScale > 0 ? lengthUnitScale : 1;
96
+ const mus = mapUnitScale && mapUnitScale > 0 ? mapUnitScale : 1;
97
+ const rawScale = ifcMapConversionScale ?? 1.0;
98
+ const effectiveScale = (rawScale * mus) / lus;
99
+ if (Math.abs(effectiveScale - 1) <= 0.005) return null;
100
+ return {
101
+ effectiveScale,
102
+ rawScale,
103
+ mapUnitScale: mus,
104
+ lengthUnitScale: lus,
105
+ expectedScale: lus / mus,
106
+ };
107
+ }
108
+
26
109
  export function inferMapUnitScale(mapUnit: string | undefined, fallback?: number): number | undefined {
27
110
  if (!mapUnit) return fallback;
28
111
  const normalized = mapUnit.toUpperCase();
@@ -19,6 +19,7 @@ import proj4 from 'proj4';
19
19
  import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
20
20
  import type { CoordinateInfo } from '@ifc-lite/geometry';
21
21
  import { lookupProj4 } from '@ifc-lite/data';
22
+ import { getEffectiveHorizontalScale } from './effective-georef';
22
23
 
23
24
  export interface LatLon {
24
25
  lat: number;
@@ -230,20 +231,24 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
230
231
  */
231
232
  function computeProjectedCenter(
232
233
  conversion: MapConversion,
233
- coordinateInfo?: CoordinateInfo,
234
- lengthUnitScale = 1,
234
+ coordinateInfo: CoordinateInfo | undefined,
235
+ mapUnitScale: number,
236
+ lengthUnitScale: number,
235
237
  ): { easting: number; northing: number } {
236
238
  const { ifcX, ifcY } = computeLocalIfcCenter(coordinateInfo);
237
239
 
238
240
  // Geometry coordinates (ifcX, ifcY) are already in metres — the geometry engine
239
241
  // converts from the IFC file's native unit during extraction. Only MapConversion
240
242
  // values (eastings, northings) are in the file's native unit and need scaling.
241
- const scale = conversion.scale ?? 1.0;
243
+ // IfcMapConversion.Scale converts project length unit → map unit (e.g. 0.001
244
+ // for mm→m); since geometry is already in metres, use the effective scale —
245
+ // see issue #595.
246
+ const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, lengthUnitScale);
242
247
  const abscissa = conversion.xAxisAbscissa ?? 1.0;
243
248
  const ordinate = conversion.xAxisOrdinate ?? 0.0;
244
249
 
245
- const easting = conversion.eastings * lengthUnitScale + scale * (abscissa * ifcX - ordinate * ifcY);
246
- const northing = conversion.northings * lengthUnitScale + scale * (ordinate * ifcX + abscissa * ifcY);
250
+ const easting = conversion.eastings * mapUnitScale + scale * (abscissa * ifcX - ordinate * ifcY);
251
+ const northing = conversion.northings * mapUnitScale + scale * (ordinate * ifcX + abscissa * ifcY);
247
252
 
248
253
  return { easting, northing };
249
254
  }
@@ -281,7 +286,7 @@ export async function reprojectToLatLon(
281
286
  // MapConversion values use the unit from IfcProjectedCRS.MapUnit. If MapUnit
282
287
  // is not specified, the IFC spec defaults to the project's length unit.
283
288
  const mapScale = crs.mapUnitScale ?? lengthUnitScale;
284
- const { easting, northing } = computeProjectedCenter(conversion, coordinateInfo, mapScale);
289
+ const { easting, northing } = computeProjectedCenter(conversion, coordinateInfo, mapScale, lengthUnitScale);
285
290
 
286
291
  try {
287
292
  const [lon, lat] = proj4(projDef, 'WGS84', [easting, northing]);
@@ -349,11 +354,12 @@ export async function reprojectFromLatLon(
349
354
  const mapScale = crs.mapUnitScale ?? lengthUnitScale;
350
355
  const invScale = mapScale !== 0 ? 1 / mapScale : 1;
351
356
  const { ifcX, ifcY } = computeLocalIfcCenter(coordinateInfo);
352
- const scale = conversion?.scale ?? 1.0;
357
+ // Effective horizontal scale for metre-converted geometry — see issue #595.
358
+ const scale = getEffectiveHorizontalScale(conversion?.scale, mapScale, lengthUnitScale);
353
359
  const abscissa = conversion?.xAxisAbscissa ?? 1.0;
354
360
  const ordinate = conversion?.xAxisOrdinate ?? 0.0;
355
361
 
356
- // Result is in IFC native units (the reverse of: E_native * LUS + geom_offset = E_metres)
362
+ // Result is in IFC native units (the reverse of: E_native * mapScale + geom_offset = E_metres)
357
363
  const easting = (projE - scale * (abscissa * ifcX - ordinate * ifcY)) * invScale;
358
364
  const northing = (projN - scale * (ordinate * ifcX + abscissa * ifcY)) * invScale;
359
365
 
@@ -387,7 +393,9 @@ export async function computeFootprintGeoJSON(
387
393
  return null;
388
394
  }
389
395
 
390
- const scale = conversion.scale ?? 1.0;
396
+ // Effective horizontal scale for metre-converted geometry — see issue #595.
397
+ const mapScale = crs.mapUnitScale ?? lengthUnitScale;
398
+ const scale = getEffectiveHorizontalScale(conversion.scale, mapScale, lengthUnitScale);
391
399
  const abscissa = conversion.xAxisAbscissa ?? 1.0;
392
400
  const ordinate = conversion.xAxisOrdinate ?? 0.0;
393
401
 
@@ -418,8 +426,8 @@ export async function computeFootprintGeoJSON(
418
426
  const ifcX = worldX;
419
427
  const ifcY = -worldZ;
420
428
 
421
- // Geometry coords (ifcX/Y) are already in metres; only MapConversion needs scaling
422
- const mapScale = crs.mapUnitScale ?? lengthUnitScale;
429
+ // Geometry coords (ifcX/Y) are already in metres; MapConversion values
430
+ // are converted to metres via mapScale.
423
431
  const easting = conversion.eastings * mapScale + scale * (abscissa * ifcX - ordinate * ifcY);
424
432
  const northing = conversion.northings * mapScale + scale * (ordinate * ifcX + abscissa * ifcY);
425
433
 
@@ -443,14 +451,19 @@ export async function computeFootprintGeoJSON(
443
451
  * Returns height in metres above sea level, or null on failure.
444
452
  */
445
453
  export async function queryTerrainElevation(latLon: LatLon): Promise<number | null> {
454
+ const controller = new AbortController();
455
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
446
456
  try {
447
457
  const url = `https://api.open-meteo.com/v1/elevation?latitude=${latLon.lat}&longitude=${latLon.lon}`;
448
- const resp = await fetch(url);
458
+ const resp = await fetch(url, { signal: controller.signal });
449
459
  if (!resp.ok) return null;
450
460
  const data = await resp.json();
451
461
  const elev = data?.elevation?.[0];
452
462
  return typeof elev === 'number' && Number.isFinite(elev) ? elev : null;
453
- } catch {
463
+ } catch (err) {
464
+ console.warn(`[reproject] queryTerrainElevation failed for ${latLon.lat},${latLon.lon}:`, err);
454
465
  return null;
466
+ } finally {
467
+ clearTimeout(timeoutId);
455
468
  }
456
469
  }