@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
@@ -26,6 +26,11 @@ import {
26
26
  Vector2,
27
27
  } from "three";
28
28
 
29
+ import {
30
+ DECLUTTER_FADE_MS,
31
+ type DeclutterCandidate,
32
+ type DeclutterParticipant,
33
+ } from "../declutter/types";
29
34
  import type { MaterialEnhancer } from "../material/enhancer/MaterialEnhancer";
30
35
  import {
31
36
  createSdfTextMaterialEnhancer,
@@ -219,6 +224,27 @@ export class SDFTextMesh
219
224
  private _requestRender?: () => void;
220
225
  // The COLRv1 color atlas is always FontManager-owned; no local field needed.
221
226
 
227
+ /** World-space anchor in ECEF meters (f64), kept in sync with the position
228
+ * uniforms so the declutter pass can project it without touching WASM. */
229
+ private _worldAnchor = new Float64Array(3);
230
+ /** Whether this label participates in screen-space decluttering. */
231
+ private _declutter = false;
232
+ /** Layer-level placement priority from the material. */
233
+ private _declutterPriority = 0;
234
+ /** Per-feature priority set through the evaluator; overrides the layer
235
+ * value when defined. */
236
+ private _declutterPriorityOverride: number | undefined;
237
+ /** Current animated hide factor (mirrors the uDeclutterHide uniform) and
238
+ * the placement target it is fading toward. */
239
+ private _declutterHide = 0;
240
+ private _declutterTarget = 0;
241
+ /** Text block dimensions in em units, mirroring the uTextWidth/uTextHeight/
242
+ * uBgYBounds uniforms; 0 width means "no collision box" (empty text). */
243
+ private _labelWidthEm = 0;
244
+ private _labelHeightEm = 0;
245
+ private _labelMinYEm = 0;
246
+ private _labelMaxYEm = 0;
247
+
222
248
  private _enhancer: MaterialEnhancer<
223
249
  ShaderMaterial,
224
250
  { base?: SdfTextBaseProps },
@@ -311,6 +337,22 @@ export class SDFTextMesh
311
337
  transform.ty,
312
338
  transform.tz,
313
339
  ]);
340
+ this._cacheWorldAnchor(position, RTE, [
341
+ transform.tx,
342
+ transform.ty,
343
+ transform.tz,
344
+ ]);
345
+
346
+ // Decluttered labels start hidden and fade in once the placement pass
347
+ // grants them space — otherwise dense tiles flash their full clutter for
348
+ // a frame before the first pass runs.
349
+ this._declutter = material.declutter ?? true;
350
+ this._declutterPriority = material.declutterPriority ?? 0;
351
+ if (this._declutter) {
352
+ this._declutterHide = 1;
353
+ this._declutterTarget = 1;
354
+ mutates.setDeclutterHide(1);
355
+ }
314
356
 
315
357
  // Register shader hook
316
358
  mat.onBeforeCompile = this._enhancer.transformShader;
@@ -446,6 +488,7 @@ export class SDFTextMesh
446
488
 
447
489
  if (!text) {
448
490
  this.geometry.instanceCount = 0;
491
+ this._labelWidthEm = 0;
449
492
  this._setGlyphKeys([]);
450
493
  return;
451
494
  }
@@ -457,6 +500,7 @@ export class SDFTextMesh
457
500
  );
458
501
  if (!shapeResult) {
459
502
  this.geometry.instanceCount = 0;
503
+ this._labelWidthEm = 0;
460
504
  this._setGlyphKeys([]);
461
505
  return;
462
506
  }
@@ -546,6 +590,36 @@ export class SDFTextMesh
546
590
  this._enhancer.update({ base: { opacity: clampedOpacity } });
547
591
  }
548
592
 
593
+ /**
594
+ * Set this label's declutter fade target; the actual hide factor animates
595
+ * toward it in {@link stepDeclutterFade}. Deliberately separate from
596
+ * `visible`/`show`: it neither churns scene-graph membership nor releases
597
+ * glyph atlas retains, so the declutter pass can retarget it every
598
+ * placement run at no cost.
599
+ */
600
+ setDeclutterHidden(hidden: boolean): void {
601
+ this._declutterTarget = hidden ? 1 : 0;
602
+ }
603
+
604
+ /**
605
+ * Advance the hide factor toward its target by `deltaMs / DECLUTTER_FADE_MS`
606
+ * and push it to the uniform. Returns true while still mid-fade.
607
+ */
608
+ stepDeclutterFade(deltaMs: number): boolean {
609
+ const target = this._declutterTarget;
610
+ let value = this._declutterHide;
611
+ if (value === target) return false;
612
+
613
+ const step = deltaMs / DECLUTTER_FADE_MS;
614
+ value =
615
+ value < target
616
+ ? Math.min(value + step, target)
617
+ : Math.max(value - step, target);
618
+ this._declutterHide = value;
619
+ this._enhancer.mutates().setDeclutterHide(value);
620
+ return value !== target;
621
+ }
622
+
549
623
  _setFeatureWidth(_width: number): void {
550
624
  // Width is not applicable to text meshes.
551
625
  // This method is intentionally a no-op to satisfy the FeatureMesh guard.
@@ -565,6 +639,80 @@ export class SDFTextMesh
565
639
  this._enhancer
566
640
  .mutates()
567
641
  .setPosition(position, RTE, [transform.tx, transform.ty, transform.tz]);
642
+ this._cacheWorldAnchor(position, RTE, [
643
+ transform.tx,
644
+ transform.ty,
645
+ transform.tz,
646
+ ]);
647
+ }
648
+
649
+ /** Reconstruct the absolute ECEF anchor from the same inputs the position
650
+ * uniforms receive (RTE high+low split, or RTC-relative + center). */
651
+ private _cacheWorldAnchor(
652
+ position: Float32Array | { high: Float32Array; low: Float32Array },
653
+ RTE: boolean,
654
+ rtcCenter: [number, number, number],
655
+ ): void {
656
+ const anchor = this._worldAnchor;
657
+ if (RTE) {
658
+ const p = position as { high: Float32Array; low: Float32Array };
659
+ anchor[0] = p.high[0] + p.low[0];
660
+ anchor[1] = p.high[1] + p.low[1];
661
+ anchor[2] = (p.high[2] ?? 0.0) + (p.low[2] ?? 0.0);
662
+ } else {
663
+ const p = position as Float32Array;
664
+ anchor[0] = p[0] + rtcCenter[0];
665
+ anchor[1] = p[1] + rtcCenter[1];
666
+ anchor[2] = (p[2] ?? 0.0) + rtcCenter[2];
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Build this label's collision candidate for the shared declutter pass, or
672
+ * null when it doesn't participate (declutter disabled, hidden, or empty).
673
+ * The local box mirrors the vertex shader's layout: glyphs span
674
+ * [0, textWidth] × [bgMinY, bgMaxY] in em units, shifted by the `center`
675
+ * anchor and scaled by the font size (sdfText.vert.glsl:111-115).
676
+ */
677
+ getDeclutterCandidate(
678
+ owner: DeclutterParticipant,
679
+ handle: number,
680
+ ): DeclutterCandidate | null {
681
+ if (!this._declutter || !this.visible || this._labelWidthEm <= 0) {
682
+ return null;
683
+ }
684
+ const state = this._enhancer.states();
685
+ const size = state.fontSize;
686
+ if (size <= 0) return null;
687
+
688
+ const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
689
+ const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
690
+ const w = this._labelWidthEm;
691
+ const h = this._labelHeightEm;
692
+ return {
693
+ anchorX: this._worldAnchor[0],
694
+ anchorY: this._worldAnchor[1],
695
+ anchorZ: this._worldAnchor[2],
696
+ addHeight: state.addHeight,
697
+ minX: (0 - cx * w) * size,
698
+ maxX: (w - cx * w) * size,
699
+ minY: (this._labelMinYEm - cy * h) * size,
700
+ maxY: (this._labelMaxYEm - cy * h) * size,
701
+ sizeInMeters: state.sizeInMeters,
702
+ priority: this._declutterPriorityOverride ?? this._declutterPriority,
703
+ isShown: this._declutterTarget === 0,
704
+ owner,
705
+ handle,
706
+ };
707
+ }
708
+
709
+ /**
710
+ * Set a per-feature placement priority (higher wins), overriding the
711
+ * layer-level `declutterPriority` for this label. Pass `undefined` to fall
712
+ * back to the layer value. Driven by the feature evaluator.
713
+ */
714
+ setDeclutterPriority(priority: number | undefined): void {
715
+ this._declutterPriorityOverride = priority;
568
716
  }
569
717
 
570
718
  /**
@@ -596,6 +744,17 @@ export class SDFTextMesh
596
744
  this.setText(this._text, true);
597
745
  }
598
746
 
747
+ // Read before the visibility early-return: the declutter pass consults
748
+ // these even while other style state is skipped for hidden labels.
749
+ const nextDeclutter = material.declutter ?? true;
750
+ if (this._declutter && !nextDeclutter) {
751
+ // Leaving declutter mode: clear any hide the pass applied — the mesh
752
+ // stops producing candidates, so nothing else would ever re-show it.
753
+ this.setDeclutterHidden(false);
754
+ }
755
+ this._declutter = nextDeclutter;
756
+ this._declutterPriority = material.declutterPriority ?? 0;
757
+
599
758
  this.visible = (material.show ?? true) && !!this._text;
600
759
  this._syncGlyphRefs();
601
760
  if (!this.visible) return;
@@ -921,6 +1080,7 @@ export class SDFTextMesh
921
1080
  const count = renderable.length;
922
1081
  if (count === 0) {
923
1082
  this.geometry.instanceCount = 0;
1083
+ this._labelWidthEm = 0;
924
1084
  return;
925
1085
  }
926
1086
 
@@ -976,14 +1136,19 @@ export class SDFTextMesh
976
1136
 
977
1137
  this.geometry.instanceCount = count + 1;
978
1138
 
979
- // Update text dimension uniforms via mutates
1139
+ // Update text dimension uniforms via mutates, mirroring them CPU-side for
1140
+ // the declutter pass's collision box.
1141
+ this._labelWidthEm = textWidth / SDF_PX_SIZE;
1142
+ this._labelHeightEm = textHeight / SDF_PX_SIZE;
1143
+ this._labelMinYEm = bgMinY === Infinity ? 0.0 : bgMinY;
1144
+ this._labelMaxYEm = bgMaxY === -Infinity ? 1.0 : bgMaxY;
980
1145
  this._enhancer
981
1146
  .mutates()
982
1147
  .updateTextDimensions(
983
- textWidth / SDF_PX_SIZE,
984
- textHeight / SDF_PX_SIZE,
985
- bgMinY === Infinity ? 0.0 : bgMinY,
986
- bgMaxY === -Infinity ? 1.0 : bgMaxY,
1148
+ this._labelWidthEm,
1149
+ this._labelHeightEm,
1150
+ this._labelMinYEm,
1151
+ this._labelMaxYEm,
987
1152
  );
988
1153
  }
989
1154
 
@@ -10,14 +10,22 @@ import {
10
10
  Object3D,
11
11
  ShaderMaterial,
12
12
  BufferAttribute,
13
+ type BufferGeometry,
13
14
  Color,
15
+ type Material,
14
16
  PerspectiveCamera,
15
17
  Vector2,
16
18
  } from "three";
17
19
  import invariant from "tiny-invariant";
18
20
 
21
+ import {
22
+ DECLUTTER_FADE_MS,
23
+ type DeclutterCandidate,
24
+ type DeclutterParticipant,
25
+ } from "../../declutter/types";
19
26
  import type { EventContext } from "../../event/context";
20
27
  import { createInstancedSpriteMaterialEnhancer } from "../../material/enhancer";
28
+ import type { CustomObject3DEventMap } from "../../object3DEvent";
21
29
  import { PickableMesh } from "../pickableMesh";
22
30
 
23
31
  import { BillboardAtlas, type AtlasRect } from "./billboardAtlas";
@@ -46,7 +54,10 @@ type PositionsInfo = {
46
54
  const _tmpSize = new Vector2();
47
55
 
48
56
  // Coupled with crates/navara_feature/src/geometry/point.rs::pixel_to_world
49
- export class InstancedSpriteMesh extends Mesh implements PickableMesh {
57
+ export class InstancedSpriteMesh
58
+ extends Mesh<BufferGeometry, Material | Material[], CustomObject3DEventMap>
59
+ implements PickableMesh, DeclutterParticipant
60
+ {
50
61
  private _batchIdToInstance = new Map<number, number>();
51
62
  private _initialColor: Color = new Color(0xffffff);
52
63
  private _initialHeight = 0.0;
@@ -72,15 +83,182 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
72
83
  private _enhancedMaterial?: ReturnType<
73
84
  typeof createInstancedSpriteMaterialEnhancer
74
85
  >;
86
+ /** Per-instance world anchors in ECEF meters (f64, 3 per instance), kept in
87
+ * sync with the position attributes for the declutter pass. */
88
+ private _anchors: Float64Array | null = null;
89
+ /** Whether this mesh's instances participate in screen-space decluttering. */
90
+ private _declutter = false;
91
+ /** Layer-level placement priority from the material. */
92
+ private _declutterPriority = 0;
93
+ /** Per-instance priorities set through the evaluator (NaN = no override,
94
+ * fall back to the layer value). Lazily allocated on first use. */
95
+ private _declutterPriorityOverrides: Float32Array | null = null;
96
+ /** Per-instance fade targets for `instanceDeclutterHide` (0 = shown,
97
+ * 1 = hidden); the attribute animates toward these in stepDeclutterFade. */
98
+ private _declutterTargets: Float32Array | null = null;
99
+ /** True while any instance's hide factor may differ from its target. */
100
+ private _declutterAnimating = false;
101
+
75
102
  constructor(options: InstancedSpriteOptions) {
76
103
  super();
77
104
  this.renderOrder = options.renderOrder ?? this.renderOrder;
78
105
  this.ctx = options.ctx;
106
+ this.ctx.declutter?.register(this);
107
+ // `processObjectRemoved` dispatches this for every removed mesh; it is the
108
+ // reliable teardown signal (this class's dispose() is not called there).
109
+ this.addEventListener("removedFromWorld", () => {
110
+ this.ctx.declutter?.unregister(this);
111
+ });
79
112
  }
80
113
 
81
114
  setActive(active: boolean) {
82
115
  this._active = active;
83
116
  this.updateVisibility();
117
+ this.ctx.declutter?.markDirty();
118
+ }
119
+
120
+ // --- DeclutterParticipant ---
121
+
122
+ collectDeclutterCandidates(out: DeclutterCandidate[]): void {
123
+ if (!this.visible || !this._declutter || !this._anchors) return;
124
+ const enhancer = this._enhancedMaterial;
125
+ const params = this.geometry?.getAttribute("instanceParams") as
126
+ InstancedBufferAttribute | undefined;
127
+ if (!enhancer || !params) return;
128
+
129
+ const state = enhancer.states();
130
+ const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
131
+ const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
132
+ // Mirror of instancedSprite.vert.glsl:100-104 — aspect is per-instance
133
+ // (from the atlas rect), not a material-level uniform; there is no
134
+ // material-wide "aspect" state to read.
135
+ const uvRect = state.billboard
136
+ ? (this.geometry?.getAttribute("instanceUvRect") as
137
+ InstancedBufferAttribute | undefined)
138
+ : undefined;
139
+ const anchors = this._anchors;
140
+ const overrides = this._declutterPriorityOverrides;
141
+ const targets = this._declutterTargets;
142
+ const count = Math.min(params.count, anchors.length / 3);
143
+
144
+ for (let i = 0; i < count; i++) {
145
+ if (params.getZ(i) <= 0.5) continue; // hidden by user `show`
146
+ const instanceSize = params.getY(i);
147
+ const size = instanceSize >= 0.0 ? instanceSize : state.scale;
148
+ if (size <= 0.0) continue;
149
+
150
+ const override = overrides ? overrides[i] : Number.NaN;
151
+ const rectH = uvRect ? uvRect.getW(i) : 0;
152
+ const aspect = uvRect && rectH > 0.0 ? uvRect.getZ(i) / rectH : 1.0;
153
+
154
+ // Mirror of instancedSprite.vert.glsl:95-97 — the quad spans
155
+ // (position.xy - center) * vec2(aspect, 1) * size around the anchor.
156
+ out.push({
157
+ anchorX: anchors[i * 3],
158
+ anchorY: anchors[i * 3 + 1],
159
+ anchorZ: anchors[i * 3 + 2],
160
+ addHeight: params.getX(i),
161
+ minX: (-0.5 - cx) * aspect * size,
162
+ maxX: (0.5 - cx) * aspect * size,
163
+ minY: (-0.5 - cy) * size,
164
+ maxY: (0.5 - cy) * size,
165
+ sizeInMeters: state.sizeInMeters,
166
+ // NaN-safe: an unset override falls back to the layer priority.
167
+ priority: Number.isNaN(override) ? this._declutterPriority : override,
168
+ isShown: targets ? targets[i] === 0 : true,
169
+ owner: this,
170
+ handle: i,
171
+ });
172
+ }
173
+ }
174
+
175
+ applyDeclutter(handle: number, hidden: boolean): void {
176
+ this.setDeclutterHiddenByInstance(handle, hidden);
177
+ }
178
+
179
+ stepDeclutterFade(deltaMs: number): boolean {
180
+ if (!this._declutterAnimating) return false;
181
+ const attr = this.geometry?.getAttribute("instanceDeclutterHide") as
182
+ InstancedBufferAttribute | undefined;
183
+ const targets = this._declutterTargets;
184
+ if (!attr || !targets) {
185
+ this._declutterAnimating = false;
186
+ return false;
187
+ }
188
+
189
+ const step = deltaMs / DECLUTTER_FADE_MS;
190
+ const count = Math.min(attr.count, targets.length);
191
+ let stillAnimating = false;
192
+ let changed = false;
193
+ for (let i = 0; i < count; i++) {
194
+ let value = attr.getX(i);
195
+ const target = targets[i];
196
+ if (value === target) continue;
197
+ value =
198
+ value < target
199
+ ? Math.min(value + step, target)
200
+ : Math.max(value - step, target);
201
+ attr.setX(i, value);
202
+ changed = true;
203
+ if (value !== target) stillAnimating = true;
204
+ }
205
+ if (changed) attr.needsUpdate = true;
206
+ this._declutterAnimating = stillAnimating;
207
+ return stillAnimating;
208
+ }
209
+
210
+ private _cacheDeclutterState(m: NavaraPointMesh | NavaraBillboardMesh) {
211
+ const nextDeclutter = m.material.declutter ?? true;
212
+ if (this._declutter && !nextDeclutter) {
213
+ // Leaving declutter mode: clear hides the pass applied — the mesh stops
214
+ // producing candidates, so nothing else would ever re-show them.
215
+ this._clearDeclutterHidden();
216
+ }
217
+ this._declutter = nextDeclutter;
218
+ this._declutterPriority = m.material.declutterPriority ?? 0;
219
+ }
220
+
221
+ private _clearDeclutterHidden(): void {
222
+ const targets = this._declutterTargets;
223
+ if (!targets) return;
224
+ targets.fill(0.0);
225
+ // Everything fades back in from wherever its hide factor currently is.
226
+ this._declutterAnimating = true;
227
+ }
228
+
229
+ /** Reconstruct absolute ECEF anchors from the same arrays the position
230
+ * attributes receive (RTE high+low split, or RTC-relative + center). */
231
+ private _cacheAnchors(
232
+ positionsInfo: PositionsInfo,
233
+ transform: { tx: number; ty: number; tz: number },
234
+ ): void {
235
+ const { nPositions, positionSize, RTE } = positionsInfo;
236
+ const anchors =
237
+ this._anchors && this._anchors.length === nPositions * 3
238
+ ? this._anchors
239
+ : new Float64Array(nPositions * 3);
240
+
241
+ if (RTE) {
242
+ const pos = positionsInfo.position as {
243
+ high: Float32Array<ArrayBufferLike>;
244
+ low: Float32Array<ArrayBufferLike>;
245
+ };
246
+ for (let i = 0; i < nPositions; i++) {
247
+ const s = i * positionSize;
248
+ anchors[i * 3] = pos.high[s] + pos.low[s];
249
+ anchors[i * 3 + 1] = pos.high[s + 1] + pos.low[s + 1];
250
+ anchors[i * 3 + 2] = (pos.high[s + 2] ?? 0.0) + (pos.low[s + 2] ?? 0.0);
251
+ }
252
+ } else {
253
+ const pos = positionsInfo.position as Float32Array<ArrayBufferLike>;
254
+ for (let i = 0; i < nPositions; i++) {
255
+ const s = i * positionSize;
256
+ anchors[i * 3] = pos[s] + transform.tx;
257
+ anchors[i * 3 + 1] = pos[s + 1] + transform.ty;
258
+ anchors[i * 3 + 2] = (pos[s + 2] ?? 0.0) + transform.tz;
259
+ }
260
+ }
261
+ this._anchors = anchors;
84
262
  }
85
263
 
86
264
  async _init(m: NavaraPointMesh | NavaraBillboardMesh) {
@@ -90,6 +268,9 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
90
268
  return;
91
269
  }
92
270
 
271
+ this._cacheDeclutterState(m);
272
+ this._cacheAnchors(positionsInfo, m.transform);
273
+
93
274
  // Create Geometry
94
275
  this.geometry = this._initGeometry(positionsInfo, m);
95
276
 
@@ -97,12 +278,15 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
97
278
  this.material = await this._initMaterial(positionsInfo, m);
98
279
 
99
280
  this.frustumCulled = false; // Disable since bounding box doesn't account for instance positions
281
+ this.ctx.declutter?.markDirty();
100
282
  }
101
283
 
102
284
  async _update(m: NavaraPointMesh | NavaraBillboardMesh) {
103
285
  const enhancer = this.getEnhancer();
104
286
  const material = this.material as ShaderMaterial;
105
287
 
288
+ this._cacheDeclutterState(m);
289
+
106
290
  if (material.visible !== m.material.show) {
107
291
  material.visible = m.material.show ?? true;
108
292
  this.updateVisibility();
@@ -162,6 +346,7 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
162
346
  const positionsInfo = this.extractPositions(m);
163
347
 
164
348
  if (positionsInfo) {
349
+ this._cacheAnchors(positionsInfo, m.transform);
165
350
  if (positionsInfo.RTE) {
166
351
  const pos = positionsInfo.position as {
167
352
  high: Float32Array<ArrayBufferLike>;
@@ -198,6 +383,8 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
198
383
  await this._setDefaultImage(m.material.url);
199
384
  }
200
385
  }
386
+
387
+ this.ctx.declutter?.markDirty();
201
388
  }
202
389
 
203
390
  private _initGeometry(
@@ -307,6 +494,16 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
307
494
  "instanceColor",
308
495
  new InstancedBufferAttribute(colorBuffer, 3),
309
496
  );
497
+ // Declutter hide factors (0 = shown … 1 = hidden). Decluttered instances
498
+ // start hidden and fade in once the placement pass grants them space, so
499
+ // dense tiles don't flash their full clutter before the first pass runs.
500
+ const initialHide = this._declutter ? 1.0 : 0.0;
501
+ const declutterBuffer = new Float32Array(instanceCount).fill(initialHide);
502
+ this._declutterTargets = new Float32Array(instanceCount).fill(initialHide);
503
+ instancedGeometry.setAttribute(
504
+ "instanceDeclutterHide",
505
+ new InstancedBufferAttribute(declutterBuffer, 1),
506
+ );
310
507
  instancedGeometry.setAttribute(
311
508
  "instanceBatchID",
312
509
  new InstancedBufferAttribute(
@@ -572,6 +769,7 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
572
769
  ) as InstancedBufferAttribute;
573
770
  paramsAttr.setZ(instanceId, rawVisible ? 1.0 : 0.0);
574
771
  paramsAttr.needsUpdate = true;
772
+ this.ctx.declutter?.markDirty();
575
773
  }
576
774
 
577
775
  setFeatureOpacityByBatchId(batchId: number, opacity: number) {
@@ -599,6 +797,46 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
599
797
  const sanitizedHeight = Number.isFinite(height) ? height : 0.0;
600
798
  paramsAttr.setX(instanceId, sanitizedHeight);
601
799
  paramsAttr.needsUpdate = true;
800
+ this.ctx.declutter?.markDirty();
801
+ }
802
+
803
+ /**
804
+ * Set one instance's declutter fade target; the attribute animates toward
805
+ * it in {@link stepDeclutterFade}. Deliberately separate from the `show`
806
+ * component of `instanceParams` so user-driven visibility and declutter
807
+ * results compose instead of clobbering each other.
808
+ */
809
+ setDeclutterHiddenByInstance(instanceIndex: number, hidden: boolean) {
810
+ const targets = this._declutterTargets;
811
+ if (!targets || instanceIndex < 0 || instanceIndex >= targets.length) {
812
+ return;
813
+ }
814
+ targets[instanceIndex] = hidden ? 1.0 : 0.0;
815
+ // Cheap over-approximation; stepDeclutterFade clears it when everything
816
+ // has reached its target.
817
+ this._declutterAnimating = true;
818
+ }
819
+
820
+ /**
821
+ * Set a per-feature placement priority (higher wins), overriding the
822
+ * layer-level `declutterPriority` for this instance. Driven by the feature
823
+ * evaluator.
824
+ */
825
+ setFeatureDeclutterPriorityByBatchId(batchId: number, priority: number) {
826
+ const instanceId = this._batchIdToInstance.get(batchId);
827
+ if (instanceId === undefined) return;
828
+
829
+ if (!this._declutterPriorityOverrides) {
830
+ const count = this._anchors ? this._anchors.length / 3 : 0;
831
+ if (count === 0) return;
832
+ this._declutterPriorityOverrides = new Float32Array(count).fill(
833
+ Number.NaN,
834
+ );
835
+ }
836
+ if (instanceId >= this._declutterPriorityOverrides.length) return;
837
+ if (this._declutterPriorityOverrides[instanceId] === priority) return;
838
+ this._declutterPriorityOverrides[instanceId] = priority;
839
+ this.ctx.declutter?.markDirty();
602
840
  }
603
841
 
604
842
  setFeatureSizeByBatchId(batchId: number, size: number) {
@@ -616,6 +854,7 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
616
854
  : -1.0;
617
855
  paramsAttr.setY(instanceId, sanitizedSize);
618
856
  paramsAttr.needsUpdate = true;
857
+ this.ctx.declutter?.markDirty();
619
858
  }
620
859
 
621
860
  /**
@@ -634,8 +873,7 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
634
873
  if (instanceId === undefined) return;
635
874
 
636
875
  const rectAttr = this.geometry.getAttribute("instanceUvRect") as
637
- | InstancedBufferAttribute
638
- | undefined;
876
+ InstancedBufferAttribute | undefined;
639
877
  if (!rectAttr) return;
640
878
 
641
879
  if (url == null) {
@@ -669,6 +907,7 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
669
907
  }
670
908
 
671
909
  dispose(): void {
910
+ this.ctx.declutter?.unregister(this);
672
911
  this.geometry?.dispose();
673
912
 
674
913
  // The material's uTexture points at the atlas texture; the atlas owns it.
@@ -688,6 +927,9 @@ export class InstancedSpriteMesh extends Mesh implements PickableMesh {
688
927
 
689
928
  // Clear internal collections to release references
690
929
  this._batchIdToInstance.clear();
930
+ this._anchors = null;
931
+ this._declutterTargets = null;
932
+ this._declutterPriorityOverrides = null;
691
933
  this._imageOverrides.clear();
692
934
  this._requestedImageUrls.clear();
693
935
  }
@@ -0,0 +1,76 @@
1
+ import type {
2
+ MagnificationTextureFilter,
3
+ MinificationTextureFilter,
4
+ Texture,
5
+ } from "three";
6
+
7
+ import type { TextureOptions } from "../../textures";
8
+
9
+ /**
10
+ * A drape resolver owns one family of composite material slots for a terrain
11
+ * tile: how its layers' pixels reach those slots (direct texture binding or an
12
+ * offscreen bake into per-layer render targets), gated so the work only runs
13
+ * when the Rust-side resolve or the backing content actually changed.
14
+ *
15
+ * Three implementations, side by side, spell out the rendering differences:
16
+ * - {@link VectorDrapeResolver} (`vectorDrapeResolver.ts`) — clamp-to-ground
17
+ * vector layers. Always baked on every tiling scheme (vector content is an
18
+ * offscreen scene, so rasterizing is intrinsic); WebMercator terrain is the
19
+ * degenerate single-source identity case, Geographic terrain the N:M mosaic.
20
+ * - `DirectRasterDrapeResolver` (`rasterDrapeResolver.ts`) — raster layers on
21
+ * WebMercator terrain. Same-scheme 1:1: the fetched tile texture is bound
22
+ * straight to its slot by `setupTextures`; every hook is a no-op.
23
+ * - `BakedRasterDrapeResolver` (`rasterDrapeResolver.ts`) — raster layers on
24
+ * Geographic (quantized-mesh) terrain. Cross-scheme N:M mosaic bake with
25
+ * per-slot Mercator latitude reprojection.
26
+ */
27
+ export type DrapeResolver = {
28
+ /**
29
+ * Per-frame hook (`_onBeforeRender`): refresh the Rust-resolved slots when
30
+ * the resolve revision moved, and re-bake when the slot/content signature
31
+ * changed (which also catches async content landing after the resolve).
32
+ */
33
+ update(): void;
34
+
35
+ /**
36
+ * `setupTextures` hook, after the material's per-slot arrays were rebuilt:
37
+ * re-derive the resolver's slot pairing and rebind (or invalidate) its
38
+ * textures. `textureFragments` holds the raster region's per-slot fragment
39
+ * ids; the vector resolver ignores it (its region is positional).
40
+ */
41
+ syncMaterialSlots(
42
+ textureFragments: readonly (string | null | undefined)[] | undefined,
43
+ ): void;
44
+
45
+ /**
46
+ * Mercator latitude reprojection band (`[south, north]`, radians) for one of
47
+ * this resolver's composite slots, or undefined when the slot samples
48
+ * without reprojection.
49
+ */
50
+ slotReproject(absSlot: number): [number, number] | undefined;
51
+
52
+ /**
53
+ * Force a re-bake + re-bind on the next `update()` even though the resolved
54
+ * slots are unchanged (e.g. picking passes repaint the offscreen content).
55
+ */
56
+ invalidate(): void;
57
+
58
+ /** Live drape render targets, counted into the tile's GPU byte report. */
59
+ liveRenderTargetCount(): number;
60
+
61
+ dispose(): void;
62
+ };
63
+
64
+ /** Apply the shared texturized-slot sampler settings to a drape render target's
65
+ * texture. Returns the texture for call-site chaining. */
66
+ export function configureTexturizedTexture(
67
+ tex: Texture,
68
+ textureOptions: TextureOptions,
69
+ ): Texture {
70
+ tex.minFilter = textureOptions.minFilter as MinificationTextureFilter;
71
+ tex.magFilter = textureOptions.magFilter as MagnificationTextureFilter;
72
+ tex.anisotropy = textureOptions.maxAnisotropy;
73
+ tex.generateMipmaps = textureOptions.useMipmaps;
74
+ tex.needsUpdate = true;
75
+ return tex;
76
+ }