@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
@@ -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;
@@ -431,8 +431,13 @@ export function setupMaterialForMRT(
431
431
  material: ShaderMaterial,
432
432
  options?: SetupMaterialForMRTOptions,
433
433
  ): void {
434
- if (material instanceof LineMaterial) {
435
- injectGBufferToLineMaterial(material);
434
+ // Detect LineMaterial by `type`, not only `instanceof`: callers may construct
435
+ // it from `three/examples/jsm/lines` while this module's `LineMaterial` comes
436
+ // from `three-stdlib`, so a bare `instanceof` misses those cross-copy
437
+ // instances and would wrongly route them through the ShaderMaterial path
438
+ // (whose normal-buffer write references an undeclared `normal`).
439
+ if (material instanceof LineMaterial || material.type === "LineMaterial") {
440
+ injectGBufferToLineMaterial(material as LineMaterial);
436
441
  return;
437
442
  }
438
443
  injectGBufferToShaderMaterial(material, options?.normal);
@@ -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) {
@@ -3,9 +3,13 @@ import type {
3
3
  TextMaterial as NavaraTextMaterial,
4
4
  } from "@navaramap/engine";
5
5
  import type { FontManager } from "@navaramap/font";
6
- import { type Color } from "three";
6
+ import { 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";
@@ -34,9 +38,25 @@ type PositionsInfo = PositionsInfoBase &
34
38
  }
35
39
  );
36
40
 
41
+ /**
42
+ * Per-feature evaluator state received before the feature's `SDFTextMesh`
43
+ * exists (meshes are created lazily on the first non-empty text — see
44
+ * `initMeshes`). Replayed onto the mesh at creation. Colors are kept as hex
45
+ * so a shared evaluator `Color` instance mutated later can't leak in.
46
+ */
47
+ type PendingFeatureState = {
48
+ colorHex?: number;
49
+ show?: boolean;
50
+ height?: number;
51
+ size?: number;
52
+ opacity?: number;
53
+ declutterPriority?: number | undefined;
54
+ hasDeclutterPriority?: boolean;
55
+ };
56
+
37
57
  export class BatchedSdfTextMesh
38
58
  extends InstancedMesh<SDFTextMesh>
39
- implements PickableMesh
59
+ implements PickableMesh, DeclutterParticipant
40
60
  {
41
61
  readonly ctx: EventContext;
42
62
  /** The font identifier from material — may be a family name or a URL. */
@@ -55,6 +75,15 @@ export class BatchedSdfTextMesh
55
75
  * and must be balanced with unloadFont() on dispose or font change.
56
76
  */
57
77
  private _loadedFaceUrls: Set<string>;
78
+ /** Geometry-extracted anchor data; per-index meshes slice it lazily. */
79
+ private _positions: PositionsInfo | null = null;
80
+ /** Sparse batchIndex → mesh; `allMeshes` holds only created meshes. */
81
+ private _meshByIndex: (SDFTextMesh | undefined)[] = [];
82
+ /** Evaluator state received before a feature's mesh exists. */
83
+ private _pending = new Map<number, PendingFeatureState>();
84
+ /** Material/transform used when creating meshes lazily. */
85
+ private _material: NavaraTextMaterial;
86
+ private _transform: NavaraTextMesh["transform"];
58
87
 
59
88
  constructor(
60
89
  ctx: EventContext,
@@ -70,7 +99,12 @@ export class BatchedSdfTextMesh
70
99
  invariant(ctx.fontManager);
71
100
  this._fontManager = ctx.fontManager;
72
101
  this._loadedFaceUrls = loadedFaceUrls ?? new Set();
73
- this.initMeshes(m);
102
+ // One getter call each: wasm getters clone, so these snapshots are owned
103
+ // by this batch and safe to use after the event object is freed.
104
+ this._material = m.material;
105
+ this._transform = m.transform;
106
+ this._positions = this.extractPositions(m);
107
+ this.initMeshes();
74
108
  // When the shared atlas evicts glyphs, a still-in-flight glyph this batch
75
109
  // already baked into a visible mesh may have had its rect reused. Rebuild
76
110
  // any such stale mesh so its UVs and retains refresh.
@@ -79,6 +113,41 @@ export class BatchedSdfTextMesh
79
113
  this._highQuality,
80
114
  () => this._revalidateStaleMeshes(),
81
115
  );
116
+ ctx.declutter?.register(this);
117
+ }
118
+
119
+ // --- DeclutterParticipant ---
120
+
121
+ collectDeclutterCandidates(out: DeclutterCandidate[]): void {
122
+ if (!this.visible) return;
123
+ const meshes = this.meshes();
124
+ for (let i = 0; i < meshes.length; i++) {
125
+ const candidate = meshes[i].getDeclutterCandidate(this, i);
126
+ if (candidate) out.push(candidate);
127
+ }
128
+ }
129
+
130
+ applyDeclutter(handle: number, hidden: boolean): void {
131
+ this.meshes()[handle]?.setDeclutterHidden(hidden);
132
+ }
133
+
134
+ stepDeclutterFade(deltaMs: number): boolean {
135
+ let animating = false;
136
+ for (const mesh of this.meshes()) {
137
+ animating = mesh.stepDeclutterFade(deltaMs) || animating;
138
+ }
139
+ return animating;
140
+ }
141
+
142
+ /** Candidates changed (text, style, position, visibility): ask the shared
143
+ * declutter pass to re-place on its next update. */
144
+ private _markDeclutterDirty(): void {
145
+ this.ctx.declutter?.markDirty();
146
+ }
147
+
148
+ override setActive(active: boolean) {
149
+ super.setActive(active);
150
+ this._markDeclutterDirty();
82
151
  }
83
152
 
84
153
  /**
@@ -111,6 +180,7 @@ export class BatchedSdfTextMesh
111
180
  // Force a rebuild even though the text string is unchanged: the baked
112
181
  // atlas rects and glyph retains must refresh against the new metrics.
113
182
  mesh.setText(text, true);
183
+ this._markDeclutterDirty();
114
184
  this._needRender?.();
115
185
  })
116
186
  .catch((err: unknown) => {
@@ -127,73 +197,149 @@ export class BatchedSdfTextMesh
127
197
  return this._highQuality;
128
198
  }
129
199
 
130
- private initMeshes(m: NavaraTextMesh) {
131
- const positionInfo = this.extractPositions(m);
200
+ /**
201
+ * A mesh per anchor is expensive (geometry + ShaderMaterial + enhancer
202
+ * mount), and with per-feature texts (MVT) most features never receive a
203
+ * non-empty text — they'd sit invisible while still costing construction and
204
+ * every per-frame loop. So meshes are created lazily by `_ensureMeshAt` on
205
+ * the first non-empty `setTextByBatchIndex`. Only a non-empty material-level
206
+ * default text (single-label sources) renders without the evaluator ever
207
+ * setting text, so only that case creates all meshes eagerly.
208
+ */
209
+ private initMeshes() {
210
+ const positionInfo = this._positions;
132
211
  if (!positionInfo) {
133
212
  return;
134
213
  }
135
214
 
136
- const { position, nPositions, positionSize, batchIDs, batchIDSize, RTE } =
137
- positionInfo;
215
+ if (this._material.text) {
216
+ // The atlas can't be reallocated within this synchronous loop, so fetch
217
+ // the shared textures once and hand them to every creation.
218
+ const shared = this._sharedTextures();
219
+ for (let i = 0; i < positionInfo.nPositions; i++) {
220
+ this._ensureMeshAt(i, shared);
221
+ }
222
+ }
223
+ }
138
224
 
139
- const material = m.material;
140
- const transform = m.transform;
225
+ /** The font-level shared atlas textures for this batch's font+quality (the
226
+ * color atlas is `null` for monochrome fonts). */
227
+ private _sharedTextures() {
228
+ return {
229
+ tex: this._fontManager.getAtlasTexture(
230
+ this._fontIdentifier,
231
+ this._highQuality,
232
+ ),
233
+ colorTex: this._fontManager.getColorAtlasTexture(
234
+ this._fontIdentifier,
235
+ this._highQuality,
236
+ ),
237
+ };
238
+ }
141
239
 
142
- // Get the font-level shared atlas textures (one DataTexture per font,
143
- // shared across all per-feature meshes). The color atlas is `null` for
144
- // monochrome fonts.
145
- const sharedTex = this._fontManager.getAtlasTexture(
240
+ /**
241
+ * The feature's mesh, created on first need (see `initMeshes`). `shared`
242
+ * lets a batch creation pass hoist the atlas lookups; when omitted (a single
243
+ * lazy creation) they are fetched here, since the atlas may have been
244
+ * (re)allocated since this batch was constructed.
245
+ */
246
+ private _ensureMeshAt(
247
+ batchIndex: number,
248
+ shared?: ReturnType<BatchedSdfTextMesh["_sharedTextures"]>,
249
+ ): SDFTextMesh | undefined {
250
+ const existing = this._meshByIndex[batchIndex];
251
+ if (existing) return existing;
252
+
253
+ const info = this._positions;
254
+ if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
255
+ return undefined;
256
+ }
257
+
258
+ const { position, positionSize, batchIDs, batchIDSize, RTE } = info;
259
+ const batchId = batchIDs ? batchIDs[batchIndex * batchIDSize] : undefined;
260
+ const posIdx = batchIndex * positionSize;
261
+ const pos = RTE
262
+ ? {
263
+ high: position.high.subarray(posIdx, posIdx + positionSize),
264
+ low: position.low.subarray(posIdx, posIdx + positionSize),
265
+ }
266
+ : position.subarray(posIdx, posIdx + positionSize);
267
+
268
+ const mesh = new SDFTextMesh(
269
+ pos,
270
+ this._material,
271
+ this._transform,
272
+ this._fontManager,
146
273
  this._fontIdentifier,
274
+ batchId,
275
+ RTE,
276
+ // Pin quality to the batch, not `this._material.highQuality`, which a
277
+ // later `_update` may have changed (quality is immutable per batch).
147
278
  this._highQuality,
148
279
  );
149
- const sharedColorTex = this._fontManager.getColorAtlasTexture(
150
- this._fontIdentifier,
151
- this._highQuality,
280
+ mesh.renderOrder = this.renderOrder;
281
+ // Re-prepare evicted glyphs through the batch so font-family faces and
282
+ // refcounts stay consistent; request a render once they're rebuilt.
283
+ mesh.setGlyphLifecycleHandlers(
284
+ (text) =>
285
+ this._fontManager.prepareText(
286
+ this._fontIdentifier,
287
+ text,
288
+ this._highQuality,
289
+ this._loadedFaceUrls,
290
+ ),
291
+ () => this._needRender?.(),
152
292
  );
153
293
 
154
- for (let i = 0; i < nPositions; i++) {
155
- const batchIdIdx = i * batchIDSize;
156
- const batchId = batchIDs ? batchIDs[batchIdIdx] : undefined;
157
- const posIdx = i * positionSize;
158
- const pos = RTE
159
- ? {
160
- high: position.high.subarray(posIdx, posIdx + positionSize),
161
- low: position.low.subarray(posIdx, posIdx + positionSize),
162
- }
163
- : position.subarray(posIdx, posIdx + positionSize);
164
-
165
- const mesh = new SDFTextMesh(
166
- pos,
167
- material,
168
- transform,
169
- this._fontManager,
170
- this._fontIdentifier,
171
- batchId,
172
- RTE,
173
- );
174
- mesh.renderOrder = this.renderOrder;
175
- // Re-prepare evicted glyphs through the batch so font-family faces and
176
- // refcounts stay consistent; request a render once they're rebuilt.
177
- mesh.setGlyphLifecycleHandlers(
178
- (text) =>
179
- this._fontManager.prepareText(
180
- this._fontIdentifier,
181
- text,
182
- this._highQuality,
183
- this._loadedFaceUrls,
184
- ),
185
- () => this._needRender?.(),
186
- );
294
+ const { tex, colorTex } = shared ?? this._sharedTextures();
295
+ if (tex) {
296
+ mesh.setAtlasTexture(tex);
297
+ }
298
+ mesh.setColorAtlasTexture(colorTex);
299
+
300
+ mesh.update(this._material);
187
301
 
188
- if (sharedTex) {
189
- mesh.setAtlasTexture(sharedTex);
302
+ // Replay evaluator state that arrived before the mesh existed.
303
+ const pending = this._pending.get(batchIndex);
304
+ if (pending) {
305
+ this._pending.delete(batchIndex);
306
+ if (pending.colorHex !== undefined) {
307
+ mesh.setColor(new Color(pending.colorHex));
190
308
  }
191
- mesh.setColorAtlasTexture(sharedColorTex);
309
+ if (pending.show !== undefined) {
310
+ mesh._setFeatureShow(pending.show);
311
+ }
312
+ if (pending.height !== undefined) {
313
+ mesh.setHeight(pending.height);
314
+ }
315
+ if (pending.size !== undefined) {
316
+ mesh.setSize(pending.size);
317
+ }
318
+ if (pending.opacity !== undefined) {
319
+ mesh.setOpacity(pending.opacity);
320
+ }
321
+ if (pending.hasDeclutterPriority) {
322
+ mesh.setDeclutterPriority(pending.declutterPriority);
323
+ }
324
+ }
192
325
 
193
- mesh.update(material);
326
+ this._meshByIndex[batchIndex] = mesh;
327
+ this.addWithBatchIndex(mesh, batchIndex);
328
+ return mesh;
329
+ }
194
330
 
195
- this.addWithBatchIndex(mesh, i);
331
+ /** Pending-state slot for a feature whose mesh doesn't exist yet. */
332
+ private _pendingAt(batchIndex: number): PendingFeatureState | undefined {
333
+ const info = this._positions;
334
+ if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
335
+ return undefined;
336
+ }
337
+ let state = this._pending.get(batchIndex);
338
+ if (!state) {
339
+ state = {};
340
+ this._pending.set(batchIndex, state);
196
341
  }
342
+ return state;
197
343
  }
198
344
 
199
345
  async _update(m: NavaraTextMesh, needRender?: () => void) {
@@ -201,29 +347,40 @@ export class BatchedSdfTextMesh
201
347
 
202
348
  const material = m.material;
203
349
  const text = material.text ?? "";
350
+ // Later lazy creations must see the updated material/transform too.
351
+ this._material = material;
352
+ this._transform = m.transform;
204
353
 
205
354
  const positionInfo = this.extractPositions(m);
206
355
  if (positionInfo) {
207
- const { position, nPositions, positionSize, RTE } = positionInfo;
208
-
209
356
  invariant(
210
- nPositions === this.meshes().length,
211
- "Number of positions in the updated geometry must match the number of existing meshes",
357
+ this._positions === null ||
358
+ positionInfo.nPositions === this._positions.nPositions,
359
+ "Number of positions in the updated geometry must match the initial geometry",
212
360
  );
361
+ this._positions = positionInfo;
213
362
 
363
+ const { position, positionSize, RTE } = positionInfo;
214
364
  const transform = m.transform;
215
- for (let i = 0; i < nPositions; i++) {
216
- const posIdx = i * positionSize;
365
+ for (const mesh of this.meshes()) {
366
+ const posIdx = (mesh.userData.batchIndex as number) * positionSize;
217
367
  const pos = RTE
218
368
  ? {
219
369
  high: position.high.subarray(posIdx, posIdx + positionSize),
220
370
  low: position.low.subarray(posIdx, posIdx + positionSize),
221
371
  }
222
372
  : position.subarray(posIdx, posIdx + positionSize);
223
- this.meshes()[i].setPosition(pos, RTE, transform);
373
+ mesh.setPosition(pos, RTE, transform);
224
374
  }
375
+ // Anchors moved (e.g. terrain height resolution) — re-place labels.
376
+ this._markDeclutterDirty();
225
377
  }
226
378
 
379
+ // A non-empty default text renders without per-feature setText calls, so
380
+ // any lazily-skipped meshes must exist before `_applyUpdate` (no-op for
381
+ // already-created ones).
382
+ if (text) this.initMeshes();
383
+
227
384
  const fontIdentifier = m.material.font ?? this._fontIdentifier;
228
385
  const needFontUpdate = fontIdentifier !== this._fontIdentifier;
229
386
 
@@ -292,6 +449,7 @@ export class BatchedSdfTextMesh
292
449
  mesh.update(material, forceUpdate);
293
450
  this.markVisibility(mesh);
294
451
  }
452
+ this._markDeclutterDirty();
295
453
 
296
454
  if (needRender) needRender();
297
455
  }
@@ -368,7 +526,12 @@ export class BatchedSdfTextMesh
368
526
  }
369
527
 
370
528
  setTextByBatchIndex(batchIndex: number, text: string) {
371
- const mesh = this.meshes()[batchIndex];
529
+ // An empty text on a not-yet-created mesh changes nothing (a lazily
530
+ // skipped mesh is exactly an invisible empty-text label); only a
531
+ // non-empty text forces the mesh into existence.
532
+ const mesh = text
533
+ ? this._ensureMeshAt(batchIndex)
534
+ : this.getMeshByBatchIndex(batchIndex);
372
535
 
373
536
  if (mesh) {
374
537
  // If the text hasn't been prepared in the worker yet, schedule async preparation
@@ -412,6 +575,7 @@ export class BatchedSdfTextMesh
412
575
  // Restore intended visibility now that text content is available
413
576
  mesh.visible = intendedVisible;
414
577
  this.markVisibility(mesh);
578
+ this._markDeclutterDirty();
415
579
  this._needRender?.();
416
580
  })
417
581
  .catch((err: unknown) => {
@@ -422,50 +586,110 @@ export class BatchedSdfTextMesh
422
586
  }
423
587
  mesh.setText(text);
424
588
  this.markVisibility(mesh);
589
+ this._markDeclutterDirty();
425
590
  this._needRender?.();
426
591
  }
427
592
  }
428
593
 
594
+ /**
595
+ * The mesh for a feature's `batchIndex`, or `undefined` if not created yet.
596
+ * Meshes are created lazily and pushed onto `allMeshes` in creation order, so
597
+ * `allMeshes[batchIndex]` (the base-class mapping) does NOT hold here; the
598
+ * batchIndex→mesh mapping lives in `_meshByIndex`. Overriding this one
599
+ * accessor keeps the base-class setters and any external caller (picking,
600
+ * plugins) correct without patching each call site.
601
+ */
602
+ override getMeshByBatchIndex(batchIndex: number): SDFTextMesh | undefined {
603
+ return this._meshByIndex[batchIndex];
604
+ }
605
+
429
606
  setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
430
- const mesh = this.meshes()[batchIndex];
607
+ const mesh = this.getMeshByBatchIndex(batchIndex);
431
608
  if (mesh) {
432
609
  mesh.setColor(color);
610
+ return;
433
611
  }
612
+ const pending = this._pendingAt(batchIndex);
613
+ if (pending) pending.colorHex = color.getHex();
434
614
  }
435
615
 
436
616
  setFeatureShowByBatchIndex(batchIndex: number, rawVisible: boolean) {
437
- const mesh = this.meshes()[batchIndex];
617
+ const mesh = this.getMeshByBatchIndex(batchIndex);
438
618
  if (mesh) {
439
619
  mesh._setFeatureShow(rawVisible);
440
620
  this.markVisibility(mesh);
621
+ this._markDeclutterDirty();
622
+ return;
623
+ }
624
+ // A lazily-created mesh starts hidden (empty text ⇒ `visible=false`), so a
625
+ // mesh-less feature is already effectively `show:false`. Only `show:true`
626
+ // needs buffering; buffering `show:false` would allocate a pending entry
627
+ // per hidden feature — the common MVT case (most features stay hidden and
628
+ // never get a mesh) and exactly the allocation lazy creation avoids. For
629
+ // `show:false` just clear any stale buffered `show`, never allocate.
630
+ if (rawVisible) {
631
+ const pending = this._pendingAt(batchIndex);
632
+ if (pending) pending.show = true;
633
+ } else {
634
+ const pending = this._pending.get(batchIndex);
635
+ if (pending) pending.show = undefined;
441
636
  }
442
637
  }
443
638
 
444
639
  setFeatureHeightByBatchIndex(batchIndex: number, height: number) {
445
- const mesh = this.meshes()[batchIndex];
640
+ const mesh = this.getMeshByBatchIndex(batchIndex);
446
641
  if (mesh) {
447
642
  mesh.setHeight(height);
643
+ this._markDeclutterDirty();
644
+ return;
448
645
  }
646
+ const pending = this._pendingAt(batchIndex);
647
+ if (pending) pending.height = height;
449
648
  }
450
649
 
451
650
  setFeatureSizeByBatchIndex(batchIndex: number, size: number) {
452
- const mesh = this.meshes()[batchIndex];
651
+ const mesh = this.getMeshByBatchIndex(batchIndex);
453
652
  if (mesh) {
454
653
  mesh.setSize(size);
654
+ this._markDeclutterDirty();
655
+ return;
656
+ }
657
+ const pending = this._pendingAt(batchIndex);
658
+ if (pending) pending.size = size;
659
+ }
660
+
661
+ setFeatureDeclutterPriorityByBatchIndex(
662
+ batchIndex: number,
663
+ priority: number,
664
+ ) {
665
+ const mesh = this.getMeshByBatchIndex(batchIndex);
666
+ if (mesh) {
667
+ mesh.setDeclutterPriority(priority);
668
+ this._markDeclutterDirty();
669
+ return;
670
+ }
671
+ const pending = this._pendingAt(batchIndex);
672
+ if (pending) {
673
+ pending.declutterPriority = priority;
674
+ pending.hasDeclutterPriority = true;
455
675
  }
456
676
  }
457
677
 
458
678
  setFeatureOpacityByBatchIndex(batchIndex: number, opacity: number) {
459
- const mesh = this.meshes()[batchIndex];
679
+ const clampedOpacity = Number.isFinite(opacity)
680
+ ? Math.max(0, Math.min(1, opacity))
681
+ : 1.0;
682
+ const mesh = this.getMeshByBatchIndex(batchIndex);
460
683
  if (mesh) {
461
- const clampedOpacity = Number.isFinite(opacity)
462
- ? Math.max(0, Math.min(1, opacity))
463
- : 1.0;
464
684
  mesh.setOpacity(clampedOpacity);
685
+ return;
465
686
  }
687
+ const pending = this._pendingAt(batchIndex);
688
+ if (pending) pending.opacity = clampedOpacity;
466
689
  }
467
690
 
468
691
  dispose() {
692
+ this.ctx.declutter?.unregister(this);
469
693
  this._unsubscribeEvict?.();
470
694
  this._unsubscribeEvict = undefined;
471
695
  const q = this._highQuality;
@@ -53,7 +53,7 @@ export class InstancedMesh<M extends Object3D>
53
53
  return this.allMeshes;
54
54
  }
55
55
 
56
- getMeshByBatchIndex(batchIndex: number) {
56
+ getMeshByBatchIndex(batchIndex: number): M | undefined {
57
57
  return this.meshes()[batchIndex];
58
58
  }
59
59
 
@@ -282,7 +282,8 @@ export class PolygonMesh extends BatchedFeatureMesh<
282
282
  this.receiveShadow = !!meshMaterial.receiveShadow;
283
283
 
284
284
  // This mesh is texturized if it has a tile handle (terrain attachment).
285
- const isTexturized = !!tileHandle;
285
+ // `!= null`, not truthiness: the root vector tile's handle is 0.
286
+ const isTexturized = tileHandle != null;
286
287
  const material = this.material;
287
288
 
288
289
  material.vertexColors = false;
@@ -390,9 +391,15 @@ export class PolygonMesh extends BatchedFeatureMesh<
390
391
  _update(material: PolygonMaterial, active: boolean, isTexturized: boolean) {
391
392
  const enhancer = this.getEnhancer();
392
393
 
393
- // Update mesh properties (not handled by enhancer)
394
- this.visible =
395
- (material.show ?? true) && (material.surfaceShow ?? true) && active;
394
+ // Update mesh properties (not handled by enhancer).
395
+ // Draped (clamp-to-ground) polygons bake offscreen only — never the main
396
+ // scene — and the Rust drape resolve, not this LOD `active` flag, picks which
397
+ // baked tile composites per terrain region. Tying bake visibility to `active`
398
+ // makes the drape flicker on LOD swaps; keep a draped mesh bakeable whenever
399
+ // shown so every built tile stays a stable drape source (see
400
+ // PolylineMesh._update).
401
+ const shown = (material.show ?? true) && (material.surfaceShow ?? true);
402
+ this.visible = isTexturized ? shown : shown && active;
396
403
  if (this._debugBoundingSphereMesh) {
397
404
  this._debugBoundingSphereMesh.visible = this.visible;
398
405
  }
@@ -404,8 +404,16 @@ export class PolylineMesh extends BatchedFeatureMesh<
404
404
 
405
405
  const enhancer = this.getEnhancer();
406
406
 
407
- // Update mesh properties (not handled by enhancer)
408
- this.visible = (material.show ?? true) && active;
407
+ // Update mesh properties (not handled by enhancer).
408
+ // Draped polylines render only through the offscreen bake, never the main
409
+ // scene, and the Rust drape resolve — not this LOD `active` flag — decides
410
+ // which baked tile is composited per terrain region. Tying bake visibility
411
+ // to `active` makes the drape flicker on every LOD swap (the tile goes
412
+ // invisible → bakes empty before its replacement is ready). Keep a draped
413
+ // mesh bakeable whenever its material is shown, so every built tile stays a
414
+ // stable drape source — the vector twin of a loaded raster texture.
415
+ const shown = material.show ?? true;
416
+ this.visible = enhancer.states().isTexturized ? shown : shown && active;
409
417
  this.castShadow = !!material.castShadow;
410
418
  this.receiveShadow = !!material.receiveShadow;
411
419