@ifc-lite/viewer 1.19.1 → 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.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-BXeEKqJG.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
|
|
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
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
viewer.camera.lookAtTransform(
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
//
|
|
229
|
-
const
|
|
230
|
-
|
|
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(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/lib/geo/reproject.ts
CHANGED
|
@@ -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
|
|
234
|
-
|
|
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
|
-
|
|
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 *
|
|
246
|
-
const northing = conversion.northings *
|
|
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
|
-
|
|
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 *
|
|
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
|
-
|
|
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;
|
|
422
|
-
|
|
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
|
}
|