@navaramap/three 0.0.0 → 0.0.2

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 (66) hide show
  1. package/README.md +70 -0
  2. package/dist/assets/fontWorker-8oWvyvwL.js +2 -0
  3. package/dist/assets/fontWorker-8oWvyvwL.js.map +1 -0
  4. package/dist/assets/index-D9G7kIEj.js +25 -0
  5. package/dist/assets/index-D9G7kIEj.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-665OLxjy.wasm → navara_wasm_font_worker_bg-CkZP9l2i.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-BXhKC5bE.wasm → navara_wasm_worker_bg-Q8zrkLCR.wasm} +0 -0
  8. package/dist/draco_decoder.wasm +0 -0
  9. package/dist/draco_decoder2.wasm +0 -0
  10. package/dist/index.d.ts +587 -67
  11. package/dist/index.js +5640 -4944
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +238 -211
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +35 -34
  16. package/src/core/EffectDesc.ts +2 -4
  17. package/src/core/MeshDesc.ts +1 -2
  18. package/src/core/MeshDescWithSelectiveEffect.ts +1 -2
  19. package/src/declutter/DeclutterManager.test.ts +235 -0
  20. package/src/declutter/DeclutterManager.ts +256 -0
  21. package/src/declutter/index.ts +7 -0
  22. package/src/declutter/kernel.ts +46 -0
  23. package/src/declutter/types.ts +61 -0
  24. package/src/declutter/wasmKernel.ts +13 -0
  25. package/src/device.ts +18 -4
  26. package/src/evaluations/FeatureEvaluator.ts +21 -0
  27. package/src/event/context.ts +17 -0
  28. package/src/event/features/modelGpuBytes.ts +1 -3
  29. package/src/event/hillshade.test.ts +7 -13
  30. package/src/event/index.ts +2 -3
  31. package/src/event/tileHandler.ts +54 -0
  32. package/src/index.ts +143 -33
  33. package/src/material/enhancer/ShaderReplacer.ts +1 -2
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +6 -0
  35. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +11 -0
  36. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.test.ts +83 -0
  37. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +19 -4
  38. package/src/material/enhancer/tileComposite/tileElevationHeatmapEnhancer/shader.ts +9 -2
  39. package/src/mesh/batchTexture.ts +2 -4
  40. package/src/mesh/batchedSdfText.ts +61 -1
  41. package/src/mesh/sdfText.ts +170 -5
  42. package/src/mesh/sprite/instancedSprite.ts +245 -3
  43. package/src/mesh/tile/drapeResolver.ts +76 -0
  44. package/src/mesh/{tile.test.ts → tile/index.test.ts} +16 -6
  45. package/src/mesh/{tile.ts → tile/index.ts} +124 -392
  46. package/src/mesh/tile/rasterDrapeResolver.ts +423 -0
  47. package/src/mesh/tile/vectorDrapeResolver.ts +404 -0
  48. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  49. package/src/passes/CustomRenderPass.ts +21 -5
  50. package/src/passes/RenderTargetCopyPass.ts +24 -2
  51. package/src/test-utils/engine.ts +53 -0
  52. package/src/tileTexture/TileTextureCache.test.ts +2 -0
  53. package/src/tileTexture/TileTextureCache.ts +1 -0
  54. package/src/tileTexture/TileTextureCompositor.test.ts +107 -0
  55. package/src/tileTexture/TileTextureCompositor.ts +248 -32
  56. package/src/tileTexture/__snapshots__/CompositeShader.snapshot.test.ts.snap +36 -16
  57. package/src/tileTexture/types.ts +7 -0
  58. package/src/utils/demNoDataColor.test.ts +47 -0
  59. package/src/utils/demNoDataColor.ts +48 -0
  60. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  61. package/src/utils/fixedGpuFootprint.ts +162 -0
  62. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  63. package/dist/assets/fontWorker-CEJEfRpb.js +0 -2
  64. package/dist/assets/fontWorker-CEJEfRpb.js.map +0 -1
  65. package/dist/assets/index-BUB-ijMO.js +0 -12
  66. package/dist/assets/index-BUB-ijMO.js.map +0 -1
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  type AnyMeshDesc,
71
71
  } from "./core/BaseHandle";
72
72
  import { Registries } from "./core/Registries";
73
+ import { DeclutterManager, wasmDeclutterKernel } from "./declutter";
73
74
  import {
74
75
  getCompositeAtlasSize,
75
76
  getDefaultDynamicSse,
@@ -95,6 +96,7 @@ import {
95
96
  type WorkerTaskHandler,
96
97
  } from "./event";
97
98
  import { TEXTURE_LOADER } from "./event/loaders";
99
+ import { createTileHandler } from "./event/tileHandler";
98
100
  import { InMemoryBufferStore } from "./inMemoryBufferStore";
99
101
  import { registerInputEvents } from "./input";
100
102
  import { Layer, type LayerEvent } from "./layer";
@@ -222,6 +224,10 @@ export type MemoryStats = {
222
224
  gpuBytesEst: number;
223
225
  externalCpuBytes: number;
224
226
  reservedBytes: number;
227
+ /** Estimated GPU bytes of fixed screen-sized allocations (the
228
+ * postprocessing render-target stack), reported via
229
+ * `Core.setFixedGpuBytes` on init, resize, and pass-list changes. */
230
+ fixedGpuBytes: number;
225
231
  budgetBytes: number | undefined;
226
232
  evictedCount: number;
227
233
  sseMultiplier: number;
@@ -252,7 +258,10 @@ export type Options = {
252
258
  container?: HTMLElement;
253
259
  /** Canvas element for rendering. If not provided, a new canvas is created. */
254
260
  canvas?: HTMLCanvasElement | OffscreenCanvas;
255
- /** Device pixel ratio override. Uses device default if not specified. */
261
+ /** Device pixel ratio override. When not specified, the device value is
262
+ * used, capped at 2 on mobile (1 with `mobileOptimization`) — screen-sized
263
+ * render targets grow quadratically with pixel ratio and dominate mobile
264
+ * GPU memory. An explicit value bypasses the caps. */
256
265
  pixelRatio?: number;
257
266
  /** Disables automatic resize handling on window resize events. */
258
267
  disableAutoResize?: boolean;
@@ -442,6 +451,14 @@ export default class ThreeView<
442
451
  forceUpdate: false,
443
452
  animation: false,
444
453
  };
454
+ /** Shared screen-space label declutterer for text/sprite features. */
455
+ private _declutter = new DeclutterManager(wasmDeclutterKernel);
456
+ /** Reusable Vector2 for the declutter pass's per-frame viewport query. */
457
+ private _declutterSize = new Vector2();
458
+ /** Pending follow-up frame for a throttled declutter pass or an active
459
+ * fade, with the delay it was scheduled at (so a shorter need preempts). */
460
+ private _declutterRetry: ReturnType<typeof setTimeout> | undefined;
461
+ private _declutterRetryDelay = 0;
445
462
  private _isIdle = false;
446
463
  private _uniforms: CommonUniforms;
447
464
 
@@ -632,35 +649,13 @@ export default class ThreeView<
632
649
  };
633
650
  // Per-frame snapshot of the WASM vector resolution revision, refreshed in `_render`.
634
651
  private _vectorRevision = 0;
635
- private _tileHandler: TileHandler = {
636
- getMartini: (id) => {
637
- return this._core?.getMartini(id);
638
- },
639
- getTile: (handle) => {
640
- return this._core?.getTile(handle);
641
- },
642
- getParentTile: (handle) => {
643
- return this._core?.getParentTile(handle);
644
- },
645
- getTileElevationDecoder: (handle) => {
646
- return this._core?.getTileElevationDecoder(handle);
647
- },
648
- getVectorTileStates: (handle) => {
649
- return this._core?.getVectorTileStates(handle);
650
- },
651
- // Returns the per-frame cached revision (read once in `_render`), so per-tile
652
- // `onBeforeRender` gating costs no WASM-boundary call.
653
- vectorRevision: () => this._vectorRevision,
654
- reportDrapeGpuBytes: (handle, bytes) => {
655
- this._core?.reportTerrainDrapeGpuBytes(handle, bytes);
656
- },
657
- calcMetersPerTexel: (tileHandle, textureZoom, textureWidth) => {
658
- return (
659
- this._core?.calcMetersPerTexel(tileHandle, textureZoom, textureWidth) ??
660
- 1.0
661
- );
662
- },
663
- };
652
+ // Per-frame snapshot of the WASM raster drape resolution revision, refreshed in `_render`.
653
+ private _rasterRevision = 0;
654
+ private _tileHandler: TileHandler = createTileHandler({
655
+ getCore: () => this._core,
656
+ getVectorRevision: () => this._vectorRevision,
657
+ getRasterRevision: () => this._rasterRevision,
658
+ });
664
659
  private _globeHandler: GlobeHandler = {
665
660
  getTransparent: () => {
666
661
  return this._core?.getGlobeTransparent();
@@ -799,6 +794,11 @@ export default class ThreeView<
799
794
 
800
795
  private pixelRatioMatchedMedia?: MediaQueryList;
801
796
 
797
+ private fixedGpuBytesReportScheduled = false;
798
+ private fixedGpuBytesResyncPending = false;
799
+ private framesSinceFixedGpuBytesReport = 0;
800
+ private lastReportedFixedGpuBytes = -1;
801
+
802
802
  /**
803
803
  * The built-in attribution (credit) UI. `undefined` when disabled via
804
804
  * `defaultAttribution: false`, or in a worker / no-DOM environment. Feed it
@@ -901,6 +901,7 @@ export default class ThreeView<
901
901
  this._tileTextureCompositor = new TileTextureCompositor({
902
902
  renderer: this._renderer,
903
903
  texturizedSceneByTileCoordinates: this._texturizedSceneByTileCoordinates,
904
+ mercatorY: this._tileHandler.mercatorY,
904
905
  size: getCompositeAtlasSize(this.isMobileOptimized()),
905
906
  });
906
907
 
@@ -923,6 +924,8 @@ export default class ThreeView<
923
924
  });
924
925
 
925
926
  this.renderPassOrchestrator.setSize(width, height);
927
+ this.renderPassOrchestrator.onPassesChanged = () =>
928
+ this._scheduleFixedGpuBytesReport();
926
929
 
927
930
  // Background color
928
931
  const bgColor = options.backgroundColor
@@ -1239,6 +1242,7 @@ export default class ThreeView<
1239
1242
  gpuBytesEst: stats.gpuBytesEst,
1240
1243
  externalCpuBytes: stats.externalCpuBytes,
1241
1244
  reservedBytes: stats.reservedBytes,
1245
+ fixedGpuBytes: stats.fixedGpuBytes,
1242
1246
  budgetBytes: stats.budgetBytes,
1243
1247
  evictedCount: stats.evictedCount,
1244
1248
  sseMultiplier: stats.sseMultiplier,
@@ -1364,6 +1368,10 @@ export default class ThreeView<
1364
1368
  costHints.rasterTileBytes,
1365
1369
  );
1366
1370
 
1371
+ // Passes added before `_core` existed could not be reported; sync the
1372
+ // fixed render-target footprint now that the ledger is reachable.
1373
+ this._scheduleFixedGpuBytesReport();
1374
+
1367
1375
  // LOD fog: stronger distance-based degrade on low-memory devices.
1368
1376
  const lodFog = {
1369
1377
  ...getDefaultLodFog({ isMobile: mobileOptimized }),
@@ -1434,6 +1442,7 @@ export default class ThreeView<
1434
1442
  textureFragmentIndex: this._textureFragmentIndex,
1435
1443
  tileMeshToFragmentIds: this._tileMeshToFragmentIds,
1436
1444
  hillshadeContext: this._hillshadeContext,
1445
+ declutter: this._declutter,
1437
1446
  });
1438
1447
 
1439
1448
  // Register built-in descriptors
@@ -1538,6 +1547,10 @@ export default class ThreeView<
1538
1547
  dispose() {
1539
1548
  this._disposed = true;
1540
1549
  this._initialized = false;
1550
+ if (this._declutterRetry !== undefined) {
1551
+ clearTimeout(this._declutterRetry);
1552
+ this._declutterRetry = undefined;
1553
+ }
1541
1554
  // Dispose the view-owned attribution plugin (other plugins are the caller's).
1542
1555
  this._attribution?.dispose();
1543
1556
  this._attribution = undefined;
@@ -1587,6 +1600,10 @@ export default class ThreeView<
1587
1600
  // Cleanup hillshade context (temp DEMs, generator, etc.)
1588
1601
  this._hillshadeContext.dispose();
1589
1602
 
1603
+ // Drop declutter participants so the manager doesn't retain removed
1604
+ // meshes' GPU/font resources past this view's lifetime.
1605
+ this._declutter.dispose();
1606
+
1590
1607
  // Clear caches and maps
1591
1608
  this._meshes.clear();
1592
1609
  this._inMemoryBufferStore.clear();
@@ -1655,10 +1672,41 @@ export default class ThreeView<
1655
1672
  this._pickHelper?.setSize(drawingBufferSize.x, drawingBufferSize.y);
1656
1673
 
1657
1674
  this._core?.resize(w, h, pixelRatio ?? 1);
1675
+ this._scheduleFixedGpuBytesReport();
1658
1676
 
1659
1677
  this.emit("resize", w, h);
1660
1678
  };
1661
1679
 
1680
+ /**
1681
+ * Walks the fixed render-target stack and pushes the estimate into the
1682
+ * memory ledger; the WASM call is skipped while the value is unchanged.
1683
+ */
1684
+ private _reportFixedGpuBytes(): void {
1685
+ if (this._disposed || !this._core) return;
1686
+ const bytes = this.renderPassOrchestrator.estimateFixedGpuBytes();
1687
+ this.framesSinceFixedGpuBytesReport = 0;
1688
+ if (bytes === this.lastReportedFixedGpuBytes) return;
1689
+ this.lastReportedFixedGpuBytes = bytes;
1690
+ this._core.setFixedGpuBytes(bytes);
1691
+ }
1692
+
1693
+ /**
1694
+ * Re-reports the fixed GPU footprint for event-triggered changes (init,
1695
+ * resize, pass add/remove). Scheduled as a microtask so bursts — e.g.
1696
+ * effect setup during init — coalesce into a single walk, and marks a
1697
+ * one-shot post-render resync because these events run before the GPU has
1698
+ * lazily allocated the affected targets.
1699
+ */
1700
+ private _scheduleFixedGpuBytesReport(): void {
1701
+ this.fixedGpuBytesResyncPending = true;
1702
+ if (this.fixedGpuBytesReportScheduled) return;
1703
+ this.fixedGpuBytesReportScheduled = true;
1704
+ queueMicrotask(() => {
1705
+ this.fixedGpuBytesReportScheduled = false;
1706
+ this._reportFixedGpuBytes();
1707
+ });
1708
+ }
1709
+
1662
1710
  private _updateUniforms() {
1663
1711
  const viewport = this._getCanvasSize();
1664
1712
  const pixelRatio = this._renderer.getPixelRatio();
@@ -1738,15 +1786,59 @@ export default class ThreeView<
1738
1786
  }
1739
1787
  }
1740
1788
 
1789
+ /**
1790
+ * Ask for a future frame on behalf of the declutter pass. Runs through a
1791
+ * timer because the main loop clears `forceUpdate` right after `_render` —
1792
+ * a flag set synchronously here would be wiped before the next tick reads
1793
+ * it. A pending longer timer is preempted when a shorter delay is needed
1794
+ * (a fade must not wait out a throttle window).
1795
+ */
1796
+ private _scheduleDeclutterFrame(delayMs: number) {
1797
+ if (this._declutterRetry !== undefined) {
1798
+ if (delayMs >= this._declutterRetryDelay) return;
1799
+ clearTimeout(this._declutterRetry);
1800
+ }
1801
+ this._declutterRetryDelay = delayMs;
1802
+ this._declutterRetry = setTimeout(() => {
1803
+ this._declutterRetry = undefined;
1804
+ this._renderFlag.forceUpdate = true;
1805
+ }, delayMs);
1806
+ }
1807
+
1741
1808
  private _render(updatedAt: number) {
1742
- // Read the vector resolution revision once per frame so each terrain tile's
1743
- // `onBeforeRender` can gate its slot re-fetch against it without its own FFI call.
1809
+ // Read the vector/raster resolution revisions once per frame so each terrain
1810
+ // tile's `onBeforeRender` can gate its slot re-fetch against them without its
1811
+ // own FFI calls.
1744
1812
  this._vectorRevision = this._core?.vectorRevision() ?? this._vectorRevision;
1813
+ this._rasterRevision = this._core?.rasterRevision() ?? this._rasterRevision;
1745
1814
 
1746
1815
  this._uniforms.time.value = updatedAt;
1747
1816
 
1748
1817
  this._atmosphere._update();
1749
1818
 
1819
+ // Screen-space label decluttering runs before the render passes so
1820
+ // placement changes land in this frame. Throttled passes and active
1821
+ // fades both need future frames, requested via _scheduleDeclutterFrame.
1822
+ {
1823
+ const size = this._renderer.getDrawingBufferSize(this._declutterSize);
1824
+ const pixelRatio = this._renderer.getPixelRatio();
1825
+ const result = this._declutter.update(
1826
+ this._camera.raw,
1827
+ size.x / pixelRatio,
1828
+ size.y / pixelRatio,
1829
+ updatedAt,
1830
+ );
1831
+ if (result === "throttled") {
1832
+ this._scheduleDeclutterFrame(
1833
+ this._declutter.remainingThrottleMs(updatedAt),
1834
+ );
1835
+ } else if (result === "animating") {
1836
+ // Keep frames coming at fade cadence until every label reaches its
1837
+ // placement target.
1838
+ this._scheduleDeclutterFrame(16);
1839
+ }
1840
+ }
1841
+
1750
1842
  this.emit("preRender", updatedAt);
1751
1843
 
1752
1844
  this.renderPassOrchestrator.render();
@@ -1755,6 +1847,19 @@ export default class ThreeView<
1755
1847
  this.shadowMapViewers.render(this._renderer);
1756
1848
 
1757
1849
  this.emit("postRender", updatedAt);
1850
+
1851
+ // Render targets are GL-allocated lazily during rendering (and freed on
1852
+ // resize), so a footprint taken at init/resize/pass-change time goes
1853
+ // stale: re-estimate once on the first frame after such an event, and
1854
+ // otherwise at a low cadence. The report skips the WASM call when the
1855
+ // value is unchanged.
1856
+ if (this.fixedGpuBytesResyncPending) {
1857
+ this.fixedGpuBytesResyncPending = false;
1858
+ this._reportFixedGpuBytes();
1859
+ } else if (++this.framesSinceFixedGpuBytesReport >= 120) {
1860
+ this.framesSinceFixedGpuBytesReport = 0;
1861
+ this._reportFixedGpuBytes();
1862
+ }
1758
1863
  }
1759
1864
 
1760
1865
  /**
@@ -2440,9 +2545,14 @@ export default class ThreeView<
2440
2545
  const { width, height } = this._getCanvasSize() ?? {};
2441
2546
  if (!width || !height) return;
2442
2547
 
2548
+ // Route through getDevicePixelRatio so a devicePixelRatio change (zoom,
2549
+ // monitor move) cannot bypass the mobile caps applied at init.
2443
2550
  const pixelRatio = isWorker()
2444
2551
  ? (this._options.pixelRatio ?? 1)
2445
- : window.devicePixelRatio;
2552
+ : getDevicePixelRatio({
2553
+ override: this._options.pixelRatio,
2554
+ mobileOptimization: this._options.mobileOptimization,
2555
+ });
2446
2556
  this.resize(width, height, pixelRatio);
2447
2557
  };
2448
2558
 
@@ -13,8 +13,7 @@ type PickMarker<Markers extends ShaderMarkers, Key extends keyof Markers> = {
13
13
  }[keyof Markers[Key]];
14
14
 
15
15
  type PickMarkers<Markers extends ShaderMarkers> =
16
- | PickMarker<Markers, "vertex">
17
- | PickMarker<Markers, "fragment">;
16
+ PickMarker<Markers, "vertex"> | PickMarker<Markers, "fragment">;
18
17
 
19
18
  /**
20
19
  * A type-safe shader string replacer that restricts operations to declared markers.
@@ -44,6 +44,7 @@ export const createBaseMutates = (
44
44
  uTextWidth: { value: 0.0 },
45
45
  uTextHeight: { value: 0.0 },
46
46
  uBgYBounds: { value: new Vector2(0.0, 1.0) },
47
+ uDeclutterHide: { value: 0.0 },
47
48
  uRTCCenter: {
48
49
  value: new Vector3(
49
50
  rtcCenter?.[0] ?? 0,
@@ -113,6 +114,7 @@ export const createBaseMutates = (
113
114
  uniforms.uTextWidth = refs.uTextWidth;
114
115
  uniforms.uTextHeight = refs.uTextHeight;
115
116
  uniforms.uBgYBounds = refs.uBgYBounds;
117
+ uniforms.uDeclutterHide = refs.uDeclutterHide;
116
118
  uniforms.uRTCCenter = refs.uRTCCenter;
117
119
  uniforms.uRTCCenterView = refs.uRTCCenterView;
118
120
  uniforms.uEyeRTELow = refs.uEyeRTELow;
@@ -228,5 +230,9 @@ export const createBaseMutates = (
228
230
  setBatchId: (id: number) => {
229
231
  refs.nvr_uBatchId.value = id;
230
232
  },
233
+
234
+ setDeclutterHide: (value: number) => {
235
+ refs.uDeclutterHide.value = value;
236
+ },
231
237
  };
232
238
  };
@@ -103,6 +103,10 @@ export type SdfTextBaseRefs = {
103
103
  uTextWidth: UniformValue<number>;
104
104
  uTextHeight: UniformValue<number>;
105
105
  uBgYBounds: UniformValue<Vector2>;
106
+ /** Screen-space declutter hide factor (0 = shown … 1 = hidden), animated
107
+ * by the declutter fade. Driven by the declutter pass, not by material
108
+ * style updates. */
109
+ uDeclutterHide: UniformValue<number>;
106
110
  uRTCCenter: UniformValue<Vector3>;
107
111
  uRTCCenterView: UniformValue<Vector3>;
108
112
  uEyeRTELow: UniformValue<Vector3>;
@@ -180,5 +184,12 @@ export type SdfTextBaseMutates = Mutates<
180
184
  * Set the batch ID uniform (immutable after mount, but needed for batch creation).
181
185
  */
182
186
  setBatchId: (batchId: number) => void;
187
+ /**
188
+ * Set the declutter hide factor (0 = shown … 1 = hidden); the fragment
189
+ * shader scales opacity by `1 - value` and the vertex shader hard-culls
190
+ * at 1. Kept separate from `show`/`opacity` so user-driven visibility and
191
+ * declutter results compose instead of clobbering each other.
192
+ */
193
+ setDeclutterHide: (value: number) => void;
183
194
  }
184
195
  >;
@@ -0,0 +1,83 @@
1
+ import { Color, Texture, Vector2 } from "three";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { createTestTileHandler } from "../../../../test-utils/engine";
5
+ import type { RasterCompositeLayer } from "../../../../tileTexture/types";
6
+
7
+ import {
8
+ createCoreUniformMutates,
9
+ type CompositeUniformTarget,
10
+ } from "./mutates";
11
+
12
+ // The real Rust `mercator_y` (clamped to the WM band), reached through the
13
+ // same context handler the app injects — the clamp regression below exercises
14
+ // the production implementation, not a reimplementation.
15
+ const { mercatorY } = createTestTileHandler();
16
+
17
+ const rasterLayer = (
18
+ reproject: [number, number] | undefined,
19
+ ): RasterCompositeLayer => ({
20
+ absSlot: 0,
21
+ region: "raster",
22
+ texture: null,
23
+ uvOffset: new Vector2(0, 0),
24
+ uvScale: new Vector2(1, 1),
25
+ kind: "raster",
26
+ color: new Color(1, 1, 1),
27
+ opacity: 1,
28
+ water: false,
29
+ reproject,
30
+ });
31
+
32
+ const bind = (reproject: [number, number] | undefined) => {
33
+ const mutates = createCoreUniformMutates(mercatorY);
34
+ const target: CompositeUniformTarget = {};
35
+ mutates.attachUniforms(target, 1, new Texture());
36
+ mutates.bindSlot(0, rasterLayer(reproject));
37
+ return target;
38
+ };
39
+
40
+ describe("bindSlot Mercator reprojection constants", () => {
41
+ it("clamps a polar latitude band to the WebMercator limit instead of NaN", () => {
42
+ // A z0 Geographic terrain tile's band is ±90°, and Rust hands it over as
43
+ // f32 — slightly PAST ±π/2, where an unclamped log(tan(...)) is NaN. A
44
+ // NaN span fails the shader's `abs(mDen) > 1e-3` guard, silently skipping
45
+ // reprojection for the whole tile (imagery squashes toward the equator).
46
+ const f32HalfPi = Math.fround(Math.PI / 2);
47
+ const target = bind([-f32HalfPi, f32HalfPi]);
48
+
49
+ const merc = target.uReprojectMerc.value[0];
50
+ // Band start/span from the clamped WM limit: mRs = -π, mDen = 2π.
51
+ expect(merc.x).toBeCloseTo(-Math.PI, 5);
52
+ expect(merc.y).toBeCloseTo(2 * Math.PI, 5);
53
+ // Both edges sit on the WM limit → both polar-cap clamp flags set.
54
+ expect(merc.z).toBe(1);
55
+ expect(merc.w).toBe(1);
56
+ expect(target.uReproject.value[0]).toBe(1);
57
+ });
58
+
59
+ it("keeps an interior band un-clamped and un-flagged", () => {
60
+ const south = 0.2;
61
+ const north = 0.7;
62
+ const target = bind([south, north]);
63
+
64
+ const merc = target.uReprojectMerc.value[0];
65
+ expect(merc.x).toBeCloseTo(mercatorY(south), 9);
66
+ expect(merc.y).toBeCloseTo(mercatorY(north) - mercatorY(south), 9);
67
+ expect(merc.z).toBe(0);
68
+ expect(merc.w).toBe(0);
69
+ });
70
+
71
+ it("flags but does not NaN a band touching one WM edge", () => {
72
+ // z1 north Geographic tile: [0, 90°]. South edge is fine; the north edge
73
+ // must clamp to the WM limit and set only the top polar-cap flag.
74
+ const target = bind([0, Math.fround(Math.PI / 2)]);
75
+
76
+ const merc = target.uReprojectMerc.value[0];
77
+ expect(merc.x).toBeCloseTo(0, 9);
78
+ expect(merc.y).toBeCloseTo(Math.PI, 5);
79
+ expect(merc.z).toBe(1);
80
+ expect(merc.w).toBe(0);
81
+ expect(Number.isFinite(merc.y)).toBe(true);
82
+ });
83
+ });
@@ -26,7 +26,11 @@ export type CoreUniformMutates = {
26
26
  bindSlot: (compactSlot: number, layer: CompositeLayer | undefined) => void;
27
27
  };
28
28
 
29
- export function createCoreUniformMutates(): CoreUniformMutates {
29
+ /** `mercatorY` is Rust's clamped WebMercator northing, injected through the
30
+ * event context (`TileHandler.mercatorY`) like every other engine call. */
31
+ export function createCoreUniformMutates(
32
+ mercatorY: (lat: number) => number,
33
+ ): CoreUniformMutates {
30
34
  const refs = {
31
35
  uShows: new Uniform<number[]>([]),
32
36
  uOpacities: new Uniform<number[]>([]),
@@ -104,8 +108,13 @@ export function createCoreUniformMutates(): CoreUniformMutates {
104
108
  refs.uLayerUvScale.value[k].set(1, 1);
105
109
  }
106
110
 
111
+ // Reproject applies to color rasters and elevation heatmaps alike — the
112
+ // shader warps the per-slot UV before either sampler runs; hillshade is
113
+ // terrain-side and never reprojects.
107
114
  const reproject =
108
- layer && layer.kind === "raster" ? layer.reproject : undefined;
115
+ layer && (layer.kind === "raster" || layer.kind === "elevationHeatmap")
116
+ ? layer.reproject
117
+ : undefined;
109
118
  if (reproject && layer) {
110
119
  const [Ts, Tn] = reproject;
111
120
  // Recover the source tile's latitude band [Rs, Rn] from the affine y
@@ -115,8 +124,14 @@ export function createCoreUniformMutates(): CoreUniformMutates {
115
124
  const span = (Tn - Ts) / layer.uvScale.y;
116
125
  const Rs = Ts - layer.uvOffset.y * span;
117
126
  const Rn = Rs + span;
118
- const mRs = Math.log(Math.tan(Math.PI * 0.25 + Rs * 0.5));
119
- const mRn = Math.log(Math.tan(Math.PI * 0.25 + Rn * 0.5));
127
+ // Rust's clamped Mercator northing — the same function the bake affine
128
+ // uses (`uv_rect_from_extents_mercator`), so the pasted band stays
129
+ // aligned with the baked render target. The clamp matters: a polar
130
+ // tile's band reaches ±90° (as f32, slightly past it), where unclamped
131
+ // log(tan(...)) returns NaN — and a NaN span fails the shader's
132
+ // `abs(mDen)` guard, silently disabling reprojection for the tile.
133
+ const mRs = mercatorY(Rs);
134
+ const mRn = mercatorY(Rn);
120
135
  // Polar cap: clamp the overshoot onto the band-edge texel when this tile's
121
136
  // north/south edge sits on the WebMercator latitude limit.
122
137
  const clampTop = Rn >= WM_MAX_LAT - 1e-4 ? 1 : 0;
@@ -10,6 +10,12 @@ export const elevationIncludes = (): string => ElevationParsFragment;
10
10
  /**
11
11
  * Replaces the per-slot sampler with a DEM-decode + colormap lookup; non-heatmap
12
12
  * slots fall back to the straight raster fetch in the else branch.
13
+ *
14
+ * The heatmap alpha derives from the decode's validity: a no-data (boundary)
15
+ * texel — including a baked drape render target's uncovered regions, which the
16
+ * bake paints with the boundary color — renders transparent instead of being
17
+ * colormapped as height 0. The DEM texel's alpha channel is never read: it is
18
+ * not part of any elevation encoding and stays reserved for future RGBA ones.
13
19
  */
14
20
  export const elevationSampleProducer = ({
15
21
  k,
@@ -17,8 +23,9 @@ export const elevationSampleProducer = ({
17
23
  vec4 texColor${k};
18
24
  if (uIsElevationHeatmaps[${k}]) {
19
25
  ivec2 demTexSize = textureSize(uTextures[${k}], 0);
20
- float normalized_h = sampleElevationBilinear(uTextures[${k}], texUv${k}, demTexSize);
21
- texColor${k} = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, 1.0);
26
+ bool demValid${k};
27
+ float normalized_h = sampleElevationBilinear(uTextures[${k}], texUv${k}, demTexSize, demValid${k});
28
+ texColor${k} = vec4(texture2D(uColorMapTexture, vec2(normalized_h, 0.5)).rgb, demValid${k} ? 1.0 : 0.0);
22
29
  } else {
23
30
  texColor${k} = texture2D(uTextures[${k}], texUv${k}) * vec4(uColors[${k}], 1.0);
24
31
  }`;
@@ -183,8 +183,7 @@ export function getRowIndex(
183
183
  row: BatchTextureRowKey,
184
184
  ): number {
185
185
  const config = material.userData.batchTextureConfig as
186
- | BatchTextureConfig
187
- | undefined;
186
+ BatchTextureConfig | undefined;
188
187
  invariant(config);
189
188
 
190
189
  const rowIndex = config.rows.indexOf(row);
@@ -238,8 +237,7 @@ export function updateBatchAttribute(
238
237
  const data = texture.image.data as Float32Array;
239
238
  const texWidth = texture.image.width;
240
239
  const config = material.userData.batchTextureConfig as
241
- | BatchTextureConfig
242
- | undefined;
240
+ BatchTextureConfig | undefined;
243
241
  const rowCount = config?.rows.length ?? BATCH_TEXTURE_ROW.length;
244
242
 
245
243
  switch (attribute) {
@@ -6,6 +6,10 @@ import type { FontManager } from "@navaramap/font";
6
6
  import { type Color } from "three";
7
7
  import invariant from "tiny-invariant";
8
8
 
9
+ import type {
10
+ DeclutterCandidate,
11
+ DeclutterParticipant,
12
+ } from "../declutter/types";
9
13
  import type { EventContext } from "../event/context";
10
14
 
11
15
  import { InstancedMesh, type InstancedMeshOptions } from "./instanced";
@@ -36,7 +40,7 @@ type PositionsInfo = PositionsInfoBase &
36
40
 
37
41
  export class BatchedSdfTextMesh
38
42
  extends InstancedMesh<SDFTextMesh>
39
- implements PickableMesh
43
+ implements PickableMesh, DeclutterParticipant
40
44
  {
41
45
  readonly ctx: EventContext;
42
46
  /** The font identifier from material — may be a family name or a URL. */
@@ -79,6 +83,41 @@ export class BatchedSdfTextMesh
79
83
  this._highQuality,
80
84
  () => this._revalidateStaleMeshes(),
81
85
  );
86
+ ctx.declutter?.register(this);
87
+ }
88
+
89
+ // --- DeclutterParticipant ---
90
+
91
+ collectDeclutterCandidates(out: DeclutterCandidate[]): void {
92
+ if (!this.visible) return;
93
+ const meshes = this.meshes();
94
+ for (let i = 0; i < meshes.length; i++) {
95
+ const candidate = meshes[i].getDeclutterCandidate(this, i);
96
+ if (candidate) out.push(candidate);
97
+ }
98
+ }
99
+
100
+ applyDeclutter(handle: number, hidden: boolean): void {
101
+ this.meshes()[handle]?.setDeclutterHidden(hidden);
102
+ }
103
+
104
+ stepDeclutterFade(deltaMs: number): boolean {
105
+ let animating = false;
106
+ for (const mesh of this.meshes()) {
107
+ animating = mesh.stepDeclutterFade(deltaMs) || animating;
108
+ }
109
+ return animating;
110
+ }
111
+
112
+ /** Candidates changed (text, style, position, visibility): ask the shared
113
+ * declutter pass to re-place on its next update. */
114
+ private _markDeclutterDirty(): void {
115
+ this.ctx.declutter?.markDirty();
116
+ }
117
+
118
+ override setActive(active: boolean) {
119
+ super.setActive(active);
120
+ this._markDeclutterDirty();
82
121
  }
83
122
 
84
123
  /**
@@ -111,6 +150,7 @@ export class BatchedSdfTextMesh
111
150
  // Force a rebuild even though the text string is unchanged: the baked
112
151
  // atlas rects and glyph retains must refresh against the new metrics.
113
152
  mesh.setText(text, true);
153
+ this._markDeclutterDirty();
114
154
  this._needRender?.();
115
155
  })
116
156
  .catch((err: unknown) => {
@@ -222,6 +262,8 @@ export class BatchedSdfTextMesh
222
262
  : position.subarray(posIdx, posIdx + positionSize);
223
263
  this.meshes()[i].setPosition(pos, RTE, transform);
224
264
  }
265
+ // Anchors moved (e.g. terrain height resolution) — re-place labels.
266
+ this._markDeclutterDirty();
225
267
  }
226
268
 
227
269
  const fontIdentifier = m.material.font ?? this._fontIdentifier;
@@ -292,6 +334,7 @@ export class BatchedSdfTextMesh
292
334
  mesh.update(material, forceUpdate);
293
335
  this.markVisibility(mesh);
294
336
  }
337
+ this._markDeclutterDirty();
295
338
 
296
339
  if (needRender) needRender();
297
340
  }
@@ -412,6 +455,7 @@ export class BatchedSdfTextMesh
412
455
  // Restore intended visibility now that text content is available
413
456
  mesh.visible = intendedVisible;
414
457
  this.markVisibility(mesh);
458
+ this._markDeclutterDirty();
415
459
  this._needRender?.();
416
460
  })
417
461
  .catch((err: unknown) => {
@@ -422,6 +466,7 @@ export class BatchedSdfTextMesh
422
466
  }
423
467
  mesh.setText(text);
424
468
  this.markVisibility(mesh);
469
+ this._markDeclutterDirty();
425
470
  this._needRender?.();
426
471
  }
427
472
  }
@@ -438,6 +483,7 @@ export class BatchedSdfTextMesh
438
483
  if (mesh) {
439
484
  mesh._setFeatureShow(rawVisible);
440
485
  this.markVisibility(mesh);
486
+ this._markDeclutterDirty();
441
487
  }
442
488
  }
443
489
 
@@ -445,6 +491,7 @@ export class BatchedSdfTextMesh
445
491
  const mesh = this.meshes()[batchIndex];
446
492
  if (mesh) {
447
493
  mesh.setHeight(height);
494
+ this._markDeclutterDirty();
448
495
  }
449
496
  }
450
497
 
@@ -452,6 +499,18 @@ export class BatchedSdfTextMesh
452
499
  const mesh = this.meshes()[batchIndex];
453
500
  if (mesh) {
454
501
  mesh.setSize(size);
502
+ this._markDeclutterDirty();
503
+ }
504
+ }
505
+
506
+ setFeatureDeclutterPriorityByBatchIndex(
507
+ batchIndex: number,
508
+ priority: number,
509
+ ) {
510
+ const mesh = this.meshes()[batchIndex];
511
+ if (mesh) {
512
+ mesh.setDeclutterPriority(priority);
513
+ this._markDeclutterDirty();
455
514
  }
456
515
  }
457
516
 
@@ -466,6 +525,7 @@ export class BatchedSdfTextMesh
466
525
  }
467
526
 
468
527
  dispose() {
528
+ this.ctx.declutter?.unregister(this);
469
529
  this._unsubscribeEvict?.();
470
530
  this._unsubscribeEvict = undefined;
471
531
  const q = this._highQuality;