@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
@@ -1,1189 +0,0 @@
1
- import { Unimplemented } from "@navaramap/core";
2
- import type {
3
- TextMaterial as NavaraTextMaterial,
4
- Transform,
5
- } from "@navaramap/engine";
6
- import {
7
- COLOR_GLYPH_PX_SIZE,
8
- GlyphCharClass,
9
- createSdfAtlasTexture,
10
- type FontManager,
11
- type GlyphMetrics,
12
- type ShapedGlyph,
13
- type ShapeTextResult,
14
- } from "@navaramap/font";
15
- import { degreeToRadian } from "@navaramap/three_api";
16
- import {
17
- BufferAttribute,
18
- type Color,
19
- type DataTexture,
20
- type PerspectiveCamera,
21
- InstancedBufferAttribute,
22
- InstancedBufferGeometry,
23
- Mesh,
24
- Object3D,
25
- ShaderMaterial,
26
- Vector2,
27
- } from "three";
28
-
29
- import {
30
- DECLUTTER_FADE_MS,
31
- type DeclutterCandidate,
32
- type DeclutterParticipant,
33
- } from "../declutter/types";
34
- import type { MaterialEnhancer } from "../material/enhancer/MaterialEnhancer";
35
- import {
36
- createSdfTextMaterialEnhancer,
37
- type SdfTextBaseMutates,
38
- type SdfTextBaseProps,
39
- type SdfTextBaseState,
40
- } from "../material/enhancer/sdfText";
41
- import { sdfRadiusFor } from "../material/enhancer/sdfText/sdfTextBaseEnhancer/types";
42
-
43
- import type { PickableMesh } from "./pickableMesh";
44
-
45
- /** Must match Rust `SDF_PX_SIZE` in `crates/navara_wasm_font_worker/src/atlas.rs`. */
46
- const SDF_PX_SIZE = 64.0;
47
-
48
- /** Reusable Vector2 to avoid per-frame allocations in onBeforeRender. */
49
- const _tmpSize = new Vector2();
50
-
51
- /** Horizontal alignment of lines within a multi-line block, as the fraction
52
- * of leftover width placed before each line. */
53
- const ALIGN_FACTORS: Record<string, number> = {
54
- left: 0,
55
- center: 0.5,
56
- right: 1,
57
- };
58
-
59
- /** Line width in font units: advances summed, trailing whitespace ignored so
60
- * it never affects alignment or the block width. Exported for tests. */
61
- export function lineWidthFu(line: ShapedGlyph[]): number {
62
- let end = line.length;
63
- while (end > 0 && line[end - 1].charClass === GlyphCharClass.Whitespace) {
64
- end--;
65
- }
66
- let width = 0;
67
- for (let i = 0; i < end; i++) width += line[i].xAdvance;
68
- return width;
69
- }
70
-
71
- /** Strong RTL code points (Hebrew through Arabic Extended, presentation
72
- * forms, and the supplementary-plane RTL blocks). Used for first-strong
73
- * paragraph direction detection, mirroring the shaper's own direction guess.
74
- */
75
- const STRONG_RTL_RE =
76
- /[\u0591-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
77
-
78
- /** Paragraph direction from the first strong directional character (UAX #9
79
- * P2/P3 approximation): the first letter decides, RTL ranges win over other
80
- * letters. Exported for tests. */
81
- export function isRtlText(text: string): boolean {
82
- for (const ch of text) {
83
- if (STRONG_RTL_RE.test(ch)) return true;
84
- if (/\p{L}/u.test(ch)) return false;
85
- }
86
- return false;
87
- }
88
-
89
- /**
90
- * Split a shaped glyph run into lines: hard breaks at newline markers, greedy
91
- * soft breaks at the last whitespace/ideographic glyph when a line would
92
- * exceed `maxWidthFu` (font units; 0 disables wrapping). A word longer than
93
- * the wrap width overflows rather than breaking mid-word.
94
- *
95
- * When `rtl` is set, glyphs are assumed to arrive in visual order (leftmost
96
- * first — how the shaper emits RTL runs), i.e. reversed logical order. Each
97
- * hard-break segment is wrapped in logical order so the greedy fill starts at
98
- * the sentence start and lines stack top-to-bottom in reading order, then
99
- * every line is flipped back to visual order for rendering. Mixed-direction
100
- * lines may still break sub-optimally — same trade other map renderers make.
101
- * Exported for tests.
102
- */
103
- export function breakLines(
104
- glyphs: ShapedGlyph[],
105
- maxWidthFu: number,
106
- rtl = false,
107
- ): ShapedGlyph[][] {
108
- const lines: ShapedGlyph[][] = [];
109
-
110
- const pushSegment = (segment: ShapedGlyph[]) => {
111
- if (!rtl) {
112
- lines.push(...wrapSegment(segment, maxWidthFu));
113
- return;
114
- }
115
- segment.reverse();
116
- for (const line of wrapSegment(segment, maxWidthFu)) {
117
- lines.push(line.reverse());
118
- }
119
- };
120
-
121
- let segment: ShapedGlyph[] = [];
122
- for (const g of glyphs) {
123
- if (g.charClass === GlyphCharClass.Newline) {
124
- pushSegment(segment);
125
- segment = [];
126
- } else {
127
- segment.push(g);
128
- }
129
- }
130
- pushSegment(segment);
131
- return lines;
132
- }
133
-
134
- /** Greedy soft-wrap of a single hard-break-free segment in logical order. */
135
- function wrapSegment(
136
- glyphs: ShapedGlyph[],
137
- maxWidthFu: number,
138
- ): ShapedGlyph[][] {
139
- const lines: ShapedGlyph[][] = [];
140
- let line: ShapedGlyph[] = [];
141
- let width = 0;
142
- let breakIdx = -1; // index in `line` of the last break opportunity
143
-
144
- for (const g of glyphs) {
145
- // Trailing whitespace is invisible at a line end, so it never triggers a
146
- // wrap itself — it just gets trimmed if a later glyph wraps.
147
- if (
148
- maxWidthFu > 0 &&
149
- g.charClass !== GlyphCharClass.Whitespace &&
150
- line.length > 0 &&
151
- breakIdx >= 0 &&
152
- width + g.xAdvance > maxWidthFu
153
- ) {
154
- const head = line.slice(0, breakIdx + 1);
155
- while (
156
- head.length > 0 &&
157
- head[head.length - 1].charClass === GlyphCharClass.Whitespace
158
- ) {
159
- head.pop();
160
- }
161
- lines.push(head);
162
-
163
- const tail = line.slice(breakIdx + 1);
164
- const start = tail.findIndex(
165
- (gg) => gg.charClass !== GlyphCharClass.Whitespace,
166
- );
167
- line = start === -1 ? [] : tail.slice(start);
168
-
169
- width = 0;
170
- for (const rest of line) width += rest.xAdvance;
171
- breakIdx = -1;
172
- }
173
-
174
- line.push(g);
175
- width += g.xAdvance;
176
- if (
177
- g.charClass === GlyphCharClass.Whitespace ||
178
- g.charClass === GlyphCharClass.Ideographic
179
- ) {
180
- breakIdx = line.length - 1;
181
- }
182
- }
183
-
184
- lines.push(line);
185
- return lines;
186
- }
187
-
188
- /**
189
- * A text mesh that renders glyphs from an SDF atlas using instanced geometry.
190
- *
191
- * Each glyph is an instanced unit quad positioned by shaping data,
192
- * sampling from a per-font SDF atlas texture. Uses billboard rendering
193
- * so text always faces the camera.
194
- */
195
- export class SDFTextMesh
196
- extends Mesh<InstancedBufferGeometry, ShaderMaterial>
197
- implements PickableMesh
198
- {
199
- private _fontManager: FontManager;
200
- private _fontUrl: string;
201
- /** Per-material text quality. Immutable after construction — switching
202
- * quality requires a separate (font, atlas) pair, so callers create a new
203
- * mesh rather than mutating this one. */
204
- private _highQuality: boolean;
205
- private _text = "";
206
- /** Wrap width in ems (multiples of font size); 0 disables soft wrapping. */
207
- private _maxWidth = 0;
208
- /** Multiplier on the font's natural line height (ascender − descender + gap). */
209
- private _lineHeight = 1.0;
210
- /** Alignment factor for multi-line blocks: 0 left, 0.5 center, 1 right. */
211
- private _textAlign = 0.5;
212
- private _atlasTexture: DataTexture | null = null;
213
- /** Atlas texture is owned externally; do not dispose on cleanup when true. */
214
- private _sharedAtlas = false;
215
- /** Unique atlas glyphs this mesh currently renders (composite keys). */
216
- private _glyphKeys: bigint[] = [];
217
- /** The glyph set currently retained in the atlas (one visible-label
218
- * reference per key), or null when this mesh holds no references. */
219
- private _retainedKeys: bigint[] | null = null;
220
- /** Re-prepare a text in the worker (re-rasterizing evicted glyphs) using the
221
- * owner's font identity and loaded faces. Set by the parent batch. */
222
- private _reprepare?: (text: string) => Promise<void>;
223
- /** Ask the owner to schedule a render after an async re-prepare. */
224
- private _requestRender?: () => void;
225
- // The COLRv1 color atlas is always FontManager-owned; no local field needed.
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
-
248
- private _enhancer: MaterialEnhancer<
249
- ShaderMaterial,
250
- { base?: SdfTextBaseProps },
251
- SdfTextBaseState,
252
- SdfTextBaseMutates,
253
- readonly ["shader"]
254
- >;
255
-
256
- constructor(
257
- position: Float32Array | { high: Float32Array; low: Float32Array },
258
- material: NavaraTextMaterial,
259
- transform: Transform,
260
- fontManager: FontManager,
261
- fontUrl: string,
262
- batchId: number | undefined,
263
- RTE: boolean,
264
- // Quality is fixed by the owning batch (flipping it needs a new batch), so
265
- // it is passed in rather than re-derived from `material.highQuality` — the
266
- // stored material can carry a newer, mismatched value by the time a mesh is
267
- // created lazily, which must not diverge from the batch's atlas/shaping.
268
- highQuality: boolean,
269
- ) {
270
- super();
271
-
272
- this._fontManager = fontManager;
273
- this._fontUrl = fontUrl;
274
- this._highQuality = highQuality;
275
- this._maxWidth = material.maxWidth ?? 0;
276
- this._lineHeight = material.lineHeight ?? 1.0;
277
- this._textAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
278
-
279
- this.geometry = this._createBaseGeometry();
280
-
281
- // Create empty ShaderMaterial — enhancer will set shaders and uniforms
282
- const mat = new ShaderMaterial({
283
- transparent: true,
284
- // depthWrite must stay enabled: the fragment shader's per-pixel outline
285
- // depth offset (sdfText.frag.glsl) relies on fills writing a nearer depth
286
- // so a neighbouring glyph's fill occludes this glyph's outline at overlaps
287
- // — without depth writes that outline-seam fix becomes a no-op.
288
- depthWrite: true,
289
- });
290
-
291
- this._enhancer = createSdfTextMaterialEnhancer(mat);
292
-
293
- // Mount enhancer with initial props
294
- // Validate and clamp opacity values to prevent invalid alpha in shader
295
- const initialOpacity = Number.isFinite(material.opacity ?? 1.0)
296
- ? Math.max(0, Math.min(1, material.opacity ?? 1.0))
297
- : 1.0;
298
- const initialOutlineOpacity = Number.isFinite(
299
- material.outlineOpacity ?? 1.0,
300
- )
301
- ? Math.max(0, Math.min(1, material.outlineOpacity ?? 1.0))
302
- : 1.0;
303
-
304
- this._enhancer.mount({
305
- base: {
306
- useRTE: RTE,
307
- useMsdf: this._highQuality,
308
- color: material.color ?? 0xffffff,
309
- opacity: initialOpacity,
310
- fontSize: material.size ?? 16.0,
311
- center: material.center
312
- ? [material.center.x, material.center.y]
313
- : undefined,
314
- sizeInMeters: material.sizeInMeters ?? true,
315
- addHeight: material.height ?? 0.0,
316
- offsetDepth: material.offsetDepth ?? true,
317
- outlineWidth: material.outlineWidth ?? 0,
318
- outlineColor: material.outlineColor ?? 0x000000,
319
- outlineOpacity: initialOutlineOpacity,
320
- showBackground: material.backgroundColor !== undefined,
321
- backgroundColor: material.backgroundColor,
322
- backgroundOutlineColor: material.borderColor ?? 0x000000,
323
- backgroundOutlineWidth: material.borderWidth ?? 0.1,
324
- depthTest: material.depthTest ?? true,
325
- transparent: material.transparent ?? true,
326
- rtcCenter: [transform.tx, transform.ty, transform.tz],
327
- },
328
- });
329
-
330
- // Populate uniforms early (before onBeforeCompile fires)
331
- const mutates = this._enhancer.mutates();
332
- mutates.updateUniforms(mat.uniforms, this._enhancer.states());
333
-
334
- // Set batch ID
335
- if (batchId !== undefined) {
336
- mutates.setBatchId(batchId);
337
- }
338
-
339
- // Set position
340
- mutates.setPosition(position, RTE, [
341
- transform.tx,
342
- transform.ty,
343
- transform.tz,
344
- ]);
345
- this._cacheWorldAnchor(position, RTE, [
346
- transform.tx,
347
- transform.ty,
348
- transform.tz,
349
- ]);
350
-
351
- // Decluttered labels start hidden and fade in once the placement pass
352
- // grants them space — otherwise dense tiles flash their full clutter for
353
- // a frame before the first pass runs.
354
- this._declutter = material.declutter ?? true;
355
- this._declutterPriority = material.declutterPriority ?? 0;
356
- if (this._declutter) {
357
- this._declutterHide = 1;
358
- this._declutterTarget = 1;
359
- mutates.setDeclutterHide(1);
360
- }
361
-
362
- // Register shader hook
363
- mat.onBeforeCompile = this._enhancer.transformShader;
364
- mat.customProgramCacheKey = this._enhancer.programCacheKey;
365
-
366
- // Per-frame camera updates
367
- const state = this._enhancer.states();
368
- mat.onBeforeRender = (renderer, _scene, camera) => {
369
- const pCam = camera as PerspectiveCamera;
370
- mutates.updatePerFrame(
371
- degreeToRadian(pCam.fov),
372
- renderer.getDrawingBufferSize(_tmpSize).y / renderer.getPixelRatio(),
373
- pCam.far,
374
- camera.position.x,
375
- camera.position.y,
376
- camera.position.z,
377
- camera.matrixWorldInverse,
378
- state,
379
- );
380
- // Keep atlas-size uniforms in sync with the (possibly resized) shared
381
- // DataTexture so glyph pixel rects always normalize to the right UV.
382
- mutates.updateAtlasSizes();
383
- };
384
-
385
- this.material = mat;
386
- this.frustumCulled = false;
387
- }
388
-
389
- /** The text this mesh currently renders (its baked glyph string). */
390
- get text(): string {
391
- return this._text;
392
- }
393
-
394
- /**
395
- * Set a shared atlas texture. When set, setText() will skip creating its own texture.
396
- * The caller is responsible for the texture lifecycle.
397
- */
398
- setAtlasTexture(tex: DataTexture): void {
399
- this._atlasTexture = tex;
400
- this._sharedAtlas = true;
401
- this._enhancer.mutates().setAtlasTexture({ value: tex });
402
- }
403
-
404
- /**
405
- * Set a shared color (COLRv1 RGBA) atlas texture.
406
- * Pass `null` to clear when the font has no color glyphs.
407
- */
408
- setColorAtlasTexture(tex: DataTexture | null): void {
409
- this._enhancer.mutates().setColorAtlasTexture({ value: tex });
410
- }
411
-
412
- /**
413
- * Wire the callbacks the parent batch uses to re-prepare evicted glyphs and
414
- * request a render. `reprepare` must apply the batch's font identity and
415
- * loaded faces so font refcounts stay balanced.
416
- */
417
- setGlyphLifecycleHandlers(
418
- reprepare: (text: string) => Promise<void>,
419
- requestRender: () => void,
420
- ): void {
421
- this._reprepare = reprepare;
422
- this._requestRender = requestRender;
423
- }
424
-
425
- /** Replace the retained glyph set, releasing the old references and (if
426
- * visible) retaining the new ones. */
427
- private _setGlyphKeys(keys: bigint[]): void {
428
- if (this._retainedKeys) {
429
- this._fontManager.releaseGlyphs(
430
- this._fontUrl,
431
- this._highQuality,
432
- this._retainedKeys,
433
- );
434
- this._retainedKeys = null;
435
- }
436
- this._glyphKeys = keys;
437
- this._syncGlyphRefs();
438
- }
439
-
440
- /** Reconcile atlas references with current visibility: retain glyphs while
441
- * on screen, release them when hidden. If shown after the glyphs were
442
- * evicted (cache no longer prepared), re-prepare to re-rasterize them and
443
- * rebuild with fresh metrics. */
444
- private _syncGlyphRefs(): void {
445
- const visible = this.visible && this._glyphKeys.length > 0;
446
-
447
- if (visible && !this._retainedKeys) {
448
- if (
449
- this._reprepare &&
450
- !this._fontManager.isTextPrepared(
451
- this._fontUrl,
452
- this._text,
453
- this._highQuality,
454
- )
455
- ) {
456
- // Glyphs were evicted while hidden; re-rasterize then rebuild (which
457
- // re-enters here with the fresh set and retains it).
458
- const text = this._text;
459
- this._reprepare(text)
460
- .then(() => {
461
- if (this._text === text) {
462
- this.setText(text, true);
463
- this._requestRender?.();
464
- }
465
- })
466
- .catch((err: unknown) => {
467
- console.error("SDFTextMesh: re-prepare on show failed:", err);
468
- });
469
- return;
470
- }
471
- this._fontManager.retainGlyphs(
472
- this._fontUrl,
473
- this._highQuality,
474
- this._glyphKeys,
475
- );
476
- this._retainedKeys = this._glyphKeys;
477
- } else if (!visible && this._retainedKeys) {
478
- this._fontManager.releaseGlyphs(
479
- this._fontUrl,
480
- this._highQuality,
481
- this._retainedKeys,
482
- );
483
- this._retainedKeys = null;
484
- }
485
- }
486
-
487
- /**
488
- * Set text to render. Shapes via WASM, rebuilds instanced geometry, updates atlas texture.
489
- */
490
- setText(text: string, forceUpdate = false): void {
491
- if (text === this._text && !forceUpdate) return;
492
- this._text = text;
493
-
494
- if (!text) {
495
- this.geometry.instanceCount = 0;
496
- this._labelWidthEm = 0;
497
- this._setGlyphKeys([]);
498
- return;
499
- }
500
-
501
- const shapeResult = this._fontManager.shapeText(
502
- this._fontUrl,
503
- text,
504
- this._highQuality,
505
- );
506
- if (!shapeResult) {
507
- this.geometry.instanceCount = 0;
508
- this._labelWidthEm = 0;
509
- this._setGlyphKeys([]);
510
- return;
511
- }
512
-
513
- const atlasData = this._fontManager.getAtlas(
514
- this._fontUrl,
515
- this._highQuality,
516
- );
517
- if (!atlasData) return;
518
-
519
- // Glyph rects are stored in pixel space and normalized in the shader using
520
- // uSdfAtlasSize / uColorAtlasSize, so atlas dimensions aren't needed here.
521
- this._buildGlyphInstances(shapeResult);
522
-
523
- // Skip texture creation if using a shared atlas from the parent container
524
- if (!this._sharedAtlas) {
525
- this._updateAtlasTexture(
526
- atlasData.data,
527
- atlasData.width,
528
- atlasData.height,
529
- atlasData.channels,
530
- );
531
- }
532
- }
533
-
534
- /**
535
- * Update visual properties: color, size, visibility, etc.
536
- */
537
- setFont(fontUrl: string): void {
538
- if (fontUrl === this._fontUrl) return;
539
-
540
- // Release glyph references against the old font before switching; the
541
- // baked instances are about to be rebuilt for the new font anyway.
542
- if (this._retainedKeys) {
543
- this._fontManager.releaseGlyphs(
544
- this._fontUrl,
545
- this._highQuality,
546
- this._retainedKeys,
547
- );
548
- this._retainedKeys = null;
549
- }
550
- this._glyphKeys = [];
551
- this._fontUrl = fontUrl;
552
-
553
- // Clear current atlas since it's tied to the previous font
554
- if (!this._sharedAtlas) {
555
- this._atlasTexture?.dispose();
556
- this._atlasTexture = null;
557
- this._enhancer.mutates().setAtlasTexture({ value: null });
558
- }
559
- }
560
-
561
- setColor(color: Color): void {
562
- this._enhancer.update({
563
- base: { color: color.getHex() },
564
- });
565
- }
566
-
567
- setFontSize(size: number): void {
568
- this._enhancer.update({ base: { fontSize: size } });
569
- }
570
-
571
- setSizeInMeters(enabled: boolean): void {
572
- this._enhancer.update({ base: { sizeInMeters: enabled } });
573
- }
574
-
575
- setCenter(x: number, y: number): void {
576
- this._enhancer.update({ base: { center: [x, y] } });
577
- }
578
-
579
- setHeight(height: number): void {
580
- this._enhancer.update({ base: { addHeight: height } });
581
- }
582
-
583
- setSize(size: number): void {
584
- const currentFontSize = this._enhancer.states().fontSize;
585
- const sanitizedSize = Number.isFinite(size)
586
- ? Math.max(0.0, size)
587
- : currentFontSize;
588
- this._enhancer.update({ base: { fontSize: sanitizedSize } });
589
- }
590
-
591
- setOpacity(opacity: number): void {
592
- const clampedOpacity = Number.isFinite(opacity)
593
- ? Math.max(0, Math.min(1, opacity))
594
- : 1.0;
595
- this._enhancer.update({ base: { opacity: clampedOpacity } });
596
- }
597
-
598
- /**
599
- * Set this label's declutter fade target; the actual hide factor animates
600
- * toward it in {@link stepDeclutterFade}. Deliberately separate from
601
- * `visible`/`show`: it neither churns scene-graph membership nor releases
602
- * glyph atlas retains, so the declutter pass can retarget it every
603
- * placement run at no cost.
604
- */
605
- setDeclutterHidden(hidden: boolean): void {
606
- this._declutterTarget = hidden ? 1 : 0;
607
- }
608
-
609
- /**
610
- * Advance the hide factor toward its target by `deltaMs / DECLUTTER_FADE_MS`
611
- * and push it to the uniform. Returns true while still mid-fade.
612
- */
613
- stepDeclutterFade(deltaMs: number): boolean {
614
- const target = this._declutterTarget;
615
- let value = this._declutterHide;
616
- if (value === target) return false;
617
-
618
- const step = deltaMs / DECLUTTER_FADE_MS;
619
- value =
620
- value < target
621
- ? Math.min(value + step, target)
622
- : Math.max(value - step, target);
623
- this._declutterHide = value;
624
- this._enhancer.mutates().setDeclutterHide(value);
625
- return value !== target;
626
- }
627
-
628
- _setFeatureWidth(_width: number): void {
629
- // Width is not applicable to text meshes.
630
- // This method is intentionally a no-op to satisfy the FeatureMesh guard.
631
- }
632
- _setFeatureOpacity(opacity: number): void {
633
- const clampedOpacity = Number.isFinite(opacity)
634
- ? Math.max(0, Math.min(1, opacity))
635
- : 1.0;
636
- this._enhancer.update({ base: { opacity: clampedOpacity } });
637
- }
638
-
639
- setPosition(
640
- position: Float32Array | { high: Float32Array; low: Float32Array },
641
- RTE: boolean,
642
- transform: Transform,
643
- ): void {
644
- this._enhancer
645
- .mutates()
646
- .setPosition(position, RTE, [transform.tx, transform.ty, transform.tz]);
647
- this._cacheWorldAnchor(position, RTE, [
648
- transform.tx,
649
- transform.ty,
650
- transform.tz,
651
- ]);
652
- }
653
-
654
- /** Reconstruct the absolute ECEF anchor from the same inputs the position
655
- * uniforms receive (RTE high+low split, or RTC-relative + center). */
656
- private _cacheWorldAnchor(
657
- position: Float32Array | { high: Float32Array; low: Float32Array },
658
- RTE: boolean,
659
- rtcCenter: [number, number, number],
660
- ): void {
661
- const anchor = this._worldAnchor;
662
- if (RTE) {
663
- const p = position as { high: Float32Array; low: Float32Array };
664
- anchor[0] = p.high[0] + p.low[0];
665
- anchor[1] = p.high[1] + p.low[1];
666
- anchor[2] = (p.high[2] ?? 0.0) + (p.low[2] ?? 0.0);
667
- } else {
668
- const p = position as Float32Array;
669
- anchor[0] = p[0] + rtcCenter[0];
670
- anchor[1] = p[1] + rtcCenter[1];
671
- anchor[2] = (p[2] ?? 0.0) + rtcCenter[2];
672
- }
673
- }
674
-
675
- /**
676
- * Build this label's collision candidate for the shared declutter pass, or
677
- * null when it doesn't participate (declutter disabled, hidden, or empty).
678
- * The local box mirrors the vertex shader's layout: glyphs span
679
- * [0, textWidth] × [bgMinY, bgMaxY] in em units, shifted by the `center`
680
- * anchor and scaled by the font size (sdfText.vert.glsl:111-115).
681
- */
682
- getDeclutterCandidate(
683
- owner: DeclutterParticipant,
684
- handle: number,
685
- ): DeclutterCandidate | null {
686
- if (!this._declutter || !this.visible || this._labelWidthEm <= 0) {
687
- return null;
688
- }
689
- const state = this._enhancer.states();
690
- const size = state.fontSize;
691
- if (size <= 0) return null;
692
-
693
- const cx = Math.min(Math.max(state.center[0], -0.5), 0.5);
694
- const cy = Math.min(Math.max(state.center[1], -0.5), 0.5);
695
- const w = this._labelWidthEm;
696
- const h = this._labelHeightEm;
697
- return {
698
- anchorX: this._worldAnchor[0],
699
- anchorY: this._worldAnchor[1],
700
- anchorZ: this._worldAnchor[2],
701
- addHeight: state.addHeight,
702
- minX: (0 - cx * w) * size,
703
- maxX: (w - cx * w) * size,
704
- minY: (this._labelMinYEm - cy * h) * size,
705
- maxY: (this._labelMaxYEm - cy * h) * size,
706
- sizeInMeters: state.sizeInMeters,
707
- priority: this._declutterPriorityOverride ?? this._declutterPriority,
708
- isShown: this._declutterTarget === 0,
709
- owner,
710
- handle,
711
- };
712
- }
713
-
714
- /**
715
- * Set a per-feature placement priority (higher wins), overriding the
716
- * layer-level `declutterPriority` for this label. Pass `undefined` to fall
717
- * back to the layer value. Driven by the feature evaluator.
718
- */
719
- setDeclutterPriority(priority: number | undefined): void {
720
- this._declutterPriorityOverride = priority;
721
- }
722
-
723
- /**
724
- * Apply material properties from WASM TextMaterial.
725
- * Maps relevant properties to enhancer updates, with change tracking.
726
- */
727
- update(material: NavaraTextMaterial, forceUpdate = false): void {
728
- const fontUrl = material.font ?? this._fontUrl;
729
- this.setFont(fontUrl);
730
-
731
- // Layout properties are baked into the glyph instance geometry, so a
732
- // change forces a rebuild even when the text itself is unchanged.
733
- const nextMaxWidth = material.maxWidth ?? 0;
734
- const nextLineHeight = material.lineHeight ?? 1.0;
735
- const nextTextAlign = ALIGN_FACTORS[material.textAlign ?? "center"] ?? 0.5;
736
- const layoutChanged =
737
- nextMaxWidth !== this._maxWidth ||
738
- nextLineHeight !== this._lineHeight ||
739
- nextTextAlign !== this._textAlign;
740
- this._maxWidth = nextMaxWidth;
741
- this._lineHeight = nextLineHeight;
742
- this._textAlign = nextTextAlign;
743
-
744
- const nextText = material.text;
745
- if (nextText !== undefined && nextText !== "") {
746
- this.setText(nextText, forceUpdate || layoutChanged);
747
- } else if (forceUpdate || layoutChanged) {
748
- // Font or layout changed — re-render existing text with the new settings
749
- this.setText(this._text, true);
750
- }
751
-
752
- // Read before the visibility early-return: the declutter pass consults
753
- // these even while other style state is skipped for hidden labels.
754
- const nextDeclutter = material.declutter ?? true;
755
- if (this._declutter && !nextDeclutter) {
756
- // Leaving declutter mode: clear any hide the pass applied — the mesh
757
- // stops producing candidates, so nothing else would ever re-show it.
758
- this.setDeclutterHidden(false);
759
- }
760
- this._declutter = nextDeclutter;
761
- this._declutterPriority = material.declutterPriority ?? 0;
762
-
763
- this.visible = (material.show ?? true) && !!this._text;
764
- this._syncGlyphRefs();
765
- if (!this.visible) return;
766
-
767
- const state = this._enhancer.states();
768
-
769
- // Build props for enhancer update
770
- const baseProps: SdfTextBaseProps = {};
771
- let hasUpdate = false;
772
-
773
- const nextColor = material.color ?? 0xffffff;
774
- if (nextColor !== state.color.getHex()) {
775
- baseProps.color = nextColor;
776
- hasUpdate = true;
777
- }
778
-
779
- const nextOpacityRaw = material.opacity ?? 1.0;
780
- const nextOpacity = Number.isFinite(nextOpacityRaw)
781
- ? Math.max(0.0, Math.min(1.0, nextOpacityRaw))
782
- : 1.0;
783
- if (nextOpacity !== state.opacity) {
784
- baseProps.opacity = nextOpacity;
785
- hasUpdate = true;
786
- }
787
-
788
- const nextFontSize = material.size ?? 16.0;
789
- if (nextFontSize !== state.fontSize) {
790
- baseProps.fontSize = nextFontSize;
791
- hasUpdate = true;
792
- }
793
-
794
- const nextCenterX = material.center?.x ?? 0.5;
795
- const nextCenterY = material.center?.y ?? 0.0;
796
- if (nextCenterX !== state.center[0] || nextCenterY !== state.center[1]) {
797
- baseProps.center = [nextCenterX, nextCenterY];
798
- hasUpdate = true;
799
- }
800
-
801
- const nextSizeInMeters = material.sizeInMeters ?? true;
802
- if (nextSizeInMeters !== state.sizeInMeters) {
803
- baseProps.sizeInMeters = nextSizeInMeters;
804
- hasUpdate = true;
805
- }
806
-
807
- const nextDepthTest = material.depthTest ?? true;
808
- if (nextDepthTest !== state.depthTest) {
809
- baseProps.depthTest = nextDepthTest;
810
- hasUpdate = true;
811
- }
812
-
813
- const nextOffsetDepth = material.offsetDepth ?? true;
814
- if (nextOffsetDepth !== state.offsetDepth) {
815
- baseProps.offsetDepth = nextOffsetDepth;
816
- hasUpdate = true;
817
- }
818
-
819
- const nextHeight = material.height ?? 0;
820
- if (nextHeight !== state.addHeight) {
821
- baseProps.addHeight = nextHeight;
822
- hasUpdate = true;
823
- }
824
-
825
- const nextOutlineWidth = material.outlineWidth ?? 0;
826
- if (nextOutlineWidth / sdfRadiusFor(state.useMsdf) !== state.outlineWidth) {
827
- baseProps.outlineWidth = nextOutlineWidth;
828
- hasUpdate = true;
829
- }
830
-
831
- const nextOutlineColor = material.outlineColor ?? 0x000000;
832
- if (nextOutlineColor !== state.outlineColor.getHex()) {
833
- baseProps.outlineColor = nextOutlineColor;
834
- hasUpdate = true;
835
- }
836
-
837
- const nextOutlineOpacityRaw = material.outlineOpacity ?? 1.0;
838
- const nextOutlineOpacity = Number.isFinite(nextOutlineOpacityRaw)
839
- ? Math.max(0.0, Math.min(1.0, nextOutlineOpacityRaw))
840
- : 1.0;
841
- if (nextOutlineOpacity !== state.outlineOpacity) {
842
- baseProps.outlineOpacity = nextOutlineOpacity;
843
- hasUpdate = true;
844
- }
845
-
846
- const nextBGColor = material.backgroundColor;
847
- if (nextBGColor !== undefined) {
848
- if (!state.showBackground) {
849
- baseProps.showBackground = true;
850
- hasUpdate = true;
851
- }
852
- if (nextBGColor !== state.backgroundColor.getHex()) {
853
- baseProps.backgroundColor = nextBGColor;
854
- hasUpdate = true;
855
- }
856
- } else if (state.showBackground) {
857
- baseProps.showBackground = false;
858
- hasUpdate = true;
859
- }
860
-
861
- const nextBGOutlineColor = material.borderColor ?? 0x000000;
862
- if (nextBGOutlineColor !== state.backgroundOutlineColor.getHex()) {
863
- baseProps.backgroundOutlineColor = nextBGOutlineColor;
864
- hasUpdate = true;
865
- }
866
-
867
- const nextBGOutlineWidth = material.borderWidth ?? 0;
868
- if (nextBGOutlineWidth !== state.backgroundOutlineWidth) {
869
- baseProps.backgroundOutlineWidth = nextBGOutlineWidth;
870
- hasUpdate = true;
871
- }
872
-
873
- if (material.transparent !== undefined) {
874
- const nextTransparent = material.transparent;
875
- if (nextTransparent !== state.transparent) {
876
- baseProps.transparent = nextTransparent;
877
- hasUpdate = true;
878
- }
879
- }
880
-
881
- if (hasUpdate) {
882
- this._enhancer.update({ base: baseProps });
883
- }
884
- }
885
-
886
- // --- FeatureMesh interface ---
887
-
888
- _setFeatureColor(color: Color): void {
889
- this.setColor(color);
890
- }
891
-
892
- _getFeatureColor(): Color {
893
- const state = this._enhancer.states();
894
- return state.color;
895
- }
896
-
897
- _setFeatureShow(visible: boolean): void {
898
- this.visible = visible;
899
- this._syncGlyphRefs();
900
- }
901
-
902
- _setFeatureExtrudedHeight(_height: number): void {
903
- throw new Unimplemented();
904
- }
905
-
906
- _setFeatureHeight(height: number): void {
907
- this.setHeight(height);
908
- }
909
-
910
- _setFrustumCulled(culled: boolean): void {
911
- this.frustumCulled = culled;
912
- }
913
-
914
- // --- PickableMesh interface ---
915
-
916
- onBeforePicking(): void {
917
- this._enhancer.update({ base: { pickable: true } });
918
- }
919
-
920
- onAfterPicking(): void {
921
- this._enhancer.update({ base: { pickable: false } });
922
- }
923
-
924
- getRenderable(): Object3D {
925
- return this;
926
- }
927
-
928
- // --- Cleanup ---
929
-
930
- dispose(): void {
931
- // Release atlas glyph references so they can be evicted once unreferenced.
932
- if (this._retainedKeys) {
933
- this._fontManager.releaseGlyphs(
934
- this._fontUrl,
935
- this._highQuality,
936
- this._retainedKeys,
937
- );
938
- this._retainedKeys = null;
939
- }
940
- this.geometry?.dispose();
941
- if (!this._sharedAtlas) {
942
- this._atlasTexture?.dispose();
943
- }
944
- // Color atlas is always FontManager-owned; never disposed here.
945
- this.material?.dispose();
946
- }
947
-
948
- // --- Private ---
949
-
950
- private _createBaseGeometry(): InstancedBufferGeometry {
951
- const geo = new InstancedBufferGeometry();
952
-
953
- // Unit quad: 2 triangles, 6 vertices
954
- // prettier-ignore
955
- const positions = new Float32Array([
956
- -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0,
957
- -0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
958
- ]);
959
- // prettier-ignore
960
- const uvs = new Float32Array([
961
- 0, 0, 1, 0, 1, 1,
962
- 0, 0, 1, 1, 0, 1,
963
- ]);
964
-
965
- geo.setAttribute("position", new BufferAttribute(positions, 3));
966
- geo.setAttribute("uv", new BufferAttribute(uvs, 2));
967
-
968
- geo.setAttribute(
969
- "glyphOffset",
970
- new InstancedBufferAttribute(new Float32Array(), 2),
971
- );
972
- geo.setAttribute(
973
- "glyphSize",
974
- new InstancedBufferAttribute(new Float32Array(), 2),
975
- );
976
- geo.setAttribute(
977
- "glyphUvRect",
978
- new InstancedBufferAttribute(new Float32Array(), 4),
979
- );
980
- /**
981
- * Per-instance flag: 1.0 → sample the COLRv1 color atlas, 0.0 → sample SDF.
982
- * Lets one batched mesh mix text and emoji from the same font family.
983
- */
984
- geo.setAttribute(
985
- "glyphIsColor",
986
- new InstancedBufferAttribute(new Float32Array(), 1),
987
- );
988
-
989
- geo.instanceCount = 0;
990
-
991
- return geo;
992
- }
993
-
994
- private _buildGlyphInstances(shapeResult: ShapeTextResult): void {
995
- const { glyphs, metrics, unitsPerEm, ascender, descender, lineGap } =
996
- shapeResult;
997
-
998
- // Build composite key -> metrics lookup.
999
- // Keys are pre-computed by the WASM font worker (composite_key in Rust)
1000
- // to ensure the key layout is always in sync between Rust and TypeScript.
1001
- const metricsMap = new Map<bigint, GlyphMetrics>();
1002
- for (const m of metrics) {
1003
- metricsMap.set(m.compositeKey, m);
1004
- }
1005
-
1006
- const fontUnitToSdfPx = SDF_PX_SIZE / unitsPerEm;
1007
- const fontUnitToColorPx = COLOR_GLYPH_PX_SIZE / unitsPerEm;
1008
-
1009
- // Baseline-to-baseline distance in font units. Older cached results may
1010
- // predate line metrics; fall back to one em.
1011
- const naturalLineHeight = ascender - descender + lineGap;
1012
- const lineHeightFu =
1013
- (naturalLineHeight > 0 ? naturalLineHeight : unitsPerEm) *
1014
- this._lineHeight;
1015
-
1016
- // `_maxWidth` is in ems so the wrap width tracks the font size in both
1017
- // sizeInMeters modes; font units are ems × unitsPerEm.
1018
- const lines = breakLines(
1019
- glyphs,
1020
- this._maxWidth * unitsPerEm,
1021
- isRtlText(this._text),
1022
- );
1023
- const widths = lines.map(lineWidthFu);
1024
- const blockWidthFu = Math.max(...widths);
1025
-
1026
- const renderable: {
1027
- offsetEmX: number;
1028
- offsetEmY: number;
1029
- sizeEmX: number;
1030
- sizeEmY: number;
1031
- uvL: number;
1032
- uvT: number;
1033
- uvR: number;
1034
- uvB: number;
1035
- isColor: boolean;
1036
- }[] = [];
1037
-
1038
- // Unique atlas glyphs this mesh references — retained while it is visible.
1039
- const glyphKeys = new Set<bigint>();
1040
-
1041
- // Lay lines out top-down: line 0 keeps its baseline at y=0 (identical to
1042
- // the old single-line behavior), later baselines step down by the line
1043
- // height. Alignment shifts each line within the widest line's width.
1044
- // Each glyph carries its own normalization scale so SDF and color glyphs
1045
- // share one em-space coordinate system downstream — the two paths may use
1046
- // different raster sizes (SDF_PX_SIZE vs COLOR_GLYPH_PX_SIZE), but both
1047
- // end up in [em]-units after dividing by their respective px.
1048
- for (let li = 0; li < lines.length; li++) {
1049
- let cursorX = (blockWidthFu - widths[li]) * this._textAlign;
1050
- let cursorY = -li * lineHeightFu;
1051
-
1052
- for (const glyph of lines[li]) {
1053
- const m = metricsMap.get(glyph.compositeKey);
1054
- if (m && m.atlasW > 0 && m.atlasH > 0) {
1055
- glyphKeys.add(glyph.compositeKey);
1056
- const px = m.isColor ? COLOR_GLYPH_PX_SIZE : SDF_PX_SIZE;
1057
- const fuToPx = m.isColor ? fontUnitToColorPx : fontUnitToSdfPx;
1058
- const offsetPxX = (cursorX + glyph.xOffset) * fuToPx + m.bearingX;
1059
- const offsetPxY = (cursorY + glyph.yOffset) * fuToPx + m.bearingY;
1060
-
1061
- // Atlas rects are stored in PIXEL space; the shader divides by the
1062
- // current uSdfAtlasSize / uColorAtlasSize uniform to derive UVs, so
1063
- // these instance attrs survive an atlas resize without rebuilding.
1064
- renderable.push({
1065
- offsetEmX: offsetPxX / px,
1066
- offsetEmY: offsetPxY / px,
1067
- sizeEmX: m.atlasW / px,
1068
- sizeEmY: m.atlasH / px,
1069
- uvL: m.atlasX,
1070
- uvT: m.atlasY,
1071
- uvR: m.atlasX + m.atlasW,
1072
- uvB: m.atlasY + m.atlasH,
1073
- isColor: m.isColor,
1074
- });
1075
- }
1076
- cursorX += glyph.xAdvance;
1077
- cursorY += glyph.yAdvance;
1078
- }
1079
- }
1080
-
1081
- // Update atlas references to this mesh's new glyph set (retains/releases
1082
- // as needed based on visibility).
1083
- this._setGlyphKeys([...glyphKeys]);
1084
-
1085
- const count = renderable.length;
1086
- if (count === 0) {
1087
- this.geometry.instanceCount = 0;
1088
- this._labelWidthEm = 0;
1089
- return;
1090
- }
1091
-
1092
- // Text-width metric uses SDF scale; same em-space as the SDF path used to.
1093
- // The block is as wide as its widest line and grows downward by one line
1094
- // height per extra line (a single line stays exactly one em tall).
1095
- const textWidth = blockWidthFu * fontUnitToSdfPx;
1096
- const textHeight =
1097
- SDF_PX_SIZE + (lines.length - 1) * lineHeightFu * fontUnitToSdfPx;
1098
-
1099
- const glyphOffsetData = new Float32Array((count + 1) * 2);
1100
- const glyphSizeData = new Float32Array((count + 1) * 2);
1101
- const glyphUvRectData = new Float32Array((count + 1) * 4);
1102
- const glyphIsColorData = new Float32Array(count + 1);
1103
-
1104
- // Compute actual Y bounding box of all rendered glyphs
1105
- let bgMinY = Infinity;
1106
- let bgMaxY = -Infinity;
1107
-
1108
- // Index 0 is reserved for the background instance (drawn first).
1109
- // Glyph data starts at index 1.
1110
- for (let i = 0; i < count; i++) {
1111
- const g = renderable[i];
1112
- const j = i + 1; // offset by 1 to leave index 0 for background
1113
-
1114
- glyphOffsetData[j * 2] = g.offsetEmX;
1115
- glyphOffsetData[j * 2 + 1] = g.offsetEmY;
1116
-
1117
- glyphSizeData[j * 2] = g.sizeEmX;
1118
- glyphSizeData[j * 2 + 1] = g.sizeEmY;
1119
-
1120
- bgMinY = Math.min(bgMinY, g.offsetEmY);
1121
- bgMaxY = Math.max(bgMaxY, g.offsetEmY + g.sizeEmY);
1122
-
1123
- glyphUvRectData[j * 4] = g.uvL;
1124
- glyphUvRectData[j * 4 + 1] = g.uvT;
1125
- glyphUvRectData[j * 4 + 2] = g.uvR;
1126
- glyphUvRectData[j * 4 + 3] = g.uvB;
1127
-
1128
- glyphIsColorData[j] = g.isColor ? 1.0 : 0.0;
1129
- }
1130
-
1131
- // Recreate geometry if instance count grew beyond the current capacity.
1132
- if (this.geometry.instanceCount < count + 1) {
1133
- this.geometry.dispose();
1134
- this.geometry = this._createBaseGeometry();
1135
- }
1136
-
1137
- this._setInstanceAttribute("glyphOffset", glyphOffsetData, 2);
1138
- this._setInstanceAttribute("glyphSize", glyphSizeData, 2);
1139
- this._setInstanceAttribute("glyphUvRect", glyphUvRectData, 4);
1140
- this._setInstanceAttribute("glyphIsColor", glyphIsColorData, 1);
1141
-
1142
- this.geometry.instanceCount = count + 1;
1143
-
1144
- // Update text dimension uniforms via mutates, mirroring them CPU-side for
1145
- // the declutter pass's collision box.
1146
- this._labelWidthEm = textWidth / SDF_PX_SIZE;
1147
- this._labelHeightEm = textHeight / SDF_PX_SIZE;
1148
- this._labelMinYEm = bgMinY === Infinity ? 0.0 : bgMinY;
1149
- this._labelMaxYEm = bgMaxY === -Infinity ? 1.0 : bgMaxY;
1150
- this._enhancer
1151
- .mutates()
1152
- .updateTextDimensions(
1153
- this._labelWidthEm,
1154
- this._labelHeightEm,
1155
- this._labelMinYEm,
1156
- this._labelMaxYEm,
1157
- );
1158
- }
1159
-
1160
- private _setInstanceAttribute(
1161
- name: string,
1162
- data: Float32Array,
1163
- itemSize: number,
1164
- ): void {
1165
- if (this.geometry.hasAttribute(name)) this.geometry.deleteAttribute(name);
1166
- this.geometry.setAttribute(
1167
- name,
1168
- new InstancedBufferAttribute(data, itemSize),
1169
- );
1170
- }
1171
-
1172
- private _updateAtlasTexture(
1173
- data: Uint8Array,
1174
- width: number,
1175
- height: number,
1176
- channels: number,
1177
- ): void {
1178
- if (this._atlasTexture) {
1179
- // Update existing texture in-place (atlas dimensions are constant)
1180
- this._atlasTexture.image = { data, width, height };
1181
- this._atlasTexture.needsUpdate = true;
1182
- return;
1183
- }
1184
-
1185
- const tex = createSdfAtlasTexture(data, width, height, channels);
1186
- this._atlasTexture = tex;
1187
- this._enhancer.mutates().setAtlasTexture({ value: tex });
1188
- }
1189
- }