@navaramap/three-plugins 0.0.8 → 0.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@navaramap/three-plugins",
3
3
  "type": "module",
4
- "version": "0.0.8",
4
+ "version": "0.1.0",
5
5
  "main": "dist/index.umd.cjs",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -20,14 +20,14 @@
20
20
  },
21
21
  "peerDependencies": {
22
22
  "three": ">=0.183.0",
23
- "@navaramap/three-default-plugin": "0.0.8",
24
- "@navaramap/three": "0.0.8"
23
+ "@navaramap/three": "0.1.0",
24
+ "@navaramap/three-default-plugin": "0.1.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/three": "0.185.1",
28
28
  "three": "0.185.1",
29
- "@navaramap/three-default-plugin": "0.0.8",
30
- "@navaramap/three": "0.0.8"
29
+ "@navaramap/three": "0.1.0",
30
+ "@navaramap/three-default-plugin": "0.1.0"
31
31
  },
32
32
  "scripts": {
33
33
  "type-check": "tsc && tsc -p tsconfig.node.json",
@@ -37,7 +37,6 @@ import ThreeView, {
37
37
  Plugin,
38
38
  geodeticToVector3,
39
39
  convertWorldToScreen,
40
- degreeToRadian,
41
40
  type ViewContext,
42
41
  type Window,
43
42
  } from "@navaramap/three";
@@ -149,11 +148,7 @@ export class OverlayPlugin extends Plugin<View, ViewContext> {
149
148
  ): { x: number; y: number } | null {
150
149
  if (!this.view) return null;
151
150
 
152
- const ecef = geodeticToVector3({
153
- lng: degreeToRadian(lng),
154
- lat: degreeToRadian(lat),
155
- height: alt,
156
- });
151
+ const ecef = geodeticToVector3({ lng, lat, height: alt });
157
152
 
158
153
  const win: Window = {
159
154
  width: this.view.screenSize.x,
@@ -172,16 +167,16 @@ export class OverlayPlugin extends Plugin<View, ViewContext> {
172
167
 
173
168
  const geo = this.view.camera.positionGeographic;
174
169
  const cameraEcef = geodeticToVector3({
175
- lng: degreeToRadian(geo.lng),
176
- lat: degreeToRadian(geo.lat),
170
+ lng: geo.lng,
171
+ lat: geo.lat,
177
172
  height: geo.height,
178
173
  });
179
174
 
180
175
  const next = new Map<string, ProjectedPosition>();
181
176
  for (const pos of this.positions) {
182
177
  const markerEcef = geodeticToVector3({
183
- lng: degreeToRadian(pos.lng),
184
- lat: degreeToRadian(pos.lat),
178
+ lng: pos.lng,
179
+ lat: pos.lat,
185
180
  height: pos.alt,
186
181
  });
187
182
  const distance = cameraEcef.distanceTo(markerEcef);
@@ -9,6 +9,7 @@ import { PersonViewPlugin, type CollisionConfig } from "./PersonViewPlugin";
9
9
  // relies on (ENU columns are east / north / up, heading 0 = north) which is
10
10
  // what these tests exercise, without pulling in the ellipsoid math.
11
11
  const R = 6378137;
12
+ const DEG = Math.PI / 180;
12
13
 
13
14
  /* eslint-disable @typescript-eslint/no-extraneous-class */
14
15
  vi.mock("@navaramap/three", () => ({
@@ -17,6 +18,7 @@ vi.mock("@navaramap/three", () => ({
17
18
  MeshHandle: class MeshHandle {},
18
19
  degreeToRadian: (deg: number) => (deg * Math.PI) / 180,
19
20
  radianToDegree: (rad: number) => (rad * 180) / Math.PI,
21
+ // Lat/lng cross this boundary in degrees, like the real helpers.
20
22
  geodeticToVector3: ({
21
23
  lat,
22
24
  lng,
@@ -27,15 +29,15 @@ vi.mock("@navaramap/three", () => ({
27
29
  height: number;
28
30
  }) =>
29
31
  new Vector3(
30
- (R + height) * Math.cos(lat) * Math.cos(lng),
31
- (R + height) * Math.cos(lat) * Math.sin(lng),
32
- (R + height) * Math.sin(lat),
32
+ (R + height) * Math.cos(lat * DEG) * Math.cos(lng * DEG),
33
+ (R + height) * Math.cos(lat * DEG) * Math.sin(lng * DEG),
34
+ (R + height) * Math.sin(lat * DEG),
33
35
  ),
34
36
  vector3ToGeodetic: (v: Vector3) => {
35
37
  const radius = v.length();
36
38
  return {
37
- lat: Math.asin(v.z / radius),
38
- lng: Math.atan2(v.y, v.x),
39
+ lat: Math.asin(v.z / radius) / DEG,
40
+ lng: Math.atan2(v.y, v.x) / DEG,
39
41
  height: radius - R,
40
42
  };
41
43
  },
@@ -60,18 +62,20 @@ vi.mock("@navaramap/three", () => ({
60
62
  /* eslint-enable @typescript-eslint/no-extraneous-class */
61
63
 
62
64
  const START = { lat: 36.25, lng: 137.64 };
63
- const START_LAT_RAD = (START.lat * Math.PI) / 180;
64
65
 
65
66
  /** Terrain rising toward the north at the given grade. */
66
67
  const northSlope =
67
68
  (grade: number): TerrainFn =>
68
69
  (lat) =>
69
- (lat - START_LAT_RAD) * R * grade;
70
+ (lat - START.lat) * DEG * R * grade;
70
71
 
71
- /** Terrain height in meters, from a position in radians. */
72
+ /** Terrain height in meters, from a position in degrees. */
72
73
  type TerrainFn = (lat: number, lng: number) => number | undefined;
73
74
 
74
- const makeFakeView = (terrain: TerrainFn) => {
75
+ const makeFakeView = (
76
+ terrain: TerrainFn,
77
+ mostDetailed: TerrainFn = terrain,
78
+ ) => {
75
79
  const loadListeners: (() => void)[] = [];
76
80
  const placements: {
77
81
  targetHeight: number;
@@ -103,6 +107,16 @@ const makeFakeView = (terrain: TerrainFn) => {
103
107
  sampleTerrainHeight: vi.fn(({ lat, lng }: { lat: number; lng: number }) =>
104
108
  terrain(lat, lng),
105
109
  ),
110
+ sampleTerrainMostDetailed: vi.fn(
111
+ (_source: unknown, positions: { lat: number; lng: number }[]) =>
112
+ Promise.resolve(
113
+ positions.map(({ lat, lng }) => ({
114
+ lat,
115
+ lng,
116
+ height: mostDetailed(lat, lng),
117
+ })),
118
+ ),
119
+ ),
106
120
  // The plugin passes a reused scratch Vector3 as the offset, so snapshot
107
121
  // each placement instead of holding on to the object.
108
122
  placements,
@@ -217,6 +231,96 @@ describe("PersonViewPlugin terrain collision", () => {
217
231
  });
218
232
  });
219
233
 
234
+ describe("resolveStartHeight", () => {
235
+ it("pins the start height to the most detailed terrain sample", async () => {
236
+ const view = makeFakeView(() => 1500);
237
+ const plugin = new PersonViewPlugin({
238
+ startLat: START.lat,
239
+ startLng: START.lng,
240
+ startHeight: 800,
241
+ });
242
+ await initPlugin(plugin, view);
243
+
244
+ await expect(plugin.resolveStartHeight("terrain")).resolves.toBe(1500);
245
+
246
+ // The sampler takes degrees, matching `ThreeView.sampleTerrainMostDetailed`.
247
+ const [, positions] = view.sampleTerrainMostDetailed.mock.calls[0];
248
+ expect(positions[0].lat).toBeCloseTo(START.lat);
249
+ expect(positions[0].lng).toBeCloseTo(START.lng);
250
+ expect(plugin.getState().alt).toBe(1500);
251
+ });
252
+
253
+ it("includes the collision's groundOffset like the collision itself", async () => {
254
+ const view = makeFakeView(() => 1500);
255
+ const plugin = new PersonViewPlugin({
256
+ collision: { mode: "ground", groundOffset: 2 },
257
+ });
258
+ await initPlugin(plugin, view);
259
+
260
+ await expect(plugin.resolveStartHeight("terrain")).resolves.toBe(1502);
261
+ expect(plugin.getState().alt).toBe(1502);
262
+ });
263
+
264
+ it("keeps the configured height when the source has no data there", async () => {
265
+ const view = makeFakeView(() => undefined);
266
+ const plugin = new PersonViewPlugin({ startHeight: 800 });
267
+ await initPlugin(plugin, view);
268
+
269
+ await expect(plugin.resolveStartHeight("terrain")).resolves.toBe(
270
+ undefined,
271
+ );
272
+ expect(plugin.getState().alt).toBe(800);
273
+ });
274
+
275
+ it("holds the resolved height against coarser tiles until movement", async () => {
276
+ // Render-resident tiles report 1000m while the most detailed data
277
+ // says 1500m — the situation right after load.
278
+ const view = makeFakeView(
279
+ () => 1000,
280
+ () => 1500,
281
+ );
282
+ const plugin = new PersonViewPlugin({
283
+ collision: { mode: "ground" },
284
+ startLat: START.lat,
285
+ startLng: START.lng,
286
+ });
287
+ await initPlugin(plugin, view);
288
+ await plugin.resolveStartHeight("terrain");
289
+
290
+ plugin.start();
291
+ let time = performance.now();
292
+ for (let i = 0; i < 100; i++) advance((time += 100));
293
+ expect(plugin.getState().alt).toBeCloseTo(1500, 6);
294
+
295
+ // The first movement input hands the altitude over to the collision,
296
+ // which settles onto the render-resident surface.
297
+ document.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
298
+ for (let i = 0; i < 200; i++) advance((time += 100));
299
+ expect(plugin.getState().alt).toBeCloseTo(1000, 6);
300
+ });
301
+
302
+ it("releases the pinned height on teleport", async () => {
303
+ const view = makeFakeView(
304
+ () => 1000,
305
+ () => 1500,
306
+ );
307
+ const plugin = new PersonViewPlugin({
308
+ collision: { mode: "ground" },
309
+ startLat: START.lat,
310
+ startLng: START.lng,
311
+ });
312
+ await initPlugin(plugin, view);
313
+ await plugin.resolveStartHeight("terrain");
314
+
315
+ plugin.start();
316
+ plugin.teleport({ ...START, alt: 800 });
317
+ let time = performance.now();
318
+ for (let i = 0; i < 5; i++) advance((time += 100));
319
+
320
+ expect(plugin.getState().alt).toBeCloseTo(1000, 6);
321
+ });
322
+ });
323
+
220
324
  describe("per-frame follow", () => {
221
325
  it("glues the altitude to the terrain in ground mode", async () => {
222
326
  const view = makeFakeView(() => 1500);
@@ -387,6 +491,27 @@ describe("PersonViewPlugin terrain collision", () => {
387
491
  for (let i = 0; i < 200; i++) advance((time += 16));
388
492
  };
389
493
 
494
+ it("keeps the tilt alive while the start height is pinned", async () => {
495
+ const grade = 0.5;
496
+ const view = makeFakeView(northSlope(grade));
497
+ const plugin = new PersonViewPlugin({
498
+ character,
499
+ collision: { mode: "ground", alignToSlope: true },
500
+ startLat: START.lat,
501
+ startLng: START.lng,
502
+ startHeading: 0,
503
+ });
504
+ await initPlugin(plugin, view);
505
+ // Pinning must not read as airborne: the slope tilt still applies.
506
+ await plugin.resolveStartHeight("terrain");
507
+ plugin.start();
508
+ view.finishModelLoad();
509
+
510
+ settleTilt();
511
+
512
+ expect(tilt(view).forward).toBeCloseTo(Math.sin(Math.atan(grade)), 3);
513
+ });
514
+
390
515
  it("pitches the character nose-up when it faces uphill", async () => {
391
516
  const grade = 0.5;
392
517
  const { view } = await walkOn(northSlope(grade), {}, 0);
@@ -402,7 +527,7 @@ describe("PersonViewPlugin terrain collision", () => {
402
527
  it("rolls the character onto a slope that falls away sideways", async () => {
403
528
  const grade = 0.5;
404
529
  // Facing east, so the slope rises to the left.
405
- const { view } = await walkOn(northSlope(grade), {}, Math.PI / 2);
530
+ const { view } = await walkOn(northSlope(grade), {}, 90);
406
531
 
407
532
  settleTilt();
408
533
 
@@ -444,7 +569,7 @@ describe("PersonViewPlugin terrain collision", () => {
444
569
  expect(tilt(view).forward).toBeCloseTo(Math.sin(Math.atan(grade)), 3);
445
570
 
446
571
  // Face east without advancing a frame: uphill is now to the left.
447
- plugin.setHeading(Math.PI / 2);
572
+ plugin.setHeading(90);
448
573
 
449
574
  expect(tilt(view).forward).toBeCloseTo(0, 3);
450
575
  expect(tilt(view).right).toBeCloseTo(-Math.sin(Math.atan(grade)), 3);
@@ -453,7 +578,7 @@ describe("PersonViewPlugin terrain collision", () => {
453
578
  it("caps the tilt on near-vertical ground", async () => {
454
579
  // A 5:1 face is 79°, well past what the character should lie down to.
455
580
  const { view } = await walkOn(northSlope(5), {
456
- maxSlopeTilt: Math.PI / 3,
581
+ maxSlopeTilt: 60,
457
582
  });
458
583
 
459
584
  settleTilt();
@@ -576,7 +701,9 @@ describe("PersonViewPlugin terrain collision", () => {
576
701
  });
577
702
 
578
703
  describe("camera slope pitch", () => {
579
- const CAMERA_PITCH = 0.1;
704
+ // Config takes degrees; the geometric assertions below work in radians.
705
+ const CAMERA_PITCH_DEG = 6;
706
+ const CAMERA_PITCH = CAMERA_PITCH_DEG * DEG;
580
707
  const CAMERA_DISTANCE = 20;
581
708
 
582
709
  /** Where the chase camera ended up, as a pitch in radians. */
@@ -602,8 +729,8 @@ describe("PersonViewPlugin terrain collision", () => {
602
729
  startHeading: heading,
603
730
  initialView,
604
731
  cameraDistance: CAMERA_DISTANCE,
605
- cameraPitch: CAMERA_PITCH,
606
- fpvPitch: CAMERA_PITCH,
732
+ cameraPitch: CAMERA_PITCH_DEG,
733
+ fpvPitch: CAMERA_PITCH_DEG,
607
734
  cameraLerpSpeed: 10,
608
735
  });
609
736
  await initPlugin(plugin, view);
@@ -625,7 +752,7 @@ describe("PersonViewPlugin terrain collision", () => {
625
752
 
626
753
  it("rises above the slope on a descent", async () => {
627
754
  const grade = 0.7;
628
- const { view } = await standOn(grade, Math.PI); // facing south, downhill
755
+ const { view } = await standOn(grade, 180); // facing south, downhill
629
756
 
630
757
  expect(cameraPitch(view)).toBeCloseTo(CAMERA_PITCH + Math.atan(grade), 2);
631
758
  });
@@ -674,7 +801,7 @@ describe("PersonViewPlugin terrain collision", () => {
674
801
  startHeight: CAMERA_DISTANCE * 2,
675
802
  startHeading: 0,
676
803
  cameraDistance: CAMERA_DISTANCE,
677
- cameraPitch: CAMERA_PITCH,
804
+ cameraPitch: CAMERA_PITCH_DEG,
678
805
  cameraLerpSpeed: 10,
679
806
  });
680
807
  await initPlugin(plugin, view);