@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.
Files changed (35) hide show
  1. package/dist/assets/fontWorker-DaqfXmZf.js +2 -0
  2. package/dist/assets/fontWorker-DaqfXmZf.js.map +1 -0
  3. package/dist/assets/index-Dydat1h1.js +25 -0
  4. package/dist/assets/index-Dydat1h1.js.map +1 -0
  5. package/dist/assets/{navara_wasm_font_worker_bg-DympKYVX.wasm → navara_wasm_font_worker_bg-XvE_oYPr.wasm} +0 -0
  6. package/dist/assets/{navara_wasm_worker_bg-Bx_l45cW.wasm → navara_wasm_worker_bg-Dg9fnGLP.wasm} +0 -0
  7. package/dist/index.d.ts +140 -4
  8. package/dist/index.js +5385 -5022
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.umd.cjs +164 -86
  11. package/dist/index.umd.cjs.map +1 -1
  12. package/package.json +12 -12
  13. package/src/declutter/DeclutterManager.test.ts +65 -0
  14. package/src/declutter/DeclutterManager.ts +60 -1
  15. package/src/declutter/types.ts +17 -0
  16. package/src/event/feature.ts +45 -2
  17. package/src/event/worker.ts +18 -0
  18. package/src/index.ts +51 -2
  19. package/src/layers/effect/TransparentPassEffectDesc.ts +1 -1
  20. package/src/mesh/nvLineGeometry.test.ts +88 -0
  21. package/src/mesh/nvLineGeometry.ts +148 -0
  22. package/src/mesh/polygonOutline.ts +126 -42
  23. package/src/mesh/sdfText/batchedSdfText.test.ts +377 -0
  24. package/src/mesh/sdfText/batchedSdfText.ts +192 -21
  25. package/src/mesh/sdfText/labelVisibility.test.ts +106 -0
  26. package/src/mesh/sdfText/labelVisibility.ts +131 -0
  27. package/src/mesh/sdfText/pendingSettlement.test.ts +151 -0
  28. package/src/mesh/sdfText/pendingSettlement.ts +68 -0
  29. package/src/pick/pickTerrain.ts +22 -18
  30. package/src/terrain/sampleTerrainMostDetailed.test.ts +236 -0
  31. package/src/terrain/sampleTerrainMostDetailed.ts +447 -0
  32. package/dist/assets/fontWorker-JfTCpd38.js +0 -2
  33. package/dist/assets/fontWorker-JfTCpd38.js.map +0 -1
  34. package/dist/assets/index-B3U_DvcP.js +0 -25
  35. 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
+ }
@@ -123,12 +123,22 @@ export class TerrainPicker {
123
123
  1.0 / PackFactors[3],
124
124
  ];
125
125
 
126
- // Calculate dot product
126
+ // readRenderTargetPixels returns bytes 0..255; a GLSL texel channel is byte / 255.
127
127
  return (
128
- (rgba[0] / 256.0) * UnpackFactors4[0] +
129
- (rgba[1] / 256.0) * UnpackFactors4[1] +
130
- (rgba[2] / 256.0) * UnpackFactors4[2] +
131
- (rgba[3] / 256.0) * UnpackFactors4[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
- // Clamp coordinates to screen bounds
151
- const clampedX = Math.max(
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(clampedX / width, 1.0 - clampedY / height); // Flip Y
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 screen coordinates to NDC [-1,1]
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 * pixelRatio) / width) * 2.0 - 1.0,
192
- -(((y * pixelRatio) / height) * 2.0 - 1.0),
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
+ });