@navaramap/three 0.0.1 → 0.0.3

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 (72) hide show
  1. package/README.md +70 -0
  2. package/dist/assets/fontWorker-wz61edjR.js +2 -0
  3. package/dist/assets/fontWorker-wz61edjR.js.map +1 -0
  4. package/dist/assets/index-ywHSGImj.js +25 -0
  5. package/dist/assets/index-ywHSGImj.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-Bnck8OGf.wasm → navara_wasm_font_worker_bg-Du9nb2Pn.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-DG9ORAmj.wasm → navara_wasm_worker_bg-Xadu67fw.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 +578 -18
  11. package/dist/index.js +5298 -4843
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +266 -234
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +34 -33
  16. package/src/atmosphere.ts +43 -1
  17. package/src/core/EffectDesc.ts +2 -4
  18. package/src/core/MeshDesc.ts +1 -2
  19. package/src/core/MeshDescWithSelectiveEffect.ts +1 -2
  20. package/src/declutter/DeclutterManager.test.ts +235 -0
  21. package/src/declutter/DeclutterManager.ts +256 -0
  22. package/src/declutter/index.ts +7 -0
  23. package/src/declutter/kernel.ts +46 -0
  24. package/src/declutter/types.ts +61 -0
  25. package/src/declutter/wasmKernel.ts +13 -0
  26. package/src/device.ts +18 -4
  27. package/src/evaluations/FeatureEvaluator.ts +21 -0
  28. package/src/event/context.ts +12 -0
  29. package/src/event/feature.ts +43 -13
  30. package/src/event/features/modelGpuBytes.ts +1 -3
  31. package/src/event/features/polygon.ts +2 -1
  32. package/src/event/hillshade.test.ts +7 -13
  33. package/src/event/index.ts +2 -3
  34. package/src/event/tileHandler.ts +54 -0
  35. package/src/index.ts +137 -35
  36. package/src/input.ts +48 -15
  37. package/src/material/enhancer/ShaderReplacer.ts +1 -2
  38. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +1 -0
  39. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +2 -0
  40. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/types.ts +2 -0
  41. package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +2 -0
  42. package/src/material/enhancer/polygon/polygonBaseEnhancer/shader.ts +5 -1
  43. package/src/material/enhancer/polygon/polygonBaseEnhancer/types.ts +2 -0
  44. package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +2 -0
  45. package/src/material/enhancer/polyline/polylineBaseEnhancer/types.ts +2 -0
  46. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +8 -0
  47. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +13 -0
  48. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.test.ts +83 -0
  49. package/src/material/enhancer/tileComposite/tileCompositeBaseEnhancer/mutates.ts +13 -3
  50. package/src/material/overrideMaterialsForMRT.ts +7 -2
  51. package/src/mesh/batchTexture.ts +2 -4
  52. package/src/mesh/batchedSdfText.ts +294 -70
  53. package/src/mesh/instanced.ts +1 -1
  54. package/src/mesh/polygon.ts +11 -4
  55. package/src/mesh/polyline.ts +10 -2
  56. package/src/mesh/sdfText.ts +176 -6
  57. package/src/mesh/sprite/instancedSprite.ts +245 -3
  58. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  59. package/src/passes/CustomRenderPass.ts +21 -5
  60. package/src/passes/RenderTargetCopyPass.ts +24 -2
  61. package/src/solar.test.ts +33 -0
  62. package/src/solar.ts +31 -0
  63. package/src/test-utils/engine.ts +53 -0
  64. package/src/tileTexture/TileTextureCompositor.test.ts +2 -0
  65. package/src/tileTexture/TileTextureCompositor.ts +7 -1
  66. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  67. package/src/utils/fixedGpuFootprint.ts +162 -0
  68. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  69. package/dist/assets/fontWorker-CCWL5pbx.js +0 -2
  70. package/dist/assets/fontWorker-CCWL5pbx.js.map +0 -1
  71. package/dist/assets/index-BdtHOOT9.js +0 -12
  72. package/dist/assets/index-BdtHOOT9.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
 
@@ -634,39 +651,11 @@ export default class ThreeView<
634
651
  private _vectorRevision = 0;
635
652
  // Per-frame snapshot of the WASM raster drape resolution revision, refreshed in `_render`.
636
653
  private _rasterRevision = 0;
637
- private _tileHandler: TileHandler = {
638
- getMartini: (id) => {
639
- return this._core?.getMartini(id);
640
- },
641
- getTile: (handle) => {
642
- return this._core?.getTile(handle);
643
- },
644
- getParentTile: (handle) => {
645
- return this._core?.getParentTile(handle);
646
- },
647
- getTileElevationDecoder: (handle) => {
648
- return this._core?.getTileElevationDecoder(handle);
649
- },
650
- getVectorTileStates: (handle) => {
651
- return this._core?.getVectorTileStates(handle);
652
- },
653
- // Returns the per-frame cached revision (read once in `_render`), so per-tile
654
- // `onBeforeRender` gating costs no WASM-boundary call.
655
- vectorRevision: () => this._vectorRevision,
656
- getRasterTileStates: (handle) => {
657
- return this._core?.getRasterTileStates(handle);
658
- },
659
- rasterRevision: () => this._rasterRevision,
660
- reportDrapeGpuBytes: (handle, bytes) => {
661
- this._core?.reportTerrainDrapeGpuBytes(handle, bytes);
662
- },
663
- calcMetersPerTexel: (tileHandle, textureZoom, textureWidth) => {
664
- return (
665
- this._core?.calcMetersPerTexel(tileHandle, textureZoom, textureWidth) ??
666
- 1.0
667
- );
668
- },
669
- };
654
+ private _tileHandler: TileHandler = createTileHandler({
655
+ getCore: () => this._core,
656
+ getVectorRevision: () => this._vectorRevision,
657
+ getRasterRevision: () => this._rasterRevision,
658
+ });
670
659
  private _globeHandler: GlobeHandler = {
671
660
  getTransparent: () => {
672
661
  return this._core?.getGlobeTransparent();
@@ -805,6 +794,11 @@ export default class ThreeView<
805
794
 
806
795
  private pixelRatioMatchedMedia?: MediaQueryList;
807
796
 
797
+ private fixedGpuBytesReportScheduled = false;
798
+ private fixedGpuBytesResyncPending = false;
799
+ private framesSinceFixedGpuBytesReport = 0;
800
+ private lastReportedFixedGpuBytes = -1;
801
+
808
802
  /**
809
803
  * The built-in attribution (credit) UI. `undefined` when disabled via
810
804
  * `defaultAttribution: false`, or in a worker / no-DOM environment. Feed it
@@ -907,6 +901,7 @@ export default class ThreeView<
907
901
  this._tileTextureCompositor = new TileTextureCompositor({
908
902
  renderer: this._renderer,
909
903
  texturizedSceneByTileCoordinates: this._texturizedSceneByTileCoordinates,
904
+ mercatorY: this._tileHandler.mercatorY,
910
905
  size: getCompositeAtlasSize(this.isMobileOptimized()),
911
906
  });
912
907
 
@@ -929,6 +924,8 @@ export default class ThreeView<
929
924
  });
930
925
 
931
926
  this.renderPassOrchestrator.setSize(width, height);
927
+ this.renderPassOrchestrator.onPassesChanged = () =>
928
+ this._scheduleFixedGpuBytesReport();
932
929
 
933
930
  // Background color
934
931
  const bgColor = options.backgroundColor
@@ -1245,6 +1242,7 @@ export default class ThreeView<
1245
1242
  gpuBytesEst: stats.gpuBytesEst,
1246
1243
  externalCpuBytes: stats.externalCpuBytes,
1247
1244
  reservedBytes: stats.reservedBytes,
1245
+ fixedGpuBytes: stats.fixedGpuBytes,
1248
1246
  budgetBytes: stats.budgetBytes,
1249
1247
  evictedCount: stats.evictedCount,
1250
1248
  sseMultiplier: stats.sseMultiplier,
@@ -1370,6 +1368,10 @@ export default class ThreeView<
1370
1368
  costHints.rasterTileBytes,
1371
1369
  );
1372
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
+
1373
1375
  // LOD fog: stronger distance-based degrade on low-memory devices.
1374
1376
  const lodFog = {
1375
1377
  ...getDefaultLodFog({ isMobile: mobileOptimized }),
@@ -1440,6 +1442,7 @@ export default class ThreeView<
1440
1442
  textureFragmentIndex: this._textureFragmentIndex,
1441
1443
  tileMeshToFragmentIds: this._tileMeshToFragmentIds,
1442
1444
  hillshadeContext: this._hillshadeContext,
1445
+ declutter: this._declutter,
1443
1446
  });
1444
1447
 
1445
1448
  // Register built-in descriptors
@@ -1544,6 +1547,10 @@ export default class ThreeView<
1544
1547
  dispose() {
1545
1548
  this._disposed = true;
1546
1549
  this._initialized = false;
1550
+ if (this._declutterRetry !== undefined) {
1551
+ clearTimeout(this._declutterRetry);
1552
+ this._declutterRetry = undefined;
1553
+ }
1547
1554
  // Dispose the view-owned attribution plugin (other plugins are the caller's).
1548
1555
  this._attribution?.dispose();
1549
1556
  this._attribution = undefined;
@@ -1593,6 +1600,10 @@ export default class ThreeView<
1593
1600
  // Cleanup hillshade context (temp DEMs, generator, etc.)
1594
1601
  this._hillshadeContext.dispose();
1595
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
+
1596
1607
  // Clear caches and maps
1597
1608
  this._meshes.clear();
1598
1609
  this._inMemoryBufferStore.clear();
@@ -1661,10 +1672,41 @@ export default class ThreeView<
1661
1672
  this._pickHelper?.setSize(drawingBufferSize.x, drawingBufferSize.y);
1662
1673
 
1663
1674
  this._core?.resize(w, h, pixelRatio ?? 1);
1675
+ this._scheduleFixedGpuBytesReport();
1664
1676
 
1665
1677
  this.emit("resize", w, h);
1666
1678
  };
1667
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
+
1668
1710
  private _updateUniforms() {
1669
1711
  const viewport = this._getCanvasSize();
1670
1712
  const pixelRatio = this._renderer.getPixelRatio();
@@ -1744,6 +1786,25 @@ export default class ThreeView<
1744
1786
  }
1745
1787
  }
1746
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
+
1747
1808
  private _render(updatedAt: number) {
1748
1809
  // Read the vector/raster resolution revisions once per frame so each terrain
1749
1810
  // tile's `onBeforeRender` can gate its slot re-fetch against them without its
@@ -1755,6 +1816,29 @@ export default class ThreeView<
1755
1816
 
1756
1817
  this._atmosphere._update();
1757
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
+
1758
1842
  this.emit("preRender", updatedAt);
1759
1843
 
1760
1844
  this.renderPassOrchestrator.render();
@@ -1763,6 +1847,19 @@ export default class ThreeView<
1763
1847
  this.shadowMapViewers.render(this._renderer);
1764
1848
 
1765
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
+ }
1766
1863
  }
1767
1864
 
1768
1865
  /**
@@ -2448,9 +2545,14 @@ export default class ThreeView<
2448
2545
  const { width, height } = this._getCanvasSize() ?? {};
2449
2546
  if (!width || !height) return;
2450
2547
 
2548
+ // Route through getDevicePixelRatio so a devicePixelRatio change (zoom,
2549
+ // monitor move) cannot bypass the mobile caps applied at init.
2451
2550
  const pixelRatio = isWorker()
2452
2551
  ? (this._options.pixelRatio ?? 1)
2453
- : window.devicePixelRatio;
2552
+ : getDevicePixelRatio({
2553
+ override: this._options.pixelRatio,
2554
+ mobileOptimization: this._options.mobileOptimization,
2555
+ });
2454
2556
  this.resize(width, height, pixelRatio);
2455
2557
  };
2456
2558
 
package/src/input.ts CHANGED
@@ -15,6 +15,21 @@ export function registerInputEvents(
15
15
 
16
16
  let lastTerrainDistance: number | undefined;
17
17
 
18
+ // Mousedown and mousemove must share the same coordinate space: the engine
19
+ // re-anchors its cursor position from the mousedown coordinates so the first
20
+ // drag delta is never computed against a stale position (e.g. after the
21
+ // window lost focus while the OS cursor kept moving).
22
+ const normalizePosition = (event: MouseEvent) => {
23
+ const rect = element.getBoundingClientRect();
24
+ const width = element.clientWidth;
25
+ const height = element.clientHeight;
26
+ const aspectRatio = width / height;
27
+ return {
28
+ x: ((event.clientX - rect.left) / width) * aspectRatio,
29
+ y: (event.clientY - rect.top) / height,
30
+ };
31
+ };
32
+
18
33
  const mousedown = (event: MouseEvent) => {
19
34
  mouseEvent = {
20
35
  type: "mousedown",
@@ -22,7 +37,11 @@ export function registerInputEvents(
22
37
  };
23
38
  const terrainDistance = getTerrainDistance?.() ?? undefined;
24
39
  lastTerrainDistance = terrainDistance;
25
- core.input({ ...mouseEvent, terrain_distance: terrainDistance });
40
+ core.input({
41
+ ...mouseEvent,
42
+ ...normalizePosition(event),
43
+ terrain_distance: terrainDistance,
44
+ });
26
45
  };
27
46
  const mouseup = () => {
28
47
  core.input({
@@ -33,13 +52,9 @@ export function registerInputEvents(
33
52
  };
34
53
 
35
54
  const mousemove = (event: MouseEvent) => {
36
- const width = element.clientWidth;
37
- const height = element.clientHeight;
38
- const aspectRatio = width / height;
39
55
  core.input({
40
56
  type: "mousemove",
41
- x: (event.clientX / width) * aspectRatio,
42
- y: event.clientY / height,
57
+ ...normalizePosition(event),
43
58
  });
44
59
  };
45
60
 
@@ -109,36 +124,54 @@ export function registerInputEvents(
109
124
  key: event?.key,
110
125
  });
111
126
  };
112
- // This is used to emit a key up event without any params.
113
- const keyupEmpty = () => {
114
- core.input({
115
- type: "keyup",
116
- });
127
+
128
+ // Releases every pressed button and modifier key. When the window loses
129
+ // focus or visibility the matching mouseup/keyup fires in another window, so
130
+ // without this the engine would keep acting on stale pressed state after the
131
+ // user comes back.
132
+ const releaseInputs = () => {
133
+ for (const button of [0, 1, 2]) {
134
+ core.input({ type: "mouseup", button });
135
+ }
136
+ mouseEvent = undefined;
137
+ core.input({ type: "keyup", key_code: "ControlLeft", key: "Control" });
138
+ core.input({ type: "keyup", key_code: "ControlRight", key: "Control" });
139
+ };
140
+ const visibilitychange = () => {
141
+ if (document.visibilityState === "hidden") {
142
+ releaseInputs();
143
+ }
117
144
  };
118
145
 
119
146
  element.addEventListener("mousedown", mousedown);
120
147
  element.addEventListener("mouseup", mouseup);
121
- element.addEventListener("mouseleave", mouseup);
122
- element.addEventListener("mouseleave", keyupEmpty);
148
+ element.addEventListener("mouseleave", releaseInputs);
123
149
  element.addEventListener("mousemove", mousemove);
124
150
  element.addEventListener("touchstart", touchstart);
125
151
  element.addEventListener("touchend", touchend);
152
+ // The browser cancels active touches on app switches and system gestures;
153
+ // treat them as ended so no stale touch stays in the engine.
154
+ element.addEventListener("touchcancel", touchend);
126
155
  element.addEventListener("touchmove", touchmove);
127
156
  element.addEventListener("wheel", wheel);
128
157
  document.addEventListener("keydown", keydown);
129
158
  document.addEventListener("keyup", keyup);
159
+ window.addEventListener("blur", releaseInputs);
160
+ document.addEventListener("visibilitychange", visibilitychange);
130
161
 
131
162
  return () => {
132
163
  element.removeEventListener("mousedown", mousedown);
133
164
  element.removeEventListener("mouseup", mouseup);
134
- element.removeEventListener("mouseleave", mouseup);
135
- element.removeEventListener("mouseleave", keyupEmpty);
165
+ element.removeEventListener("mouseleave", releaseInputs);
136
166
  element.removeEventListener("mousemove", mousemove);
137
167
  element.removeEventListener("touchstart", touchstart);
138
168
  element.removeEventListener("touchend", touchend);
169
+ element.removeEventListener("touchcancel", touchend);
139
170
  element.removeEventListener("touchmove", touchmove);
140
171
  element.removeEventListener("wheel", wheel);
141
172
  document.removeEventListener("keydown", keydown);
142
173
  document.removeEventListener("keyup", keyup);
174
+ window.removeEventListener("blur", releaseInputs);
175
+ document.removeEventListener("visibilitychange", visibilitychange);
143
176
  };
144
177
  }
@@ -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.
@@ -10,6 +10,7 @@ import type { InstancedSpriteBaseState } from "./types";
10
10
 
11
11
  vi.mock("@navaramap/three_api", () => ({
12
12
  degreeToRadian: (degree: number) => (degree * Math.PI) / 180,
13
+ RTE_ONE_UNIFORM: { value: 1.0 },
13
14
  }));
14
15
 
15
16
  vi.mock("@navaramap/engine-api", () => ({
@@ -1,4 +1,5 @@
1
1
  import { encodePosition } from "@navaramap/engine-api";
2
+ import { RTE_ONE_UNIFORM } from "@navaramap/three_api";
2
3
  import {
3
4
  type Matrix4,
4
5
  type Texture,
@@ -82,6 +83,7 @@ export const createBaseMutates = (
82
83
  uniforms.uRTCCenterView = refs.uRTCCenterView;
83
84
  uniforms.uEyeRTELow = refs.uEyeRTELow;
84
85
  uniforms.uEyeRTEHigh = refs.uEyeRTEHigh;
86
+ uniforms.u_rteOne = RTE_ONE_UNIFORM;
85
87
  uniforms.uScale = refs.uScale;
86
88
  uniforms.uCenter = refs.uCenter;
87
89
  uniforms.uSizeInMeters = refs.uSizeInMeters;
@@ -77,6 +77,8 @@ export type InstancedSpriteBaseRefs = {
77
77
  uRTCCenterView: UniformValue<Vector3>;
78
78
  uEyeRTELow: UniformValue<Vector3>;
79
79
  uEyeRTEHigh: UniformValue<Vector3>;
80
+ /** Always 1.0 — blocks fast-math reassociation of the RTE recombination. */
81
+ u_rteOne?: UniformValue<number>;
80
82
  uScale: UniformValue<number>;
81
83
  uCenter: UniformValue<Vector2>;
82
84
  uSizeInMeters: UniformValue<boolean>;
@@ -1,3 +1,4 @@
1
+ import { RTE_ONE_UNIFORM } from "@navaramap/three_api";
1
2
  import type { Matrix4, Texture, Vector3 } from "three";
2
3
  import { Matrix4 as ThreeMatrix4, Vector3 as ThreeVector3 } from "three";
3
4
 
@@ -78,6 +79,7 @@ export const createBaseMutates = (useRTE: boolean): PolygonBaseMutates => {
78
79
  uniforms.u_cameraPositionHigh = refs.u_cameraPositionHigh;
79
80
  uniforms.u_cameraPositionLow = refs.u_cameraPositionLow;
80
81
  uniforms.modelViewMatrixRTE = refs.modelViewMatrixRTE;
82
+ uniforms.u_rteOne = RTE_ONE_UNIFORM;
81
83
  }
82
84
 
83
85
  // Height uniforms
@@ -115,7 +115,11 @@ ${ShadowMapDepthVertex}
115
115
  #include <envmap_vertex>
116
116
 
117
117
  vPosition = absTransformed.xyz;
118
- vViewPosition = -absMvPosition.xyz;
118
+ // mvPosition comes from the RTE projection (modelViewMatrixRTE * camera-relative
119
+ // position), so it is precise at any distance. Deriving vViewPosition from
120
+ // modelViewMatrix * absTransformed instead would suffer f32 cancellation at
121
+ // ECEF magnitude and make view-dependent shading wobble as the camera moves.
122
+ vViewPosition = -mvPosition.xyz;
119
123
  `,
120
124
  useRTE,
121
125
  )
@@ -108,6 +108,8 @@ export type PolygonBaseRefs = {
108
108
  modelViewMatrixRTE?: UniformValue<Matrix4>;
109
109
  u_cameraPositionHigh?: UniformValue<Vector3>;
110
110
  u_cameraPositionLow?: UniformValue<Vector3>;
111
+ /** Always 1.0 — blocks fast-math reassociation of the RTE recombination. */
112
+ u_rteOne?: UniformValue<number>;
111
113
  };
112
114
 
113
115
  export type PolygonBaseUniforms = Partial<PolygonBaseRefs>;
@@ -1,3 +1,4 @@
1
+ import { RTE_ONE_UNIFORM } from "@navaramap/three_api";
1
2
  import {
2
3
  Color as ThreeColor,
3
4
  Matrix4 as ThreeMatrix4,
@@ -101,6 +102,7 @@ export const createBaseMutates = (useRTE: boolean): PolylineBaseMutates => {
101
102
  uniforms.u_cameraPositionHigh = refs.u_cameraPositionHigh;
102
103
  uniforms.u_cameraPositionLow = refs.u_cameraPositionLow;
103
104
  uniforms.modelViewMatrixRTE = refs.modelViewMatrixRTE;
105
+ uniforms.u_rteOne = RTE_ONE_UNIFORM;
104
106
  }
105
107
 
106
108
  // Batch texture
@@ -114,6 +114,8 @@ export type PolylineBaseRefs = {
114
114
  modelViewMatrixRTE?: UniformValue<Matrix4>;
115
115
  u_cameraPositionHigh?: UniformValue<Vector3>;
116
116
  u_cameraPositionLow?: UniformValue<Vector3>;
117
+ /** Always 1.0 — blocks fast-math reassociation of the RTE recombination. */
118
+ u_rteOne?: UniformValue<number>;
117
119
  };
118
120
 
119
121
  export type PolylineBaseUniforms = Partial<PolylineBaseRefs>;
@@ -1,4 +1,5 @@
1
1
  import { encodePosition } from "@navaramap/engine-api";
2
+ import { RTE_ONE_UNIFORM } from "@navaramap/three_api";
2
3
  import { Color, DataTexture, type Matrix4, Vector2, Vector3 } from "three";
3
4
 
4
5
  import type { UniformValue } from "../../../types";
@@ -44,6 +45,7 @@ export const createBaseMutates = (
44
45
  uTextWidth: { value: 0.0 },
45
46
  uTextHeight: { value: 0.0 },
46
47
  uBgYBounds: { value: new Vector2(0.0, 1.0) },
48
+ uDeclutterHide: { value: 0.0 },
47
49
  uRTCCenter: {
48
50
  value: new Vector3(
49
51
  rtcCenter?.[0] ?? 0,
@@ -113,10 +115,12 @@ export const createBaseMutates = (
113
115
  uniforms.uTextWidth = refs.uTextWidth;
114
116
  uniforms.uTextHeight = refs.uTextHeight;
115
117
  uniforms.uBgYBounds = refs.uBgYBounds;
118
+ uniforms.uDeclutterHide = refs.uDeclutterHide;
116
119
  uniforms.uRTCCenter = refs.uRTCCenter;
117
120
  uniforms.uRTCCenterView = refs.uRTCCenterView;
118
121
  uniforms.uEyeRTELow = refs.uEyeRTELow;
119
122
  uniforms.uEyeRTEHigh = refs.uEyeRTEHigh;
123
+ uniforms.u_rteOne = RTE_ONE_UNIFORM;
120
124
  uniforms.nvr_uBatchId = refs.nvr_uBatchId;
121
125
  uniforms.nvr_uPickable = refs.nvr_uPickable;
122
126
  uniforms.uAtlas = refs.uAtlas;
@@ -228,5 +232,9 @@ export const createBaseMutates = (
228
232
  setBatchId: (id: number) => {
229
233
  refs.nvr_uBatchId.value = id;
230
234
  },
235
+
236
+ setDeclutterHide: (value: number) => {
237
+ refs.uDeclutterHide.value = value;
238
+ },
231
239
  };
232
240
  };
@@ -103,10 +103,16 @@ 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>;
109
113
  uEyeRTEHigh: UniformValue<Vector3>;
114
+ /** Always 1.0 — blocks fast-math reassociation of the RTE recombination. */
115
+ u_rteOne?: UniformValue<number>;
110
116
  nvr_uBatchId: UniformValue<number>;
111
117
  nvr_uPickable: UniformValue<number>;
112
118
  uAtlas: UniformValue<DataTexture | null>;
@@ -180,5 +186,12 @@ export type SdfTextBaseMutates = Mutates<
180
186
  * Set the batch ID uniform (immutable after mount, but needed for batch creation).
181
187
  */
182
188
  setBatchId: (batchId: number) => void;
189
+ /**
190
+ * Set the declutter hide factor (0 = shown … 1 = hidden); the fragment
191
+ * shader scales opacity by `1 - value` and the vertex shader hard-culls
192
+ * at 1. Kept separate from `show`/`opacity` so user-driven visibility and
193
+ * declutter results compose instead of clobbering each other.
194
+ */
195
+ setDeclutterHide: (value: number) => void;
183
196
  }
184
197
  >;
@@ -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
+ });