three-text 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,10 +71,10 @@ three-text has a framework-agnostic core that processes fonts and generates geom
71
71
  - **`three-text/mesh/webgpu`** - WebGPU mesh buffer utility
72
72
  - **`three-text/mesh/p5`** - p5.js adapter
73
73
  - **`three-text/core`** - Framework-agnostic core (returns raw arrays)
74
- - **`three-text/vector`** - Vector rendering (Loop-Blinn and Kokojima stencil fill, resolution-independent)
74
+ - **`three-text/vector`** - Vector rendering (Loop-Blinn curve eval + Kokojima stencil fill), `Text.create()` returns a `THREE.Group` ready for `scene.add()`
75
75
  - **`three-text/vector/react`** - React Three Fiber component for vector text
76
- - **`three-text/vector/webgl`** - WebGL vector renderer
77
- - **`three-text/vector/webgpu`** - WebGPU vector renderer
76
+ - **`three-text/vector/webgl`** - Raw WebGL2 vector renderer (no Three.js dependency)
77
+ - **`three-text/vector/webgpu`** - Raw WebGPU vector renderer (no Three.js dependency)
78
78
  - **`three-text/webgl`** - Deprecated, use `three-text/mesh/webgl`
79
79
  - **`three-text/webgpu`** - Deprecated, use `three-text/mesh/webgpu`
80
80
  - **`three-text/p5`** - Deprecated, use `three-text/mesh/p5`
@@ -121,20 +121,18 @@ Resolution-independent outlines via Loop-Blinn stencil passes (see [Vector rende
121
121
 
122
122
  ```javascript
123
123
  import { Text } from 'three-text/vector';
124
- import { woff2Decode } from 'woff-lib/woff2/decode';
125
124
 
126
125
  Text.setHarfBuzzPath('/hb/hb.wasm');
127
- Text.enableWoff2(woff2Decode);
128
126
  const result = await Text.create({
129
127
  text: 'Hello Vector',
130
128
  font: '/fonts/Font.woff2',
131
- size: 72
129
+ size: 72,
130
+ color: '#ffffff'
132
131
  });
133
-
134
- const vectorData = result.geometryData;
132
+ scene.add(result.group);
135
133
  ```
136
134
 
137
- Use `createVectorMeshes(vectorData)` from `three-text/vector` for TSL / `WebGPURenderer`, or build your own stencil materials (see [Vector rendering](#vector-rendering))
135
+ `Text.create()` handles stencil setup, render ordering, and geometry centering internally. Pass `positionNode` and `colorNode` for TSL animation/styling. For raw WebGL2 or WebGPU without Three.js, see [Vector rendering](#vector-rendering)
138
136
 
139
137
  #### Mesh + vector in one scene
140
138
 
@@ -142,7 +140,7 @@ Alias one import to avoid the name collision between `Text` components. The entr
142
140
 
143
141
  ```javascript
144
142
  import { Text as MeshText } from 'three-text';
145
- import { Text as VectorText, createVectorMeshes } from 'three-text/vector';
143
+ import { Text as VectorText } from 'three-text/vector';
146
144
 
147
145
  MeshText.setHarfBuzzPath('/hb/hb.wasm');
148
146
 
@@ -156,10 +154,10 @@ scene.add(new THREE.Mesh(heading.geometry, material));
156
154
  const caption = await VectorText.create({
157
155
  text: 'Caption text',
158
156
  font: '/fonts/Font.woff2',
159
- size: 24
157
+ size: 24,
158
+ color: '#ffffff'
160
159
  });
161
- const { interiorMesh, curveMesh, fillMesh } = createVectorMeshes(caption.geometryData);
162
- scene.add(interiorMesh, curveMesh, fillMesh);
160
+ scene.add(caption.group);
163
161
  ```
164
162
 
165
163
  #### React Three Fiber — mesh
@@ -477,8 +475,9 @@ three-text/
477
475
  │ │ ├── react.tsx # React component export
478
476
  │ │ └── ThreeText.tsx # React Three Fiber component
479
477
  │ ├── vector/ # Vector rendering (Loop-Blinn)
480
- │ │ ├── index.ts # Vector entry point and TSL re-exports
481
- │ │ ├── loopBlinnTSL.ts # TSL adapter for Three.js WebGPURenderer
478
+ │ │ ├── index.ts # Main entry point (wraps core + Three.js integration)
479
+ │ │ ├── core.ts # Three.js-free layout engine (used by raw WebGL/WebGPU)
480
+ │ │ ├── loopBlinnTSL.ts # TSL stencil materials and mesh construction
482
481
  │ │ ├── LoopBlinnGeometry.ts # Fan triangulation + curve extraction
483
482
  │ │ ├── GlyphVectorGeometryBuilder.ts # Outline collection and geometry packing
484
483
  │ │ ├── GlyphOutlineCollector.ts # Collects draw callbacks for vector path
@@ -537,7 +536,7 @@ The multi-stage geometry approach (curve polygonization followed by cleanup, the
537
536
 
538
537
  The vector pipeline (`three-text/vector`) renders glyphs directly from their mathematical outlines without tessellation or curve flattening. Text stays sharp at any zoom level and the geometry footprint is small -- just the control points of each curve
539
538
 
540
- Curves use the [Loop-Blinn](https://www.microsoft.com/en-us/research/wp-content/uploads/2005/01/p1000-loop.pdf) technique: each quadratic curve is rendered as a triangle whose fragment shader evaluates `u² - v` to resolve inside/outside, with screen-space derivatives producing a signed distance that feeds alpha-to-coverage for smooth MSAA edges. Glyph interiors use [Kokojima et al.](https://dl.acm.org/doi/10.1145/1179849.1179997) stencil filling: fan-triangulate, stencil XOR, fill where nonzero
539
+ Curves use the [Loop-Blinn](https://www.microsoft.com/en-us/research/wp-content/uploads/2005/01/p1000-loop.pdf) technique: each quadratic curve is rendered as a triangle whose fragment shader evaluates `u² - v` to resolve inside/outside, with screen-space derivatives producing a signed distance that feeds alpha-to-coverage for smooth MSAA edges. Glyph interiors use [Kokojima et al.](https://dl.acm.org/doi/10.1145/1179849.1179997) stencil filling with a nonzero winding rule (`INCR_WRAP`/`DECR_WRAP`), which correctly handles overlapping contours in variable fonts and connected scripts
541
540
 
542
541
  An alternative for this sort of resolution-independent rendering is [Slug](https://github.com/EricLengyel/Slug) by Eric Lengyel, which casts rays against all curves per fragment to compute winding numbers. Loop-Blinn was chosen here because it integrates with hardware MSAA and alpha-to-coverage directly, without the overhead of adaptive supersampling that Slug requires for comparable antialiasing
543
542
 
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /*!
2
2
  * @license
3
- * three-text v0.5.0
3
+ * three-text v0.5.2
4
4
  * Copyright © 2025-2026 Jeremy Tribby, Countertype LLC
5
5
  * SPDX-License-Identifier: MIT
6
6
  */
@@ -3263,6 +3263,8 @@ class Text {
3263
3263
  static { this.patternCache = new Map(); }
3264
3264
  static { this.hbInitPromise = null; }
3265
3265
  static { this.fontCache = new Map(); }
3266
+ static { this.fontLoadPromises = new Map(); }
3267
+ static { this.fontRefCounts = new Map(); }
3266
3268
  static { this.fontCacheMemoryBytes = 0; }
3267
3269
  static { this.maxFontCacheMemoryBytes = Infinity; }
3268
3270
  static { this.fontIdCounter = 0; }
@@ -3307,9 +3309,9 @@ class Text {
3307
3309
  if (!Text.hbInitPromise) {
3308
3310
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
3309
3311
  }
3310
- const loadedFont = await Text.resolveFont(options);
3312
+ const { loadedFont, fontKey } = await Text.resolveFont(options);
3311
3313
  const text = new Text();
3312
- text.setLoadedFont(loadedFont);
3314
+ text.setLoadedFont(loadedFont, fontKey);
3313
3315
  const result = await text.createLayout(options);
3314
3316
  const update = async (newOptions) => {
3315
3317
  const mergedOptions = { ...options };
@@ -3322,8 +3324,8 @@ class Text {
3322
3324
  if (newOptions.font !== undefined ||
3323
3325
  newOptions.fontVariations !== undefined ||
3324
3326
  newOptions.fontFeatures !== undefined) {
3325
- const newLoadedFont = await Text.resolveFont(mergedOptions);
3326
- text.setLoadedFont(newLoadedFont);
3327
+ const { loadedFont: newLoadedFont, fontKey: newFontKey } = await Text.resolveFont(mergedOptions);
3328
+ text.setLoadedFont(newLoadedFont, newFontKey);
3327
3329
  text.resetHelpers();
3328
3330
  }
3329
3331
  options = mergedOptions;
@@ -3344,6 +3346,22 @@ class Text {
3344
3346
  dispose: () => text.destroy()
3345
3347
  };
3346
3348
  }
3349
+ static retainFont(fontKey) {
3350
+ Text.fontRefCounts.set(fontKey, (Text.fontRefCounts.get(fontKey) ?? 0) + 1);
3351
+ }
3352
+ static releaseFont(fontKey, loadedFont) {
3353
+ const nextCount = (Text.fontRefCounts.get(fontKey) ?? 0) - 1;
3354
+ if (nextCount > 0) {
3355
+ Text.fontRefCounts.set(fontKey, nextCount);
3356
+ return;
3357
+ }
3358
+ Text.fontRefCounts.delete(fontKey);
3359
+ // Cached fonts stay alive while present in the cache. If a font has been
3360
+ // evicted, destroy it once the last live handle releases it.
3361
+ if (!Text.fontCache.has(fontKey)) {
3362
+ FontLoader.destroyFont(loadedFont);
3363
+ }
3364
+ }
3347
3365
  static async resolveFont(options) {
3348
3366
  const baseFontKey = typeof options.font === 'string'
3349
3367
  ? options.font
@@ -3357,9 +3375,17 @@ class Text {
3357
3375
  }
3358
3376
  let loadedFont = Text.fontCache.get(fontKey);
3359
3377
  if (!loadedFont) {
3360
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
3378
+ let loadPromise = Text.fontLoadPromises.get(fontKey);
3379
+ if (!loadPromise) {
3380
+ loadPromise = Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures).finally(() => {
3381
+ Text.fontLoadPromises.delete(fontKey);
3382
+ });
3383
+ Text.fontLoadPromises.set(fontKey, loadPromise);
3384
+ }
3385
+ loadedFont = await loadPromise;
3361
3386
  }
3362
- return loadedFont;
3387
+ Text.retainFont(fontKey);
3388
+ return { loadedFont, fontKey };
3363
3389
  }
3364
3390
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
3365
3391
  const tempText = new Text();
@@ -3391,8 +3417,12 @@ class Text {
3391
3417
  const firstKey = Text.fontCache.keys().next().value;
3392
3418
  if (firstKey === undefined)
3393
3419
  break;
3420
+ const font = Text.fontCache.get(firstKey);
3394
3421
  Text.trackFontCacheRemove(firstKey);
3395
3422
  Text.fontCache.delete(firstKey);
3423
+ if ((Text.fontRefCounts.get(firstKey) ?? 0) <= 0 && font) {
3424
+ FontLoader.destroyFont(font);
3425
+ }
3396
3426
  }
3397
3427
  }
3398
3428
  static generateFontContentHash(buffer) {
@@ -3414,8 +3444,12 @@ class Text {
3414
3444
  return `c${++Text.fontIdCounter}`;
3415
3445
  }
3416
3446
  }
3417
- setLoadedFont(loadedFont) {
3447
+ setLoadedFont(loadedFont, fontKey) {
3448
+ if (this.loadedFont && this.loadedFont !== loadedFont) {
3449
+ this.releaseCurrentFont();
3450
+ }
3418
3451
  this.loadedFont = loadedFont;
3452
+ this.currentFontCacheKey = fontKey;
3419
3453
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
3420
3454
  this.currentFontId = `font_${contentHash}`;
3421
3455
  if (loadedFont.fontVariations) {
@@ -3425,6 +3459,29 @@ class Text {
3425
3459
  this.currentFontId += `_feat_${Text.stableStringify(loadedFont.fontFeatures)}`;
3426
3460
  }
3427
3461
  }
3462
+ releaseCurrentFont() {
3463
+ if (!this.loadedFont)
3464
+ return;
3465
+ const currentFont = this.loadedFont;
3466
+ const currentFontKey = this.currentFontCacheKey;
3467
+ try {
3468
+ if (currentFontKey) {
3469
+ Text.releaseFont(currentFontKey, currentFont);
3470
+ }
3471
+ else {
3472
+ FontLoader.destroyFont(currentFont);
3473
+ }
3474
+ }
3475
+ catch (error) {
3476
+ logger.warn('Error destroying HarfBuzz objects:', error);
3477
+ }
3478
+ finally {
3479
+ this.loadedFont = undefined;
3480
+ this.currentFontCacheKey = undefined;
3481
+ this.textLayout = undefined;
3482
+ this.textShaper = undefined;
3483
+ }
3484
+ }
3428
3485
  async loadFont(fontSrc, fontVariations, fontFeatures) {
3429
3486
  perfLogger.start('Text.loadFont', {
3430
3487
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
@@ -3644,18 +3701,7 @@ class Text {
3644
3701
  if (!this.loadedFont) {
3645
3702
  return;
3646
3703
  }
3647
- const currentFont = this.loadedFont;
3648
- try {
3649
- FontLoader.destroyFont(currentFont);
3650
- }
3651
- catch (error) {
3652
- logger.warn('Error destroying HarfBuzz objects:', error);
3653
- }
3654
- finally {
3655
- this.loadedFont = undefined;
3656
- this.textLayout = undefined;
3657
- this.textShaper = undefined;
3658
- }
3704
+ this.releaseCurrentFont();
3659
3705
  }
3660
3706
  }
3661
3707
 
package/dist/index.d.ts CHANGED
@@ -457,6 +457,8 @@ declare class Text {
457
457
  private static patternCache;
458
458
  private static hbInitPromise;
459
459
  private static fontCache;
460
+ private static fontLoadPromises;
461
+ private static fontRefCounts;
460
462
  private static fontCacheMemoryBytes;
461
463
  private static maxFontCacheMemoryBytes;
462
464
  private static fontIdCounter;
@@ -465,6 +467,7 @@ declare class Text {
465
467
  private fontLoader;
466
468
  private loadedFont?;
467
469
  private currentFontId;
470
+ private currentFontCacheKey?;
468
471
  private textShaper?;
469
472
  private textLayout?;
470
473
  private constructor();
@@ -472,6 +475,8 @@ declare class Text {
472
475
  static setHarfBuzzBuffer(wasmBuffer: ArrayBuffer): void;
473
476
  static init(): Promise<HarfBuzzInstance>;
474
477
  static create(options: TextOptions): Promise<TextLayoutHandle>;
478
+ private static retainFont;
479
+ private static releaseFont;
475
480
  private static resolveFont;
476
481
  private static loadAndCacheFont;
477
482
  private static trackFontCacheAdd;
@@ -479,6 +484,7 @@ declare class Text {
479
484
  private static enforceFontCacheMemoryLimit;
480
485
  private static generateFontContentHash;
481
486
  private setLoadedFont;
487
+ private releaseCurrentFont;
482
488
  private loadFont;
483
489
  private createLayout;
484
490
  private prepareHyphenation;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /*!
2
2
  * @license
3
- * three-text v0.5.0
3
+ * three-text v0.5.2
4
4
  * Copyright © 2025-2026 Jeremy Tribby, Countertype LLC
5
5
  * SPDX-License-Identifier: MIT
6
6
  */
@@ -3260,6 +3260,8 @@ class Text {
3260
3260
  static { this.patternCache = new Map(); }
3261
3261
  static { this.hbInitPromise = null; }
3262
3262
  static { this.fontCache = new Map(); }
3263
+ static { this.fontLoadPromises = new Map(); }
3264
+ static { this.fontRefCounts = new Map(); }
3263
3265
  static { this.fontCacheMemoryBytes = 0; }
3264
3266
  static { this.maxFontCacheMemoryBytes = Infinity; }
3265
3267
  static { this.fontIdCounter = 0; }
@@ -3304,9 +3306,9 @@ class Text {
3304
3306
  if (!Text.hbInitPromise) {
3305
3307
  Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
3306
3308
  }
3307
- const loadedFont = await Text.resolveFont(options);
3309
+ const { loadedFont, fontKey } = await Text.resolveFont(options);
3308
3310
  const text = new Text();
3309
- text.setLoadedFont(loadedFont);
3311
+ text.setLoadedFont(loadedFont, fontKey);
3310
3312
  const result = await text.createLayout(options);
3311
3313
  const update = async (newOptions) => {
3312
3314
  const mergedOptions = { ...options };
@@ -3319,8 +3321,8 @@ class Text {
3319
3321
  if (newOptions.font !== undefined ||
3320
3322
  newOptions.fontVariations !== undefined ||
3321
3323
  newOptions.fontFeatures !== undefined) {
3322
- const newLoadedFont = await Text.resolveFont(mergedOptions);
3323
- text.setLoadedFont(newLoadedFont);
3324
+ const { loadedFont: newLoadedFont, fontKey: newFontKey } = await Text.resolveFont(mergedOptions);
3325
+ text.setLoadedFont(newLoadedFont, newFontKey);
3324
3326
  text.resetHelpers();
3325
3327
  }
3326
3328
  options = mergedOptions;
@@ -3341,6 +3343,22 @@ class Text {
3341
3343
  dispose: () => text.destroy()
3342
3344
  };
3343
3345
  }
3346
+ static retainFont(fontKey) {
3347
+ Text.fontRefCounts.set(fontKey, (Text.fontRefCounts.get(fontKey) ?? 0) + 1);
3348
+ }
3349
+ static releaseFont(fontKey, loadedFont) {
3350
+ const nextCount = (Text.fontRefCounts.get(fontKey) ?? 0) - 1;
3351
+ if (nextCount > 0) {
3352
+ Text.fontRefCounts.set(fontKey, nextCount);
3353
+ return;
3354
+ }
3355
+ Text.fontRefCounts.delete(fontKey);
3356
+ // Cached fonts stay alive while present in the cache. If a font has been
3357
+ // evicted, destroy it once the last live handle releases it.
3358
+ if (!Text.fontCache.has(fontKey)) {
3359
+ FontLoader.destroyFont(loadedFont);
3360
+ }
3361
+ }
3344
3362
  static async resolveFont(options) {
3345
3363
  const baseFontKey = typeof options.font === 'string'
3346
3364
  ? options.font
@@ -3354,9 +3372,17 @@ class Text {
3354
3372
  }
3355
3373
  let loadedFont = Text.fontCache.get(fontKey);
3356
3374
  if (!loadedFont) {
3357
- loadedFont = await Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures);
3375
+ let loadPromise = Text.fontLoadPromises.get(fontKey);
3376
+ if (!loadPromise) {
3377
+ loadPromise = Text.loadAndCacheFont(fontKey, options.font, options.fontVariations, options.fontFeatures).finally(() => {
3378
+ Text.fontLoadPromises.delete(fontKey);
3379
+ });
3380
+ Text.fontLoadPromises.set(fontKey, loadPromise);
3381
+ }
3382
+ loadedFont = await loadPromise;
3358
3383
  }
3359
- return loadedFont;
3384
+ Text.retainFont(fontKey);
3385
+ return { loadedFont, fontKey };
3360
3386
  }
3361
3387
  static async loadAndCacheFont(fontKey, font, fontVariations, fontFeatures) {
3362
3388
  const tempText = new Text();
@@ -3388,8 +3414,12 @@ class Text {
3388
3414
  const firstKey = Text.fontCache.keys().next().value;
3389
3415
  if (firstKey === undefined)
3390
3416
  break;
3417
+ const font = Text.fontCache.get(firstKey);
3391
3418
  Text.trackFontCacheRemove(firstKey);
3392
3419
  Text.fontCache.delete(firstKey);
3420
+ if ((Text.fontRefCounts.get(firstKey) ?? 0) <= 0 && font) {
3421
+ FontLoader.destroyFont(font);
3422
+ }
3393
3423
  }
3394
3424
  }
3395
3425
  static generateFontContentHash(buffer) {
@@ -3411,8 +3441,12 @@ class Text {
3411
3441
  return `c${++Text.fontIdCounter}`;
3412
3442
  }
3413
3443
  }
3414
- setLoadedFont(loadedFont) {
3444
+ setLoadedFont(loadedFont, fontKey) {
3445
+ if (this.loadedFont && this.loadedFont !== loadedFont) {
3446
+ this.releaseCurrentFont();
3447
+ }
3415
3448
  this.loadedFont = loadedFont;
3449
+ this.currentFontCacheKey = fontKey;
3416
3450
  const contentHash = Text.generateFontContentHash(loadedFont._buffer);
3417
3451
  this.currentFontId = `font_${contentHash}`;
3418
3452
  if (loadedFont.fontVariations) {
@@ -3422,6 +3456,29 @@ class Text {
3422
3456
  this.currentFontId += `_feat_${Text.stableStringify(loadedFont.fontFeatures)}`;
3423
3457
  }
3424
3458
  }
3459
+ releaseCurrentFont() {
3460
+ if (!this.loadedFont)
3461
+ return;
3462
+ const currentFont = this.loadedFont;
3463
+ const currentFontKey = this.currentFontCacheKey;
3464
+ try {
3465
+ if (currentFontKey) {
3466
+ Text.releaseFont(currentFontKey, currentFont);
3467
+ }
3468
+ else {
3469
+ FontLoader.destroyFont(currentFont);
3470
+ }
3471
+ }
3472
+ catch (error) {
3473
+ logger.warn('Error destroying HarfBuzz objects:', error);
3474
+ }
3475
+ finally {
3476
+ this.loadedFont = undefined;
3477
+ this.currentFontCacheKey = undefined;
3478
+ this.textLayout = undefined;
3479
+ this.textShaper = undefined;
3480
+ }
3481
+ }
3425
3482
  async loadFont(fontSrc, fontVariations, fontFeatures) {
3426
3483
  perfLogger.start('Text.loadFont', {
3427
3484
  fontSrc: typeof fontSrc === 'string' ? fontSrc : `buffer(${fontSrc.byteLength})`
@@ -3641,18 +3698,7 @@ class Text {
3641
3698
  if (!this.loadedFont) {
3642
3699
  return;
3643
3700
  }
3644
- const currentFont = this.loadedFont;
3645
- try {
3646
- FontLoader.destroyFont(currentFont);
3647
- }
3648
- catch (error) {
3649
- logger.warn('Error destroying HarfBuzz objects:', error);
3650
- }
3651
- finally {
3652
- this.loadedFont = undefined;
3653
- this.textLayout = undefined;
3654
- this.textShaper = undefined;
3655
- }
3701
+ this.releaseCurrentFont();
3656
3702
  }
3657
3703
  }
3658
3704