@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
|
@@ -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 {
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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:
|
|
138
|
-
latitude:
|
|
139
|
-
height:
|
|
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
|
-
|
|
305
|
-
|
|
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
|
+
}
|