@navaramap/three 0.0.1 → 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 (54) 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-Bnck8OGf.wasm → navara_wasm_font_worker_bg-CkZP9l2i.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-DG9ORAmj.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 +445 -16
  11. package/dist/index.js +6151 -5717
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.umd.cjs +241 -222
  14. package/dist/index.umd.cjs.map +1 -1
  15. package/package.json +34 -33
  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 +12 -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 +137 -35
  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 +13 -3
  38. package/src/mesh/batchTexture.ts +2 -4
  39. package/src/mesh/batchedSdfText.ts +61 -1
  40. package/src/mesh/sdfText.ts +170 -5
  41. package/src/mesh/sprite/instancedSprite.ts +245 -3
  42. package/src/orchestrators/RenderPassOrchestrator.ts +25 -0
  43. package/src/passes/CustomRenderPass.ts +21 -5
  44. package/src/passes/RenderTargetCopyPass.ts +24 -2
  45. package/src/test-utils/engine.ts +53 -0
  46. package/src/tileTexture/TileTextureCompositor.test.ts +2 -0
  47. package/src/tileTexture/TileTextureCompositor.ts +7 -1
  48. package/src/utils/fixedGpuFootprint.test.ts +110 -0
  49. package/src/utils/fixedGpuFootprint.ts +162 -0
  50. package/src/worker/tasks/toCreasedNormals.ts +2 -7
  51. package/dist/assets/fontWorker-CCWL5pbx.js +0 -2
  52. package/dist/assets/fontWorker-CCWL5pbx.js.map +0 -1
  53. package/dist/assets/index-BdtHOOT9.js +0 -12
  54. package/dist/assets/index-BdtHOOT9.js.map +0 -1
@@ -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[]>([]),
@@ -120,8 +124,14 @@ export function createCoreUniformMutates(): CoreUniformMutates {
120
124
  const span = (Tn - Ts) / layer.uvScale.y;
121
125
  const Rs = Ts - layer.uvOffset.y * span;
122
126
  const Rn = Rs + span;
123
- const mRs = Math.log(Math.tan(Math.PI * 0.25 + Rs * 0.5));
124
- 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);
125
135
  // Polar cap: clamp the overshoot onto the band-edge texel when this tile's
126
136
  // north/south edge sits on the WebMercator latitude limit.
127
137
  const clampTop = Rn >= WM_MAX_LAT - 1e-4 ? 1 : 0;
@@ -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;
@@ -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