@ifc-lite/viewer 1.21.0 → 1.22.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 +57 -50
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/assets/arrow-fie-E7fe.js +20 -0
- package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
- package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
- package/dist/assets/bcf-Bhx-K17f.js +281 -0
- package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
- package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
- package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
- package/dist/assets/e57-source-CQHxE8n3.js +1 -0
- package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
- package/dist/assets/exporters-KTio0Tdm.js +5723 -0
- package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
- package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
- package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
- package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
- package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
- package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
- package/dist/assets/index-BZC2YaOP.css +1 -0
- package/dist/assets/index-HqAIQkr6.js +22 -0
- package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
- package/dist/assets/las-BW6LIc_j.js +1 -0
- package/dist/assets/las-source-C_IGrgRq.js +1 -0
- package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
- package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
- package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
- package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
- package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
- package/dist/assets/ply-source-C8jjyzxE.js +4 -0
- package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
- package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
- package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
- package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
- package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
- package/dist/assets/zip-BJqVbRkU.js +2 -0
- package/dist/index.html +10 -12
- package/package.json +11 -11
- package/src/components/mcp/PlaygroundChat.tsx +90 -52
- package/src/components/viewer/CesiumOverlay.tsx +150 -91
- package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
- package/src/components/viewer/ChatPanel.tsx +76 -93
- package/src/components/viewer/EntityContextMenu.tsx +68 -10
- package/src/components/viewer/MainToolbar.tsx +33 -3
- package/src/components/viewer/ViewportContainer.tsx +70 -16
- package/src/components/viewer/ViewportOverlays.tsx +2 -98
- package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
- package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
- package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
- package/src/components/viewer/selectionHandlers.ts +7 -1
- package/src/lib/geo/cesium-bridge.ts +86 -50
- package/src/lib/geo/cesium-placement.test.ts +244 -0
- package/src/lib/geo/cesium-placement.ts +231 -0
- package/src/lib/geo/effective-georef.test.ts +74 -1
- package/src/lib/geo/effective-georef.ts +40 -93
- package/src/lib/geo/geo-scale.ts +104 -0
- package/src/lib/geo/reproject.test.ts +130 -0
- package/src/lib/geo/reproject.ts +37 -12
- package/src/lib/geo/terrain-elevation.ts +198 -89
- package/src/lib/lens/adapter.ts +52 -6
- package/src/lib/llm/clipboard-detect.test.ts +150 -0
- package/src/lib/llm/clipboard-detect.ts +90 -0
- package/src/lib/llm/models.ts +28 -0
- package/src/lib/llm/stream-direct.ts +16 -4
- package/src/lib/llm/types.ts +8 -0
- package/src/services/playground-model.ts +55 -0
- package/src/store/index.ts +4 -5
- package/src/store/slices/cesiumSlice.ts +100 -19
- package/src/store.ts +3 -0
- package/dist/assets/arrow-CZ5kQ26f.js +0 -20
- package/dist/assets/bcf-4K724hw0.js +0 -281
- package/dist/assets/cesium-DUOzBlqv.js +0 -17817
- package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
- package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
- package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
- package/dist/assets/index-CSWgTe1s.css +0 -1
- package/dist/assets/index-XwKzDuw6.js +0 -22
- package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
- package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
- package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
- package/dist/assets/zip-DBEtpeu6.js +0 -12
- package/src/components/viewer/CesiumSettingsDialog.tsx +0 -100
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
import { describe, it } from 'node:test';
|
|
6
6
|
import assert from 'node:assert';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
detectScaleUnitMismatch,
|
|
10
|
+
getEffectiveHorizontalScale,
|
|
11
|
+
inferMapUnitScale,
|
|
12
|
+
mergeMapConversion,
|
|
13
|
+
mergeProjectedCRS,
|
|
14
|
+
supportsStandardGeoreferencing,
|
|
15
|
+
} from './effective-georef.js';
|
|
9
16
|
import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
|
|
10
17
|
|
|
11
18
|
describe('effective georeferencing', () => {
|
|
@@ -37,6 +44,72 @@ describe('effective georeferencing', () => {
|
|
|
37
44
|
assert.strictEqual(merged?.mapUnitScale, 2.5);
|
|
38
45
|
});
|
|
39
46
|
|
|
47
|
+
it('treats IFC2X3 files with IfcMapConversion and IfcProjectedCRS as standard georeferencing', () => {
|
|
48
|
+
assert.strictEqual(
|
|
49
|
+
supportsStandardGeoreferencing('IFC2X3', {
|
|
50
|
+
source: 'mapConversion',
|
|
51
|
+
projectedCRS: {
|
|
52
|
+
id: 1,
|
|
53
|
+
name: 'EPSG:2056',
|
|
54
|
+
},
|
|
55
|
+
mapConversion: {
|
|
56
|
+
id: 2,
|
|
57
|
+
sourceCRS: 10,
|
|
58
|
+
targetCRS: 11,
|
|
59
|
+
eastings: 2681750,
|
|
60
|
+
northings: 1225750,
|
|
61
|
+
orthogonalHeight: 0,
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
true,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('treats IFC2X3 files with only IfcMapConversion (no IfcProjectedCRS name yet) as editable', () => {
|
|
69
|
+
// Extension-bearing IFC2X3 files sometimes carry one half of the
|
|
70
|
+
// georef pair; once we've parsed it, the editor should surface the
|
|
71
|
+
// data instead of hiding behind a schema notice. See issue #683.
|
|
72
|
+
assert.strictEqual(
|
|
73
|
+
supportsStandardGeoreferencing('IFC2X3', {
|
|
74
|
+
source: 'mapConversion',
|
|
75
|
+
mapConversion: {
|
|
76
|
+
id: 2,
|
|
77
|
+
sourceCRS: 10,
|
|
78
|
+
targetCRS: 11,
|
|
79
|
+
eastings: 2681750,
|
|
80
|
+
northings: 1225750,
|
|
81
|
+
orthogonalHeight: 0,
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
true,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('treats IFC2X3 files with only IfcProjectedCRS as editable so users can add IfcMapConversion', () => {
|
|
89
|
+
assert.strictEqual(
|
|
90
|
+
supportsStandardGeoreferencing('IFC2X3', {
|
|
91
|
+
projectedCRS: {
|
|
92
|
+
id: 1,
|
|
93
|
+
name: 'EPSG:2056',
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
true,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps pure IfcSite IFC2X3 geolocation in read-only mode', () => {
|
|
101
|
+
assert.strictEqual(
|
|
102
|
+
supportsStandardGeoreferencing('IFC2X3', {
|
|
103
|
+
source: 'siteLocation',
|
|
104
|
+
projectedCRS: {
|
|
105
|
+
id: 226,
|
|
106
|
+
name: 'EPSG:4326',
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
40
113
|
it('overlays edited IfcMapConversion fields without dropping original rotation and scale', () => {
|
|
41
114
|
const original: MapConversion = {
|
|
42
115
|
id: 2,
|
|
@@ -11,6 +11,19 @@ import {
|
|
|
11
11
|
type ProjectedCRS,
|
|
12
12
|
} from '@ifc-lite/parser';
|
|
13
13
|
import type { CoordinateInfo } from '@ifc-lite/geometry';
|
|
14
|
+
import {
|
|
15
|
+
detectScaleUnitMismatch,
|
|
16
|
+
getEffectiveHorizontalScale,
|
|
17
|
+
inferMapUnitScale,
|
|
18
|
+
type ScaleUnitMismatch,
|
|
19
|
+
} from './geo-scale';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
detectScaleUnitMismatch,
|
|
23
|
+
getEffectiveHorizontalScale,
|
|
24
|
+
inferMapUnitScale,
|
|
25
|
+
type ScaleUnitMismatch,
|
|
26
|
+
} from './geo-scale';
|
|
14
27
|
|
|
15
28
|
export interface GeorefMutationDataLike {
|
|
16
29
|
projectedCRS?: Partial<ProjectedCRS>;
|
|
@@ -23,102 +36,35 @@ export interface EffectiveGeoreference extends GeoreferenceInfo {
|
|
|
23
36
|
lengthUnitScale: number;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
};
|
|
39
|
+
export function hasStandardGeoreferencing(
|
|
40
|
+
georef: Pick<GeoreferenceInfo, 'source' | 'projectedCRS' | 'mapConversion'> | null | undefined,
|
|
41
|
+
): boolean {
|
|
42
|
+
return Boolean(
|
|
43
|
+
georef
|
|
44
|
+
&& georef.source !== 'siteLocation'
|
|
45
|
+
&& georef.projectedCRS?.name
|
|
46
|
+
&& georef.mapConversion,
|
|
47
|
+
);
|
|
107
48
|
}
|
|
108
49
|
|
|
109
|
-
export function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
50
|
+
export function supportsStandardGeoreferencing(
|
|
51
|
+
schemaVersion: string | undefined,
|
|
52
|
+
georef: Pick<GeoreferenceInfo, 'source' | 'projectedCRS' | 'mapConversion'> | null | undefined,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (hasStandardGeoreferencing(georef)) return true;
|
|
55
|
+
// Any extracted IfcProjectedCRS / IfcMapConversion makes editing useful,
|
|
56
|
+
// regardless of the declared schema. IFC2X3 files commonly carry these
|
|
57
|
+
// via extensions; once we've parsed them, surface the full editor instead
|
|
58
|
+
// of hiding behind a schema-version notice that contradicts what the
|
|
59
|
+
// properties panel already shows for the same entities.
|
|
60
|
+
if (
|
|
61
|
+
georef
|
|
62
|
+
&& georef.source !== 'siteLocation'
|
|
63
|
+
&& (georef.projectedCRS?.name || georef.mapConversion)
|
|
64
|
+
) {
|
|
65
|
+
return true;
|
|
114
66
|
}
|
|
115
|
-
|
|
116
|
-
if (normalized.includes('MILLI')) return 0.001;
|
|
117
|
-
if (normalized.includes('CENTI')) return 0.01;
|
|
118
|
-
if (normalized.includes('DECI')) return 0.1;
|
|
119
|
-
if (normalized.includes('KILO')) return 1000;
|
|
120
|
-
if (normalized.includes('METRE') || normalized.includes('METER')) return 1;
|
|
121
|
-
return fallback;
|
|
67
|
+
return !schemaVersion?.toUpperCase().includes('2X3');
|
|
122
68
|
}
|
|
123
69
|
|
|
124
70
|
export function getIfcLengthUnitScale(dataStore: IfcDataStore | null | undefined): number {
|
|
@@ -189,6 +135,7 @@ export function getEffectiveGeoreference(
|
|
|
189
135
|
mapConversion,
|
|
190
136
|
coordinateInfo,
|
|
191
137
|
lengthUnitScale,
|
|
138
|
+
source: original?.source,
|
|
192
139
|
transformMatrix: original?.transformMatrix,
|
|
193
140
|
};
|
|
194
141
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
* Compute the effective horizontal scale to apply to viewer-space coordinates
|
|
7
|
+
* (which are already in metres) when transforming through IfcMapConversion.
|
|
8
|
+
*
|
|
9
|
+
* Per the IFC schema, IfcMapConversion.Scale converts LOCAL ENGINEERING
|
|
10
|
+
* coordinates (in the project's length unit) to MAP coordinates (in the map
|
|
11
|
+
* CRS unit). For a typical file with mm project units and m map units, the
|
|
12
|
+
* Scale attribute is 0.001.
|
|
13
|
+
*
|
|
14
|
+
* The IFC formula is:
|
|
15
|
+
* E_map_units = Eastings + (X_local * absc - Y_local * ordi) * Scale
|
|
16
|
+
*
|
|
17
|
+
* To produce metres for proj4, we multiply by mapUnitScale; and X_local can be
|
|
18
|
+
* recovered from the metre-converted geometry as X_metres / lengthUnitScale.
|
|
19
|
+
* Substituting:
|
|
20
|
+
* E_metres = mapUnitScale * Eastings
|
|
21
|
+
* + (mapUnitScale * Scale / lengthUnitScale)
|
|
22
|
+
* * (X_metres * absc - Y_metres * ordi)
|
|
23
|
+
*
|
|
24
|
+
* So when geometry has already been converted to metres (as ifc-lite does),
|
|
25
|
+
* the effective horizontal scale is (Scale * mapUnitScale) / lengthUnitScale.
|
|
26
|
+
* For files where Scale is set per IFC spec to bridge the unit difference
|
|
27
|
+
* (Scale = lengthUnitScale / mapUnitScale), this evaluates to 1.0 and the
|
|
28
|
+
* geometry passes through unchanged. Applying the raw Scale would otherwise
|
|
29
|
+
* double-scale and shrink/expand the model — see issue #595.
|
|
30
|
+
*/
|
|
31
|
+
export function getEffectiveHorizontalScale(
|
|
32
|
+
ifcMapConversionScale: number | undefined,
|
|
33
|
+
mapUnitScale: number,
|
|
34
|
+
lengthUnitScale: number,
|
|
35
|
+
): number {
|
|
36
|
+
const scale = ifcMapConversionScale ?? 1.0;
|
|
37
|
+
const lus = lengthUnitScale > 0 ? lengthUnitScale : 1;
|
|
38
|
+
const mus = mapUnitScale > 0 ? mapUnitScale : 1;
|
|
39
|
+
return (scale * mus) / lus;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ScaleUnitMismatch {
|
|
43
|
+
/** Effective horizontal scale applied to viewer-space (metre) geometry. */
|
|
44
|
+
effectiveScale: number;
|
|
45
|
+
/** Raw IfcMapConversion.Scale (or 1 if absent). */
|
|
46
|
+
rawScale: number;
|
|
47
|
+
/** Map unit → metres factor (e.g. 1 for METRE, 0.001 for MILLIMETRE). */
|
|
48
|
+
mapUnitScale: number;
|
|
49
|
+
/** Project length unit → metres factor. */
|
|
50
|
+
lengthUnitScale: number;
|
|
51
|
+
/**
|
|
52
|
+
* Scale value the file would need for the IFC formula to map local→map
|
|
53
|
+
* coordinates without any extra scaling (i.e. lengthUnitScale / mapUnitScale).
|
|
54
|
+
*/
|
|
55
|
+
expectedScale: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Detect when IfcMapConversion.Scale is inconsistent with the project and map
|
|
60
|
+
* units. Per the IFC schema, Scale × mapUnitScale should equal lengthUnitScale
|
|
61
|
+
* (i.e. effectiveScale = 1.0). A deviation usually means the authoring tool
|
|
62
|
+
* forgot to set Scale to bridge a unit difference (e.g. mm project + m map
|
|
63
|
+
* with Scale=1.0). Files like this render at the wrong size in any tool that
|
|
64
|
+
* follows the schema strictly — see issue #595.
|
|
65
|
+
*
|
|
66
|
+
* Returns null when the values are consistent (within 0.5% of 1.0); otherwise
|
|
67
|
+
* returns the diagnostic data so callers can surface a warning.
|
|
68
|
+
*/
|
|
69
|
+
export function detectScaleUnitMismatch(
|
|
70
|
+
ifcMapConversionScale: number | undefined,
|
|
71
|
+
mapUnitScale: number | undefined,
|
|
72
|
+
lengthUnitScale: number | undefined,
|
|
73
|
+
): ScaleUnitMismatch | null {
|
|
74
|
+
const lus = lengthUnitScale && lengthUnitScale > 0 ? lengthUnitScale : 1;
|
|
75
|
+
const mus = mapUnitScale && mapUnitScale > 0 ? mapUnitScale : 1;
|
|
76
|
+
const rawScale = ifcMapConversionScale ?? 1.0;
|
|
77
|
+
const effectiveScale = (rawScale * mus) / lus;
|
|
78
|
+
if (Math.abs(effectiveScale - 1) <= 0.005) return null;
|
|
79
|
+
return {
|
|
80
|
+
effectiveScale,
|
|
81
|
+
rawScale,
|
|
82
|
+
mapUnitScale: mus,
|
|
83
|
+
lengthUnitScale: lus,
|
|
84
|
+
expectedScale: lus / mus,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function inferMapUnitScale(
|
|
89
|
+
mapUnit: string | undefined,
|
|
90
|
+
fallback?: number,
|
|
91
|
+
): number | undefined {
|
|
92
|
+
if (!mapUnit) return fallback;
|
|
93
|
+
const normalized = mapUnit.toUpperCase();
|
|
94
|
+
if (normalized.includes('US') && (normalized.includes('SURVEY') || normalized.includes('FTUS'))) {
|
|
95
|
+
return 0.3048006096;
|
|
96
|
+
}
|
|
97
|
+
if (normalized.includes('FOOT') || normalized.includes('FEET')) return 0.3048;
|
|
98
|
+
if (normalized.includes('MILLI')) return 0.001;
|
|
99
|
+
if (normalized.includes('CENTI')) return 0.01;
|
|
100
|
+
if (normalized.includes('DECI')) return 0.1;
|
|
101
|
+
if (normalized.includes('KILO')) return 1000;
|
|
102
|
+
if (normalized.includes('METRE') || normalized.includes('METER')) return 1;
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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 { computeCesiumModelOrigin } from './cesium-bridge.js';
|
|
9
|
+
import {
|
|
10
|
+
computeFootprintGeoJSON,
|
|
11
|
+
computeModelCenterInIfcMeters,
|
|
12
|
+
reprojectFromLatLon,
|
|
13
|
+
reprojectToLatLon,
|
|
14
|
+
resolveProjection,
|
|
15
|
+
} from './reproject.js';
|
|
16
|
+
import type { CoordinateInfo } from '@ifc-lite/geometry';
|
|
17
|
+
import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
|
|
18
|
+
|
|
19
|
+
function makeCoordinateInfo(): CoordinateInfo {
|
|
20
|
+
return {
|
|
21
|
+
originShift: { x: 1000, y: 5, z: 2000 },
|
|
22
|
+
originalBounds: {
|
|
23
|
+
min: { x: -10, y: -1, z: -20 },
|
|
24
|
+
max: { x: 10, y: 11, z: 20 },
|
|
25
|
+
},
|
|
26
|
+
shiftedBounds: {
|
|
27
|
+
min: { x: -10, y: -1, z: -20 },
|
|
28
|
+
max: { x: 10, y: 11, z: 20 },
|
|
29
|
+
},
|
|
30
|
+
hasLargeCoordinates: true,
|
|
31
|
+
wasmRtcOffset: { x: 3, y: 7, z: 11 },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('reproject helpers', () => {
|
|
36
|
+
it('computes the IFC-space model center from originShift and RTC', () => {
|
|
37
|
+
const center = computeModelCenterInIfcMeters(makeCoordinateInfo());
|
|
38
|
+
assert.deepStrictEqual(center, {
|
|
39
|
+
ifcX: 1003,
|
|
40
|
+
ifcY: -1993,
|
|
41
|
+
ifcZ: 21,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('round-trips the #652 EPSG:5514 issue fixture coordinates', async () => {
|
|
46
|
+
const crs: ProjectedCRS = {
|
|
47
|
+
id: 114,
|
|
48
|
+
name: 'EPSG:5514',
|
|
49
|
+
verticalDatum: 'EPSG:8357',
|
|
50
|
+
mapUnit: 'METRE',
|
|
51
|
+
mapUnitScale: 1,
|
|
52
|
+
};
|
|
53
|
+
const conversion: MapConversion = {
|
|
54
|
+
id: 115,
|
|
55
|
+
sourceCRS: 14,
|
|
56
|
+
targetCRS: 114,
|
|
57
|
+
eastings: -740344,
|
|
58
|
+
northings: -1048817,
|
|
59
|
+
orthogonalHeight: 244,
|
|
60
|
+
scale: 0.001,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const latLon = await reprojectToLatLon(conversion, crs, undefined, 0.001);
|
|
64
|
+
assert.ok(latLon);
|
|
65
|
+
const roundTrip = await reprojectFromLatLon(latLon!, crs, conversion, undefined, 0.001);
|
|
66
|
+
assert.ok(roundTrip);
|
|
67
|
+
assert.ok(Math.abs(roundTrip!.easting - conversion.eastings) < 0.001);
|
|
68
|
+
assert.ok(Math.abs(roundTrip!.northing - conversion.northings) < 0.001);
|
|
69
|
+
|
|
70
|
+
const origin = await computeCesiumModelOrigin(conversion, crs, undefined, 0.001);
|
|
71
|
+
assert.ok(origin);
|
|
72
|
+
assert.ok(Math.abs(origin!.longitude - latLon!.lon) < 1e-9);
|
|
73
|
+
assert.ok(Math.abs(origin!.latitude - latLon!.lat) < 1e-9);
|
|
74
|
+
assert.strictEqual(origin!.ifcOriginHeight, 244);
|
|
75
|
+
assert.strictEqual(origin!.horizontalScale, 1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('resolves EPSG:28992 and round-trips projected coordinates', async () => {
|
|
79
|
+
const crs: ProjectedCRS = {
|
|
80
|
+
id: 1,
|
|
81
|
+
name: 'EPSG:28992',
|
|
82
|
+
mapUnit: 'METRE',
|
|
83
|
+
mapUnitScale: 1,
|
|
84
|
+
};
|
|
85
|
+
const conversion: MapConversion = {
|
|
86
|
+
id: 2,
|
|
87
|
+
sourceCRS: 10,
|
|
88
|
+
targetCRS: 1,
|
|
89
|
+
eastings: 121687.331,
|
|
90
|
+
northings: 487326.994,
|
|
91
|
+
orthogonalHeight: 0,
|
|
92
|
+
xAxisAbscissa: 1,
|
|
93
|
+
xAxisOrdinate: 0,
|
|
94
|
+
scale: 1,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const projDef = await resolveProjection(crs);
|
|
98
|
+
assert.ok(projDef);
|
|
99
|
+
|
|
100
|
+
const latLon = await reprojectToLatLon(conversion, crs);
|
|
101
|
+
assert.ok(latLon);
|
|
102
|
+
const roundTrip = await reprojectFromLatLon(latLon!, crs, conversion);
|
|
103
|
+
assert.ok(roundTrip);
|
|
104
|
+
assert.ok(Math.abs(roundTrip!.easting - conversion.eastings) < 0.01);
|
|
105
|
+
assert.ok(Math.abs(roundTrip!.northing - conversion.northings) < 0.01);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('builds a closed footprint polygon and preserves corner count', async () => {
|
|
109
|
+
const crs: ProjectedCRS = {
|
|
110
|
+
id: 114,
|
|
111
|
+
name: 'EPSG:5514',
|
|
112
|
+
mapUnit: 'METRE',
|
|
113
|
+
mapUnitScale: 1,
|
|
114
|
+
};
|
|
115
|
+
const conversion: MapConversion = {
|
|
116
|
+
id: 115,
|
|
117
|
+
sourceCRS: 14,
|
|
118
|
+
targetCRS: 114,
|
|
119
|
+
eastings: -740344,
|
|
120
|
+
northings: -1048817,
|
|
121
|
+
orthogonalHeight: 244,
|
|
122
|
+
scale: 0.001,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const footprint = await computeFootprintGeoJSON(conversion, crs, makeCoordinateInfo(), 0.001);
|
|
126
|
+
assert.ok(footprint);
|
|
127
|
+
assert.strictEqual(footprint!.length, 5);
|
|
128
|
+
assert.deepStrictEqual(footprint![0], footprint![4]);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/lib/geo/reproject.ts
CHANGED
|
@@ -19,7 +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 './
|
|
22
|
+
import { getEffectiveHorizontalScale } from './geo-scale';
|
|
23
23
|
|
|
24
24
|
export interface LatLon {
|
|
25
25
|
lat: number;
|
|
@@ -28,6 +28,7 @@ export interface LatLon {
|
|
|
28
28
|
|
|
29
29
|
// Cache resolved projection definitions (from any source).
|
|
30
30
|
const projDefCache = new Map<string, string | null>();
|
|
31
|
+
const approxDatumWarningCache = new Set<string>();
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Extract EPSG numeric code from a CRS name like "EPSG:32632" or "EPSG 2056".
|
|
@@ -90,7 +91,7 @@ const DATUM_TOWGS84: Record<string, string> = {
|
|
|
90
91
|
* Strip +nadgrids=... from a proj4 string and add a +towgs84 approximation
|
|
91
92
|
* based on the ellipsoid. Grid files cannot be loaded in the browser.
|
|
92
93
|
*/
|
|
93
|
-
function sanitizeProj4(def: string): string {
|
|
94
|
+
function sanitizeProj4(def: string, code?: string | null): string {
|
|
94
95
|
if (!def.includes('+nadgrids') || def.includes('+nadgrids=@null')) return def;
|
|
95
96
|
|
|
96
97
|
// Extract the ellipsoid to find the right towgs84 approximation
|
|
@@ -98,6 +99,15 @@ function sanitizeProj4(def: string): string {
|
|
|
98
99
|
const ellps = ellpsMatch?.[1] ?? '';
|
|
99
100
|
const towgs84 = DATUM_TOWGS84[ellps] ?? '+towgs84=0,0,0,0,0,0,0';
|
|
100
101
|
|
|
102
|
+
if (code && !approxDatumWarningCache.has(code)) {
|
|
103
|
+
approxDatumWarningCache.add(code);
|
|
104
|
+
console.warn(
|
|
105
|
+
`[reproject] EPSG:${code} requires browser-unavailable datum grids; `
|
|
106
|
+
+ 'using an approximate +towgs84 transform instead. '
|
|
107
|
+
+ 'Expect metre-level XY differences for some locations.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
// Remove +nadgrids=... and add +towgs84
|
|
102
112
|
return def.replace(/\+nadgrids=\S+/g, '').replace(/\s+/g, ' ').trim() + ' ' + towgs84;
|
|
103
113
|
}
|
|
@@ -142,7 +152,7 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
|
|
|
142
152
|
try {
|
|
143
153
|
const bundled = await lookupProj4(code);
|
|
144
154
|
if (bundled) {
|
|
145
|
-
const sanitized = sanitizeProj4(bundled);
|
|
155
|
+
const sanitized = sanitizeProj4(bundled, code);
|
|
146
156
|
projDefCache.set(code, sanitized);
|
|
147
157
|
return sanitized;
|
|
148
158
|
}
|
|
@@ -163,7 +173,7 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
|
|
|
163
173
|
try {
|
|
164
174
|
const bundled = await lookupProj4(code);
|
|
165
175
|
if (bundled) {
|
|
166
|
-
const sanitized = sanitizeProj4(bundled);
|
|
176
|
+
const sanitized = sanitizeProj4(bundled, code);
|
|
167
177
|
projDefCache.set(code, sanitized);
|
|
168
178
|
// For geographic CRS (longlat), check if we can infer a projected CRS
|
|
169
179
|
// from the UTM zone metadata — a projected CRS is much more useful.
|
|
@@ -205,7 +215,7 @@ export async function resolveProjection(crs: ProjectedCRS): Promise<string | nul
|
|
|
205
215
|
// 5. Network fallback — fetch from epsg.io
|
|
206
216
|
if (code) {
|
|
207
217
|
const raw = await fetchProj4Def(code);
|
|
208
|
-
const fetched = raw ? sanitizeProj4(raw) : null;
|
|
218
|
+
const fetched = raw ? sanitizeProj4(raw, code) : null;
|
|
209
219
|
projDefCache.set(code, fetched);
|
|
210
220
|
return fetched;
|
|
211
221
|
}
|
|
@@ -235,7 +245,7 @@ function computeProjectedCenter(
|
|
|
235
245
|
mapUnitScale: number,
|
|
236
246
|
lengthUnitScale: number,
|
|
237
247
|
): { easting: number; northing: number } {
|
|
238
|
-
const { ifcX, ifcY } =
|
|
248
|
+
const { ifcX, ifcY } = computeModelCenterInIfcMeters(coordinateInfo);
|
|
239
249
|
|
|
240
250
|
// Geometry coordinates (ifcX, ifcY) are already in metres — the geometry engine
|
|
241
251
|
// converts from the IFC file's native unit during extraction. Only MapConversion
|
|
@@ -299,11 +309,13 @@ export async function reprojectToLatLon(
|
|
|
299
309
|
}
|
|
300
310
|
|
|
301
311
|
/**
|
|
302
|
-
* Compute the model's
|
|
303
|
-
* This is the geometry center
|
|
312
|
+
* Compute the model's center in IFC Z-up metres from coordinate info.
|
|
313
|
+
* This is the geometry center before MapConversion is applied.
|
|
304
314
|
*/
|
|
305
|
-
function
|
|
306
|
-
|
|
315
|
+
export function computeModelCenterInIfcMeters(
|
|
316
|
+
coordinateInfo?: CoordinateInfo,
|
|
317
|
+
): { ifcX: number; ifcY: number; ifcZ: number } {
|
|
318
|
+
if (!coordinateInfo) return { ifcX: 0, ifcY: 0, ifcZ: 0 };
|
|
307
319
|
|
|
308
320
|
const bounds = coordinateInfo.originalBounds;
|
|
309
321
|
const shift = coordinateInfo.originShift;
|
|
@@ -314,12 +326,18 @@ function computeLocalIfcCenter(coordinateInfo?: CoordinateInfo): { ifcX: number;
|
|
|
314
326
|
: { x: 0, y: 0, z: 0 };
|
|
315
327
|
|
|
316
328
|
const cx = (bounds.min.x + bounds.max.x) / 2;
|
|
329
|
+
const cy = (bounds.min.y + bounds.max.y) / 2;
|
|
317
330
|
const cz = (bounds.min.z + bounds.max.z) / 2;
|
|
318
331
|
|
|
319
332
|
const worldYupX = cx + shift.x + rtcYup.x;
|
|
333
|
+
const worldYupY = cy + shift.y + rtcYup.y;
|
|
320
334
|
const worldYupZ = cz + shift.z + rtcYup.z;
|
|
321
335
|
|
|
322
|
-
return {
|
|
336
|
+
return {
|
|
337
|
+
ifcX: worldYupX,
|
|
338
|
+
ifcY: -worldYupZ,
|
|
339
|
+
ifcZ: worldYupY,
|
|
340
|
+
};
|
|
323
341
|
}
|
|
324
342
|
|
|
325
343
|
/**
|
|
@@ -353,7 +371,7 @@ export async function reprojectFromLatLon(
|
|
|
353
371
|
// Geometry offsets (ifcX/Y) are already in metres.
|
|
354
372
|
const mapScale = crs.mapUnitScale ?? lengthUnitScale;
|
|
355
373
|
const invScale = mapScale !== 0 ? 1 / mapScale : 1;
|
|
356
|
-
const { ifcX, ifcY } =
|
|
374
|
+
const { ifcX, ifcY } = computeModelCenterInIfcMeters(coordinateInfo);
|
|
357
375
|
// Effective horizontal scale for metre-converted geometry — see issue #595.
|
|
358
376
|
const scale = getEffectiveHorizontalScale(conversion?.scale, mapScale, lengthUnitScale);
|
|
359
377
|
const abscissa = conversion?.xAxisAbscissa ?? 1.0;
|
|
@@ -393,6 +411,13 @@ export async function computeFootprintGeoJSON(
|
|
|
393
411
|
return null;
|
|
394
412
|
}
|
|
395
413
|
|
|
414
|
+
// Geographic CRS values are degrees, while the model bounds are metres.
|
|
415
|
+
// Without a projected CRS / map conversion, we can show the model pin but
|
|
416
|
+
// not a trustworthy footprint polygon.
|
|
417
|
+
if (isGeographicProj4(projDef)) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
396
421
|
// Effective horizontal scale for metre-converted geometry — see issue #595.
|
|
397
422
|
const mapScale = crs.mapUnitScale ?? lengthUnitScale;
|
|
398
423
|
const scale = getEffectiveHorizontalScale(conversion.scale, mapScale, lengthUnitScale);
|