@navaramap/three-api 0.0.7 → 0.0.8

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.
@@ -0,0 +1,69 @@
1
+ import { XYZ } from '@navaramap/core';
2
+ import { Matrix4 } from 'three';
3
+ /**
4
+ * High-level geographic placement of a mesh object.
5
+ *
6
+ * All angles are in **degrees** and `height` is in metres, matching
7
+ * `ThreeView.setCamera`. The asset is oriented in a West-Up-North tangent
8
+ * frame, which matches glTF's own convention (front `+Z`, up `+Y`,
9
+ * right `-X`) on all three axes — so an unmodified glTF asset needs no
10
+ * up-axis correction.
11
+ */
12
+ export type GeodeticPlacement = {
13
+ /** Longitude in degrees. */
14
+ lng: number;
15
+ /** Latitude in degrees. */
16
+ lat: number;
17
+ /**
18
+ * Metres above the ellipsoid, or above terrain when `heightReference` is
19
+ * `"terrain"`. Defaults to 0.
20
+ */
21
+ height?: number;
22
+ /**
23
+ * Degrees clockwise from north that the asset's front (glTF `+Z`) faces.
24
+ * Same convention as `setCamera`'s heading. Defaults to 0.
25
+ */
26
+ heading?: number;
27
+ /** Degrees, nose up positive. Defaults to 0. */
28
+ pitch?: number;
29
+ /** Degrees, right wing down positive. Defaults to 0. */
30
+ roll?: number;
31
+ /** Uniform or per-axis scale of the frame. Defaults to 1. */
32
+ scale?: number | XYZ;
33
+ /** How `height` is interpreted. Defaults to `"ellipsoid"`. */
34
+ heightReference?: "ellipsoid" | "terrain";
35
+ };
36
+ /**
37
+ * Basis change from the NUE frame `(north, up, east)` to the WUN frame
38
+ * `(west, up, north)`.
39
+ *
40
+ * With `Ry(+90°)` columns `(0,0,-1), (0,1,0), (1,0,0)`, the product
41
+ * `NUE · Ry(+90°)` has columns `(-east, up, north)`, which is WUN. WUN is the
42
+ * unique right-handed, Y-up tangent frame whose `+Z` is north, which is why it
43
+ * agrees with glTF on every axis.
44
+ *
45
+ * Returns a fresh matrix on every call, so callers cannot corrupt a shared
46
+ * instance.
47
+ *
48
+ * @returns A new `Matrix4` performing the NUE-to-WUN basis change.
49
+ */
50
+ export declare function nueToWunBasis(): Matrix4;
51
+ /**
52
+ * Composes a tangent frame with heading, pitch, roll and scale.
53
+ *
54
+ * Pure: takes the frame as a parameter and uses three.js `MathUtils.degToRad`,
55
+ * so it touches no WASM and is directly unit-testable.
56
+ *
57
+ * `Ry(-heading) · Rx(-pitch) · Rz(+roll)` is exactly three.js Euler order
58
+ * `"YXZ"`, the standard intrinsic yaw -> pitch -> roll order. Signs follow from
59
+ * the glTF axes in a WUN frame: positive heading swings `+Z` toward `-X`
60
+ * (east), positive pitch lifts `+Z` toward `+Y`, and positive roll about `+Z`
61
+ * drives the `-X` wing toward `-Y`.
62
+ *
63
+ * @param frame - Tangent frame to compose onto, normally from
64
+ * `westUpNorthToFixedFrame`
65
+ * @param p - Heading, pitch, roll (degrees) and scale; each defaults to
66
+ * no-op
67
+ * @returns `frame · R(heading, pitch, roll) · S(scale)`
68
+ */
69
+ export declare function composeHeadingPitchRoll(frame: Matrix4, p: Pick<GeodeticPlacement, "heading" | "pitch" | "roll" | "scale">): Matrix4;
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { LatLngHeight, Window as WindowObject } from '@navaramap/core';
2
2
  import { Vector3, Vector2, Matrix4, PerspectiveCamera } from 'three';
3
+ import { GeodeticPlacement } from './frames';
3
4
  export type { LatLngHeight, LatLng } from '@navaramap/core';
4
5
  export type { Window, Transform, CameraFrustum, Plane, Ray, Vec2, Vec3, } from '@navaramap/core';
6
+ export type { GeodeticPlacement } from './frames';
5
7
  export * from './intersection';
6
8
  export * from './rte';
7
9
  export * from './ellipsoidGeodesic';
@@ -71,6 +73,41 @@ export declare function northUpEastToFixedFrame(origin: Vector3): Matrix4;
71
73
  * @returns 4x4 transformation matrix from NWU to ECEF
72
74
  */
73
75
  export declare function northWestUpToFixedFrame(origin: Vector3): Matrix4;
76
+ /**
77
+ * Creates a local West-Up-North (WUN) reference frame transformation matrix at
78
+ * a position.
79
+ *
80
+ * WUN is the only right-handed, Y-up tangent frame whose `+Z` axis is north, so
81
+ * it agrees with glTF's own convention (front `+Z`, up `+Y`, right `-X`) on all
82
+ * three axes. An unmodified glTF asset placed in this frame needs **no**
83
+ * `Rx(+90°)` up-axis correction — the correction is absorbed here, once, as the
84
+ * `Ry(+90°)` basis change from NUE.
85
+ *
86
+ * @param origin - Origin position in ECEF coordinates
87
+ * @returns 4x4 transformation matrix from WUN to ECEF
88
+ */
89
+ export declare function westUpNorthToFixedFrame(origin: Vector3): Matrix4;
90
+ /**
91
+ * Builds a WUN tangent frame at a geodetic position and composes heading,
92
+ * pitch, roll and scale onto it — the single call that turns a geographic
93
+ * placement into a world matrix.
94
+ *
95
+ * `heading` is the compass bearing the asset's front faces, where the front is
96
+ * glTF's own `+Z` — the same convention as `ThreeView.setCamera`. Other globe
97
+ * engines differ in which axis they treat as an asset's front, so a model
98
+ * ported from one may need a multiple of 90° added to its heading.
99
+ *
100
+ * NOTE: unlike {@link geodeticToVector3} and the other helpers in this module,
101
+ * `lng`/`lat` and all angles here are in **degrees**, matching
102
+ * `setCamera` and the `geodetic` mesh Descriptor field.
103
+ *
104
+ * `heightReference` is intentionally not accepted: resolving terrain-relative
105
+ * height needs a view to sample from, so it is the Descriptor's job.
106
+ *
107
+ * @param placement - Geographic placement in degrees and metres
108
+ * @returns 4x4 transformation matrix from the placed local frame to ECEF
109
+ */
110
+ export declare function headingPitchRollToFixedFrame(placement: Omit<GeodeticPlacement, "heightReference">): Matrix4;
74
111
  /**
75
112
  * Converts world coordinates to screen coordinates.
76
113
  * @param window - Window configuration with width, height, and pixelRatio
package/dist/index.js CHANGED
@@ -1,42 +1,56 @@
1
- import e, { CameraFrustum as t, EllipsoidGeodesic as n, LLE as r, Ray as i, Transform as a, Vec2 as o, Vec3 as s, Window as c, angleToDegree as l, angleToRadian as u, eastNorthUpToFixedFrame as ee, encodePosition as d, geodeticSurfaceNormal as f, geodeticToXyz as p, getHeightFromEllipsoid as m, getPickRay as h, getPlaneFromPointNormal as g, getRayPlaneIntersection as _, getWGS84Eccentricity as v, getWGS84EccentricitySquared as y, getWGS84Flattening as b, getWGS84SemiMajorAxis as x, getWGS84SemiMinorAxis as S, northEastDownToFixedFrame as C, northUpEastToFixedFrame as w, northWestUpToFixedFrame as T, screenToWorld as E, worldToScreen as D, xyzToGeodetic as O } from "@navaramap/engine-api";
2
- import { Matrix4 as k, Plane as A, Ray as j, Vector2 as M, Vector3 as N } from "three";
1
+ import e, { CameraFrustum as t, EllipsoidGeodesic as n, LLE as r, Ray as i, Transform as a, Vec2 as o, Vec3 as s, Window as c, angleToDegree as l, angleToRadian as u, eastNorthUpToFixedFrame as d, encodePosition as f, geodeticSurfaceNormal as p, geodeticToXyz as m, getHeightFromEllipsoid as h, getPickRay as g, getPlaneFromPointNormal as _, getRayPlaneIntersection as v, getWGS84Eccentricity as y, getWGS84EccentricitySquared as b, getWGS84Flattening as x, getWGS84SemiMajorAxis as ee, getWGS84SemiMinorAxis as te, northEastDownToFixedFrame as ne, northUpEastToFixedFrame as S, northWestUpToFixedFrame as C, screenToWorld as w, worldToScreen as T, xyzToGeodetic as E } from "@navaramap/engine-api";
2
+ import { Euler as D, MathUtils as O, Matrix4 as k, Plane as A, Ray as j, Vector2 as M, Vector3 as N } from "three";
3
+ //#region src/frames.ts
4
+ function P() {
5
+ return new k().makeRotationY(Math.PI / 2);
6
+ }
7
+ function F(e, t) {
8
+ let n = new k().multiplyMatrices(e, new k().makeRotationFromEuler(new D(-O.degToRad(t.pitch ?? 0), -O.degToRad(t.heading ?? 0), O.degToRad(t.roll ?? 0), "YXZ")));
9
+ if (t.scale !== void 0) {
10
+ let e = t.scale;
11
+ n.scale(typeof e == "number" ? new N(e, e, e) : new N(e.x, e.y, e.z));
12
+ }
13
+ return n;
14
+ }
15
+ //#endregion
3
16
  //#region src/intersection.ts
4
- function P(e, t) {
5
- let n = g(new s(e.x, e.y, e.z), new s(t.x, t.y, t.z)), r = new A(new N(n.normal.x, n.normal.y, n.normal.z), -n.distance);
6
- return n.free(), r;
17
+ function I(e, t) {
18
+ let n = new s(e.x, e.y, e.z), r = new s(t.x, t.y, t.z), i = _(n, r), a = new N(i.normal.x, i.normal.y, i.normal.z), o = new A(a, -i.distance);
19
+ return i.free(), o;
7
20
  }
8
- function F(e, n, r) {
21
+ function L(e, n, r) {
9
22
  let i = new c(e.width, e.height, e.pixelRatio);
10
23
  i.width *= i.pixelRatio, i.height *= i.pixelRatio;
11
- let s = h(i, new a(n.position.x, n.position.y, n.position.z, n.quaternion.x, n.quaternion.y, n.quaternion.z, n.quaternion.w, n.scale.x, n.scale.y, n.scale.z), new t(n.near, n.far, u(n.fov), n.aspect), new o(r.x, r.y)), l = new j(new N(s.origin.x, s.origin.y, s.origin.z), new N(s.direction.x, s.direction.y, s.direction.z));
12
- return s.free(), l;
13
- }
14
- function I(e, t) {
15
- let n = _(new i(new s(e.origin.x, e.origin.y, e.origin.z), new s(e.direction.x, e.direction.y, e.direction.z)), g(new s(t.normal.x * -t.constant, t.normal.y * -t.constant, t.normal.z * -t.constant), new s(t.normal.x, t.normal.y, t.normal.z)));
16
- if (n) {
17
- let e = new N(n.x, n.y, n.z);
18
- return n.free(), e;
24
+ let s = new a(n.position.x, n.position.y, n.position.z, n.quaternion.x, n.quaternion.y, n.quaternion.z, n.quaternion.w, n.scale.x, n.scale.y, n.scale.z), l = new t(n.near, n.far, u(n.fov), n.aspect), d = g(i, s, l, new o(r.x, r.y)), f = new N(d.origin.x, d.origin.y, d.origin.z), p = new N(d.direction.x, d.direction.y, d.direction.z), m = new j(f, p);
25
+ return d.free(), m;
26
+ }
27
+ function R(e, t) {
28
+ let n = new i(new s(e.origin.x, e.origin.y, e.origin.z), new s(e.direction.x, e.direction.y, e.direction.z)), r = _(new s(t.normal.x * -t.constant, t.normal.y * -t.constant, t.normal.z * -t.constant), new s(t.normal.x, t.normal.y, t.normal.z)), a = v(n, r);
29
+ if (a) {
30
+ let e = new N(a.x, a.y, a.z);
31
+ return a.free(), e;
19
32
  }
20
33
  }
21
- function te(e) {
22
- return m(new s(e.x, e.y, e.z));
34
+ function z(e) {
35
+ let t = new s(e.x, e.y, e.z);
36
+ return h(t);
23
37
  }
24
38
  //#endregion
25
39
  //#region src/rte.ts
26
- var L = Object.freeze({ value: 1 });
27
- function R(e, t = new N(), n = new N()) {
28
- let r = d(e.x, e.y, e.z);
40
+ var B = Object.freeze({ value: 1 });
41
+ function V(e, t = new N(), n = new N()) {
42
+ let r = f(e.x, e.y, e.z);
29
43
  return t.set(r.high.x, r.high.y, r.high.z), n.set(r.low.x, r.low.y, r.low.z), r.free(), {
30
44
  high: t,
31
45
  low: n
32
46
  };
33
47
  }
34
- var z = (e, t, n = new k()) => {
48
+ var re = (e, t, n = new k()) => {
35
49
  n.multiplyMatrices(t, e);
36
50
  let r = n.elements;
37
51
  return r[12] = 0, r[13] = 0, r[14] = 0, n;
38
52
  };
39
- function B(e, t, n = new N(), r = new k()) {
53
+ function H(e, t, n = new N(), r = new k()) {
40
54
  r.multiplyMatrices(e, t), n.setFromMatrixPosition(r);
41
55
  let i = r.elements;
42
56
  return i[12] = 0, i[13] = 0, i[14] = 0, {
@@ -44,14 +58,14 @@ function B(e, t, n = new N(), r = new k()) {
44
58
  rotationScale: r
45
59
  };
46
60
  }
47
- var V = new k(), H = new N(), U = (e, t) => {
48
- V.copy(t).invert(), H.copy(e).applyMatrix4(V);
49
- let n = d(H.x, H.y, H.z), r = n.high, i = n.low, a = {
61
+ var U = new k(), W = new N(), G = (e, t) => {
62
+ U.copy(t).invert(), W.copy(e).applyMatrix4(U);
63
+ let n = f(W.x, W.y, W.z), r = n.high, i = n.low, a = {
50
64
  high: new N(r.x, r.y, r.z),
51
65
  low: new N(i.x, i.y, i.z)
52
66
  };
53
67
  return n.free(), a;
54
- }, W = class {
68
+ }, K = class {
55
69
  _raw;
56
70
  constructor(e, t) {
57
71
  this._raw = new n(new r(e.lat, e.lng, e.height), new r(t.lat, t.lng, t.height));
@@ -107,77 +121,87 @@ var V = new k(), H = new N(), U = (e, t) => {
107
121
  };
108
122
  //#endregion
109
123
  //#region src/index.ts
110
- async function G() {
124
+ async function q() {
111
125
  await e();
112
126
  }
113
- function K(e) {
114
- let t = p(new r(e.lat, e.lng, e.height)), n = new N(t.x, t.y, t.z);
127
+ function J(e) {
128
+ let t = m(new r(e.lat, e.lng, e.height)), n = new N(t.x, t.y, t.z);
115
129
  return t.free(), n;
116
130
  }
117
- function q(e) {
118
- let t = O(new s(e.x, e.y, e.z)), n = {
119
- lat: t.lat,
120
- lng: t.lng,
121
- height: t.height
131
+ function Y(e) {
132
+ let t = new s(e.x, e.y, e.z), n = E(t), r = {
133
+ lat: n.lat,
134
+ lng: n.lng,
135
+ height: n.height
122
136
  };
123
- return t.free(), n;
137
+ return n.free(), r;
124
138
  }
125
- function J(e) {
139
+ function X(e) {
126
140
  return u(e);
127
141
  }
128
- function Y(e) {
142
+ function ie(e) {
129
143
  return l(e);
130
144
  }
131
- function X(e, n, r) {
145
+ function ae(e, n, r) {
132
146
  let i = new c(e.width, e.height, e.pixelRatio);
133
147
  i.width *= i.pixelRatio, i.height *= i.pixelRatio;
134
- let s = E(i, new a(n.position.x, n.position.y, n.position.z, n.quaternion.x, n.quaternion.y, n.quaternion.z, n.quaternion.w, n.scale.x, n.scale.y, n.scale.z), new t(n.near, n.far, u(n.fov), n.aspect), new o(r.x, r.y));
135
- if (!s) return;
136
- let l = new N(s.x, s.y, s.z);
137
- return s.free(), l;
148
+ let s = new a(n.position.x, n.position.y, n.position.z, n.quaternion.x, n.quaternion.y, n.quaternion.z, n.quaternion.w, n.scale.x, n.scale.y, n.scale.z), l = new t(n.near, n.far, u(n.fov), n.aspect), d = w(i, s, l, new o(r.x, r.y));
149
+ if (!d) return;
150
+ let f = new N(d.x, d.y, d.z);
151
+ return d.free(), f;
138
152
  }
139
- function Z(e) {
140
- let t = f(new r(e.lat, e.lng, e.height)), n = new N(t.x, t.y, t.z);
153
+ function oe(e) {
154
+ let t = p(new r(e.lat, e.lng, e.height)), n = new N(t.x, t.y, t.z);
141
155
  return t.free(), n;
142
156
  }
157
+ function Z(e) {
158
+ let t = new s(e.x, e.y, e.z), n = d(t);
159
+ return new k().fromArray(n);
160
+ }
161
+ function se(e) {
162
+ let t = new s(e.x, e.y, e.z), n = ne(t);
163
+ return new k().fromArray(n);
164
+ }
143
165
  function Q(e) {
144
- let t = ee(new s(e.x, e.y, e.z));
145
- return new k().fromArray(t);
166
+ let t = new s(e.x, e.y, e.z), n = S(t);
167
+ return new k().fromArray(n);
146
168
  }
147
- function ne(e) {
148
- let t = C(new s(e.x, e.y, e.z));
149
- return new k().fromArray(t);
169
+ function ce(e) {
170
+ let t = new s(e.x, e.y, e.z), n = C(t);
171
+ return new k().fromArray(n);
150
172
  }
151
- function re(e) {
152
- let t = w(new s(e.x, e.y, e.z));
153
- return new k().fromArray(t);
173
+ function $(e) {
174
+ return Q(e).multiply(P());
154
175
  }
155
- function ie(e) {
156
- let t = T(new s(e.x, e.y, e.z));
157
- return new k().fromArray(t);
176
+ function le(e) {
177
+ return F($(J({
178
+ lng: O.degToRad(e.lng),
179
+ lat: O.degToRad(e.lat),
180
+ height: e.height ?? 0
181
+ })), e);
158
182
  }
159
- function ae(e, n, r) {
183
+ function ue(e, n, r) {
160
184
  let i = new c(e.width, e.height, e.pixelRatio);
161
185
  i.width *= i.pixelRatio, i.height *= i.pixelRatio;
162
- let o = D(i, new a(n.position.x, n.position.y, n.position.z, n.quaternion.x, n.quaternion.y, n.quaternion.z, n.quaternion.w, n.scale.x, n.scale.y, n.scale.z), new t(n.near, n.far, u(n.fov), n.aspect), new s(r.x, r.y, r.z));
163
- if (!o) return;
164
- let l = new M(o.x, o.y);
165
- return o.free(), l;
166
- }
167
- function oe() {
168
- return x();
186
+ let o = new a(n.position.x, n.position.y, n.position.z, n.quaternion.x, n.quaternion.y, n.quaternion.z, n.quaternion.w, n.scale.x, n.scale.y, n.scale.z), l = new t(n.near, n.far, u(n.fov), n.aspect), d = T(i, o, l, new s(r.x, r.y, r.z));
187
+ if (!d) return;
188
+ let f = new M(d.x, d.y);
189
+ return d.free(), f;
169
190
  }
170
- function $() {
171
- return S();
191
+ function de() {
192
+ return ee();
172
193
  }
173
- function se() {
174
- return y();
194
+ function fe() {
195
+ return te();
175
196
  }
176
- function ce() {
197
+ function pe() {
177
198
  return b();
178
199
  }
179
- function le() {
180
- return v();
200
+ function me() {
201
+ return x();
202
+ }
203
+ function he() {
204
+ return y();
181
205
  }
182
206
  //#endregion
183
- export { W as EllipsoidGeodesic, L as RTE_ONE_UNIFORM, U as calcCameraPosition, z as calcModelMatrixRTE, B as composeWorldMatrixForRTE, X as convertScreenToWorld, ae as convertWorldToScreen, J as degreeToRadian, Q as eastNorthUpToFixedFrame, R as encodePositionRTE, Z as geodeticSurfaceNormal, K as geodeticToVector3, te as getHeightFromEllipsoid, F as getPickRay, P as getPlaneFromPointNormal, I as getRayPlaneIntersection, le as getWGS84Eccentricity, se as getWGS84EccentricitySquared, ce as getWGS84Flattening, oe as getWGS84SemiMajorAxis, $ as getWGS84SemiMinorAxis, G as initNavaraApi, ne as northEastDownToFixedFrame, re as northUpEastToFixedFrame, ie as northWestUpToFixedFrame, Y as radianToDegree, q as vector3ToGeodetic };
207
+ export { K as EllipsoidGeodesic, B as RTE_ONE_UNIFORM, G as calcCameraPosition, re as calcModelMatrixRTE, H as composeWorldMatrixForRTE, ae as convertScreenToWorld, ue as convertWorldToScreen, X as degreeToRadian, Z as eastNorthUpToFixedFrame, V as encodePositionRTE, oe as geodeticSurfaceNormal, J as geodeticToVector3, z as getHeightFromEllipsoid, L as getPickRay, I as getPlaneFromPointNormal, R as getRayPlaneIntersection, he as getWGS84Eccentricity, pe as getWGS84EccentricitySquared, me as getWGS84Flattening, de as getWGS84SemiMajorAxis, fe as getWGS84SemiMinorAxis, le as headingPitchRollToFixedFrame, q as initNavaraApi, se as northEastDownToFixedFrame, Q as northUpEastToFixedFrame, ce as northWestUpToFixedFrame, ie as radianToDegree, Y as vector3ToGeodetic, $ as westUpNorthToFixedFrame };
@@ -0,0 +1 @@
1
+ export {};
@@ -1 +1 @@
1
- (function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require("@navaramap/engine-api"),require("three")):typeof define==`function`&&define.amd?define([`exports`,`@navaramap/engine-api`,`three`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.NavaraThreeApi={},e._navaramap_engine_api,e.three))})(this,function(e,t,n){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var r=Object.create,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,s=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty,l=(e,t,n,r)=>{if(t&&typeof t==`object`||typeof t==`function`)for(var s=o(t),l=0,u=s.length,d;l<u;l++)d=s[l],!c.call(e,d)&&d!==n&&i(e,d,{get:(e=>t[e]).bind(null,d),enumerable:!(r=a(t,d))||r.enumerable});return e};t=((e,t,n)=>(n=e==null?{}:r(s(e)),l(t||!e||!e.__esModule?i(n,`default`,{value:e,enumerable:!0}):n,e)))(t,1);function u(e,r){let i=(0,t.getPlaneFromPointNormal)(new t.Vec3(e.x,e.y,e.z),new t.Vec3(r.x,r.y,r.z)),a=new n.Plane(new n.Vector3(i.normal.x,i.normal.y,i.normal.z),-i.distance);return i.free(),a}function d(e,r,i){let a=new t.Window(e.width,e.height,e.pixelRatio);a.width*=a.pixelRatio,a.height*=a.pixelRatio;let o=(0,t.getPickRay)(a,new t.Transform(r.position.x,r.position.y,r.position.z,r.quaternion.x,r.quaternion.y,r.quaternion.z,r.quaternion.w,r.scale.x,r.scale.y,r.scale.z),new t.CameraFrustum(r.near,r.far,(0,t.angleToRadian)(r.fov),r.aspect),new t.Vec2(i.x,i.y)),s=new n.Ray(new n.Vector3(o.origin.x,o.origin.y,o.origin.z),new n.Vector3(o.direction.x,o.direction.y,o.direction.z));return o.free(),s}function f(e,r){let i=(0,t.getRayPlaneIntersection)(new t.Ray(new t.Vec3(e.origin.x,e.origin.y,e.origin.z),new t.Vec3(e.direction.x,e.direction.y,e.direction.z)),(0,t.getPlaneFromPointNormal)(new t.Vec3(r.normal.x*-r.constant,r.normal.y*-r.constant,r.normal.z*-r.constant),new t.Vec3(r.normal.x,r.normal.y,r.normal.z)));if(i){let e=new n.Vector3(i.x,i.y,i.z);return i.free(),e}}function p(e){return(0,t.getHeightFromEllipsoid)(new t.Vec3(e.x,e.y,e.z))}var m=Object.freeze({value:1});function h(e,r=new n.Vector3,i=new n.Vector3){let a=(0,t.encodePosition)(e.x,e.y,e.z);return r.set(a.high.x,a.high.y,a.high.z),i.set(a.low.x,a.low.y,a.low.z),a.free(),{high:r,low:i}}var g=(e,t,r=new n.Matrix4)=>{r.multiplyMatrices(t,e);let i=r.elements;return i[12]=0,i[13]=0,i[14]=0,r};function _(e,t,r=new n.Vector3,i=new n.Matrix4){i.multiplyMatrices(e,t),r.setFromMatrixPosition(i);let a=i.elements;return a[12]=0,a[13]=0,a[14]=0,{position:r,rotationScale:i}}var v=new n.Matrix4,y=new n.Vector3,b=(e,r)=>{v.copy(r).invert(),y.copy(e).applyMatrix4(v);let i=(0,t.encodePosition)(y.x,y.y,y.z),a=i.high,o=i.low,s={high:new n.Vector3(a.x,a.y,a.z),low:new n.Vector3(o.x,o.y,o.z)};return i.free(),s},x=class{_raw;constructor(e,n){this._raw=new t.EllipsoidGeodesic(new t.LLE(e.lat,e.lng,e.height),new t.LLE(n.lat,n.lng,n.height))}get distance(){return this._raw.distance}get converged(){return this._raw.converged}get startHeading(){return this._raw.start_heading}get endHeading(){return this._raw.end_heading}get start(){let e=this._raw.start;return{lat:e.lat,lng:e.lng,height:e.height}}get end(){let e=this._raw.end;return{lat:e.lat,lng:e.lng,height:e.height}}interpolatePoints(e){let t=this._raw.interpolateGeodeticPoints(e??null),n=[];for(let e of t)n.push({lat:e.lat,lng:e.lng,height:e.height}),e.free();return n}interpolateDistance(e){let t=this._raw.interpolateDistance(e),n={lat:t.lat,lng:t.lng,height:t.height};return t.free(),n}dispose(){this._raw.free()}};async function S(){await(0,t.default)()}function C(e){let r=(0,t.geodeticToXyz)(new t.LLE(e.lat,e.lng,e.height)),i=new n.Vector3(r.x,r.y,r.z);return r.free(),i}function w(e){let n=(0,t.xyzToGeodetic)(new t.Vec3(e.x,e.y,e.z)),r={lat:n.lat,lng:n.lng,height:n.height};return n.free(),r}function T(e){return(0,t.angleToRadian)(e)}function E(e){return(0,t.angleToDegree)(e)}function D(e,r,i){let a=new t.Window(e.width,e.height,e.pixelRatio);a.width*=a.pixelRatio,a.height*=a.pixelRatio;let o=(0,t.screenToWorld)(a,new t.Transform(r.position.x,r.position.y,r.position.z,r.quaternion.x,r.quaternion.y,r.quaternion.z,r.quaternion.w,r.scale.x,r.scale.y,r.scale.z),new t.CameraFrustum(r.near,r.far,(0,t.angleToRadian)(r.fov),r.aspect),new t.Vec2(i.x,i.y));if(!o)return;let s=new n.Vector3(o.x,o.y,o.z);return o.free(),s}function O(e){let r=(0,t.geodeticSurfaceNormal)(new t.LLE(e.lat,e.lng,e.height)),i=new n.Vector3(r.x,r.y,r.z);return r.free(),i}function k(e){let r=(0,t.eastNorthUpToFixedFrame)(new t.Vec3(e.x,e.y,e.z));return new n.Matrix4().fromArray(r)}function A(e){let r=(0,t.northEastDownToFixedFrame)(new t.Vec3(e.x,e.y,e.z));return new n.Matrix4().fromArray(r)}function j(e){let r=(0,t.northUpEastToFixedFrame)(new t.Vec3(e.x,e.y,e.z));return new n.Matrix4().fromArray(r)}function M(e){let r=(0,t.northWestUpToFixedFrame)(new t.Vec3(e.x,e.y,e.z));return new n.Matrix4().fromArray(r)}function N(e,r,i){let a=new t.Window(e.width,e.height,e.pixelRatio);a.width*=a.pixelRatio,a.height*=a.pixelRatio;let o=(0,t.worldToScreen)(a,new t.Transform(r.position.x,r.position.y,r.position.z,r.quaternion.x,r.quaternion.y,r.quaternion.z,r.quaternion.w,r.scale.x,r.scale.y,r.scale.z),new t.CameraFrustum(r.near,r.far,(0,t.angleToRadian)(r.fov),r.aspect),new t.Vec3(i.x,i.y,i.z));if(!o)return;let s=new n.Vector2(o.x,o.y);return o.free(),s}function P(){return(0,t.getWGS84SemiMajorAxis)()}function F(){return(0,t.getWGS84SemiMinorAxis)()}function I(){return(0,t.getWGS84EccentricitySquared)()}function L(){return(0,t.getWGS84Flattening)()}function R(){return(0,t.getWGS84Eccentricity)()}e.EllipsoidGeodesic=x,e.RTE_ONE_UNIFORM=m,e.calcCameraPosition=b,e.calcModelMatrixRTE=g,e.composeWorldMatrixForRTE=_,e.convertScreenToWorld=D,e.convertWorldToScreen=N,e.degreeToRadian=T,e.eastNorthUpToFixedFrame=k,e.encodePositionRTE=h,e.geodeticSurfaceNormal=O,e.geodeticToVector3=C,e.getHeightFromEllipsoid=p,e.getPickRay=d,e.getPlaneFromPointNormal=u,e.getRayPlaneIntersection=f,e.getWGS84Eccentricity=R,e.getWGS84EccentricitySquared=I,e.getWGS84Flattening=L,e.getWGS84SemiMajorAxis=P,e.getWGS84SemiMinorAxis=F,e.initNavaraApi=S,e.northEastDownToFixedFrame=A,e.northUpEastToFixedFrame=j,e.northWestUpToFixedFrame=M,e.radianToDegree=E,e.vector3ToGeodetic=w});
1
+ (function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require("@navaramap/engine-api"),require("three")):typeof define==`function`&&define.amd?define([`exports`,`@navaramap/engine-api`,`three`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.NavaraThreeApi={},e._navaramap_engine_api,e.three))})(this,function(e,t,n){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var r=Object.create,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyNames,s=Object.getPrototypeOf,c=Object.prototype.hasOwnProperty,l=(e,t,n,r)=>{if(t&&typeof t==`object`||typeof t==`function`)for(var s=o(t),l=0,u=s.length,d;l<u;l++)d=s[l],!c.call(e,d)&&d!==n&&i(e,d,{get:(e=>t[e]).bind(null,d),enumerable:!(r=a(t,d))||r.enumerable});return e};t=((e,t,n)=>(n=e==null?{}:r(s(e)),l(t||!e||!e.__esModule||!c.call(e,`default`)?i(n,`default`,{value:e,enumerable:!0}):n,e)))(t,1);function u(){return new n.Matrix4().makeRotationY(Math.PI/2)}function d(e,t){let r=new n.Matrix4().multiplyMatrices(e,new n.Matrix4().makeRotationFromEuler(new n.Euler(-n.MathUtils.degToRad(t.pitch??0),-n.MathUtils.degToRad(t.heading??0),n.MathUtils.degToRad(t.roll??0),`YXZ`)));if(t.scale!==void 0){let e=t.scale;r.scale(typeof e==`number`?new n.Vector3(e,e,e):new n.Vector3(e.x,e.y,e.z))}return r}function f(e,r){let i=new t.Vec3(e.x,e.y,e.z),a=new t.Vec3(r.x,r.y,r.z),o=(0,t.getPlaneFromPointNormal)(i,a),s=new n.Vector3(o.normal.x,o.normal.y,o.normal.z),c=new n.Plane(s,-o.distance);return o.free(),c}function p(e,r,i){let a=new t.Window(e.width,e.height,e.pixelRatio);a.width*=a.pixelRatio,a.height*=a.pixelRatio;let o=new t.Transform(r.position.x,r.position.y,r.position.z,r.quaternion.x,r.quaternion.y,r.quaternion.z,r.quaternion.w,r.scale.x,r.scale.y,r.scale.z),s=new t.CameraFrustum(r.near,r.far,(0,t.angleToRadian)(r.fov),r.aspect),c=(0,t.getPickRay)(a,o,s,new t.Vec2(i.x,i.y)),l=new n.Vector3(c.origin.x,c.origin.y,c.origin.z),u=new n.Vector3(c.direction.x,c.direction.y,c.direction.z),d=new n.Ray(l,u);return c.free(),d}function m(e,r){let i=new t.Ray(new t.Vec3(e.origin.x,e.origin.y,e.origin.z),new t.Vec3(e.direction.x,e.direction.y,e.direction.z)),a=(0,t.getPlaneFromPointNormal)(new t.Vec3(r.normal.x*-r.constant,r.normal.y*-r.constant,r.normal.z*-r.constant),new t.Vec3(r.normal.x,r.normal.y,r.normal.z)),o=(0,t.getRayPlaneIntersection)(i,a);if(o){let e=new n.Vector3(o.x,o.y,o.z);return o.free(),e}}function h(e){let n=new t.Vec3(e.x,e.y,e.z);return(0,t.getHeightFromEllipsoid)(n)}var g=Object.freeze({value:1});function _(e,r=new n.Vector3,i=new n.Vector3){let a=(0,t.encodePosition)(e.x,e.y,e.z);return r.set(a.high.x,a.high.y,a.high.z),i.set(a.low.x,a.low.y,a.low.z),a.free(),{high:r,low:i}}var v=(e,t,r=new n.Matrix4)=>{r.multiplyMatrices(t,e);let i=r.elements;return i[12]=0,i[13]=0,i[14]=0,r};function y(e,t,r=new n.Vector3,i=new n.Matrix4){i.multiplyMatrices(e,t),r.setFromMatrixPosition(i);let a=i.elements;return a[12]=0,a[13]=0,a[14]=0,{position:r,rotationScale:i}}var b=new n.Matrix4,x=new n.Vector3,S=(e,r)=>{b.copy(r).invert(),x.copy(e).applyMatrix4(b);let i=(0,t.encodePosition)(x.x,x.y,x.z),a=i.high,o=i.low,s={high:new n.Vector3(a.x,a.y,a.z),low:new n.Vector3(o.x,o.y,o.z)};return i.free(),s},C=class{_raw;constructor(e,n){this._raw=new t.EllipsoidGeodesic(new t.LLE(e.lat,e.lng,e.height),new t.LLE(n.lat,n.lng,n.height))}get distance(){return this._raw.distance}get converged(){return this._raw.converged}get startHeading(){return this._raw.start_heading}get endHeading(){return this._raw.end_heading}get start(){let e=this._raw.start;return{lat:e.lat,lng:e.lng,height:e.height}}get end(){let e=this._raw.end;return{lat:e.lat,lng:e.lng,height:e.height}}interpolatePoints(e){let t=this._raw.interpolateGeodeticPoints(e??null),n=[];for(let e of t)n.push({lat:e.lat,lng:e.lng,height:e.height}),e.free();return n}interpolateDistance(e){let t=this._raw.interpolateDistance(e),n={lat:t.lat,lng:t.lng,height:t.height};return t.free(),n}dispose(){this._raw.free()}};async function w(){await(0,t.default)()}function T(e){let r=(0,t.geodeticToXyz)(new t.LLE(e.lat,e.lng,e.height)),i=new n.Vector3(r.x,r.y,r.z);return r.free(),i}function E(e){let n=new t.Vec3(e.x,e.y,e.z),r=(0,t.xyzToGeodetic)(n),i={lat:r.lat,lng:r.lng,height:r.height};return r.free(),i}function D(e){return(0,t.angleToRadian)(e)}function O(e){return(0,t.angleToDegree)(e)}function k(e,r,i){let a=new t.Window(e.width,e.height,e.pixelRatio);a.width*=a.pixelRatio,a.height*=a.pixelRatio;let o=new t.Transform(r.position.x,r.position.y,r.position.z,r.quaternion.x,r.quaternion.y,r.quaternion.z,r.quaternion.w,r.scale.x,r.scale.y,r.scale.z),s=new t.CameraFrustum(r.near,r.far,(0,t.angleToRadian)(r.fov),r.aspect),c=(0,t.screenToWorld)(a,o,s,new t.Vec2(i.x,i.y));if(!c)return;let l=new n.Vector3(c.x,c.y,c.z);return c.free(),l}function A(e){let r=(0,t.geodeticSurfaceNormal)(new t.LLE(e.lat,e.lng,e.height)),i=new n.Vector3(r.x,r.y,r.z);return r.free(),i}function j(e){let r=new t.Vec3(e.x,e.y,e.z),i=(0,t.eastNorthUpToFixedFrame)(r);return new n.Matrix4().fromArray(i)}function M(e){let r=new t.Vec3(e.x,e.y,e.z),i=(0,t.northEastDownToFixedFrame)(r);return new n.Matrix4().fromArray(i)}function N(e){let r=new t.Vec3(e.x,e.y,e.z),i=(0,t.northUpEastToFixedFrame)(r);return new n.Matrix4().fromArray(i)}function P(e){let r=new t.Vec3(e.x,e.y,e.z),i=(0,t.northWestUpToFixedFrame)(r);return new n.Matrix4().fromArray(i)}function F(e){return N(e).multiply(u())}function I(e){return d(F(T({lng:n.MathUtils.degToRad(e.lng),lat:n.MathUtils.degToRad(e.lat),height:e.height??0})),e)}function L(e,r,i){let a=new t.Window(e.width,e.height,e.pixelRatio);a.width*=a.pixelRatio,a.height*=a.pixelRatio;let o=new t.Transform(r.position.x,r.position.y,r.position.z,r.quaternion.x,r.quaternion.y,r.quaternion.z,r.quaternion.w,r.scale.x,r.scale.y,r.scale.z),s=new t.CameraFrustum(r.near,r.far,(0,t.angleToRadian)(r.fov),r.aspect),c=(0,t.worldToScreen)(a,o,s,new t.Vec3(i.x,i.y,i.z));if(!c)return;let l=new n.Vector2(c.x,c.y);return c.free(),l}function R(){return(0,t.getWGS84SemiMajorAxis)()}function z(){return(0,t.getWGS84SemiMinorAxis)()}function B(){return(0,t.getWGS84EccentricitySquared)()}function V(){return(0,t.getWGS84Flattening)()}function H(){return(0,t.getWGS84Eccentricity)()}e.EllipsoidGeodesic=C,e.RTE_ONE_UNIFORM=g,e.calcCameraPosition=S,e.calcModelMatrixRTE=v,e.composeWorldMatrixForRTE=y,e.convertScreenToWorld=k,e.convertWorldToScreen=L,e.degreeToRadian=D,e.eastNorthUpToFixedFrame=j,e.encodePositionRTE=_,e.geodeticSurfaceNormal=A,e.geodeticToVector3=T,e.getHeightFromEllipsoid=h,e.getPickRay=p,e.getPlaneFromPointNormal=f,e.getRayPlaneIntersection=m,e.getWGS84Eccentricity=H,e.getWGS84EccentricitySquared=B,e.getWGS84Flattening=V,e.getWGS84SemiMajorAxis=R,e.getWGS84SemiMinorAxis=z,e.headingPitchRollToFixedFrame=I,e.initNavaraApi=w,e.northEastDownToFixedFrame=M,e.northUpEastToFixedFrame=N,e.northWestUpToFixedFrame=P,e.radianToDegree=O,e.vector3ToGeodetic=E,e.westUpNorthToFixedFrame=F});
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@navaramap/three-api",
3
3
  "type": "module",
4
- "version": "0.0.7",
4
+ "version": "0.0.8",
5
5
  "main": "dist/index.umd.cjs",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -22,8 +22,8 @@
22
22
  "three": ">=0.183.0"
23
23
  },
24
24
  "dependencies": {
25
- "@navaramap/engine-api": "0.0.7",
26
- "@navaramap/core": "0.0.7"
25
+ "@navaramap/core": "0.0.8",
26
+ "@navaramap/engine-api": "0.0.8"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/three": "0.185.1",
@@ -0,0 +1,154 @@
1
+ import { Matrix4, Vector3 } from "three";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { composeHeadingPitchRoll, nueToWunBasis } from "./frames";
5
+
6
+ // glTF 2.0: an asset's front faces +Z, its up is +Y, and -X is its right.
7
+ const FRONT = new Vector3(0, 0, 1);
8
+ const RIGHT = new Vector3(-1, 0, 0);
9
+
10
+ // Directions in the WUN local frame, where +x = west, +y = up, +z = north.
11
+ const NORTH = [0, 0, 1] as const;
12
+ const SOUTH = [0, 0, -1] as const;
13
+ const EAST = [-1, 0, 0] as const;
14
+ const WEST = [1, 0, 0] as const;
15
+ const UP = [0, 1, 0] as const;
16
+ const DOWN = [0, -1, 0] as const;
17
+
18
+ const expectDir = (
19
+ m: Matrix4,
20
+ axis: Vector3,
21
+ expected: readonly [number, number, number],
22
+ ) => {
23
+ const actual = axis.clone().transformDirection(m);
24
+ expect(actual.x).toBeCloseTo(expected[0], 6);
25
+ expect(actual.y).toBeCloseTo(expected[1], 6);
26
+ expect(actual.z).toBeCloseTo(expected[2], 6);
27
+ };
28
+
29
+ describe("composeHeadingPitchRoll", () => {
30
+ it("faces north with no rotation, right hand to the east", () => {
31
+ const m = composeHeadingPitchRoll(new Matrix4(), {});
32
+ expectDir(m, FRONT, NORTH);
33
+ expectDir(m, RIGHT, EAST);
34
+ });
35
+
36
+ it("reads heading as a clockwise compass bearing", () => {
37
+ expectDir(
38
+ composeHeadingPitchRoll(new Matrix4(), { heading: 0 }),
39
+ FRONT,
40
+ NORTH,
41
+ );
42
+ expectDir(
43
+ composeHeadingPitchRoll(new Matrix4(), { heading: 90 }),
44
+ FRONT,
45
+ EAST,
46
+ );
47
+ expectDir(
48
+ composeHeadingPitchRoll(new Matrix4(), { heading: 180 }),
49
+ FRONT,
50
+ SOUTH,
51
+ );
52
+ expectDir(
53
+ composeHeadingPitchRoll(new Matrix4(), { heading: 270 }),
54
+ FRONT,
55
+ WEST,
56
+ );
57
+ });
58
+
59
+ it("lifts the nose for positive pitch", () => {
60
+ expectDir(composeHeadingPitchRoll(new Matrix4(), { pitch: 90 }), FRONT, UP);
61
+ expectDir(
62
+ composeHeadingPitchRoll(new Matrix4(), { pitch: -90 }),
63
+ FRONT,
64
+ DOWN,
65
+ );
66
+ });
67
+
68
+ it("drops the right wing for positive roll, leaving the nose alone", () => {
69
+ const m = composeHeadingPitchRoll(new Matrix4(), { roll: 90 });
70
+ expectDir(m, RIGHT, DOWN);
71
+ expectDir(m, FRONT, NORTH);
72
+ });
73
+
74
+ it("applies yaw, then pitch, then roll", () => {
75
+ // Roll is innermost: it turns the asset about its own nose before pitch
76
+ // tips that nose up, so the rolled right wing ends up pointing north.
77
+ // Reversing the order would put the right wing east instead.
78
+ const m = composeHeadingPitchRoll(new Matrix4(), { pitch: 90, roll: 90 });
79
+ expectDir(m, FRONT, UP);
80
+ expectDir(m, RIGHT, NORTH);
81
+ });
82
+
83
+ it("accepts uniform and per-axis scale", () => {
84
+ const uniform = new Vector3().setFromMatrixScale(
85
+ composeHeadingPitchRoll(new Matrix4(), { scale: 2.5 }),
86
+ );
87
+ expect(uniform.x).toBeCloseTo(2.5, 6);
88
+ expect(uniform.y).toBeCloseTo(2.5, 6);
89
+ expect(uniform.z).toBeCloseTo(2.5, 6);
90
+
91
+ const perAxis = new Vector3().setFromMatrixScale(
92
+ composeHeadingPitchRoll(new Matrix4(), { scale: { x: 2, y: 3, z: 4 } }),
93
+ );
94
+ expect(perAxis.x).toBeCloseTo(2, 6);
95
+ expect(perAxis.y).toBeCloseTo(3, 6);
96
+ expect(perAxis.z).toBeCloseTo(4, 6);
97
+ });
98
+
99
+ it("round-trips heading through a bearing, matching setCamera", () => {
100
+ // navara_camera::helpers::get_heading yields 0 for north and 90 for east.
101
+ // This test is the guard against the mesh and camera conventions drifting.
102
+ for (const heading of [0, 17, 45, 90, 137, 180, 233, 270, 359]) {
103
+ const front = FRONT.clone().transformDirection(
104
+ composeHeadingPitchRoll(new Matrix4(), { heading }),
105
+ );
106
+ const bearing = (Math.atan2(-front.x, front.z) * 180) / Math.PI;
107
+ expect((bearing + 360) % 360).toBeCloseTo(heading, 4);
108
+ }
109
+ });
110
+
111
+ it("maps the asset front into ECEF through a real WUN frame", () => {
112
+ // WUN at lng 0, lat 0: west = -Y_ecef, up = +X_ecef, north = +Z_ecef.
113
+ const wun = new Matrix4().makeBasis(
114
+ new Vector3(0, -1, 0),
115
+ new Vector3(1, 0, 0),
116
+ new Vector3(0, 0, 1),
117
+ );
118
+ expectDir(composeHeadingPitchRoll(wun, { heading: 0 }), FRONT, [0, 0, 1]);
119
+ expectDir(composeHeadingPitchRoll(wun, { heading: 90 }), FRONT, [0, 1, 0]);
120
+ expectDir(composeHeadingPitchRoll(wun, { pitch: 90 }), FRONT, [1, 0, 0]);
121
+ });
122
+ });
123
+
124
+ describe("nueToWunBasis", () => {
125
+ // navara_core's local_frame_to_fixed_frame builds
126
+ // east = normalize(-origin.y, origin.x, 0) and north = up x east,
127
+ // so the ECEF axes at these two points are exact and hand-checkable.
128
+ const cases = [
129
+ {
130
+ name: "lng 0, lat 0",
131
+ up: new Vector3(1, 0, 0),
132
+ east: new Vector3(0, 1, 0),
133
+ north: new Vector3(0, 0, 1),
134
+ },
135
+ {
136
+ name: "lng 90, lat 0",
137
+ up: new Vector3(0, 1, 0),
138
+ east: new Vector3(-1, 0, 0),
139
+ north: new Vector3(0, 0, 1),
140
+ },
141
+ ];
142
+
143
+ it.each(cases)(
144
+ "turns an NUE basis into a WUN basis at $name",
145
+ ({ up, east, north }) => {
146
+ const nue = new Matrix4().makeBasis(north, up, east);
147
+ const wun = new Matrix4().makeBasis(east.clone().negate(), up, north);
148
+ const converted = nue.clone().multiply(nueToWunBasis());
149
+ converted.elements.forEach((value, i) => {
150
+ expect(value).toBeCloseTo(wun.elements[i], 6);
151
+ });
152
+ },
153
+ );
154
+ });
package/src/frames.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { XYZ } from "@navaramap/core";
2
+ import { Euler, MathUtils, Matrix4, Vector3 } from "three";
3
+
4
+ /**
5
+ * High-level geographic placement of a mesh object.
6
+ *
7
+ * All angles are in **degrees** and `height` is in metres, matching
8
+ * `ThreeView.setCamera`. The asset is oriented in a West-Up-North tangent
9
+ * frame, which matches glTF's own convention (front `+Z`, up `+Y`,
10
+ * right `-X`) on all three axes — so an unmodified glTF asset needs no
11
+ * up-axis correction.
12
+ */
13
+ export type GeodeticPlacement = {
14
+ /** Longitude in degrees. */
15
+ lng: number;
16
+ /** Latitude in degrees. */
17
+ lat: number;
18
+ /**
19
+ * Metres above the ellipsoid, or above terrain when `heightReference` is
20
+ * `"terrain"`. Defaults to 0.
21
+ */
22
+ height?: number;
23
+ /**
24
+ * Degrees clockwise from north that the asset's front (glTF `+Z`) faces.
25
+ * Same convention as `setCamera`'s heading. Defaults to 0.
26
+ */
27
+ heading?: number;
28
+ /** Degrees, nose up positive. Defaults to 0. */
29
+ pitch?: number;
30
+ /** Degrees, right wing down positive. Defaults to 0. */
31
+ roll?: number;
32
+ /** Uniform or per-axis scale of the frame. Defaults to 1. */
33
+ scale?: number | XYZ;
34
+ /** How `height` is interpreted. Defaults to `"ellipsoid"`. */
35
+ heightReference?: "ellipsoid" | "terrain";
36
+ };
37
+
38
+ /**
39
+ * Basis change from the NUE frame `(north, up, east)` to the WUN frame
40
+ * `(west, up, north)`.
41
+ *
42
+ * With `Ry(+90°)` columns `(0,0,-1), (0,1,0), (1,0,0)`, the product
43
+ * `NUE · Ry(+90°)` has columns `(-east, up, north)`, which is WUN. WUN is the
44
+ * unique right-handed, Y-up tangent frame whose `+Z` is north, which is why it
45
+ * agrees with glTF on every axis.
46
+ *
47
+ * Returns a fresh matrix on every call, so callers cannot corrupt a shared
48
+ * instance.
49
+ *
50
+ * @returns A new `Matrix4` performing the NUE-to-WUN basis change.
51
+ */
52
+ export function nueToWunBasis(): Matrix4 {
53
+ return new Matrix4().makeRotationY(Math.PI / 2);
54
+ }
55
+
56
+ /**
57
+ * Composes a tangent frame with heading, pitch, roll and scale.
58
+ *
59
+ * Pure: takes the frame as a parameter and uses three.js `MathUtils.degToRad`,
60
+ * so it touches no WASM and is directly unit-testable.
61
+ *
62
+ * `Ry(-heading) · Rx(-pitch) · Rz(+roll)` is exactly three.js Euler order
63
+ * `"YXZ"`, the standard intrinsic yaw -> pitch -> roll order. Signs follow from
64
+ * the glTF axes in a WUN frame: positive heading swings `+Z` toward `-X`
65
+ * (east), positive pitch lifts `+Z` toward `+Y`, and positive roll about `+Z`
66
+ * drives the `-X` wing toward `-Y`.
67
+ *
68
+ * @param frame - Tangent frame to compose onto, normally from
69
+ * `westUpNorthToFixedFrame`
70
+ * @param p - Heading, pitch, roll (degrees) and scale; each defaults to
71
+ * no-op
72
+ * @returns `frame · R(heading, pitch, roll) · S(scale)`
73
+ */
74
+ export function composeHeadingPitchRoll(
75
+ frame: Matrix4,
76
+ p: Pick<GeodeticPlacement, "heading" | "pitch" | "roll" | "scale">,
77
+ ): Matrix4 {
78
+ const result = new Matrix4().multiplyMatrices(
79
+ frame,
80
+ new Matrix4().makeRotationFromEuler(
81
+ new Euler(
82
+ -MathUtils.degToRad(p.pitch ?? 0),
83
+ -MathUtils.degToRad(p.heading ?? 0),
84
+ MathUtils.degToRad(p.roll ?? 0),
85
+ "YXZ",
86
+ ),
87
+ ),
88
+ );
89
+
90
+ if (p.scale !== undefined) {
91
+ const s = p.scale;
92
+ result.scale(
93
+ typeof s === "number" ? new Vector3(s, s, s) : new Vector3(s.x, s.y, s.z),
94
+ );
95
+ }
96
+
97
+ return result;
98
+ }
@@ -0,0 +1,135 @@
1
+ import { Vector3 } from "three";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ // The WASM math is covered by Rust unit tests; mock it with a transparent JS
5
+ // model so these tests exercise only the TypeScript wiring — specifically that
6
+ // the basis change is applied on the RIGHT (`multiply`, not `premultiply`) and that
7
+ // degrees are converted before reaching geodeticToXyz.
8
+ const SEMI_MAJOR = 6378137;
9
+
10
+ vi.mock("@navaramap/engine-api", () => {
11
+ class LLE {
12
+ constructor(
13
+ public lat: number,
14
+ public lng: number,
15
+ public height: number,
16
+ ) {}
17
+ free() {}
18
+ }
19
+ class Vec3 {
20
+ constructor(
21
+ public x: number,
22
+ public y: number,
23
+ public z: number,
24
+ ) {}
25
+ free() {}
26
+ }
27
+ return {
28
+ default: vi.fn(),
29
+ LLE,
30
+ Vec3,
31
+ // Transparent model: the rotation is pinned to the NUE basis at
32
+ // lng 0 / lat 0 (column-major: x = north (0,0,1), y = up (1,0,0),
33
+ // z = east (0,1,0)), but the ORIGIN is threaded through from the
34
+ // argument. Hardcoding the translation would make the
35
+ // degree-conversion assertion below unobservable — it could never
36
+ // fail, whatever the implementation did.
37
+ northUpEastToFixedFrame: (origin: Vec3) => [
38
+ 0,
39
+ 0,
40
+ 1,
41
+ 0,
42
+ 1,
43
+ 0,
44
+ 0,
45
+ 0,
46
+ 0,
47
+ 1,
48
+ 0,
49
+ 0,
50
+ origin.x,
51
+ origin.y,
52
+ origin.z,
53
+ 1,
54
+ ],
55
+ geodeticToXyz: (lle: LLE) => ({
56
+ x: SEMI_MAJOR + lle.height,
57
+ y: lle.lng,
58
+ z: lle.lat,
59
+ free() {},
60
+ }),
61
+ };
62
+ });
63
+
64
+ const { headingPitchRollToFixedFrame, westUpNorthToFixedFrame } =
65
+ await import("./index");
66
+
67
+ describe("westUpNorthToFixedFrame", () => {
68
+ it("turns the NUE columns into (-east, up, north)", () => {
69
+ const wun = westUpNorthToFixedFrame(new Vector3(SEMI_MAJOR, 0, 0));
70
+
71
+ // `makeRotationY(Math.PI / 2)` leaves cos(PI/2) = 6.1e-17 in the
72
+ // matrix, so these are near-equalities, not exact ones.
73
+ const expectColumn = (
74
+ index: number,
75
+ expected: readonly [number, number, number],
76
+ ) => {
77
+ const column = new Vector3().setFromMatrixColumn(wun, index);
78
+ expect(column.x).toBeCloseTo(expected[0], 12);
79
+ expect(column.y).toBeCloseTo(expected[1], 12);
80
+ expect(column.z).toBeCloseTo(expected[2], 12);
81
+ };
82
+
83
+ expectColumn(0, [0, -1, 0]); // west
84
+ expectColumn(1, [1, 0, 0]); // up
85
+ expectColumn(2, [0, 0, 1]); // north
86
+ });
87
+
88
+ it("keeps the ECEF origin in the translation column", () => {
89
+ const wun = westUpNorthToFixedFrame(new Vector3(SEMI_MAJOR, 0, 0));
90
+ const origin = new Vector3().setFromMatrixPosition(wun);
91
+ expect(origin.x).toBeCloseTo(SEMI_MAJOR, 6);
92
+ expect(origin.y).toBeCloseTo(0, 12);
93
+ expect(origin.z).toBeCloseTo(0, 12);
94
+ });
95
+ });
96
+
97
+ describe("headingPitchRollToFixedFrame", () => {
98
+ it("converts lng/lat from degrees before building the origin", () => {
99
+ // The mock echoes the radian lng/lat back as y/z, so the conversion is
100
+ // observable in the translation column.
101
+ const m = headingPitchRollToFixedFrame({ lng: 180, lat: 90, height: 12 });
102
+ const origin = new Vector3().setFromMatrixPosition(m);
103
+ expect(origin.x).toBeCloseTo(SEMI_MAJOR + 12, 6);
104
+ expect(origin.y).toBeCloseTo(Math.PI, 6);
105
+ expect(origin.z).toBeCloseTo(Math.PI / 2, 6);
106
+ });
107
+
108
+ it("orients the asset front by heading in the WUN frame", () => {
109
+ // At lng 0 / lat 0 the mocked frame gives ECEF north = +Z, east = +Y.
110
+ const front = new Vector3(0, 0, 1);
111
+
112
+ const north = front
113
+ .clone()
114
+ .transformDirection(
115
+ headingPitchRollToFixedFrame({ lng: 0, lat: 0, heading: 0 }),
116
+ );
117
+ expect(north.x).toBeCloseTo(0, 6);
118
+ expect(north.y).toBeCloseTo(0, 6);
119
+ expect(north.z).toBeCloseTo(1, 6);
120
+
121
+ const east = front
122
+ .clone()
123
+ .transformDirection(
124
+ headingPitchRollToFixedFrame({ lng: 0, lat: 0, heading: 90 }),
125
+ );
126
+ expect(east.x).toBeCloseTo(0, 6);
127
+ expect(east.y).toBeCloseTo(1, 6);
128
+ expect(east.z).toBeCloseTo(0, 6);
129
+ });
130
+
131
+ it("defaults height to zero", () => {
132
+ const m = headingPitchRollToFixedFrame({ lng: 0, lat: 0 });
133
+ expect(new Vector3().setFromMatrixPosition(m).x).toBeCloseTo(SEMI_MAJOR, 6);
134
+ });
135
+ });
package/src/index.ts CHANGED
@@ -23,7 +23,13 @@ import initApi, {
23
23
  getWGS84Flattening as nvGetWGS84Flattening,
24
24
  getWGS84Eccentricity as nvGetWGS84Eccentricity,
25
25
  } from "@navaramap/engine-api";
26
- import { Vector3, Vector2, Matrix4, PerspectiveCamera } from "three";
26
+ import { Vector3, Vector2, Matrix4, MathUtils, PerspectiveCamera } from "three";
27
+
28
+ import {
29
+ composeHeadingPitchRoll,
30
+ nueToWunBasis,
31
+ type GeodeticPlacement,
32
+ } from "./frames";
27
33
 
28
34
  export type { LatLngHeight, LatLng } from "@navaramap/core";
29
35
  export type {
@@ -35,6 +41,7 @@ export type {
35
41
  Vec2,
36
42
  Vec3,
37
43
  } from "@navaramap/core";
44
+ export type { GeodeticPlacement } from "./frames";
38
45
 
39
46
  export * from "./intersection";
40
47
  export * from "./rte";
@@ -206,6 +213,54 @@ export function northWestUpToFixedFrame(origin: Vector3): Matrix4 {
206
213
  return matrix;
207
214
  }
208
215
 
216
+ /**
217
+ * Creates a local West-Up-North (WUN) reference frame transformation matrix at
218
+ * a position.
219
+ *
220
+ * WUN is the only right-handed, Y-up tangent frame whose `+Z` axis is north, so
221
+ * it agrees with glTF's own convention (front `+Z`, up `+Y`, right `-X`) on all
222
+ * three axes. An unmodified glTF asset placed in this frame needs **no**
223
+ * `Rx(+90°)` up-axis correction — the correction is absorbed here, once, as the
224
+ * `Ry(+90°)` basis change from NUE.
225
+ *
226
+ * @param origin - Origin position in ECEF coordinates
227
+ * @returns 4x4 transformation matrix from WUN to ECEF
228
+ */
229
+ export function westUpNorthToFixedFrame(origin: Vector3): Matrix4 {
230
+ return northUpEastToFixedFrame(origin).multiply(nueToWunBasis());
231
+ }
232
+
233
+ /**
234
+ * Builds a WUN tangent frame at a geodetic position and composes heading,
235
+ * pitch, roll and scale onto it — the single call that turns a geographic
236
+ * placement into a world matrix.
237
+ *
238
+ * `heading` is the compass bearing the asset's front faces, where the front is
239
+ * glTF's own `+Z` — the same convention as `ThreeView.setCamera`. Other globe
240
+ * engines differ in which axis they treat as an asset's front, so a model
241
+ * ported from one may need a multiple of 90° added to its heading.
242
+ *
243
+ * NOTE: unlike {@link geodeticToVector3} and the other helpers in this module,
244
+ * `lng`/`lat` and all angles here are in **degrees**, matching
245
+ * `setCamera` and the `geodetic` mesh Descriptor field.
246
+ *
247
+ * `heightReference` is intentionally not accepted: resolving terrain-relative
248
+ * height needs a view to sample from, so it is the Descriptor's job.
249
+ *
250
+ * @param placement - Geographic placement in degrees and metres
251
+ * @returns 4x4 transformation matrix from the placed local frame to ECEF
252
+ */
253
+ export function headingPitchRollToFixedFrame(
254
+ placement: Omit<GeodeticPlacement, "heightReference">,
255
+ ): Matrix4 {
256
+ const origin = geodeticToVector3({
257
+ lng: MathUtils.degToRad(placement.lng),
258
+ lat: MathUtils.degToRad(placement.lat),
259
+ height: placement.height ?? 0,
260
+ });
261
+ return composeHeadingPitchRoll(westUpNorthToFixedFrame(origin), placement);
262
+ }
263
+
209
264
  /**
210
265
  * Converts world coordinates to screen coordinates.
211
266
  * @param window - Window configuration with width, height, and pixelRatio