@navaramap/three 0.0.3 → 0.0.5

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 (64) hide show
  1. package/README.md +7 -7
  2. package/dist/assets/fontWorker-OwvKiQ8o.js +2 -0
  3. package/dist/assets/fontWorker-OwvKiQ8o.js.map +1 -0
  4. package/dist/assets/{index-ywHSGImj.js → index-TKhRxurv.js} +2 -2
  5. package/dist/assets/index-TKhRxurv.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-Du9nb2Pn.wasm → navara_wasm_font_worker_bg-Kk06ArRB.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-Xadu67fw.wasm → navara_wasm_worker_bg-BZjLKf4i.wasm} +0 -0
  8. package/dist/index.d.ts +351 -284
  9. package/dist/index.js +5394 -5470
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.umd.cjs +287 -235
  12. package/dist/index.umd.cjs.map +1 -1
  13. package/package.json +12 -12
  14. package/src/ShadowMapViewers.ts +1 -1
  15. package/src/atmosphere.test.ts +227 -28
  16. package/src/atmosphere.ts +219 -55
  17. package/src/constants/assets.ts +63 -13
  18. package/src/declutter/DeclutterManager.ts +1 -1
  19. package/src/declutter/types.ts +1 -1
  20. package/src/evaluations/FeatureEvaluator.test.ts +152 -0
  21. package/src/evaluations/FeatureEvaluator.ts +73 -2
  22. package/src/event/features/billboard.ts +1 -0
  23. package/src/event/features/model.ts +1 -1
  24. package/src/event/features/point.ts +1 -0
  25. package/src/event/index.ts +1 -1
  26. package/src/index.iife.ts +3 -3
  27. package/src/index.ts +10 -12
  28. package/src/layers/effect/README.md +1 -1
  29. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.test.ts +2 -2
  30. package/src/material/enhancer/instancedSprite/instancedSpriteBaseEnhancer/mutates.ts +1 -1
  31. package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +1 -1
  32. package/src/material/enhancer/polyline/polylineBaseEnhancer/mutates.ts +1 -1
  33. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/index.ts +10 -7
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.test.ts +74 -81
  35. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/mutates.ts +19 -86
  36. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.test.ts +77 -0
  37. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.ts +9 -1
  38. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.test.ts +15 -19
  39. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/state.ts +0 -13
  40. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +46 -48
  41. package/src/mesh/constants.ts +22 -0
  42. package/src/mesh/index.ts +1 -1
  43. package/src/mesh/model.ts +6 -0
  44. package/src/mesh/polygon.ts +6 -0
  45. package/src/mesh/polyline.ts +6 -0
  46. package/src/mesh/rtcRteHelper.ts +1 -1
  47. package/src/mesh/sdfText/batchedSdfText.ts +1223 -0
  48. package/src/mesh/sdfText/glyphBuffers.ts +222 -0
  49. package/src/mesh/sdfText/glyphSlots.test.ts +151 -0
  50. package/src/mesh/sdfText/glyphSlots.ts +98 -0
  51. package/src/mesh/sdfText/index.ts +10 -0
  52. package/src/mesh/sdfText/labelData.test.ts +178 -0
  53. package/src/mesh/sdfText/labelData.ts +187 -0
  54. package/src/mesh/{sdfText.test.ts → sdfText/layout.test.ts} +1 -1
  55. package/src/mesh/sdfText/layout.ts +306 -0
  56. package/src/mesh/sprite/instancedSprite.ts +33 -1
  57. package/src/utils/screen.ts +1 -1
  58. package/dist/assets/fontWorker-wz61edjR.js +0 -2
  59. package/dist/assets/fontWorker-wz61edjR.js.map +0 -1
  60. package/dist/assets/index-ywHSGImj.js.map +0 -1
  61. package/src/mesh/batchedSdfText.ts +0 -708
  62. package/src/mesh/sdfText.ts +0 -1189
  63. /package/dist/{draco_decoder.wasm → assets/draco_decoder-C32yEggz.wasm} +0 -0
  64. /package/dist/{draco_decoder2.wasm → assets/draco_decoder-Z1_iN-Ht.wasm} +0 -0
@@ -0,0 +1,1223 @@
1
+ import type {
2
+ TextMesh as NavaraTextMesh,
3
+ TextMaterial as NavaraTextMaterial,
4
+ Transform,
5
+ } from "@navaramap/engine";
6
+ import type { FontManager } from "@navaramap/font";
7
+ import { degreeToRadian } from "@navaramap/three-api";
8
+ import {
9
+ Color,
10
+ type PerspectiveCamera,
11
+ Object3D,
12
+ ShaderMaterial,
13
+ Vector2,
14
+ } from "three";
15
+ import invariant from "tiny-invariant";
16
+
17
+ import {
18
+ DECLUTTER_FADE_MS,
19
+ type DeclutterCandidate,
20
+ type DeclutterParticipant,
21
+ } from "../../declutter/types";
22
+ import type { EventContext } from "../../event/context";
23
+ import type { MaterialEnhancer } from "../../material/enhancer/MaterialEnhancer";
24
+ import {
25
+ createSdfTextMaterialEnhancer,
26
+ type SdfTextBaseMutates,
27
+ type SdfTextBaseProps,
28
+ type SdfTextBaseState,
29
+ } from "../../material/enhancer/sdfText";
30
+ import { GEOMETRY_TYPES } from "../constants";
31
+ import { InstancedMesh, type InstancedMeshOptions } from "../instanced";
32
+ import type { PickableMesh } from "../pickableMesh";
33
+
34
+ import { GlyphBuffers } from "./glyphBuffers";
35
+ import { GlyphSlotAllocator, type GlyphRun } from "./glyphSlots";
36
+ import { LabelDataTexture, LabelRow } from "./labelData";
37
+ import { ALIGN_FACTORS, buildLabelLayout, type LayoutOptions } from "./layout";
38
+
39
+ /** Reusable scratch to avoid per-frame / per-write allocations. */
40
+ const _tmpSize = new Vector2();
41
+ const _tmpColor = new Color();
42
+
43
+ type PositionsInfoBase = {
44
+ batchIDs: Float32Array<ArrayBufferLike> | null;
45
+ positionSize: number;
46
+ batchIDSize: number;
47
+ nPositions: number;
48
+ };
49
+
50
+ type PositionsInfo = PositionsInfoBase &
51
+ (
52
+ | {
53
+ RTE: true;
54
+ position: {
55
+ high: Float32Array<ArrayBufferLike>;
56
+ low: Float32Array<ArrayBufferLike>;
57
+ };
58
+ }
59
+ | {
60
+ RTE: false;
61
+ position: Float32Array<ArrayBufferLike>;
62
+ }
63
+ );
64
+
65
+ /**
66
+ * One label in the batch.
67
+ *
68
+ * This replaces what used to be a whole `Mesh` + `ShaderMaterial` +
69
+ * `InstancedBufferGeometry` per label. Fields that the shader reads are
70
+ * mirrored into the label data texture on write; they are kept here too
71
+ * because the declutter pass and the glyph-retain bookkeeping need them on the
72
+ * CPU every frame.
73
+ */
74
+ type LabelRecord = {
75
+ /** Row block in the label data texture, and this label's declutter handle. */
76
+ slot: number;
77
+ batchIndex: number;
78
+ batchId: number;
79
+ /** The text currently laid out into the glyph run. */
80
+ text: string;
81
+ /**
82
+ * The most recent text asked for. Differs from {@link text} only while an
83
+ * async font preparation is in flight; the callback compares against it so a
84
+ * slow prepare can't clobber a newer text that landed meanwhile.
85
+ */
86
+ requestedText: string;
87
+ /** Glyph-instance slots this label owns, or null when it has no text. */
88
+ run: GlyphRun | null;
89
+ /** Unique atlas glyphs the current text renders. */
90
+ glyphKeys: bigint[];
91
+ /** The set currently retained in the atlas, or null when holding none. */
92
+ retainedKeys: bigint[] | null;
93
+ /**
94
+ * Visibility asked for by the material's `show` or the evaluator, held
95
+ * separately from {@link show} because it routinely arrives *before* the
96
+ * label has any text (the evaluator emits `show` ahead of `text`). Folding
97
+ * the two together would drop it.
98
+ */
99
+ requestedShow: boolean;
100
+ /** Effective visibility: `requestedShow && text !== ""`. Mirrors the STATE
101
+ * row's `show` channel. */
102
+ show: boolean;
103
+ fontSize: number;
104
+ addHeight: number;
105
+ colorHex: number;
106
+ opacity: number;
107
+ /** Block metrics in ems; `widthEm === 0` means "no collision box". */
108
+ widthEm: number;
109
+ heightEm: number;
110
+ minYEm: number;
111
+ maxYEm: number;
112
+ /** Current animated hide factor and the placement target it fades toward. */
113
+ declutterHide: number;
114
+ declutterTarget: number;
115
+ /** Per-feature placement priority; overrides the layer value when defined. */
116
+ priorityOverride: number | undefined;
117
+ /** World-space anchor in ECEF meters (f64), for the declutter pass. */
118
+ anchor: Float64Array;
119
+ };
120
+
121
+ /**
122
+ * Every text label in a tile-layer, drawn in a single call.
123
+ *
124
+ * Instances are glyphs, not labels: each label owns a contiguous run of glyph
125
+ * slots ({@link GlyphSlotAllocator}) and one row block in a float texture
126
+ * ({@link LabelDataTexture}) that the vertex shader reads through the
127
+ * `labelIndex` attribute. A label changing its text rewrites only its own run
128
+ * — and not even that when the new glyph count stays inside the run's
129
+ * power-of-two size class.
130
+ *
131
+ * Still extends {@link InstancedMesh} because `FeatureEvaluator` dispatches
132
+ * per-feature styling on `instanceof InstancedMesh`; the inherited child-mesh
133
+ * machinery (`allMeshes`, `markVisibility`) is deliberately unused.
134
+ */
135
+ export class BatchedSdfTextMesh
136
+ extends InstancedMesh<Object3D>
137
+ implements PickableMesh, DeclutterParticipant
138
+ {
139
+ /**
140
+ * Geometry type of this mesh.
141
+ */
142
+ readonly geometryType = GEOMETRY_TYPES.Text;
143
+
144
+ readonly ctx: EventContext;
145
+ /** The font identifier from material — may be a family name or a URL. */
146
+ private _fontIdentifier: string;
147
+ /** Per-batch text quality. All labels in the batch share it because they
148
+ * sample the same atlas texture; flipping quality requires a new batch. */
149
+ private _highQuality: boolean;
150
+ private _fontManager: FontManager;
151
+ private _needRender?: () => void;
152
+ /** Unsubscribe from the font manager's atlas-eviction notifications. */
153
+ private _unsubscribeEvict?: () => void;
154
+ /**
155
+ * Face URLs loaded by this mesh for font-family fonts.
156
+ * Each URL in this set has had loadFont() called exactly once by this mesh
157
+ * and must be balanced with unloadFont() on dispose or font change.
158
+ */
159
+ private _loadedFaceUrls: Set<string>;
160
+ private _positions: PositionsInfo | null = null;
161
+ private _material: NavaraTextMaterial;
162
+ private _transform: Transform;
163
+
164
+ /** Labels in slot order — `_labels[slot]` is also the declutter handle map. */
165
+ private _labels: LabelRecord[] = [];
166
+ /** Sparse batchIndex → label; labels are created on first per-feature touch. */
167
+ private _labelByIndex: (LabelRecord | undefined)[] = [];
168
+
169
+ private _glyphs: GlyphBuffers;
170
+ private _slots = new GlyphSlotAllocator();
171
+ private _labelData: LabelDataTexture;
172
+
173
+ /** Layout inputs baked into glyph quads; a change forces a re-layout. */
174
+ private _maxWidth: number;
175
+ private _lineHeight: number;
176
+ private _textAlign: number;
177
+
178
+ /** Layer-level declutter settings, mirrored from the material. */
179
+ private _declutter: boolean;
180
+ private _declutterPriority: number;
181
+ /** Skips the per-frame fade walk entirely once everything has settled. */
182
+ private _declutterAnimating = false;
183
+
184
+ private _enhancer: MaterialEnhancer<
185
+ ShaderMaterial,
186
+ { base?: SdfTextBaseProps },
187
+ SdfTextBaseState,
188
+ SdfTextBaseMutates,
189
+ readonly ["shader"]
190
+ >;
191
+
192
+ constructor(
193
+ ctx: EventContext,
194
+ m: NavaraTextMesh,
195
+ fontIdentifier: string,
196
+ options: InstancedMeshOptions,
197
+ loadedFaceUrls?: Set<string>,
198
+ ) {
199
+ super(options);
200
+ this.ctx = ctx;
201
+ this._fontIdentifier = fontIdentifier;
202
+ invariant(ctx.fontManager);
203
+ this._fontManager = ctx.fontManager;
204
+ this._loadedFaceUrls = loadedFaceUrls ?? new Set();
205
+
206
+ // One getter call each: wasm getters clone, so these snapshots are owned
207
+ // by this batch and safe to use after the event object is freed.
208
+ const material = m.material;
209
+ this._material = material;
210
+ this._transform = m.transform;
211
+ this._highQuality = material.highQuality ?? false;
212
+
213
+ this._maxWidth = material.maxWidth ?? 0;
214
+ this._lineHeight = material.lineHeight ?? 1.0;
215
+ this._textAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
216
+ this._declutter = material.declutter ?? true;
217
+ this._declutterPriority = material.declutterPriority ?? 0;
218
+
219
+ this._positions = this.extractPositions(m);
220
+ this._glyphs = new GlyphBuffers();
221
+ this._labelData = new LabelDataTexture();
222
+
223
+ this.geometry = this._glyphs.geometry;
224
+ const mat = new ShaderMaterial({
225
+ transparent: true,
226
+ // depthWrite must stay enabled: the fragment shader's per-pixel outline
227
+ // depth offset (sdfText.frag.glsl) relies on fills writing a nearer depth
228
+ // so a neighbouring glyph's fill occludes this glyph's outline at overlaps
229
+ // — without depth writes that outline-seam fix becomes a no-op.
230
+ depthWrite: true,
231
+ });
232
+ this._enhancer = createSdfTextMaterialEnhancer(mat);
233
+ this._setupMaterial(mat, material);
234
+ this.material = mat;
235
+ this.renderOrder = options.renderOrder ?? this.renderOrder;
236
+ // Geometry positions are unit quads; the real transform happens in the
237
+ // shader, so a three.js bounding sphere would be meaningless here.
238
+ this.frustumCulled = false;
239
+
240
+ this._syncLabelDataUniform();
241
+ this._initLabels();
242
+
243
+ // When the shared atlas evicts glyphs, a still-in-flight glyph this batch
244
+ // already baked into a visible label may have had its rect reused. Rebuild
245
+ // any such stale label so its UVs and retains refresh.
246
+ this._unsubscribeEvict = this._fontManager.onAtlasEvicted(
247
+ this._fontIdentifier,
248
+ this._highQuality,
249
+ () => this._revalidateStaleLabels(),
250
+ );
251
+ ctx.declutter?.register(this);
252
+ }
253
+
254
+ get fontIdentifier(): string {
255
+ return this._fontIdentifier;
256
+ }
257
+
258
+ get highQuality(): boolean {
259
+ return this._highQuality;
260
+ }
261
+
262
+ // --- Material ---
263
+
264
+ /** Mount the enhancer and install the batch's single per-frame hook. */
265
+ private _setupMaterial(
266
+ mat: ShaderMaterial,
267
+ material: NavaraTextMaterial,
268
+ ): void {
269
+ this._enhancer.mount({
270
+ base: {
271
+ useRTE: this._positions?.RTE ?? false,
272
+ useMsdf: this._highQuality,
273
+ center: material.center
274
+ ? [material.center.x, material.center.y]
275
+ : undefined,
276
+ sizeInMeters: material.sizeInMeters ?? true,
277
+ offsetDepth: material.offsetDepth ?? true,
278
+ outlineWidth: material.outlineWidth ?? 0,
279
+ outlineColor: material.outlineColor ?? 0x000000,
280
+ outlineOpacity: clamp01(material.outlineOpacity ?? 1.0),
281
+ showBackground: material.backgroundColor !== undefined,
282
+ backgroundColor: material.backgroundColor,
283
+ backgroundOutlineColor: material.borderColor ?? 0x000000,
284
+ backgroundOutlineWidth: material.borderWidth ?? 0.1,
285
+ depthTest: material.depthTest ?? true,
286
+ transparent: material.transparent ?? true,
287
+ rtcCenter: [this._transform.tx, this._transform.ty, this._transform.tz],
288
+ },
289
+ });
290
+
291
+ // Populate uniforms early (before onBeforeCompile fires).
292
+ const mutates = this._enhancer.mutates();
293
+ mutates.updateUniforms(mat.uniforms, this._enhancer.states());
294
+
295
+ mat.onBeforeCompile = this._enhancer.transformShader;
296
+ mat.customProgramCacheKey = this._enhancer.programCacheKey;
297
+
298
+ // One closure for the whole batch, where there used to be one per label.
299
+ const state = this._enhancer.states();
300
+ mat.onBeforeRender = (renderer, _scene, camera) => {
301
+ const pCam = camera as PerspectiveCamera;
302
+ mutates.updatePerFrame(
303
+ degreeToRadian(pCam.fov),
304
+ renderer.getDrawingBufferSize(_tmpSize).y / renderer.getPixelRatio(),
305
+ pCam.far,
306
+ camera.position.x,
307
+ camera.position.y,
308
+ camera.position.z,
309
+ camera.matrixWorldInverse,
310
+ state,
311
+ );
312
+ // Keep atlas-size uniforms in sync with the (possibly resized) shared
313
+ // DataTexture so glyph pixel rects always normalize to the right UV.
314
+ mutates.updateAtlasSizes();
315
+ };
316
+ }
317
+
318
+ /** Re-point the shader at the label texture (it is swapped on grow). */
319
+ private _syncLabelDataUniform(): void {
320
+ const { texture, size } = this._labelData;
321
+ this._enhancer.mutates().setLabelDataTexture(texture, size.x, size.y);
322
+ }
323
+
324
+ // --- Label lifecycle ---
325
+
326
+ /**
327
+ * A material-level text renders without the evaluator ever setting text, so
328
+ * that case needs a label per anchor up front. With per-feature texts (MVT)
329
+ * most features never receive one, so labels are otherwise created lazily on
330
+ * the first per-feature setter — a label slot is just an index plus a few
331
+ * float writes, unlike the mesh-per-label this replaced.
332
+ */
333
+ private _initLabels(): void {
334
+ const info = this._positions;
335
+ if (!info || !this._material.text) return;
336
+ for (let i = 0; i < info.nPositions; i++) this._ensureLabel(i);
337
+ }
338
+
339
+ private _ensureLabel(batchIndex: number): LabelRecord | undefined {
340
+ const existing = this._labelByIndex[batchIndex];
341
+ if (existing) return existing;
342
+
343
+ const info = this._positions;
344
+ if (!info || batchIndex < 0 || batchIndex >= info.nPositions) {
345
+ return undefined;
346
+ }
347
+
348
+ const material = this._material;
349
+ const slot = this._labels.length;
350
+ if (this._labelData.ensureCapacity(slot + 1)) {
351
+ this._syncLabelDataUniform();
352
+ }
353
+
354
+ const record: LabelRecord = {
355
+ slot,
356
+ batchIndex,
357
+ batchId: info.batchIDs ? info.batchIDs[batchIndex * info.batchIDSize] : 0,
358
+ text: "",
359
+ requestedText: "",
360
+ run: null,
361
+ glyphKeys: [],
362
+ retainedKeys: null,
363
+ requestedShow: material.show ?? true,
364
+ // No text yet, so nothing is shown regardless of `requestedShow`.
365
+ show: false,
366
+ fontSize: material.size ?? 16.0,
367
+ addHeight: material.height ?? 0.0,
368
+ colorHex: material.color ?? 0xffffff,
369
+ opacity: clamp01(material.opacity ?? 1.0),
370
+ widthEm: 0,
371
+ heightEm: 0,
372
+ minYEm: 0,
373
+ maxYEm: 1,
374
+ // Decluttered labels start hidden and fade in once the placement pass
375
+ // grants them space — otherwise dense tiles flash their full clutter for
376
+ // a frame before the first pass runs.
377
+ declutterHide: this._declutter ? 1 : 0,
378
+ declutterTarget: this._declutter ? 1 : 0,
379
+ priorityOverride: undefined,
380
+ anchor: new Float64Array(3),
381
+ };
382
+
383
+ this._labels.push(record);
384
+ this._labelByIndex[batchIndex] = record;
385
+
386
+ this._writeAnchor(record);
387
+ this._writeStyle(record);
388
+ this._writeBox(record);
389
+ this._writeState(record);
390
+ return record;
391
+ }
392
+
393
+ // --- Label data texture writes ---
394
+
395
+ private _writeAnchor(record: LabelRecord): void {
396
+ const info = this._positions;
397
+ if (!info) return;
398
+ const idx = record.batchIndex * info.positionSize;
399
+ const anchor = record.anchor;
400
+
401
+ if (info.RTE) {
402
+ const { high, low } = info.position;
403
+ const hx = high[idx];
404
+ const hy = high[idx + 1];
405
+ const hz = high[idx + 2] ?? 0;
406
+ const lx = low[idx];
407
+ const ly = low[idx + 1];
408
+ const lz = low[idx + 2] ?? 0;
409
+ this._labelData.setRow(
410
+ record.slot,
411
+ LabelRow.POSITION_HIGH_SIZE,
412
+ hx,
413
+ hy,
414
+ hz,
415
+ record.fontSize,
416
+ );
417
+ this._labelData.setRow(
418
+ record.slot,
419
+ LabelRow.POSITION_LOW_HEIGHT,
420
+ lx,
421
+ ly,
422
+ lz,
423
+ record.addHeight,
424
+ );
425
+ anchor[0] = hx + lx;
426
+ anchor[1] = hy + ly;
427
+ anchor[2] = hz + lz;
428
+ } else {
429
+ const p = info.position;
430
+ const px = p[idx];
431
+ const py = p[idx + 1];
432
+ const pz = p[idx + 2] ?? 0;
433
+ const { tx, ty, tz } = this._transform;
434
+ this._labelData.setRow(
435
+ record.slot,
436
+ LabelRow.POSITION_HIGH_SIZE,
437
+ px,
438
+ py,
439
+ pz,
440
+ record.fontSize,
441
+ );
442
+ this._labelData.setRow(
443
+ record.slot,
444
+ LabelRow.POSITION_LOW_HEIGHT,
445
+ 0,
446
+ 0,
447
+ 0,
448
+ record.addHeight,
449
+ );
450
+ anchor[0] = px + tx;
451
+ anchor[1] = py + ty;
452
+ anchor[2] = pz + tz;
453
+ }
454
+ }
455
+
456
+ private _writeStyle(record: LabelRecord): void {
457
+ _tmpColor.setHex(record.colorHex);
458
+ this._labelData.setRow(
459
+ record.slot,
460
+ LabelRow.COLOR_OPACITY,
461
+ _tmpColor.r,
462
+ _tmpColor.g,
463
+ _tmpColor.b,
464
+ record.opacity,
465
+ );
466
+ }
467
+
468
+ private _writeBox(record: LabelRecord): void {
469
+ this._labelData.setRow(
470
+ record.slot,
471
+ LabelRow.BOX,
472
+ record.widthEm,
473
+ record.heightEm,
474
+ record.minYEm,
475
+ record.maxYEm,
476
+ );
477
+ }
478
+
479
+ private _writeState(record: LabelRecord): void {
480
+ this._labelData.setRow(
481
+ record.slot,
482
+ LabelRow.STATE,
483
+ record.declutterHide,
484
+ record.batchId,
485
+ record.show ? 1 : 0,
486
+ 0,
487
+ );
488
+ }
489
+
490
+ private _writeFontSize(record: LabelRecord): void {
491
+ this._labelData.setComponent(
492
+ record.slot,
493
+ LabelRow.POSITION_HIGH_SIZE,
494
+ 3,
495
+ record.fontSize,
496
+ );
497
+ }
498
+
499
+ private _writeAddHeight(record: LabelRecord): void {
500
+ this._labelData.setComponent(
501
+ record.slot,
502
+ LabelRow.POSITION_LOW_HEIGHT,
503
+ 3,
504
+ record.addHeight,
505
+ );
506
+ }
507
+
508
+ private _writeShow(record: LabelRecord): void {
509
+ this._labelData.setComponent(
510
+ record.slot,
511
+ LabelRow.STATE,
512
+ 2,
513
+ record.show ? 1 : 0,
514
+ );
515
+ }
516
+
517
+ private _writeDeclutterHide(record: LabelRecord): void {
518
+ this._labelData.setComponent(
519
+ record.slot,
520
+ LabelRow.STATE,
521
+ 0,
522
+ record.declutterHide,
523
+ );
524
+ }
525
+
526
+ // --- Glyph runs ---
527
+
528
+ private _layoutOptions(text: string): LayoutOptions {
529
+ return {
530
+ text,
531
+ maxWidth: this._maxWidth,
532
+ lineHeight: this._lineHeight,
533
+ textAlign: this._textAlign,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Shape `text`, lay it out, and write the label's glyph run. Assumes the
539
+ * text is already prepared in the font worker.
540
+ */
541
+ private _applyText(record: LabelRecord, text: string): void {
542
+ record.text = text;
543
+ record.requestedText = text;
544
+
545
+ if (!text) {
546
+ this._releaseRun(record);
547
+ record.widthEm = 0;
548
+ record.heightEm = 0;
549
+ this._writeBox(record);
550
+ this._setGlyphKeys(record, []);
551
+ this._recomputeShow(record);
552
+ return;
553
+ }
554
+
555
+ const shapeResult = this._fontManager.shapeText(
556
+ this._fontIdentifier,
557
+ text,
558
+ this._highQuality,
559
+ );
560
+ if (!shapeResult) {
561
+ this._releaseRun(record);
562
+ record.widthEm = 0;
563
+ this._writeBox(record);
564
+ this._setGlyphKeys(record, []);
565
+ this._recomputeShow(record);
566
+ return;
567
+ }
568
+
569
+ const layout = buildLabelLayout(shapeResult, this._layoutOptions(text));
570
+
571
+ record.widthEm = layout.widthEm;
572
+ record.heightEm = layout.heightEm;
573
+ record.minYEm = layout.minYEm;
574
+ record.maxYEm = layout.maxYEm;
575
+ this._writeBox(record);
576
+
577
+ if (layout.quads.length === 0) {
578
+ this._releaseRun(record);
579
+ this._setGlyphKeys(record, layout.glyphKeys);
580
+ this._recomputeShow(record);
581
+ return;
582
+ }
583
+
584
+ // One extra slot for the background quad, which always leads the run so it
585
+ // draws before the label's glyphs.
586
+ const needed = layout.quads.length + 1;
587
+ const previous = record.run;
588
+ const run = this._slots.realloc(previous, needed);
589
+ record.run = run;
590
+
591
+ this._glyphs.ensureCapacity(this._slots.highWater);
592
+ this._glyphs.setInstanceCount(this._slots.highWater);
593
+
594
+ // A relocated run leaves its old slots live in the buffer; blank them so
595
+ // they don't keep drawing the previous text.
596
+ if (previous && previous.start !== run.start) {
597
+ this._glyphs.clearRun(previous.start, previous.capacity);
598
+ }
599
+
600
+ this._glyphs.writeRun(
601
+ run.start,
602
+ run.capacity,
603
+ record.slot,
604
+ layout.quads,
605
+ true,
606
+ );
607
+
608
+ this._setGlyphKeys(record, layout.glyphKeys);
609
+ this._recomputeShow(record);
610
+ }
611
+
612
+ /** Return a label's glyph slots and blank them. */
613
+ private _releaseRun(record: LabelRecord): void {
614
+ if (!record.run) return;
615
+ this._glyphs.clearRun(record.run.start, record.run.capacity);
616
+ this._slots.free(record.run);
617
+ record.run = null;
618
+ }
619
+
620
+ // --- Atlas glyph references ---
621
+
622
+ /** Replace the retained glyph set, releasing the old references and (if
623
+ * shown) retaining the new ones. */
624
+ private _setGlyphKeys(record: LabelRecord, keys: bigint[]): void {
625
+ if (record.retainedKeys) {
626
+ this._fontManager.releaseGlyphs(
627
+ this._fontIdentifier,
628
+ this._highQuality,
629
+ record.retainedKeys,
630
+ );
631
+ record.retainedKeys = null;
632
+ }
633
+ record.glyphKeys = keys;
634
+ this._syncGlyphRefs(record);
635
+ }
636
+
637
+ /**
638
+ * Reconcile atlas references with visibility: retain glyphs while the label
639
+ * is shown, release them when hidden. If shown after the glyphs were evicted
640
+ * (cache no longer prepared), re-prepare to re-rasterize them and rebuild
641
+ * with fresh metrics.
642
+ */
643
+ private _syncGlyphRefs(record: LabelRecord): void {
644
+ const visible = record.show && record.glyphKeys.length > 0;
645
+
646
+ if (visible && !record.retainedKeys) {
647
+ if (
648
+ !this._fontManager.isTextPrepared(
649
+ this._fontIdentifier,
650
+ record.text,
651
+ this._highQuality,
652
+ )
653
+ ) {
654
+ // Glyphs were evicted while hidden; re-rasterize then rebuild (which
655
+ // re-enters here with the fresh set and retains it).
656
+ const text = record.text;
657
+ this._fontManager
658
+ .prepareText(
659
+ this._fontIdentifier,
660
+ text,
661
+ this._highQuality,
662
+ this._loadedFaceUrls,
663
+ )
664
+ .then(() => {
665
+ if (record.text !== text) return;
666
+ this._applyText(record, text);
667
+ this._markDeclutterDirty();
668
+ this._needRender?.();
669
+ })
670
+ .catch((err: unknown) => {
671
+ console.error("SDF text: re-prepare on show failed:", err);
672
+ });
673
+ return;
674
+ }
675
+ this._fontManager.retainGlyphs(
676
+ this._fontIdentifier,
677
+ this._highQuality,
678
+ record.glyphKeys,
679
+ );
680
+ record.retainedKeys = record.glyphKeys;
681
+ } else if (!visible && record.retainedKeys) {
682
+ this._fontManager.releaseGlyphs(
683
+ this._fontIdentifier,
684
+ this._highQuality,
685
+ record.retainedKeys,
686
+ );
687
+ record.retainedKeys = null;
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Fold `requestedShow` and "has text" into the effective visibility, pushing
693
+ * it to the shader and reconciling atlas retains when it flips.
694
+ */
695
+ private _recomputeShow(record: LabelRecord): void {
696
+ const next = record.requestedShow && !!record.text;
697
+ if (record.show === next) return;
698
+ record.show = next;
699
+ this._writeShow(record);
700
+ this._syncGlyphRefs(record);
701
+ }
702
+
703
+ /**
704
+ * After an atlas eviction, re-lay-out any shown label whose text is now
705
+ * stale (its pinned glyphs may have been evicted before the retain landed
706
+ * and its rect reused). Fresh labels short-circuit on the cheap
707
+ * `isTextPrepared` check, so this is inexpensive to run per eviction.
708
+ */
709
+ private _revalidateStaleLabels(): void {
710
+ const q = this._highQuality;
711
+ for (const record of this._labels) {
712
+ const text = record.text;
713
+ if (!record.show || !text) continue;
714
+ if (this._fontManager.isTextPrepared(this._fontIdentifier, text, q)) {
715
+ continue;
716
+ }
717
+ this._fontManager
718
+ .prepareText(this._fontIdentifier, text, q, this._loadedFaceUrls)
719
+ .then(() => {
720
+ // Text may have changed (or the label hidden) while re-preparing.
721
+ if (record.text !== text || !record.show) return;
722
+ this._refreshAtlasTextures();
723
+ // Force a rebuild even though the text string is unchanged: the
724
+ // baked atlas rects and glyph retains must refresh.
725
+ this._applyText(record, text);
726
+ this._markDeclutterDirty();
727
+ this._needRender?.();
728
+ })
729
+ .catch((err: unknown) => {
730
+ console.error("Failed to revalidate text after eviction:", err);
731
+ });
732
+ }
733
+ }
734
+
735
+ private _refreshAtlasTextures(): void {
736
+ const mutates = this._enhancer.mutates();
737
+ const tex = this._fontManager.getAtlasTexture(
738
+ this._fontIdentifier,
739
+ this._highQuality,
740
+ );
741
+ if (tex) mutates.setAtlasTexture({ value: tex });
742
+ mutates.setColorAtlasTexture({
743
+ value: this._fontManager.getColorAtlasTexture(
744
+ this._fontIdentifier,
745
+ this._highQuality,
746
+ ),
747
+ });
748
+ }
749
+
750
+ // --- DeclutterParticipant ---
751
+
752
+ /**
753
+ * Build each shown label's collision box. The local box mirrors the vertex
754
+ * shader's layout: glyphs span [0, textWidth] x [bgMinY, bgMaxY] in em
755
+ * units, shifted by the `center` anchor and scaled by the font size.
756
+ */
757
+ collectDeclutterCandidates(out: DeclutterCandidate[]): void {
758
+ if (!this.visible || !this._declutter) return;
759
+ const state = this._enhancer.states();
760
+ const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
761
+ const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
762
+
763
+ for (const record of this._labels) {
764
+ if (!record.show || record.widthEm <= 0) continue;
765
+ const size = record.fontSize;
766
+ if (size <= 0) continue;
767
+
768
+ const w = record.widthEm;
769
+ const h = record.heightEm;
770
+ out.push({
771
+ anchorX: record.anchor[0],
772
+ anchorY: record.anchor[1],
773
+ anchorZ: record.anchor[2],
774
+ addHeight: record.addHeight,
775
+ minX: (0 - cx * w) * size,
776
+ maxX: (w - cx * w) * size,
777
+ minY: (record.minYEm - cy * h) * size,
778
+ maxY: (record.maxYEm - cy * h) * size,
779
+ sizeInMeters: state.sizeInMeters,
780
+ priority: record.priorityOverride ?? this._declutterPriority,
781
+ isShown: record.declutterTarget === 0,
782
+ owner: this,
783
+ handle: record.slot,
784
+ });
785
+ }
786
+ }
787
+
788
+ applyDeclutter(handle: number, hidden: boolean): void {
789
+ const record = this._labels[handle];
790
+ if (!record) return;
791
+ const target = hidden ? 1 : 0;
792
+ if (record.declutterTarget === target) return;
793
+ record.declutterTarget = target;
794
+ if (record.declutterHide !== target) this._declutterAnimating = true;
795
+ }
796
+
797
+ stepDeclutterFade(deltaMs: number): boolean {
798
+ if (!this._declutterAnimating) return false;
799
+
800
+ const step = deltaMs / DECLUTTER_FADE_MS;
801
+ let stillAnimating = false;
802
+ for (const record of this._labels) {
803
+ const target = record.declutterTarget;
804
+ let value = record.declutterHide;
805
+ if (value === target) continue;
806
+ value =
807
+ value < target
808
+ ? Math.min(value + step, target)
809
+ : Math.max(value - step, target);
810
+ record.declutterHide = value;
811
+ this._writeDeclutterHide(record);
812
+ if (value !== target) stillAnimating = true;
813
+ }
814
+ this._declutterAnimating = stillAnimating;
815
+ return stillAnimating;
816
+ }
817
+
818
+ /** Candidates changed (text, style, position, visibility): ask the shared
819
+ * declutter pass to re-place on its next update. */
820
+ private _markDeclutterDirty(): void {
821
+ this.ctx.declutter?.markDirty();
822
+ }
823
+
824
+ override setActive(active: boolean) {
825
+ super.setActive(active);
826
+ this._markDeclutterDirty();
827
+ }
828
+
829
+ // --- Picking ---
830
+
831
+ override onBeforePicking() {
832
+ this._enhancer.update({ base: { pickable: true } });
833
+ }
834
+
835
+ override onAfterPicking() {
836
+ this._enhancer.update({ base: { pickable: false } });
837
+ }
838
+
839
+ override getRenderable(): Object3D {
840
+ return this;
841
+ }
842
+
843
+ // --- Geometry / material updates from the engine ---
844
+
845
+ private extractPositions(m: NavaraTextMesh): PositionsInfo | null {
846
+ const { buf } = this.ctx;
847
+ const g = m.geometry;
848
+
849
+ const batchIdsData = g.batch_ids;
850
+ const batchIDs = buf.removeF32(batchIdsData.data);
851
+ const batchIDSize = batchIdsData.size;
852
+
853
+ const positionData = g.position;
854
+ const position = positionData
855
+ ? buf.removeF32(positionData.data)
856
+ : undefined;
857
+
858
+ if (position && positionData) {
859
+ const positionSize = positionData.size;
860
+ const nPositions = position.length / positionSize;
861
+
862
+ return {
863
+ position,
864
+ batchIDs,
865
+ batchIDSize,
866
+ positionSize,
867
+ nPositions,
868
+ RTE: false,
869
+ };
870
+ }
871
+
872
+ const positionHighData = g.position_3d_high;
873
+ const positionLowData = g.position_3d_low;
874
+ const positionHigh = positionHighData
875
+ ? buf.removeF32(positionHighData.data)
876
+ : undefined;
877
+ const positionLow = positionLowData
878
+ ? buf.removeF32(positionLowData.data)
879
+ : undefined;
880
+
881
+ if (positionHigh && positionLow && positionHighData && positionLowData) {
882
+ const positionLowSize = positionLowData.size;
883
+ const positionHighSize = positionHighData.size;
884
+ invariant(
885
+ positionLowSize === positionHighSize,
886
+ "Position high and low size mismatch",
887
+ );
888
+
889
+ const nPositions = positionHigh.length / positionHighSize;
890
+
891
+ return {
892
+ position: { high: positionHigh, low: positionLow },
893
+ batchIDs,
894
+ batchIDSize,
895
+ positionSize: positionHighSize,
896
+ nPositions,
897
+ RTE: true,
898
+ };
899
+ }
900
+
901
+ return null;
902
+ }
903
+
904
+ async _update(m: NavaraTextMesh, needRender?: () => void) {
905
+ if (needRender) this._needRender = needRender;
906
+
907
+ const material = m.material;
908
+ const text = material.text ?? "";
909
+ this._material = material;
910
+ this._transform = m.transform;
911
+
912
+ const positionInfo = this.extractPositions(m);
913
+ if (positionInfo) {
914
+ invariant(
915
+ this._positions === null ||
916
+ positionInfo.nPositions === this._positions.nPositions,
917
+ "Number of positions in the updated geometry must match the initial geometry",
918
+ );
919
+ this._positions = positionInfo;
920
+ this._enhancer
921
+ .mutates()
922
+ .setRtcCenter([m.transform.tx, m.transform.ty, m.transform.tz]);
923
+ for (const record of this._labels) this._writeAnchor(record);
924
+ // Anchors moved (e.g. terrain height resolution) — re-place labels.
925
+ this._markDeclutterDirty();
926
+ }
927
+
928
+ // A non-empty default text renders without per-feature setText calls, so
929
+ // any lazily-skipped labels must exist before `_applyUpdate`.
930
+ if (text) this._initLabels();
931
+
932
+ const fontIdentifier = m.material.font ?? this._fontIdentifier;
933
+ const needFontUpdate = fontIdentifier !== this._fontIdentifier;
934
+
935
+ // Quality is immutable per batch (see _highQuality docs); use the batch's
936
+ // quality everywhere, ignoring `m.material.highQuality` on updates.
937
+ const q = this._highQuality;
938
+
939
+ if (needFontUpdate) {
940
+ // Unload old font resources.
941
+ if (this._loadedFaceUrls.size > 0) {
942
+ await Promise.all(
943
+ [...this._loadedFaceUrls].map((url) =>
944
+ this._fontManager.unloadFont(url, q),
945
+ ),
946
+ );
947
+ this._loadedFaceUrls.clear();
948
+ } else if (!this._fontManager.isFamily(this._fontIdentifier)) {
949
+ await this._fontManager.unloadFont(this._fontIdentifier, q);
950
+ }
951
+ // For standalone new fonts load upfront; family faces are loaded lazily
952
+ // by prepareText below.
953
+ if (!this._fontManager.isFamily(fontIdentifier)) {
954
+ await this._fontManager.loadFont(fontIdentifier, q);
955
+ }
956
+ }
957
+ this._fontIdentifier = fontIdentifier;
958
+
959
+ // If the text hasn't been prepared in the worker yet, schedule async preparation
960
+ if (
961
+ (needFontUpdate || text) &&
962
+ !this._fontManager.isTextPrepared(this._fontIdentifier, text, q)
963
+ ) {
964
+ this._fontManager
965
+ .prepareText(this._fontIdentifier, text, q, this._loadedFaceUrls)
966
+ .then(() => {
967
+ this._applyUpdate(material, needRender, needFontUpdate);
968
+ })
969
+ .catch((err: unknown) => {
970
+ console.error("Failed to prepare text:", err);
971
+ needRender?.();
972
+ });
973
+ return;
974
+ }
975
+
976
+ this._applyUpdate(material, needRender, needFontUpdate);
977
+ }
978
+
979
+ private _applyUpdate(
980
+ material: NavaraTextMaterial,
981
+ needRender?: () => void,
982
+ forceUpdate = false,
983
+ ) {
984
+ this._refreshAtlasTextures();
985
+
986
+ // Layout properties are baked into the glyph instances, so a change forces
987
+ // a re-layout even when the text itself is unchanged.
988
+ const nextMaxWidth = material.maxWidth ?? 0;
989
+ const nextLineHeight = material.lineHeight ?? 1.0;
990
+ const nextTextAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
991
+ const layoutChanged =
992
+ nextMaxWidth !== this._maxWidth ||
993
+ nextLineHeight !== this._lineHeight ||
994
+ nextTextAlign !== this._textAlign;
995
+ this._maxWidth = nextMaxWidth;
996
+ this._lineHeight = nextLineHeight;
997
+ this._textAlign = nextTextAlign;
998
+
999
+ // Read before applying visibility: the declutter pass consults these even
1000
+ // while other style state is skipped for hidden labels.
1001
+ const nextDeclutter = material.declutter ?? true;
1002
+ if (this._declutter && !nextDeclutter) {
1003
+ // Leaving declutter mode: clear any hide the pass applied — the batch
1004
+ // stops producing candidates, so nothing else would ever re-show it.
1005
+ for (const record of this._labels) {
1006
+ record.declutterTarget = 0;
1007
+ if (record.declutterHide !== 0) this._declutterAnimating = true;
1008
+ }
1009
+ }
1010
+ this._declutter = nextDeclutter;
1011
+ this._declutterPriority = material.declutterPriority ?? 0;
1012
+
1013
+ this._enhancer.update({
1014
+ base: {
1015
+ center: material.center
1016
+ ? [material.center.x, material.center.y]
1017
+ : [0.5, 0.0],
1018
+ sizeInMeters: material.sizeInMeters ?? true,
1019
+ offsetDepth: material.offsetDepth ?? true,
1020
+ outlineWidth: material.outlineWidth ?? 0,
1021
+ outlineColor: material.outlineColor ?? 0x000000,
1022
+ outlineOpacity: clamp01(material.outlineOpacity ?? 1.0),
1023
+ showBackground: material.backgroundColor !== undefined,
1024
+ backgroundColor: material.backgroundColor,
1025
+ backgroundOutlineColor: material.borderColor ?? 0x000000,
1026
+ backgroundOutlineWidth: material.borderWidth ?? 0,
1027
+ depthTest: material.depthTest ?? true,
1028
+ transparent: material.transparent ?? true,
1029
+ },
1030
+ });
1031
+
1032
+ // Per-label values the material supplies defaults for. This mirrors the
1033
+ // pre-batching behaviour where a material update overwrote whatever the
1034
+ // evaluator had set per feature.
1035
+ const materialText = material.text;
1036
+ const materialShow = material.show ?? true;
1037
+ const colorHex = material.color ?? 0xffffff;
1038
+ const opacity = clamp01(material.opacity ?? 1.0);
1039
+ const fontSize = material.size ?? 16.0;
1040
+ const addHeight = material.height ?? 0;
1041
+
1042
+ for (const record of this._labels) {
1043
+ record.colorHex = colorHex;
1044
+ record.opacity = opacity;
1045
+ record.fontSize = fontSize;
1046
+ record.addHeight = addHeight;
1047
+ this._writeStyle(record);
1048
+ this._writeFontSize(record);
1049
+ this._writeAddHeight(record);
1050
+
1051
+ record.requestedShow = materialShow;
1052
+ if (materialText !== undefined && materialText !== "") {
1053
+ this._applyText(record, materialText);
1054
+ } else if (forceUpdate || layoutChanged) {
1055
+ // Font or layout changed — re-lay-out existing text.
1056
+ this._applyText(record, record.text);
1057
+ }
1058
+ this._recomputeShow(record);
1059
+ }
1060
+
1061
+ this._markDeclutterDirty();
1062
+ if (needRender) needRender();
1063
+ }
1064
+
1065
+ // --- Per-feature API (the FeatureEvaluator contract) ---
1066
+
1067
+ setTextByBatchIndex(batchIndex: number, text: string) {
1068
+ // An empty text on a label that doesn't exist yet changes nothing (a
1069
+ // lazily-skipped label is exactly an invisible empty-text label); only a
1070
+ // non-empty text forces one into existence.
1071
+ const record = text
1072
+ ? this._ensureLabel(batchIndex)
1073
+ : this._labelByIndex[batchIndex];
1074
+ if (!record) return;
1075
+
1076
+ // Record the intent up front so a slower prepare for an older text can
1077
+ // detect that it has been superseded.
1078
+ record.requestedText = text;
1079
+
1080
+ if (
1081
+ text &&
1082
+ !this._fontManager.isTextPrepared(
1083
+ this._fontIdentifier,
1084
+ text,
1085
+ this._highQuality,
1086
+ )
1087
+ ) {
1088
+ this._fontManager
1089
+ .prepareText(
1090
+ this._fontIdentifier,
1091
+ text,
1092
+ this._highQuality,
1093
+ this._loadedFaceUrls,
1094
+ )
1095
+ .then(() => {
1096
+ // A newer text may have landed while the font loaded.
1097
+ if (record.requestedText !== text) return;
1098
+ this._refreshAtlasTextures();
1099
+ this._applyText(record, text);
1100
+ this._markDeclutterDirty();
1101
+ this._needRender?.();
1102
+ })
1103
+ .catch((err: unknown) => {
1104
+ console.error("Failed to prepare text:", err);
1105
+ this._needRender?.();
1106
+ });
1107
+ return;
1108
+ }
1109
+
1110
+ this._applyText(record, text);
1111
+ this._markDeclutterDirty();
1112
+ this._needRender?.();
1113
+ }
1114
+
1115
+ override setFeatureColorByBatchIndex(batchIndex: number, color: Color) {
1116
+ const record = this._ensureLabel(batchIndex);
1117
+ if (!record) return;
1118
+ record.colorHex = color.getHex();
1119
+ this._writeStyle(record);
1120
+ }
1121
+
1122
+ override setFeatureShowByBatchIndex(batchIndex: number, rawVisible: boolean) {
1123
+ // A label that doesn't exist yet is already effectively `show:false`, so
1124
+ // only `show:true` needs to force one into existence — the common MVT case
1125
+ // is thousands of features that stay hidden and never get a label.
1126
+ const record = rawVisible
1127
+ ? this._ensureLabel(batchIndex)
1128
+ : this._labelByIndex[batchIndex];
1129
+ if (!record) return;
1130
+ record.requestedShow = rawVisible;
1131
+ this._recomputeShow(record);
1132
+ this._markDeclutterDirty();
1133
+ }
1134
+
1135
+ override setFeatureHeightByBatchIndex(batchIndex: number, height: number) {
1136
+ const record = this._ensureLabel(batchIndex);
1137
+ if (!record) return;
1138
+ record.addHeight = height;
1139
+ this._writeAddHeight(record);
1140
+ this._markDeclutterDirty();
1141
+ }
1142
+
1143
+ setFeatureSizeByBatchIndex(batchIndex: number, size: number) {
1144
+ const record = this._ensureLabel(batchIndex);
1145
+ if (!record) return;
1146
+ record.fontSize = Number.isFinite(size)
1147
+ ? Math.max(0.0, size)
1148
+ : record.fontSize;
1149
+ this._writeFontSize(record);
1150
+ this._markDeclutterDirty();
1151
+ }
1152
+
1153
+ setFeatureOpacityByBatchIndex(batchIndex: number, opacity: number) {
1154
+ const record = this._ensureLabel(batchIndex);
1155
+ if (!record) return;
1156
+ record.opacity = clamp01(opacity);
1157
+ this._writeStyle(record);
1158
+ }
1159
+
1160
+ setFeatureDeclutterPriorityByBatchIndex(
1161
+ batchIndex: number,
1162
+ priority: number,
1163
+ ) {
1164
+ const record = this._ensureLabel(batchIndex);
1165
+ if (!record) return;
1166
+ record.priorityOverride = priority;
1167
+ this._markDeclutterDirty();
1168
+ }
1169
+
1170
+ /**
1171
+ * Labels are no longer three.js objects, so there is no child mesh to hand
1172
+ * back. Overridden to stop the base class returning an unrelated object from
1173
+ * its (unused) `allMeshes` array.
1174
+ */
1175
+ override getMeshByBatchIndex(): undefined {
1176
+ return undefined;
1177
+ }
1178
+
1179
+ // --- Cleanup ---
1180
+
1181
+ dispose() {
1182
+ this.ctx.declutter?.unregister(this);
1183
+ this._unsubscribeEvict?.();
1184
+ this._unsubscribeEvict = undefined;
1185
+
1186
+ const q = this._highQuality;
1187
+ // Release every label's atlas retains. The per-label meshes this replaced
1188
+ // were never disposed (they were detached from `children` while hidden),
1189
+ // so these references used to leak.
1190
+ for (const record of this._labels) {
1191
+ if (!record.retainedKeys) continue;
1192
+ this._fontManager.releaseGlyphs(
1193
+ this._fontIdentifier,
1194
+ q,
1195
+ record.retainedKeys,
1196
+ );
1197
+ record.retainedKeys = null;
1198
+ }
1199
+ this._labels.length = 0;
1200
+ this._labelByIndex.length = 0;
1201
+
1202
+ this._labelData.dispose();
1203
+ this._glyphs.dispose();
1204
+ (this.material as ShaderMaterial).dispose();
1205
+
1206
+ const unload =
1207
+ this._loadedFaceUrls.size > 0
1208
+ ? Promise.all(
1209
+ [...this._loadedFaceUrls].map((url) =>
1210
+ this._fontManager.unloadFont(url, q),
1211
+ ),
1212
+ )
1213
+ : this._fontManager.unloadFont(this._fontIdentifier, q);
1214
+ void unload.catch((err: unknown) => {
1215
+ console.error("Failed to unload font during dispose:", err);
1216
+ });
1217
+ }
1218
+ }
1219
+
1220
+ /** Clamp to [0, 1], treating non-finite input as fully opaque. */
1221
+ function clamp01(value: number): number {
1222
+ return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1.0;
1223
+ }