@navaramap/three 0.0.6 → 0.0.7
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/dist/assets/fontWorker-DaqfXmZf.js +2 -0
- package/dist/assets/fontWorker-DaqfXmZf.js.map +1 -0
- package/dist/assets/index-Dydat1h1.js +25 -0
- package/dist/assets/index-Dydat1h1.js.map +1 -0
- package/dist/assets/{navara_wasm_font_worker_bg-DympKYVX.wasm → navara_wasm_font_worker_bg-XvE_oYPr.wasm} +0 -0
- package/dist/assets/{navara_wasm_worker_bg-Bx_l45cW.wasm → navara_wasm_worker_bg-Dg9fnGLP.wasm} +0 -0
- package/dist/index.d.ts +140 -4
- package/dist/index.js +5385 -5022
- package/dist/index.js.map +1 -1
- package/dist/index.umd.cjs +164 -86
- package/dist/index.umd.cjs.map +1 -1
- package/package.json +12 -12
- package/src/declutter/DeclutterManager.test.ts +65 -0
- package/src/declutter/DeclutterManager.ts +60 -1
- package/src/declutter/types.ts +17 -0
- package/src/event/feature.ts +45 -2
- package/src/event/worker.ts +18 -0
- package/src/index.ts +51 -2
- package/src/layers/effect/TransparentPassEffectDesc.ts +1 -1
- package/src/mesh/nvLineGeometry.test.ts +88 -0
- package/src/mesh/nvLineGeometry.ts +148 -0
- package/src/mesh/polygonOutline.ts +126 -42
- package/src/mesh/sdfText/batchedSdfText.test.ts +377 -0
- package/src/mesh/sdfText/batchedSdfText.ts +192 -21
- package/src/mesh/sdfText/labelVisibility.test.ts +106 -0
- package/src/mesh/sdfText/labelVisibility.ts +131 -0
- package/src/mesh/sdfText/pendingSettlement.test.ts +151 -0
- package/src/mesh/sdfText/pendingSettlement.ts +68 -0
- package/src/pick/pickTerrain.ts +22 -18
- package/src/terrain/sampleTerrainMostDetailed.test.ts +236 -0
- package/src/terrain/sampleTerrainMostDetailed.ts +447 -0
- package/dist/assets/fontWorker-JfTCpd38.js +0 -2
- package/dist/assets/fontWorker-JfTCpd38.js.map +0 -1
- package/dist/assets/index-B3U_DvcP.js +0 -25
- package/dist/assets/index-B3U_DvcP.js.map +0 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { PendingSettlement } from "./pendingSettlement";
|
|
4
|
+
|
|
5
|
+
/** Drains several microtask ticks so chained `.then` callbacks have run. */
|
|
6
|
+
const flushMicrotasks = async () => {
|
|
7
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe("PendingSettlement", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("resolves immediately when nothing is in flight", async () => {
|
|
20
|
+
const s = new PendingSettlement();
|
|
21
|
+
let settled = false;
|
|
22
|
+
void s.whenSettled(1000).then(() => (settled = true));
|
|
23
|
+
await flushMicrotasks();
|
|
24
|
+
expect(settled).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("waits for a tracked promise to resolve", async () => {
|
|
28
|
+
const s = new PendingSettlement();
|
|
29
|
+
let resolveOp!: () => void;
|
|
30
|
+
s.track(new Promise<void>((r) => (resolveOp = r)));
|
|
31
|
+
|
|
32
|
+
let settled = false;
|
|
33
|
+
void s.whenSettled(1000).then(() => (settled = true));
|
|
34
|
+
await flushMicrotasks();
|
|
35
|
+
expect(settled).toBe(false);
|
|
36
|
+
|
|
37
|
+
resolveOp();
|
|
38
|
+
await flushMicrotasks();
|
|
39
|
+
expect(settled).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("counts a rejected promise as settled", async () => {
|
|
43
|
+
const s = new PendingSettlement();
|
|
44
|
+
let rejectOp!: (e: Error) => void;
|
|
45
|
+
s.track(new Promise<void>((_, rj) => (rejectOp = rj)));
|
|
46
|
+
|
|
47
|
+
let settled = false;
|
|
48
|
+
void s.whenSettled(1000).then(() => (settled = true));
|
|
49
|
+
|
|
50
|
+
rejectOp(new Error("prepare failed"));
|
|
51
|
+
await flushMicrotasks();
|
|
52
|
+
expect(settled).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("waits for the last of several tracked promises", async () => {
|
|
56
|
+
const s = new PendingSettlement();
|
|
57
|
+
const resolvers: (() => void)[] = [];
|
|
58
|
+
for (let i = 0; i < 3; i++) {
|
|
59
|
+
s.track(new Promise<void>((r) => resolvers.push(r)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let settled = false;
|
|
63
|
+
void s.whenSettled(1000).then(() => (settled = true));
|
|
64
|
+
|
|
65
|
+
resolvers[0]();
|
|
66
|
+
resolvers[1]();
|
|
67
|
+
await flushMicrotasks();
|
|
68
|
+
expect(settled).toBe(false);
|
|
69
|
+
|
|
70
|
+
resolvers[2]();
|
|
71
|
+
await flushMicrotasks();
|
|
72
|
+
expect(settled).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("resolves at the timeout when an operation never settles", async () => {
|
|
76
|
+
const s = new PendingSettlement();
|
|
77
|
+
s.track(new Promise<void>(() => {}));
|
|
78
|
+
|
|
79
|
+
let settled = false;
|
|
80
|
+
void s.whenSettled(1000).then(() => (settled = true));
|
|
81
|
+
|
|
82
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
83
|
+
expect(settled).toBe(false);
|
|
84
|
+
|
|
85
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
86
|
+
expect(settled).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// A timed-out waiter used to stay queued forever when its operation never
|
|
90
|
+
// settled, so repeated calls grew the queue without bound and a late settle
|
|
91
|
+
// still invoked every one of them.
|
|
92
|
+
it("drops timed-out waiters instead of queueing them forever", async () => {
|
|
93
|
+
const s = new PendingSettlement();
|
|
94
|
+
s.track(new Promise<void>(() => {}));
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < 5; i++) {
|
|
97
|
+
void s.whenSettled(1000);
|
|
98
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Private, but the leak is only observable here — the queue is the bug.
|
|
102
|
+
const waiters = (s as unknown as { _waiters: unknown[] })._waiters;
|
|
103
|
+
expect(waiters.length).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("completes once when a settle races the timeout", async () => {
|
|
107
|
+
const s = new PendingSettlement();
|
|
108
|
+
let resolveOp!: () => void;
|
|
109
|
+
s.track(new Promise<void>((r) => (resolveOp = r)));
|
|
110
|
+
|
|
111
|
+
let count = 0;
|
|
112
|
+
void s.whenSettled(1000).then(() => count++);
|
|
113
|
+
|
|
114
|
+
// Time out first, then let the operation settle afterwards.
|
|
115
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
116
|
+
expect(count).toBe(1);
|
|
117
|
+
|
|
118
|
+
resolveOp();
|
|
119
|
+
await flushMicrotasks();
|
|
120
|
+
expect(count).toBe(1);
|
|
121
|
+
|
|
122
|
+
// The settle still drained the queue, so a fresh call resolves at once.
|
|
123
|
+
let after = false;
|
|
124
|
+
void s.whenSettled(1000).then(() => (after = true));
|
|
125
|
+
await flushMicrotasks();
|
|
126
|
+
expect(after).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("supports waiters registered across separate settle cycles", async () => {
|
|
130
|
+
const s = new PendingSettlement();
|
|
131
|
+
|
|
132
|
+
let resolveA!: () => void;
|
|
133
|
+
s.track(new Promise<void>((r) => (resolveA = r)));
|
|
134
|
+
let firstSettled = false;
|
|
135
|
+
void s.whenSettled(1000).then(() => (firstSettled = true));
|
|
136
|
+
resolveA();
|
|
137
|
+
await flushMicrotasks();
|
|
138
|
+
expect(firstSettled).toBe(true);
|
|
139
|
+
|
|
140
|
+
let resolveB!: () => void;
|
|
141
|
+
s.track(new Promise<void>((r) => (resolveB = r)));
|
|
142
|
+
let secondSettled = false;
|
|
143
|
+
void s.whenSettled(1000).then(() => (secondSettled = true));
|
|
144
|
+
await flushMicrotasks();
|
|
145
|
+
expect(secondSettled).toBe(false);
|
|
146
|
+
|
|
147
|
+
resolveB();
|
|
148
|
+
await flushMicrotasks();
|
|
149
|
+
expect(secondSettled).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts in-flight async operations and lets callers await the moment the
|
|
3
|
+
* count returns to zero, bounded by a timeout.
|
|
4
|
+
*
|
|
5
|
+
* Used to defer a text batch's render-completion report until its labels'
|
|
6
|
+
* async font preparations have landed: the report drives the Rust tile-LOD
|
|
7
|
+
* swap, and reporting while glyph runs are still being shaped swaps a parent
|
|
8
|
+
* tile out for a child that draws nothing (a tile-shaped blank).
|
|
9
|
+
*/
|
|
10
|
+
export class PendingSettlement {
|
|
11
|
+
private _pending = 0;
|
|
12
|
+
private _waiters: (() => void)[] = [];
|
|
13
|
+
|
|
14
|
+
get pending(): number {
|
|
15
|
+
return this._pending;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Track an in-flight operation. Rejection counts as settled too — a failed
|
|
19
|
+
* font prepare must not hold the LOD swap hostage. */
|
|
20
|
+
track(p: Promise<unknown>): void {
|
|
21
|
+
this._pending++;
|
|
22
|
+
const done = () => {
|
|
23
|
+
this._pending--;
|
|
24
|
+
if (this._pending === 0) {
|
|
25
|
+
const waiters = this._waiters;
|
|
26
|
+
this._waiters = [];
|
|
27
|
+
for (const w of waiters) w();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
p.then(done, done);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolves once nothing tracked is in flight, or after `timeoutMs`,
|
|
35
|
+
* whichever comes first. Only operations already tracked when this is
|
|
36
|
+
* called are waited on (plus any tracked before the count next reaches
|
|
37
|
+
* zero); resolves immediately when already settled.
|
|
38
|
+
*
|
|
39
|
+
* Settle and timeout are mutually exclusive completion paths, and the
|
|
40
|
+
* timeout drops its own waiter: an operation that never settles would
|
|
41
|
+
* otherwise leave every timed-out waiter queued forever, so repeated calls
|
|
42
|
+
* would grow `_waiters` without bound.
|
|
43
|
+
*/
|
|
44
|
+
whenSettled(timeoutMs: number): Promise<void> {
|
|
45
|
+
if (this._pending === 0) return Promise.resolve();
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let finished = false;
|
|
48
|
+
const finish = () => {
|
|
49
|
+
if (finished) return;
|
|
50
|
+
finished = true;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
// Declared after `finish` so it can be `const`; `finish` only reads it
|
|
55
|
+
// when invoked, which is always after this assignment.
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
this._removeWaiter(finish);
|
|
58
|
+
finish();
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
this._waiters.push(finish);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private _removeWaiter(waiter: () => void): void {
|
|
65
|
+
const i = this._waiters.indexOf(waiter);
|
|
66
|
+
if (i !== -1) this._waiters.splice(i, 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/pick/pickTerrain.ts
CHANGED
|
@@ -123,12 +123,22 @@ export class TerrainPicker {
|
|
|
123
123
|
1.0 / PackFactors[3],
|
|
124
124
|
];
|
|
125
125
|
|
|
126
|
-
//
|
|
126
|
+
// readRenderTargetPixels returns bytes 0..255; a GLSL texel channel is byte / 255.
|
|
127
127
|
return (
|
|
128
|
-
(rgba[0] /
|
|
129
|
-
(rgba[1] /
|
|
130
|
-
(rgba[2] /
|
|
131
|
-
(rgba[3] /
|
|
128
|
+
(rgba[0] / 255.0) * UnpackFactors4[0] +
|
|
129
|
+
(rgba[1] / 255.0) * UnpackFactors4[1] +
|
|
130
|
+
(rgba[2] / 255.0) * UnpackFactors4[2] +
|
|
131
|
+
(rgba[3] / 255.0) * UnpackFactors4[3]
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Clamped device-pixel center for a CSS-pixel screen coordinate. Sampling
|
|
136
|
+
// and ray reconstruction must both use this exact point: the depth copy
|
|
137
|
+
// target is linearly filtered, and any off-center coordinate would blend
|
|
138
|
+
// neighboring packed-RGBA texels into meaningless depth values.
|
|
139
|
+
private _texelCenter(cssCoord: number, size: number, pixelRatio: number) {
|
|
140
|
+
return (
|
|
141
|
+
Math.max(0, Math.min(size - 1, Math.floor(cssCoord * pixelRatio))) + 0.5
|
|
132
142
|
);
|
|
133
143
|
}
|
|
134
144
|
|
|
@@ -147,18 +157,11 @@ export class TerrainPicker {
|
|
|
147
157
|
const height = renderer.getContext().drawingBufferHeight;
|
|
148
158
|
const pixelRatio = renderer.getPixelRatio();
|
|
149
159
|
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
0,
|
|
153
|
-
Math.min(width - 1, Math.floor(x * pixelRatio)),
|
|
154
|
-
);
|
|
155
|
-
const clampedY = Math.max(
|
|
156
|
-
0,
|
|
157
|
-
Math.min(height - 1, Math.floor(y * pixelRatio)),
|
|
158
|
-
);
|
|
160
|
+
const centerX = this._texelCenter(x, width, pixelRatio);
|
|
161
|
+
const centerY = this._texelCenter(y, height, pixelRatio);
|
|
159
162
|
|
|
160
163
|
// Update the depth pick pass with current parameters
|
|
161
|
-
const samplePos = new Vector2(
|
|
164
|
+
const samplePos = new Vector2(centerX / width, 1.0 - centerY / height); // Flip Y
|
|
162
165
|
this.depthPickPass.update(depthTexture, samplePos);
|
|
163
166
|
|
|
164
167
|
// Render and get pixels
|
|
@@ -186,10 +189,11 @@ export class TerrainPicker {
|
|
|
186
189
|
const height = renderer.getContext().drawingBufferHeight;
|
|
187
190
|
const pixelRatio = renderer.getPixelRatio();
|
|
188
191
|
|
|
189
|
-
// Convert
|
|
192
|
+
// Convert the sampled texel's center to NDC [-1,1], so the reconstructed
|
|
193
|
+
// ray goes through the exact pixel whose depth was read.
|
|
190
194
|
const screenCoords = new Vector2(
|
|
191
|
-
((x
|
|
192
|
-
-(((y
|
|
195
|
+
(this._texelCenter(x, width, pixelRatio) / width) * 2.0 - 1.0,
|
|
196
|
+
-((this._texelCenter(y, height, pixelRatio) / height) * 2.0 - 1.0),
|
|
193
197
|
);
|
|
194
198
|
|
|
195
199
|
let clipCoords: Vector4;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { QuantizedMeshSource } from "../type";
|
|
4
|
+
|
|
5
|
+
import { sampleTerrainMostDetailed } from "./sampleTerrainMostDetailed";
|
|
6
|
+
|
|
7
|
+
// The worker pool package probes os.cpus at import time, which jsdom lacks;
|
|
8
|
+
// report "no pool" so the DEM decode falls back to the main thread (the
|
|
9
|
+
// quantized-mesh tests never decode images anyway).
|
|
10
|
+
vi.mock("@navaramap/worker", () => ({
|
|
11
|
+
workerPoolStats: () => undefined,
|
|
12
|
+
queueTask: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// The WASM math is covered by Rust unit tests; here it is mocked with a
|
|
16
|
+
// transparent JS model (geographic scheme, TMS y-flip, constant height per
|
|
17
|
+
// level) so the tests exercise the orchestration: grouping, the 404 descent,
|
|
18
|
+
// retries, status-code policy and aborts.
|
|
19
|
+
vi.mock("@navaramap/engine-api", () => {
|
|
20
|
+
const tileFor = (
|
|
21
|
+
geographic: boolean,
|
|
22
|
+
level: number,
|
|
23
|
+
lng: number,
|
|
24
|
+
lat: number,
|
|
25
|
+
) => {
|
|
26
|
+
const columns = (geographic ? 2 : 1) * 2 ** level;
|
|
27
|
+
const rows = 2 ** level;
|
|
28
|
+
const x = Math.min(
|
|
29
|
+
Math.floor(((lng + Math.PI) / (2 * Math.PI)) * columns),
|
|
30
|
+
columns - 1,
|
|
31
|
+
);
|
|
32
|
+
const y = Math.min(
|
|
33
|
+
Math.floor(((Math.PI / 2 - lat) / Math.PI) * rows),
|
|
34
|
+
rows - 1,
|
|
35
|
+
);
|
|
36
|
+
return { x, y };
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
default: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
terrainPositionsToTiles: vi.fn(
|
|
41
|
+
(geographic: boolean, level: number, positions: Float64Array) => {
|
|
42
|
+
const result = new Uint32Array(positions.length);
|
|
43
|
+
for (let i = 0; i < positions.length; i += 2) {
|
|
44
|
+
const { x, y } = tileFor(
|
|
45
|
+
geographic,
|
|
46
|
+
level,
|
|
47
|
+
positions[i],
|
|
48
|
+
positions[i + 1],
|
|
49
|
+
);
|
|
50
|
+
result[i] = x;
|
|
51
|
+
result[i + 1] = y;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
),
|
|
56
|
+
terrainTileUrl: vi.fn(
|
|
57
|
+
(
|
|
58
|
+
template: string,
|
|
59
|
+
_geographic: boolean,
|
|
60
|
+
tms: boolean,
|
|
61
|
+
x: number,
|
|
62
|
+
y: number,
|
|
63
|
+
z: number,
|
|
64
|
+
) => {
|
|
65
|
+
const urlY = tms ? 2 ** z - 1 - y : y;
|
|
66
|
+
return template
|
|
67
|
+
.replace("{x}", String(x))
|
|
68
|
+
.replace("{y}", String(urlY))
|
|
69
|
+
.replace("{z}", String(z));
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
// Heights encode the sampled level so tests can assert which LOD won.
|
|
73
|
+
sampleQuantizedMeshHeights: vi.fn(
|
|
74
|
+
(
|
|
75
|
+
bytes: Uint8Array,
|
|
76
|
+
_geographic: boolean,
|
|
77
|
+
_x: number,
|
|
78
|
+
_y: number,
|
|
79
|
+
z: number,
|
|
80
|
+
positions: Float64Array,
|
|
81
|
+
) => {
|
|
82
|
+
if (bytes.length === 0) return undefined; // undecodable payload
|
|
83
|
+
return new Float64Array(positions.length / 2).fill(1000 + z);
|
|
84
|
+
},
|
|
85
|
+
),
|
|
86
|
+
sampleRasterDemHeights: vi.fn(),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const KARIYA = { lat: (35.0 * Math.PI) / 180, lng: (137.0 * Math.PI) / 180 };
|
|
91
|
+
const NEARBY = {
|
|
92
|
+
lat: (35.001 * Math.PI) / 180,
|
|
93
|
+
lng: (137.001 * Math.PI) / 180,
|
|
94
|
+
};
|
|
95
|
+
const TOKYO = { lat: (35.68 * Math.PI) / 180, lng: (139.76 * Math.PI) / 180 };
|
|
96
|
+
|
|
97
|
+
const SOURCE: QuantizedMeshSource = {
|
|
98
|
+
type: "quantized-mesh",
|
|
99
|
+
url: "https://terrain.test/{z}/{x}/{y}.terrain",
|
|
100
|
+
maxZoom: 14,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** fetch stub: tiles exist only at or below `coverageLevel`. */
|
|
104
|
+
function stubFetch(
|
|
105
|
+
coverageLevel: number,
|
|
106
|
+
overrides?: (url: string) => Response | undefined,
|
|
107
|
+
) {
|
|
108
|
+
const seen: string[] = [];
|
|
109
|
+
const mocked = vi.fn(async (input: RequestInfo | URL) => {
|
|
110
|
+
const url = String(input);
|
|
111
|
+
seen.push(url);
|
|
112
|
+
const override = overrides?.(url);
|
|
113
|
+
if (override) return override;
|
|
114
|
+
const z = Number(url.match(/\/(\d+)\/\d+\/\d+\.terrain/)?.[1]);
|
|
115
|
+
if (z <= coverageLevel) {
|
|
116
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
117
|
+
}
|
|
118
|
+
return new Response(null, { status: 404 });
|
|
119
|
+
});
|
|
120
|
+
vi.stubGlobal("fetch", mocked);
|
|
121
|
+
return { seen, mocked };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("sampleTerrainMostDetailed", () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
});
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
vi.unstubAllGlobals();
|
|
130
|
+
vi.clearAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns an empty array for no positions", async () => {
|
|
134
|
+
await expect(sampleTerrainMostDetailed(SOURCE, [])).resolves.toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("samples at maxZoom when the tile exists, without mutating the input", async () => {
|
|
138
|
+
stubFetch(14);
|
|
139
|
+
const input = [{ ...KARIYA }];
|
|
140
|
+
const results = await sampleTerrainMostDetailed(SOURCE, input);
|
|
141
|
+
expect(results).toHaveLength(1);
|
|
142
|
+
expect(results[0].height).toBe(1014);
|
|
143
|
+
expect(results[0].level).toBe(14);
|
|
144
|
+
expect(results[0].lat).toBe(KARIYA.lat);
|
|
145
|
+
expect(input[0]).toEqual(KARIYA);
|
|
146
|
+
expect((input[0] as Record<string, unknown>).height).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("descends to the deepest available level on 404", async () => {
|
|
150
|
+
stubFetch(11);
|
|
151
|
+
const results = await sampleTerrainMostDetailed(SOURCE, [KARIYA]);
|
|
152
|
+
expect(results[0].height).toBe(1011);
|
|
153
|
+
expect(results[0].level).toBe(11);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("fetches a shared tile once for positions in the same tile", async () => {
|
|
157
|
+
const { mocked } = stubFetch(14);
|
|
158
|
+
const results = await sampleTerrainMostDetailed(SOURCE, [KARIYA, NEARBY]);
|
|
159
|
+
// Both positions fall in the same z14 geographic tile.
|
|
160
|
+
expect(mocked).toHaveBeenCalledTimes(1);
|
|
161
|
+
expect(results.map((r) => r.height)).toEqual([1014, 1014]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("samples distant positions from their own tiles", async () => {
|
|
165
|
+
const { mocked } = stubFetch(14);
|
|
166
|
+
const results = await sampleTerrainMostDetailed(SOURCE, [KARIYA, TOKYO]);
|
|
167
|
+
expect(mocked).toHaveBeenCalledTimes(2);
|
|
168
|
+
expect(results.map((r) => r.height)).toEqual([1014, 1014]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("keeps a fixed options.level from falling back to parents", async () => {
|
|
172
|
+
const { seen } = stubFetch(10);
|
|
173
|
+
const results = await sampleTerrainMostDetailed(SOURCE, [KARIYA], {
|
|
174
|
+
level: 12,
|
|
175
|
+
});
|
|
176
|
+
expect(results[0].height).toBeUndefined();
|
|
177
|
+
expect(results[0].level).toBeUndefined();
|
|
178
|
+
expect(seen.every((url) => url.includes("/12/"))).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects on 401/403 instead of silently coarsening", async () => {
|
|
182
|
+
stubFetch(14, () => new Response(null, { status: 403 }));
|
|
183
|
+
await expect(sampleTerrainMostDetailed(SOURCE, [KARIYA])).rejects.toThrow(
|
|
184
|
+
/403/,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("retries 5xx and then reports undefined heights", async () => {
|
|
189
|
+
let calls = 0;
|
|
190
|
+
vi.stubGlobal(
|
|
191
|
+
"fetch",
|
|
192
|
+
vi.fn(async () => {
|
|
193
|
+
calls++;
|
|
194
|
+
return new Response(null, { status: 503 });
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
const results = await sampleTerrainMostDetailed(SOURCE, [KARIYA]);
|
|
198
|
+
expect(results[0].height).toBeUndefined();
|
|
199
|
+
// Initial attempt + 2 retries for the deepest level, and no descent to a
|
|
200
|
+
// parent — a persistent server error is not "tile missing".
|
|
201
|
+
expect(calls).toBe(3);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("sends the quantized-mesh Accept header and the bearer token", async () => {
|
|
205
|
+
const { mocked } = stubFetch(14);
|
|
206
|
+
await sampleTerrainMostDetailed({ ...SOURCE, token: "secret" }, [KARIYA]);
|
|
207
|
+
const call = mocked.mock.calls[0] as unknown as [string, RequestInit];
|
|
208
|
+
const headers = call[1].headers as Record<string, string>;
|
|
209
|
+
expect(headers.Accept).toContain("application/vnd.quantized-mesh");
|
|
210
|
+
expect(headers.Authorization).toBe("Bearer secret");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects when already aborted", async () => {
|
|
214
|
+
stubFetch(14);
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
controller.abort();
|
|
217
|
+
await expect(
|
|
218
|
+
sampleTerrainMostDetailed(SOURCE, [KARIYA], {
|
|
219
|
+
signal: controller.signal,
|
|
220
|
+
}),
|
|
221
|
+
).rejects.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("marks heights undefined when the payload does not decode", async () => {
|
|
225
|
+
// 200 with an empty body: the mocked sampler returns undefined for it.
|
|
226
|
+
stubFetch(14, () => new Response(new Uint8Array(0), { status: 200 }));
|
|
227
|
+
const results = await sampleTerrainMostDetailed(SOURCE, [KARIYA]);
|
|
228
|
+
expect(results[0].height).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("requires source.url", async () => {
|
|
232
|
+
await expect(
|
|
233
|
+
sampleTerrainMostDetailed({ type: "quantized-mesh", url: "" }, [KARIYA]),
|
|
234
|
+
).rejects.toThrow(/url/);
|
|
235
|
+
});
|
|
236
|
+
});
|