@navaramap/three-plugins 0.0.8 → 0.1.1
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/LICENSE +1 -1
- package/README.md +5 -5
- package/dist/index.d.ts +57 -20
- package/dist/index.js +85 -69
- package/dist/index.umd.cjs +1 -1
- package/package.json +5 -5
- package/src/OverlayPlugin.ts +5 -10
- package/src/PersonViewPlugin.test.ts +144 -17
- package/src/PersonViewPlugin.ts +142 -71
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navaramap/three-plugins",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.1.1",
|
|
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
|
|
24
|
-
"@navaramap/three": "0.
|
|
23
|
+
"@navaramap/three": "0.1.1",
|
|
24
|
+
"@navaramap/three-default-plugin": "0.1.1"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/three": "0.185.1",
|
|
28
28
|
"three": "0.185.1",
|
|
29
|
-
"@navaramap/three
|
|
30
|
-
"@navaramap/three": "0.
|
|
29
|
+
"@navaramap/three": "0.1.1",
|
|
30
|
+
"@navaramap/three-default-plugin": "0.1.1"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"type-check": "tsc && tsc -p tsconfig.node.json",
|
package/src/OverlayPlugin.ts
CHANGED
|
@@ -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:
|
|
176
|
-
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:
|
|
184
|
-
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 -
|
|
70
|
+
(lat - START.lat) * DEG * R * grade;
|
|
70
71
|
|
|
71
|
-
/** Terrain height in meters, from a position in
|
|
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 = (
|
|
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), {},
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
606
|
-
fpvPitch:
|
|
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,
|
|
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:
|
|
804
|
+
cameraPitch: CAMERA_PITCH_DEG,
|
|
678
805
|
cameraLerpSpeed: 10,
|
|
679
806
|
});
|
|
680
807
|
await initPlugin(plugin, view);
|