@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
@@ -28,9 +28,13 @@
28
28
  import proj4 from 'proj4';
29
29
  import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
30
30
  import type { CoordinateInfo } from '@ifc-lite/geometry';
31
- import { resolveProjection } from './reproject';
32
- import { resolveTerrainElevation } from './terrain-elevation';
33
- import { getEffectiveHorizontalScale } from './effective-georef';
31
+ import { computeModelCenterInIfcMeters, resolveProjection } from './reproject';
32
+ import {
33
+ resolveTerrainElevationDetailed,
34
+ type ResolveTerrainElevationOptions,
35
+ type TerrainElevationSample,
36
+ } from './terrain-elevation';
37
+ import { getEffectiveHorizontalScale } from './geo-scale';
34
38
 
35
39
  export interface GeodesicPosition {
36
40
  longitude: number;
@@ -61,11 +65,65 @@ export interface CesiumBridge {
61
65
  queryTerrainHeight(
62
66
  Cesium: typeof import('cesium'),
63
67
  viewer: InstanceType<typeof import('cesium').Viewer>,
64
- ): Promise<number | null>;
68
+ options?: ResolveTerrainElevationOptions,
69
+ ): Promise<TerrainElevationSample | null>;
65
70
 
66
71
  viewerToGeodetic(vx: number, vy: number, vz: number): GeodesicPosition | null;
67
72
  }
68
73
 
74
+ export interface CesiumModelOriginInfo extends GeodesicPosition {
75
+ longitude: number;
76
+ latitude: number;
77
+ height: number;
78
+ ifcOriginHeight: number;
79
+ easting: number;
80
+ northing: number;
81
+ horizontalScale: number;
82
+ }
83
+
84
+ export async function computeCesiumModelOrigin(
85
+ mapConversion: MapConversion,
86
+ projectedCRS: ProjectedCRS,
87
+ coordinateInfo?: CoordinateInfo,
88
+ lengthUnitScale = 1,
89
+ placementHeightOverride?: number,
90
+ ): Promise<CesiumModelOriginInfo | null> {
91
+ const projDef = await resolveProjection(projectedCRS);
92
+ if (!projDef) return null;
93
+
94
+ const absc = mapConversion.xAxisAbscissa ?? 1.0;
95
+ const ordi = mapConversion.xAxisOrdinate ?? 0.0;
96
+ const center = computeModelCenterInIfcMeters(coordinateInfo);
97
+ const mapScale = projectedCRS.mapUnitScale ?? lengthUnitScale;
98
+ const horizontalScale = getEffectiveHorizontalScale(
99
+ mapConversion.scale,
100
+ mapScale,
101
+ lengthUnitScale,
102
+ );
103
+ const easting = mapConversion.eastings * mapScale
104
+ + horizontalScale * (absc * center.ifcX - ordi * center.ifcY);
105
+ const northing = mapConversion.northings * mapScale
106
+ + horizontalScale * (ordi * center.ifcX + absc * center.ifcY);
107
+ const ifcOriginHeight = mapConversion.orthogonalHeight * mapScale + center.ifcZ;
108
+ const height = placementHeightOverride ?? ifcOriginHeight;
109
+
110
+ try {
111
+ const [lon, lat] = proj4(projDef, 'WGS84', [easting, northing]);
112
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
113
+ return {
114
+ longitude: lon,
115
+ latitude: lat,
116
+ height,
117
+ ifcOriginHeight,
118
+ easting,
119
+ northing,
120
+ horizontalScale,
121
+ };
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
69
127
  export async function createCesiumBridge(
70
128
  mapConversion: MapConversion,
71
129
  projectedCRS: ProjectedCRS,
@@ -87,57 +145,34 @@ export async function createCesiumBridge(
87
145
  const ordi = mapConversion.xAxisOrdinate ?? 0.0;
88
146
  const rotAngle = Math.atan2(ordi, absc);
89
147
 
90
- const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
91
- const rtc = coordinateInfo?.wasmRtcOffset;
92
- const rtcYup = rtc
93
- ? { x: rtc.x, y: rtc.z, z: -rtc.y }
94
- : { x: 0, y: 0, z: 0 };
95
-
96
148
  const bounds = coordinateInfo?.originalBounds;
97
149
  const modelVX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0;
98
150
  const modelVY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0;
99
151
  const modelVZ = bounds ? (bounds.min.z + bounds.max.z) / 2 : 0;
100
152
 
101
- // ── Compute model origin in WGS84 ──
102
- const owx = modelVX + shift.x + rtcYup.x;
103
- const owy = modelVY + shift.y + rtcYup.y;
104
- const owz = modelVZ + shift.z + rtcYup.z;
105
- // Viewer Y-up IFC Z-up
106
- const oIfcX = owx;
107
- const oIfcY = -owz;
108
- const oIfcZ = owy;
109
- // Geometry coordinates (oIfcX/Y/Z) are already in metres (the geometry engine
110
- // converts from the IFC file's native unit during extraction). MapConversion
111
- // values use the unit from IfcProjectedCRS.MapUnit; fall back to project unit.
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);
117
- const oEasting = mapConversion.eastings * mapScale + hScale * (absc * oIfcX - ordi * oIfcY);
118
- const oNorthing = mapConversion.northings * mapScale + hScale * (ordi * oIfcX + absc * oIfcY);
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;
125
-
126
- let originLon: number, originLat: number;
127
- try {
128
- const [lon, lat] = proj4(projDef, 'WGS84', [oEasting, oNorthing]);
129
- if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
130
- originLon = lon;
131
- originLat = lat;
132
- } catch {
133
- return null;
134
- }
135
-
153
+ const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
154
+ const rtc = coordinateInfo?.wasmRtcOffset;
155
+ const rtcYup = rtc
156
+ ? { x: rtc.x, y: rtc.z, z: -rtc.y }
157
+ : { x: 0, y: 0, z: 0 };
158
+ const origin = await computeCesiumModelOrigin(
159
+ mapConversion,
160
+ projectedCRS,
161
+ coordinateInfo,
162
+ lengthUnitScale,
163
+ placementHeightOverride,
164
+ );
165
+ if (!origin) return null;
136
166
  const modelOrigin: GeodesicPosition = {
137
- longitude: originLon,
138
- latitude: originLat,
139
- height: oHeight,
167
+ longitude: origin.longitude,
168
+ latitude: origin.latitude,
169
+ height: origin.height,
140
170
  };
171
+ const hScale = origin.horizontalScale;
172
+ const mapScale = projectedCRS.mapUnitScale ?? lengthUnitScale;
173
+ const oHeight = origin.height;
174
+ const originLon = origin.longitude;
175
+ const originLat = origin.latitude;
141
176
 
142
177
  // ── Build the viewer→ENU 3x3 rotation matrix ──
143
178
  // This converts a DELTA vector from viewer space to ENU.
@@ -301,8 +336,9 @@ export async function createCesiumBridge(
301
336
  function queryTerrainHeight(
302
337
  Cesium: typeof import('cesium'),
303
338
  viewer: InstanceType<typeof import('cesium').Viewer>,
304
- ): Promise<number | null> {
305
- return resolveTerrainElevation(Cesium, viewer, originLat, originLon);
339
+ options: ResolveTerrainElevationOptions = {},
340
+ ): Promise<TerrainElevationSample | null> {
341
+ return resolveTerrainElevationDetailed(Cesium, viewer, originLat, originLon, options);
306
342
  }
307
343
 
308
344
  function viewerToGeodetic(vx: number, vy: number, vz: number): GeodesicPosition | null {
@@ -0,0 +1,244 @@
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 {
9
+ closestYOnVerticalLineFromRay,
10
+ computeCesiumPlacement,
11
+ computeIfcOriginHeight,
12
+ computeOrthogonalHeightForBaseAltitude,
13
+ getMapUnitScale,
14
+ intersectRayWithHorizontalPlane,
15
+ mapUnitsToMeters,
16
+ metersToMapUnits,
17
+ projectedDeltaToViewerDelta,
18
+ shouldPreferOrthometricTerrain,
19
+ viewerDeltaToProjectedDelta,
20
+ } from './cesium-placement.js';
21
+
22
+ describe('cesium placement helpers', () => {
23
+ it('falls back to the project length unit when mapUnitScale is absent', () => {
24
+ assert.strictEqual(getMapUnitScale(undefined, 0.001), 0.001);
25
+ assert.strictEqual(getMapUnitScale({ mapUnitScale: 1 }, 0.001), 1);
26
+ });
27
+
28
+ it('converts between metres and map units using ProjectedCRS.mapUnitScale', () => {
29
+ const usFoot = { mapUnitScale: 0.3048006096 };
30
+ assert.strictEqual(mapUnitsToMeters(10, usFoot, 1), 3.048006096);
31
+ assert.strictEqual(
32
+ metersToMapUnits(3.048006096, usFoot, 1),
33
+ 10,
34
+ );
35
+ });
36
+
37
+ it('prefers orthometric terrain when a vertical datum is present', () => {
38
+ assert.strictEqual(shouldPreferOrthometricTerrain({ verticalDatum: 'EPSG:8357' }), true);
39
+ assert.strictEqual(shouldPreferOrthometricTerrain({ verticalDatum: '$' }), false);
40
+ assert.strictEqual(shouldPreferOrthometricTerrain({ verticalDatum: '' }), false);
41
+ assert.strictEqual(shouldPreferOrthometricTerrain(undefined), false);
42
+ });
43
+
44
+ it('computes terrain-clamped placement and clip plane from storey anchor', () => {
45
+ const placement = computeCesiumPlacement({
46
+ coordinateInfo: {
47
+ originShift: { x: 0, y: 0, z: 0 },
48
+ originalBounds: {
49
+ min: { x: 0, y: -3, z: 0 },
50
+ max: { x: 10, y: 9, z: 10 },
51
+ },
52
+ shiftedBounds: {
53
+ min: { x: 0, y: -3, z: 0 },
54
+ max: { x: 10, y: 9, z: 10 },
55
+ },
56
+ hasLargeCoordinates: false,
57
+ },
58
+ projectedCRS: { verticalDatum: 'EPSG:8357' },
59
+ ifcOriginHeight: 244,
60
+ terrainHeight: 245,
61
+ storeyElevations: new Map([[1, -3], [2, 0], [3, 3]]),
62
+ });
63
+
64
+ assert.strictEqual(placement.clampAnchorY, 0);
65
+ assert.strictEqual(placement.anchorOffset, 3);
66
+ assert.strictEqual(placement.placementHeight, 248);
67
+ assert.strictEqual(placement.terrainClipY, 0);
68
+ assert.strictEqual(placement.preferOrthometricTerrain, true);
69
+ });
70
+
71
+ it('preserves authored OrthogonalHeight when it is already above terrain', () => {
72
+ const placement = computeCesiumPlacement({
73
+ ifcOriginHeight: 244,
74
+ terrainHeight: 195.4,
75
+ });
76
+
77
+ assert.strictEqual(placement.placementHeight, 244);
78
+ assert.strictEqual(placement.terrainClipY, -48.599999999999994);
79
+ });
80
+
81
+ it('computes OrthogonalHeight from target base altitude with shift and RTC', () => {
82
+ const orthogonalHeight = computeOrthogonalHeightForBaseAltitude({
83
+ coordinateInfo: {
84
+ originShift: { x: 0, y: 2, z: 0 },
85
+ originalBounds: {
86
+ min: { x: 0, y: -1, z: 0 },
87
+ max: { x: 10, y: 11, z: 10 },
88
+ },
89
+ shiftedBounds: {
90
+ min: { x: 0, y: -1, z: 0 },
91
+ max: { x: 10, y: 11, z: 10 },
92
+ },
93
+ hasLargeCoordinates: false,
94
+ wasmRtcOffset: { x: 0, y: 0, z: 3 },
95
+ },
96
+ projectedCRS: { mapUnitScale: 0.3048 },
97
+ lengthUnitScale: 1,
98
+ storeyElevations: new Map([[1, 0]]),
99
+ targetBaseAltitude: 245,
100
+ });
101
+
102
+ assert.strictEqual(orthogonalHeight, 787.4);
103
+ });
104
+
105
+ it('computes the IFC origin height from OrthogonalHeight and model center', () => {
106
+ const height = computeIfcOriginHeight(
107
+ { orthogonalHeight: 12 },
108
+ { mapUnitScale: 0.5 },
109
+ {
110
+ originShift: { x: 0, y: 3, z: 0 },
111
+ originalBounds: {
112
+ min: { x: 0, y: 2, z: 0 },
113
+ max: { x: 10, y: 8, z: 10 },
114
+ },
115
+ shiftedBounds: {
116
+ min: { x: 0, y: 2, z: 0 },
117
+ max: { x: 10, y: 8, z: 10 },
118
+ },
119
+ hasLargeCoordinates: false,
120
+ },
121
+ 1,
122
+ );
123
+
124
+ assert.strictEqual(height, 14);
125
+ });
126
+
127
+ it('converts viewer XY drag deltas into projected map deltas', () => {
128
+ const projected = viewerDeltaToProjectedDelta(
129
+ 2,
130
+ -1,
131
+ { xAxisAbscissa: 1, xAxisOrdinate: 0, scale: 1 },
132
+ { mapUnitScale: 1 },
133
+ 1,
134
+ );
135
+
136
+ assert.deepStrictEqual(projected, { eastings: 2, northings: 1 });
137
+ });
138
+
139
+ it('converts projected map deltas back to viewer drag deltas', () => {
140
+ const viewer = projectedDeltaToViewerDelta(
141
+ 2,
142
+ 1,
143
+ { xAxisAbscissa: 1, xAxisOrdinate: 0, scale: 1 },
144
+ { mapUnitScale: 1 },
145
+ 1,
146
+ );
147
+
148
+ assert.deepStrictEqual(viewer, { x: 2, z: -1 });
149
+ });
150
+
151
+ it('intersects a downward ray with a horizontal plane at the expected point', () => {
152
+ const hit = intersectRayWithHorizontalPlane(
153
+ { origin: { x: 5, y: 10, z: -3 }, direction: { x: 0, y: -1, z: 0 } },
154
+ 0,
155
+ );
156
+ assert.deepStrictEqual(hit, { x: 5, y: 0, z: -3 });
157
+ });
158
+
159
+ it('intersects an oblique ray with a horizontal plane consistently for two cursor samples', () => {
160
+ // Two parallel rays separated by a known horizontal offset should map to
161
+ // hit points separated by the same offset on the plane. This is the
162
+ // invariant the placement gizmo relies on for stable XY drag at any
163
+ // camera angle.
164
+ const direction = { x: 0.4, y: -0.6, z: 0.5 };
165
+ const hitA = intersectRayWithHorizontalPlane(
166
+ { origin: { x: 0, y: 12, z: 0 }, direction },
167
+ 0,
168
+ );
169
+ const hitB = intersectRayWithHorizontalPlane(
170
+ { origin: { x: 1, y: 12, z: 2 }, direction },
171
+ 0,
172
+ );
173
+ assert.ok(hitA && hitB);
174
+ assert.strictEqual(Math.round((hitB.x - hitA.x) * 1e6) / 1e6, 1);
175
+ assert.strictEqual(Math.round((hitB.z - hitA.z) * 1e6) / 1e6, 2);
176
+ });
177
+
178
+ it('rejects rays that are parallel to the horizontal plane', () => {
179
+ const hit = intersectRayWithHorizontalPlane(
180
+ { origin: { x: 0, y: 5, z: 0 }, direction: { x: 1, y: 0, z: 0 } },
181
+ 0,
182
+ );
183
+ assert.strictEqual(hit, null);
184
+ });
185
+
186
+ it('rejects rays whose intersection lies behind the origin', () => {
187
+ // Ray going up, plane below origin: t < 0.
188
+ const hit = intersectRayWithHorizontalPlane(
189
+ { origin: { x: 0, y: 5, z: 0 }, direction: { x: 0, y: 1, z: 0 } },
190
+ 0,
191
+ );
192
+ assert.strictEqual(hit, null);
193
+ });
194
+
195
+ it('returns the cursor-aligned Y on a vertical line for an oblique ray', () => {
196
+ // Ray that passes exactly through (anchorX, 7, anchorZ) — the closest
197
+ // point on the vertical axis is the same point, so Y = 7.
198
+ const anchorX = 4;
199
+ const anchorZ = -2;
200
+ const target = { x: anchorX, y: 7, z: anchorZ };
201
+ const origin = { x: 0, y: 0, z: 0 };
202
+ const dx = target.x - origin.x;
203
+ const dy = target.y - origin.y;
204
+ const dz = target.z - origin.z;
205
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
206
+ const direction = { x: dx / len, y: dy / len, z: dz / len };
207
+ const y = closestYOnVerticalLineFromRay({ origin, direction }, anchorX, anchorZ);
208
+ assert.ok(y !== null);
209
+ assert.ok(Math.abs((y as number) - 7) < 1e-9);
210
+ });
211
+
212
+ it('preserves vertical drag amount when cursor moves up by a known screen offset', () => {
213
+ // Two rays that pass through points (anchorX, y1, anchorZ) and
214
+ // (anchorX, y2, anchorZ) on the vertical line: returned Y values must
215
+ // equal y1 and y2 exactly — the basis of frame-rate-independent height
216
+ // dragging at oblique camera angles.
217
+ const anchorX = 0;
218
+ const anchorZ = 0;
219
+ const origin = { x: 6, y: 0, z: 4 };
220
+
221
+ const aim = (y: number) => {
222
+ const dx = anchorX - origin.x;
223
+ const dy = y - origin.y;
224
+ const dz = anchorZ - origin.z;
225
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
226
+ return { origin, direction: { x: dx / len, y: dy / len, z: dz / len } };
227
+ };
228
+
229
+ const y1 = closestYOnVerticalLineFromRay(aim(2), anchorX, anchorZ);
230
+ const y2 = closestYOnVerticalLineFromRay(aim(9), anchorX, anchorZ);
231
+ assert.ok(y1 !== null && y2 !== null);
232
+ assert.ok(Math.abs((y1 as number) - 2) < 1e-9);
233
+ assert.ok(Math.abs((y2 as number) - 9) < 1e-9);
234
+ });
235
+
236
+ it('rejects vertical rays for closest-Y-on-line (degenerate)', () => {
237
+ const y = closestYOnVerticalLineFromRay(
238
+ { origin: { x: 0, y: 10, z: 0 }, direction: { x: 0, y: -1, z: 0 } },
239
+ 0,
240
+ 0,
241
+ );
242
+ assert.strictEqual(y, null);
243
+ });
244
+ });
@@ -0,0 +1,231 @@
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 type { CoordinateInfo } from '@ifc-lite/geometry';
6
+ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
7
+
8
+ import { findClampAnchorY } from './clamp-anchor';
9
+ import { computeModelCenterInIfcMeters } from './reproject';
10
+ import { getEffectiveHorizontalScale } from './geo-scale';
11
+
12
+ export function getMapUnitScale(
13
+ projectedCRS: Pick<ProjectedCRS, 'mapUnitScale'> | undefined,
14
+ lengthUnitScale: number,
15
+ ): number {
16
+ const mapUnitScale = projectedCRS?.mapUnitScale;
17
+ if (typeof mapUnitScale === 'number' && mapUnitScale > 0) return mapUnitScale;
18
+ return lengthUnitScale > 0 ? lengthUnitScale : 1;
19
+ }
20
+
21
+ export function mapUnitsToMeters(
22
+ value: number,
23
+ projectedCRS: Pick<ProjectedCRS, 'mapUnitScale'> | undefined,
24
+ lengthUnitScale: number,
25
+ ): number {
26
+ return value * getMapUnitScale(projectedCRS, lengthUnitScale);
27
+ }
28
+
29
+ export function metersToMapUnits(
30
+ value: number,
31
+ projectedCRS: Pick<ProjectedCRS, 'mapUnitScale'> | undefined,
32
+ lengthUnitScale: number,
33
+ ): number {
34
+ return value / getMapUnitScale(projectedCRS, lengthUnitScale);
35
+ }
36
+
37
+ export function shouldPreferOrthometricTerrain(
38
+ projectedCRS: Pick<ProjectedCRS, 'verticalDatum'> | undefined,
39
+ ): boolean {
40
+ const verticalDatum = projectedCRS?.verticalDatum?.trim();
41
+ return Boolean(verticalDatum && verticalDatum !== '$');
42
+ }
43
+
44
+ export interface CesiumPlacementInput {
45
+ coordinateInfo?: CoordinateInfo;
46
+ projectedCRS?: Pick<ProjectedCRS, 'verticalDatum'> | Pick<ProjectedCRS, 'mapUnitScale'> & Pick<ProjectedCRS, 'verticalDatum'>;
47
+ ifcOriginHeight: number;
48
+ terrainHeight: number | null;
49
+ storeyElevations?: Map<number, number>;
50
+ }
51
+
52
+ export interface CesiumPlacementResult {
53
+ clampAnchorY: number;
54
+ minY: number;
55
+ modelCenterY: number;
56
+ anchorOffset: number;
57
+ ifcOriginHeight: number;
58
+ placementHeight: number;
59
+ terrainClipY: number | null;
60
+ preferOrthometricTerrain: boolean;
61
+ }
62
+
63
+ export function computeCesiumPlacement({
64
+ coordinateInfo,
65
+ projectedCRS,
66
+ ifcOriginHeight,
67
+ terrainHeight,
68
+ storeyElevations,
69
+ }: CesiumPlacementInput): CesiumPlacementResult {
70
+ const bounds = coordinateInfo?.originalBounds;
71
+ const modelCenterY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0;
72
+ const minY = bounds?.min.y ?? 0;
73
+ const clampAnchorY = findClampAnchorY(bounds, storeyElevations);
74
+ const anchorOffset = modelCenterY - clampAnchorY;
75
+ const terrainPlacementHeight = terrainHeight !== null
76
+ ? terrainHeight + anchorOffset
77
+ : null;
78
+ const placementHeight = terrainPlacementHeight !== null
79
+ ? Math.max(ifcOriginHeight, terrainPlacementHeight)
80
+ : ifcOriginHeight;
81
+
82
+ return {
83
+ clampAnchorY,
84
+ minY,
85
+ modelCenterY,
86
+ anchorOffset,
87
+ ifcOriginHeight,
88
+ placementHeight,
89
+ terrainClipY: terrainHeight !== null
90
+ ? terrainHeight - placementHeight + modelCenterY
91
+ : null,
92
+ preferOrthometricTerrain: shouldPreferOrthometricTerrain(projectedCRS),
93
+ };
94
+ }
95
+
96
+ export interface OrthogonalHeightForBaseAltitudeInput {
97
+ coordinateInfo?: CoordinateInfo;
98
+ projectedCRS?: Pick<ProjectedCRS, 'mapUnitScale'>;
99
+ lengthUnitScale: number;
100
+ storeyElevations?: Map<number, number>;
101
+ targetBaseAltitude: number;
102
+ }
103
+
104
+ export function computeOrthogonalHeightForBaseAltitude({
105
+ coordinateInfo,
106
+ projectedCRS,
107
+ lengthUnitScale,
108
+ storeyElevations,
109
+ targetBaseAltitude,
110
+ }: OrthogonalHeightForBaseAltitudeInput): number {
111
+ const bounds = coordinateInfo?.originalBounds;
112
+ const anchorY = findClampAnchorY(bounds, storeyElevations);
113
+ const shiftY = coordinateInfo?.originShift?.y ?? 0;
114
+ // RTC offset is stored in IFC Z-up; viewer-Y aligns to its Z component.
115
+ const rtcYupY = coordinateInfo?.wasmRtcOffset?.z ?? 0;
116
+ const orthogonalHeightMeters = targetBaseAltitude - shiftY - rtcYupY - anchorY;
117
+
118
+ return Math.round(
119
+ metersToMapUnits(orthogonalHeightMeters, projectedCRS, lengthUnitScale) * 100,
120
+ ) / 100;
121
+ }
122
+
123
+ export function computeIfcOriginHeight(
124
+ mapConversion: Pick<MapConversion, 'orthogonalHeight'>,
125
+ projectedCRS: Pick<ProjectedCRS, 'mapUnitScale'> | undefined,
126
+ coordinateInfo: CoordinateInfo | undefined,
127
+ lengthUnitScale: number,
128
+ ): number {
129
+ const mapScale = getMapUnitScale(projectedCRS, lengthUnitScale);
130
+ return mapConversion.orthogonalHeight * mapScale + computeModelCenterInIfcMeters(coordinateInfo).ifcZ;
131
+ }
132
+
133
+ export function viewerDeltaToProjectedDelta(
134
+ deltaX: number,
135
+ deltaZ: number,
136
+ mapConversion: Pick<MapConversion, 'xAxisAbscissa' | 'xAxisOrdinate' | 'scale'>,
137
+ projectedCRS: Pick<ProjectedCRS, 'mapUnitScale'> | undefined,
138
+ lengthUnitScale: number,
139
+ ): { eastings: number; northings: number } {
140
+ const mapScale = getMapUnitScale(projectedCRS, lengthUnitScale);
141
+ const hScale = getEffectiveHorizontalScale(
142
+ mapConversion.scale,
143
+ mapScale,
144
+ lengthUnitScale,
145
+ );
146
+ const abscissa = mapConversion.xAxisAbscissa ?? 1;
147
+ const ordinate = mapConversion.xAxisOrdinate ?? 0;
148
+ const eastMeters = hScale * (abscissa * deltaX + ordinate * deltaZ);
149
+ const northMeters = hScale * (ordinate * deltaX - abscissa * deltaZ);
150
+
151
+ return {
152
+ eastings: metersToMapUnits(eastMeters, projectedCRS, lengthUnitScale),
153
+ northings: metersToMapUnits(northMeters, projectedCRS, lengthUnitScale),
154
+ };
155
+ }
156
+
157
+ export interface Ray3 {
158
+ origin: { x: number; y: number; z: number };
159
+ direction: { x: number; y: number; z: number };
160
+ }
161
+
162
+ /**
163
+ * Intersect a ray with the horizontal plane y = planeY. Returns null when the
164
+ * ray is (near-)parallel to the plane or the hit lies behind the ray origin.
165
+ *
166
+ * Used by the placement gizmo's XY drag: a stable horizontal drag plane
167
+ * through the gizmo anchor avoids the projection-Jacobian instability of
168
+ * linearised screen-axis approximations, which blow up to "huge jumps" at
169
+ * oblique camera angles when the gizmo plane is near-edge-on to the camera.
170
+ */
171
+ export function intersectRayWithHorizontalPlane(
172
+ ray: Ray3,
173
+ planeY: number,
174
+ ): { x: number; y: number; z: number } | null {
175
+ const dirY = ray.direction.y;
176
+ if (!Number.isFinite(dirY) || Math.abs(dirY) < 1e-6) return null;
177
+ const t = (planeY - ray.origin.y) / dirY;
178
+ if (!Number.isFinite(t) || t < 0) return null;
179
+ return {
180
+ x: ray.origin.x + ray.direction.x * t,
181
+ y: planeY,
182
+ z: ray.origin.z + ray.direction.z * t,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Find the Y-coordinate on the vertical line (anchorX, *, anchorZ) closest
188
+ * to a ray. Returns null when the ray's horizontal component vanishes (the
189
+ * ray is parallel to the vertical line — no meaningful "grab" point).
190
+ *
191
+ * Used by the placement gizmo's height drag so the slider tracks the cursor
192
+ * accurately at any camera tilt, instead of linearising screen-space pixels
193
+ * per metre.
194
+ */
195
+ export function closestYOnVerticalLineFromRay(
196
+ ray: Ray3,
197
+ anchorX: number,
198
+ anchorZ: number,
199
+ ): number | null {
200
+ const dx = ray.direction.x;
201
+ const dz = ray.direction.z;
202
+ const horiz = dx * dx + dz * dz;
203
+ if (!Number.isFinite(horiz) || horiz < 1e-12) return null;
204
+ const s = (dx * (anchorX - ray.origin.x) + dz * (anchorZ - ray.origin.z)) / horiz;
205
+ return ray.origin.y + s * ray.direction.y;
206
+ }
207
+
208
+ export function projectedDeltaToViewerDelta(
209
+ eastingsDelta: number,
210
+ northingsDelta: number,
211
+ mapConversion: Pick<MapConversion, 'xAxisAbscissa' | 'xAxisOrdinate' | 'scale'>,
212
+ projectedCRS: Pick<ProjectedCRS, 'mapUnitScale'> | undefined,
213
+ lengthUnitScale: number,
214
+ ): { x: number; z: number } {
215
+ const mapScale = getMapUnitScale(projectedCRS, lengthUnitScale);
216
+ const hScale = getEffectiveHorizontalScale(
217
+ mapConversion.scale,
218
+ mapScale,
219
+ lengthUnitScale,
220
+ );
221
+ const abscissa = mapConversion.xAxisAbscissa ?? 1;
222
+ const ordinate = mapConversion.xAxisOrdinate ?? 0;
223
+ const eastMeters = mapUnitsToMeters(eastingsDelta, projectedCRS, lengthUnitScale);
224
+ const northMeters = mapUnitsToMeters(northingsDelta, projectedCRS, lengthUnitScale);
225
+ const denom = Math.max((abscissa * abscissa + ordinate * ordinate) * hScale, 1e-12);
226
+
227
+ return {
228
+ x: (abscissa * eastMeters + ordinate * northMeters) / denom,
229
+ z: (ordinate * eastMeters - abscissa * northMeters) / denom,
230
+ };
231
+ }