@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,447 @@
1
+ import type { LatLng } from "@navaramap/core";
2
+ import {
3
+ sampleQuantizedMeshHeights,
4
+ sampleRasterDemHeights,
5
+ terrainPositionsToTiles,
6
+ terrainTileUrl,
7
+ } from "@navaramap/engine-api";
8
+ import { workerPoolStats } from "@navaramap/worker";
9
+
10
+ import { getImageDataFromBlob } from "../tasks/getImageDataFromBlob";
11
+ import type { QuantizedMeshSource, RasterDemSource } from "../type";
12
+
13
+ /**
14
+ * A terrain source `sampleTerrainMostDetailed` can sample: the same
15
+ * `quantized-mesh` / `raster-dem` descriptions `addSource` accepts.
16
+ */
17
+ export type TerrainSampleSource = QuantizedMeshSource | RasterDemSource;
18
+
19
+ export type SampleTerrainOptions = {
20
+ /** Aborts in-flight tile fetches and rejects with the abort reason. */
21
+ signal?: AbortSignal;
22
+ /**
23
+ * Sample at this fixed zoom level instead of probing down from the
24
+ * source's `maxZoom`. A tile missing at this level yields `undefined`
25
+ * heights rather than falling back to a coarser level (Cesium's
26
+ * `sampleTerrain` semantics).
27
+ */
28
+ level?: number;
29
+ };
30
+
31
+ export type SampledTerrainPosition = {
32
+ /** Latitude in radians, echoed from the input. */
33
+ lat: number;
34
+ /** Longitude in radians, echoed from the input. */
35
+ lng: number;
36
+ /**
37
+ * Terrain height in meters, or `undefined` when no tile covering the
38
+ * position could be fetched or decoded.
39
+ */
40
+ height: number | undefined;
41
+ /** Zoom level the height was sampled at; `undefined` on failure. */
42
+ level: number | undefined;
43
+ };
44
+
45
+ /** Same fallback as the engine (`DEFAULT_MAX_ZOOM` in `source_types.rs`). */
46
+ const DEFAULT_MAX_ZOOM = 20;
47
+ /** All-zero decoder, mirroring the engine's `ElevationDecoder::default()`. */
48
+ const DEFAULT_ELEVATION_DECODER = {
49
+ r_scaler: 0,
50
+ g_scaler: 0,
51
+ b_scaler: 0,
52
+ offset: 0,
53
+ max_offset: 0,
54
+ min_offset: 0,
55
+ boundary: 0,
56
+ epsilon: 0,
57
+ };
58
+ const MAX_CONCURRENT_TILE_FETCHES = 6;
59
+ /** Levels probed concurrently per descent step while hunting for coverage. */
60
+ const PROBE_BATCH_LEVELS = 3;
61
+ /** Retries for transient failures (5xx / network errors) per tile URL. */
62
+ const TRANSIENT_RETRIES = 2;
63
+ const RETRY_BASE_DELAY_MS = 200;
64
+
65
+ type FetchOutcome =
66
+ | { kind: "ok"; response: Response }
67
+ /** The server answered 404: the tile does not exist at this level. */
68
+ | { kind: "missing" }
69
+ /** The request kept failing (5xx / network) or was otherwise rejected. */
70
+ | { kind: "failed" };
71
+
72
+ type SourceConfig = {
73
+ kind: "quantized-mesh" | "raster-dem";
74
+ url: string;
75
+ geographic: boolean;
76
+ tms: boolean;
77
+ minZoom: number;
78
+ maxZoom: number;
79
+ token: string | undefined;
80
+ decoder: typeof DEFAULT_ELEVATION_DECODER;
81
+ };
82
+
83
+ /**
84
+ * Samples terrain heights at the most detailed zoom level available from
85
+ * `source`, fetching the needed tiles over the network — unlike the
86
+ * synchronous `ThreeView.sampleTerrainHeight`, which only reads tiles already
87
+ * resident for rendering and therefore returns coarse-LOD heights (or
88
+ * nothing) when the camera is far away.
89
+ *
90
+ * Positions are grouped by tile, each unique tile is fetched once, and
91
+ * heights are interpolated by the same engine code that builds the rendered
92
+ * surface. Sampling starts at the source's `maxZoom` and falls back to
93
+ * parent tiles on 404 until data is found (down to `minZoom`), so sources
94
+ * whose real coverage is shallower than `maxZoom` still resolve — at the
95
+ * cost of a few extra requests.
96
+ *
97
+ * Failures follow HTTP semantics: 404 means "no tile at this level" and
98
+ * descends; 401/403 rejects the whole call (misconfigured token); 5xx and
99
+ * network errors are retried, then yield `height: undefined` for the
100
+ * affected positions.
101
+ *
102
+ * Requires the API WASM module to be initialized (`ThreeView.init()` does
103
+ * this).
104
+ *
105
+ * @param source - A `quantized-mesh` or `raster-dem` source description (the
106
+ * same shape `addSource` accepts)
107
+ * @param positions - Geodetic positions (lat/lng in radians)
108
+ * @param options - Optional abort signal and fixed-level sampling
109
+ * @returns One result per input position, in the same order (the input
110
+ * objects are not mutated)
111
+ */
112
+ export async function sampleTerrainMostDetailed(
113
+ source: TerrainSampleSource,
114
+ positions: LatLng[],
115
+ options?: SampleTerrainOptions,
116
+ ): Promise<SampledTerrainPosition[]> {
117
+ if (positions.length === 0) return [];
118
+ const config = resolveSourceConfig(source);
119
+ const signal = options?.signal;
120
+ throwIfAborted(signal);
121
+
122
+ const startLevel = clampLevel(options?.level ?? config.maxZoom, config);
123
+ // Fixed-level sampling never falls back to parents.
124
+ const floorLevel = options?.level != null ? startLevel : config.minZoom;
125
+
126
+ const flat = new Float64Array(positions.length * 2);
127
+ positions.forEach((p, i) => {
128
+ flat[i * 2] = p.lng;
129
+ flat[i * 2 + 1] = p.lat;
130
+ });
131
+
132
+ // Group positions by their containing tile at the starting level; each
133
+ // group is fetched and sampled once no matter how many positions share it.
134
+ const tileXYs = terrainPositionsToTiles(config.geographic, startLevel, flat);
135
+ const groups = new Map<string, { x: number; y: number; indices: number[] }>();
136
+ for (let i = 0; i < positions.length; i++) {
137
+ const x = tileXYs[i * 2];
138
+ const y = tileXYs[i * 2 + 1];
139
+ const key = `${x}/${y}`;
140
+ let group = groups.get(key);
141
+ if (!group) {
142
+ group = { x, y, indices: [] };
143
+ groups.set(key, group);
144
+ }
145
+ group.indices.push(i);
146
+ }
147
+
148
+ const results: SampledTerrainPosition[] = positions.map((p) => ({
149
+ lat: p.lat,
150
+ lng: p.lng,
151
+ height: undefined,
152
+ level: undefined,
153
+ }));
154
+
155
+ // Distinct groups can descend onto the same parent tile; sharing outcomes
156
+ // by URL keeps each tile fetched at most once per call.
157
+ const outcomeCache = new Map<string, Promise<FetchOutcome>>();
158
+ const limit = createLimiter(MAX_CONCURRENT_TILE_FETCHES);
159
+
160
+ await Promise.all(
161
+ [...groups.values()].map(async (group) => {
162
+ const groupFlat = new Float64Array(group.indices.length * 2);
163
+ group.indices.forEach((originalIndex, i) => {
164
+ groupFlat[i * 2] = flat[originalIndex * 2];
165
+ groupFlat[i * 2 + 1] = flat[originalIndex * 2 + 1];
166
+ });
167
+
168
+ const found = await probeMostDetailedTile(
169
+ config,
170
+ group,
171
+ startLevel,
172
+ floorLevel,
173
+ outcomeCache,
174
+ limit,
175
+ signal,
176
+ );
177
+ if (!found) return;
178
+
179
+ const heights = await sampleTile(config, found, groupFlat, signal);
180
+ if (!heights) return;
181
+ group.indices.forEach((originalIndex, i) => {
182
+ const h = heights[i];
183
+ if (h !== undefined && Number.isFinite(h)) {
184
+ results[originalIndex].height = h;
185
+ results[originalIndex].level = found.level;
186
+ }
187
+ });
188
+ }),
189
+ );
190
+
191
+ return results;
192
+ }
193
+
194
+ function resolveSourceConfig(source: TerrainSampleSource): SourceConfig {
195
+ const url = source.url;
196
+ if (!url) {
197
+ throw new TypeError("sampleTerrainMostDetailed: source.url is required");
198
+ }
199
+ // Defaults mirror the engine's source registration (`source_types.rs`), so
200
+ // sampling reads the same tiles rendering would for the same description.
201
+ if (source.type === "quantized-mesh") {
202
+ return {
203
+ kind: "quantized-mesh",
204
+ url,
205
+ geographic: source.geographic ?? true,
206
+ tms: source.tms ?? true,
207
+ minZoom: source.minZoom ?? 0,
208
+ maxZoom: source.maxZoom ?? DEFAULT_MAX_ZOOM,
209
+ token: source.token ?? undefined,
210
+ decoder: DEFAULT_ELEVATION_DECODER,
211
+ };
212
+ }
213
+ const dem = source as RasterDemSource;
214
+ return {
215
+ kind: "raster-dem",
216
+ url,
217
+ geographic: false,
218
+ tms: dem.tms ?? false,
219
+ minZoom: dem.minZoom ?? 0,
220
+ maxZoom: dem.maxZoom ?? DEFAULT_MAX_ZOOM,
221
+ token: undefined,
222
+ decoder: { ...DEFAULT_ELEVATION_DECODER, ...dem.elevationDecoder },
223
+ };
224
+ }
225
+
226
+ function clampLevel(level: number, config: SourceConfig): number {
227
+ return Math.min(Math.max(Math.floor(level), config.minZoom), config.maxZoom);
228
+ }
229
+
230
+ /**
231
+ * Walks levels from `startLevel` down to `floorLevel` looking for the
232
+ * deepest tile the server actually has. The first probe is `startLevel`
233
+ * alone — sources are usually fully covered at `maxZoom`, so the common case
234
+ * costs exactly one request. Only after a 404 does each further step probe
235
+ * {@link PROBE_BATCH_LEVELS} consecutive levels concurrently, so a source
236
+ * whose coverage stops well short of `maxZoom` costs one round-trip per
237
+ * batch of missing levels instead of one per level.
238
+ */
239
+ async function probeMostDetailedTile(
240
+ config: SourceConfig,
241
+ group: { x: number; y: number },
242
+ startLevel: number,
243
+ floorLevel: number,
244
+ outcomeCache: Map<string, Promise<FetchOutcome>>,
245
+ limit: <T>(task: () => Promise<T>) => Promise<T>,
246
+ signal: AbortSignal | undefined,
247
+ ): Promise<{ x: number; y: number; level: number; response: Response } | null> {
248
+ let batchTop = startLevel;
249
+ while (batchTop >= floorLevel) {
250
+ const batchSize = batchTop === startLevel ? 1 : PROBE_BATCH_LEVELS;
251
+ const batchBottom = Math.max(batchTop - batchSize + 1, floorLevel);
252
+ const levels: number[] = [];
253
+ for (let level = batchTop; level >= batchBottom; level--)
254
+ levels.push(level);
255
+
256
+ const outcomes = await Promise.all(
257
+ levels.map((level) => {
258
+ const shift = startLevel - level;
259
+ const x = group.x >> shift;
260
+ const y = group.y >> shift;
261
+ const url = terrainTileUrl(
262
+ config.url,
263
+ config.geographic,
264
+ config.tms,
265
+ x,
266
+ y,
267
+ level,
268
+ );
269
+ let outcome = outcomeCache.get(url);
270
+ if (!outcome) {
271
+ outcome = limit(() => fetchTile(config, url, signal));
272
+ outcomeCache.set(url, outcome);
273
+ }
274
+ return outcome.then((o) => ({ level, x, y, outcome: o }));
275
+ }),
276
+ );
277
+
278
+ // Deepest success in the batch wins; a hard failure stops the descent
279
+ // (the tile exists but is unreadable — a coarser parent would silently
280
+ // hide the error behind a coarse height).
281
+ for (const { level, x, y, outcome } of outcomes) {
282
+ if (outcome.kind === "ok")
283
+ return { x, y, level, response: outcome.response };
284
+ if (outcome.kind === "failed") return null;
285
+ }
286
+ batchTop = batchBottom - 1;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ /** Fetch one tile URL, classifying the result per HTTP semantics. */
292
+ async function fetchTile(
293
+ config: SourceConfig,
294
+ url: string,
295
+ signal: AbortSignal | undefined,
296
+ ): Promise<FetchOutcome> {
297
+ const headers: Record<string, string> = {};
298
+ if (config.kind === "quantized-mesh") {
299
+ // Same content negotiation as the rendering fetch path, minus the
300
+ // normals/watermask extensions — sampling only needs positions.
301
+ headers.Accept =
302
+ "application/vnd.quantized-mesh,application/octet-stream;q=0.9,*/*;q=0.01";
303
+ }
304
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
305
+
306
+ for (let attempt = 0; ; attempt++) {
307
+ throwIfAborted(signal);
308
+ let response: Response;
309
+ try {
310
+ response = await fetch(url, { headers, signal });
311
+ } catch (e) {
312
+ // fetch() rejects on network errors and aborts; only retry the former.
313
+ if (signal?.aborted) throw e;
314
+ if (attempt < TRANSIENT_RETRIES) {
315
+ await delay(RETRY_BASE_DELAY_MS * 2 ** attempt);
316
+ continue;
317
+ }
318
+ return { kind: "failed" };
319
+ }
320
+ if (response.ok) return { kind: "ok", response };
321
+ // 404/410: the tile genuinely doesn't exist at this level — the caller
322
+ // descends to the parent. Anything else is an error, not "no data".
323
+ if (response.status === 404 || response.status === 410) {
324
+ return { kind: "missing" };
325
+ }
326
+ if (response.status === 401 || response.status === 403) {
327
+ throw new Error(
328
+ `sampleTerrainMostDetailed: ${response.status} ${response.statusText} for ${url} — check the source's token`,
329
+ );
330
+ }
331
+ if (response.status >= 500 && attempt < TRANSIENT_RETRIES) {
332
+ await delay(RETRY_BASE_DELAY_MS * 2 ** attempt);
333
+ continue;
334
+ }
335
+ return { kind: "failed" };
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Decode the fetched tile and interpolate a height per position, on the same
341
+ * engine code paths rendering uses. Returns `null` when the payload doesn't
342
+ * decode; `NaN` entries mean the tile had no data at that position.
343
+ */
344
+ async function sampleTile(
345
+ config: SourceConfig,
346
+ tile: { x: number; y: number; level: number; response: Response },
347
+ groupFlat: Float64Array,
348
+ signal: AbortSignal | undefined,
349
+ ): Promise<Float64Array | null> {
350
+ if (config.kind === "quantized-mesh") {
351
+ const bytes = new Uint8Array(await tile.response.arrayBuffer());
352
+ throwIfAborted(signal);
353
+ return (
354
+ sampleQuantizedMeshHeights(
355
+ bytes,
356
+ config.geographic,
357
+ tile.x,
358
+ tile.y,
359
+ tile.level,
360
+ groupFlat,
361
+ ) ?? null
362
+ );
363
+ }
364
+
365
+ const rgba = await decodeImageToRgba(await tile.response.blob());
366
+ throwIfAborted(signal);
367
+ if (!rgba) return null;
368
+ return (
369
+ sampleRasterDemHeights(
370
+ rgba,
371
+ config.decoder,
372
+ tile.x,
373
+ tile.y,
374
+ tile.level,
375
+ groupFlat,
376
+ ) ?? null
377
+ );
378
+ }
379
+
380
+ /**
381
+ * Decode an image blob (PNG/WebP DEM tile) into tightly-packed RGBA bytes.
382
+ * When a view has initialized the worker pool, the decode runs there — the
383
+ * same off-main-thread path the rendering fetches use. Standalone callers
384
+ * without a pool decode on the calling thread instead.
385
+ */
386
+ async function decodeImageToRgba(blob: Blob): Promise<Uint8Array | null> {
387
+ if (workerPoolStats()) {
388
+ try {
389
+ const data = await getImageDataFromBlob(blob);
390
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
391
+ } catch {
392
+ return null;
393
+ }
394
+ }
395
+
396
+ let bitmap: ImageBitmap;
397
+ try {
398
+ bitmap = await createImageBitmap(blob);
399
+ } catch {
400
+ return null;
401
+ }
402
+ try {
403
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
404
+ const context = canvas.getContext("2d");
405
+ if (!context) return null;
406
+ context.drawImage(bitmap, 0, 0);
407
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
408
+ return new Uint8Array(image.data.buffer, 0, image.data.length);
409
+ } finally {
410
+ bitmap.close();
411
+ }
412
+ }
413
+
414
+ /** Minimal promise concurrency limiter for the tile fetches. */
415
+ function createLimiter(
416
+ maxConcurrent: number,
417
+ ): <T>(task: () => Promise<T>) => Promise<T> {
418
+ let active = 0;
419
+ const queue: (() => void)[] = [];
420
+ const next = () => {
421
+ active--;
422
+ queue.shift()?.();
423
+ };
424
+ return async <T>(task: () => Promise<T>): Promise<T> => {
425
+ if (active >= maxConcurrent) {
426
+ await new Promise<void>((resolve) => queue.push(resolve));
427
+ }
428
+ active++;
429
+ try {
430
+ return await task();
431
+ } finally {
432
+ next();
433
+ }
434
+ };
435
+ }
436
+
437
+ function throwIfAborted(signal: AbortSignal | undefined) {
438
+ if (signal?.aborted) {
439
+ throw signal.reason instanceof Error
440
+ ? signal.reason
441
+ : new DOMException("The operation was aborted.", "AbortError");
442
+ }
443
+ }
444
+
445
+ function delay(ms: number): Promise<void> {
446
+ return new Promise((resolve) => setTimeout(resolve, ms));
447
+ }