@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.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +57 -50
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +10 -0
  4. package/dist/assets/arrow-fie-E7fe.js +20 -0
  5. package/dist/assets/ascii-points-source-bTjLVmUX.js +1 -0
  6. package/dist/assets/{basketViewActivator-Bzw51jhm.js → basketViewActivator-EHAhHlwN.js} +12 -13
  7. package/dist/assets/bcf-Bhx-K17f.js +281 -0
  8. package/dist/assets/{browser-C5TFR7sH.js → browser-CVf8ATeW.js} +6 -6
  9. package/dist/assets/cesium-B4ZIU9jS.js +17742 -0
  10. package/dist/assets/decode-worker-CYqSjk1n.js +172 -0
  11. package/dist/assets/e57-source-CQHxE8n3.js +1 -0
  12. package/dist/assets/emscripten-module.browser-DcFZLAUx.js +1 -0
  13. package/dist/assets/exporters-KTio0Tdm.js +5723 -0
  14. package/dist/assets/geometry-controller.worker-Cm2P_EJr.js +7 -0
  15. package/dist/assets/geometry.worker-DchLBqZ8.js +1 -0
  16. package/dist/assets/{ids-B7AXEv7h.js → ids-CS7VCFin.js} +5 -5
  17. package/dist/assets/ifc-lite-C6wEhXa6.js +7 -0
  18. package/dist/assets/{ifc-lite_bg-DlKs5-yM.wasm → ifc-lite_bg-CSeT3fNI.wasm} +0 -0
  19. package/dist/assets/{ifc-lite_bg-PqmRe3Ph.wasm → ifc-lite_bg-ns4cSnX2.wasm} +0 -0
  20. package/dist/assets/{index-DVNSvEMh.js → index-8k9h-ANq.js} +60997 -59926
  21. package/dist/assets/index-BZC2YaOP.css +1 -0
  22. package/dist/assets/index-HqAIQkr6.js +22 -0
  23. package/dist/assets/inline-worker-BpBzlmd6.js +1 -0
  24. package/dist/assets/las-BW6LIc_j.js +1 -0
  25. package/dist/assets/las-source-C_IGrgRq.js +1 -0
  26. package/dist/assets/laz-source-jj3xI5Y4.js +125 -0
  27. package/dist/assets/maplibre-gl-C4LXKM6c.js +808 -0
  28. package/dist/assets/{native-bridge-BiD01jI9.js → native-bridge-DNrEhx2R.js} +5 -8
  29. package/dist/assets/{parser.worker-Bnbrl6gy.js → parser.worker-BcjkIo89.js} +2 -2
  30. package/dist/assets/pcd-source-Ck0UnVDn.js +3 -0
  31. package/dist/assets/ply-source-C8jjyzxE.js +4 -0
  32. package/dist/assets/{exporters-u0sz2Upj.js → sandbox-BSn5MyEJ.js} +11745 -7412
  33. package/dist/assets/{server-client-DP8fMPY9.js → server-client-D-kU2XAF.js} +4 -4
  34. package/dist/assets/{three-CDRZThFA.js → three-DwNDHx9-.js} +163 -171
  35. package/dist/assets/wasm-bridge-Cha08LdC.js +1 -0
  36. package/dist/assets/{workerHelpers-CBbWSJmd.js → workerHelpers-pUUnk9Wc.js} +1 -1
  37. package/dist/assets/zip-BJqVbRkU.js +2 -0
  38. package/dist/index.html +10 -12
  39. package/package.json +11 -11
  40. package/src/components/mcp/PlaygroundChat.tsx +90 -52
  41. package/src/components/viewer/CesiumOverlay.tsx +150 -91
  42. package/src/components/viewer/CesiumPlacementEditor.tsx +1009 -0
  43. package/src/components/viewer/ChatPanel.tsx +76 -93
  44. package/src/components/viewer/EntityContextMenu.tsx +68 -10
  45. package/src/components/viewer/MainToolbar.tsx +33 -3
  46. package/src/components/viewer/ViewportContainer.tsx +70 -16
  47. package/src/components/viewer/ViewportOverlays.tsx +2 -98
  48. package/src/components/viewer/chat/ByokKeyModal.tsx +338 -0
  49. package/src/components/viewer/chat/ByokStreamingPill.tsx +62 -0
  50. package/src/components/viewer/chat/ByokTrustDiagram.tsx +192 -0
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +49 -52
  52. package/src/components/viewer/properties/ModelMetadataPanel.tsx +55 -44
  53. package/src/components/viewer/selectionHandlers.ts +7 -1
  54. package/src/lib/geo/cesium-bridge.ts +86 -50
  55. package/src/lib/geo/cesium-placement.test.ts +244 -0
  56. package/src/lib/geo/cesium-placement.ts +231 -0
  57. package/src/lib/geo/effective-georef.test.ts +74 -1
  58. package/src/lib/geo/effective-georef.ts +40 -93
  59. package/src/lib/geo/geo-scale.ts +104 -0
  60. package/src/lib/geo/reproject.test.ts +130 -0
  61. package/src/lib/geo/reproject.ts +37 -12
  62. package/src/lib/geo/terrain-elevation.ts +198 -89
  63. package/src/lib/lens/adapter.ts +52 -6
  64. package/src/lib/llm/clipboard-detect.test.ts +150 -0
  65. package/src/lib/llm/clipboard-detect.ts +90 -0
  66. package/src/lib/llm/models.ts +28 -0
  67. package/src/lib/llm/stream-direct.ts +16 -4
  68. package/src/lib/llm/types.ts +8 -0
  69. package/src/services/playground-model.ts +55 -0
  70. package/src/store/index.ts +4 -5
  71. package/src/store/slices/cesiumSlice.ts +100 -19
  72. package/src/store.ts +3 -0
  73. package/dist/assets/arrow-CZ5kQ26f.js +0 -20
  74. package/dist/assets/bcf-4K724hw0.js +0 -281
  75. package/dist/assets/cesium-DUOzBlqv.js +0 -17817
  76. package/dist/assets/decode-worker-t2EGKAxO.js +0 -1708
  77. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +0 -1
  78. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +0 -7
  79. package/dist/assets/geometry.worker-Bp4rW_R1.js +0 -1
  80. package/dist/assets/ifc-lite-DfZHk36-.js +0 -7
  81. package/dist/assets/index-CSWgTe1s.css +0 -1
  82. package/dist/assets/index-XwKzDuw6.js +0 -22
  83. package/dist/assets/maplibre-gl-CGLcoNXc.js +0 -811
  84. package/dist/assets/sandbox-DPD1ROr0.js +0 -9700
  85. package/dist/assets/wasm-bridge-CErti6zX.js +0 -1
  86. package/dist/assets/zip-DBEtpeu6.js +0 -12
  87. 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 { detectScaleUnitMismatch, getEffectiveHorizontalScale, inferMapUnitScale, mergeMapConversion, mergeProjectedCRS } from './effective-georef.js';
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
- * 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
- };
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 inferMapUnitScale(mapUnit: string | undefined, fallback?: number): number | undefined {
110
- if (!mapUnit) return fallback;
111
- const normalized = mapUnit.toUpperCase();
112
- if (normalized.includes('US') && (normalized.includes('SURVEY') || normalized.includes('FTUS'))) {
113
- return 0.3048006096;
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
- if (normalized.includes('FOOT') || normalized.includes('FEET')) return 0.3048;
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
+ });
@@ -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 './effective-georef';
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 } = computeLocalIfcCenter(coordinateInfo);
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 local IFC center offset (ifcX, ifcY) from coordinate info.
303
- * This is the geometry center in IFC Z-up coordinates, before MapConversion is applied.
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 computeLocalIfcCenter(coordinateInfo?: CoordinateInfo): { ifcX: number; ifcY: number } {
306
- if (!coordinateInfo) return { ifcX: 0, ifcY: 0 };
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 { ifcX: worldYupX, ifcY: -worldYupZ };
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 } = computeLocalIfcCenter(coordinateInfo);
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);