three-text 0.3.5 → 0.4.0

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
@@ -13,7 +13,7 @@ High fidelity 3D mesh font geometry and text layout engine for the web
13
13
  ## Overview
14
14
 
15
15
  > [!CAUTION]
16
- > three-text is an alpha release and the API may break rapidly. This warning will last at least through the end of 2025. If API stability is important to you, consider pinning your version. Community feedback is encouraged; please open an issue if you have any suggestions or feedback, thank you
16
+ > three-text is an alpha release and the API may break rapidly. This warning will likely last until the end of March 2026. If API stability is important to you, consider pinning your version. Community feedback is encouraged; please open an issue if you have any suggestions or feedback, thank you
17
17
 
18
18
  **three-text** is a 3D mesh font geometry and text layout library for the web. It supports TTF, OTF, and WOFF font files. For layout, it uses [TeX](https://en.wikipedia.org/wiki/TeX)-based parameters for breaking text into paragraphs across multiple lines and supports CJK and RTL scripts. three-text caches the geometries it generates for low CPU overhead in languages with lots of repeating glyphs. Variable fonts are supported as static instances at a given axis coordinate, and can be animated by re-drawing each frame with new coordinates
19
19
 
@@ -248,10 +248,10 @@ three-text generates high-fidelity 3D mesh geometry from font files. Unlike text
248
248
  Existing solutions take different approaches:
249
249
 
250
250
  - **Three.js native TextGeometry** uses fonts converted by facetype.js to JSON format. It creates 3D text by extruding flat 2D character outlines. While this produces true 3D geometry with depth, there is no support for real fonts or OpenType features needed for many of the world's scripts
251
- - **three-bmfont-text** is a 2D approach for Three.js, using pre-rendered bitmap fonts with SDF support. Texture atlases are generated at specific sizes, and artifacts are apparent up close
252
- - **troika-three-text** uses MSDF, which improves quality, and like three-text, it is built on HarfBuzz, which provides substantial language coverage, but is ultimately a 2D technique in image space. For flat text that does not need formatting or extrusion, and where artifacts are acceptable up close, troika works well
251
+ - **three-bmfont-text** renders from pre-generated SDF atlas textures. Atlases are built offline at fixed sizes
252
+ - **troika-three-text** generates SDF glyphs at runtime from font files via HarfBuzz. More flexible than bmfont, but still a 2D image-space technique with artifacts up close
253
253
 
254
- three-text generates true 3D geometry from font files via HarfBuzz. It is sharper at close distances than bitmap approaches when flat, and produces real mesh data that can be used with any rendering system. The library caches glyph geometry, so a paragraph of 1000 words might only require 50 unique glyphs to be processed. This makes it well-suited to longer texts. In addition to performance considerations, three-text provides control over typesetting and paragraph justification via TeX-based parameters
254
+ three-text generates true 3D geometry from font files via HarfBuzz. It is sharper at close distances than bitmap approaches, and produces mesh data that can be used with any rendering system. The library caches glyph geometry, so a paragraph of 1000 words might only require 50 unique glyphs to be processed. This makes it well-suited to longer texts. three-text also provides control over typesetting and paragraph justification via TeX-based parameters
255
255
 
256
256
  ## Library structure
257
257
 
@@ -309,15 +309,14 @@ Hyphenation uses patterns derived from the Tex hyphenation project, converted in
309
309
  The geometry pipeline runs once per unique glyph (or glyph cluster), with intermediate results cached to avoid redundant work:
310
310
 
311
311
  1. **Path collection**: HarfBuzz callbacks provide low level drawing operations
312
- 2. **Curve polygonization**: Uses Anti-Grain Geometry's recursive subdivision to convert bezier curves into polygons, concentrating points where curvature is high
312
+ 2. **Curve polygonization**: Flattens bezier curves into line segments, placing more points where curves are tight
313
313
  3. **Geometry optimization**:
314
- - **Visvalingam-Whyatt simplification**: removes vertices that contribute the least to the overall shape, preserving sharp corners and subtle curves
315
- - **Colinear point removal**: eliminates redundant points that lie on straight lines within angle tolerances
314
+ - **Visvalingam-Whyatt simplification**: removes vertices that form tiny triangles with their neighbors, smoothing out subtle bumps while preserving sharp corners
316
315
  4. **Overlap removal**: removes self-intersections and resolves overlapping paths between glyphs, preserving correct winding rules for triangulation
317
316
  5. **Triangulation**: converts cleaned 2D shapes into triangles using libtess2 with non-zero winding rule
318
317
  6. **Mesh construction**: generates 2D or 3D geometry with front faces and optional depth/extrusion (back faces and side walls)
319
318
 
320
- The multi-stage geometry approach (curve polygonization followed by cleanup, then triangulation) can reduce triangle counts while maintaining high visual fidelity and removing overlaps in variable fonts
319
+ The multi-stage geometry approach (curve polygonization followed by cleanup, then triangulation) reduces triangle counts and removes overlaps in variable fonts
321
320
 
322
321
  #### Glyph caching
323
322
 
@@ -340,54 +339,49 @@ When `depth` is 0, the library generates single-sided geometry, reducing triangl
340
339
 
341
340
  ### Curve fidelity
342
341
 
343
- The library converts bezier curves into line segments by recursively subdividing curves until they meet specified quality thresholds. This is based on the AGG library, attempting to place vertices only where they are needed to maintain the integrity of the curve. You can control curve fidelity with `distanceTolerance` and `angleTolerance`
342
+ Font outlines are bezier curves, but screens render curves flattened into many line segments. The library uses one of two modes:
344
343
 
345
- - `distanceTolerance`: The maximum allowed deviation of the curve from a straight line segment, measured in font units. Lower values produce higher fidelity and more vertices. Default is `0.5`, which is nearly imperceptable without extrusion
346
- - `angleTolerance`: The maximum angle in radians between segments at a join. This helps preserve sharp corners. Default is `0.2`
344
+ **Adaptive (default)** - The algorithm splits each curve at its midpoint, checks if the resulting line segment is close enough to the true curve, and recurses until it is. Tight curves get more segments; gentle curves get fewer. Two tolerances control when to stop subdividing:
347
345
 
348
- In general, this step helps more with time to first render than ongoing interactions in the scene
346
+ - `distanceTolerance`: how far the line segment can stray from the true curve, in font units. Lower values trace the curve more faithfully (default: `0.5`)
347
+ - `angleTolerance`: the maximum angle between adjacent segments, in radians. Smaller values preserve sharp corners better (default: `0.2`)
348
+
349
+ **Fixed-step** - Divides each curve into exactly `curveSteps` segments, regardless of curvature. Simpler and predictable. Overrides adaptive mode when set
349
350
 
350
351
  ```javascript
352
+ // Adaptive (default)
351
353
  const text = await Text.create({
352
- text: 'Sample text',
354
+ text: 'Sample',
353
355
  font: '/fonts/Font.ttf',
354
- curveFidelity: {
356
+ curveFidelity: {
355
357
  distanceTolerance: 0.2,
356
- angleTolerance: 0.1,
358
+ angleTolerance: 0.1
357
359
  },
358
360
  });
359
- ```
360
-
361
- ### Geometry optimization
362
-
363
- `three-text` uses a line simplification algorithm after creating lines to reduce the complexity of the shapes as well, which can be combined with `curveFidelity` for different types of control. It is enabled by default:
364
361
 
365
-
366
- ```javascript
367
- // Default optimization (automatic)
362
+ // Fixed-step: 8 segments per curve
368
363
  const text = await Text.create({
369
- text: 'Sample text',
364
+ text: 'Sample',
370
365
  font: '/fonts/Font.ttf',
366
+ curveSteps: 8,
371
367
  });
368
+ ```
369
+
370
+ ### Geometry optimization
371
+
372
+ After curve polygonization, the library applies Visvalingam-Whyatt simplification. Unlike curve flattening, which operates on each bezier independently, V-W sees the complete assembled path. The algorithm looks at each vertex and the triangle it forms with its two neighbors. Vertices that form tiny triangles - nearly collinear with their neighbors - are removed first. The process repeats until no triangle is smaller than `areaThreshold`, measured in square font units. Sharp corners form large triangles, so they survive; subtle bumps form small ones and get smoothed out:
372
373
 
373
- // Custom optimization settings
374
374
  ```javascript
375
375
  const text = await Text.create({
376
376
  text: 'Sample text',
377
377
  font: '/fonts/Font.ttf',
378
378
  geometryOptimization: {
379
- areaThreshold: 1.0, // Default: 1.0 (remove triangles < 1 font unit²)
380
- colinearThreshold: 0.0087, // Default: ~0.5° in radians
381
- minSegmentLength: 10, // Default: 10 font units
379
+ areaThreshold: 1.0, // remove triangles < 1 font unit²
382
380
  },
383
381
  });
384
382
  ```
385
383
 
386
- **The Visvalingam-Whyatt simplification** removes vertices whose removal creates triangles with area below the threshold
387
-
388
- **Colinear point removal** eliminates redundant vertices that lie on straight lines within the specified angle tolerance
389
-
390
- The default settings provide a significant reduction while maintaining high visual quality, but won't be perfect for every font. Adjust thresholds based on your quality requirements, performance constraints, and testing
384
+ Defaults work well for most fonts. Adjust thresholds based on quality requirements and testing
391
385
 
392
386
  ### Line breaking parameters
393
387
 
@@ -740,6 +734,7 @@ interface TextOptions {
740
734
  removeOverlaps?: boolean; // Override default overlap removal (auto-enabled for VF only)
741
735
  perGlyphAttributes?: boolean; // Keep per-glyph identity and add per-glyph shader attributes
742
736
  color?: [number, number, number] | ColorOptions; // Text coloring (simple or complex)
737
+ curveSteps?: number; // Fixed segments per curve; overrides curveFidelity when set
743
738
  curveFidelity?: CurveFidelityConfig;
744
739
  geometryOptimization?: GeometryOptimizationOptions;
745
740
  layout?: LayoutOptions;
@@ -796,12 +791,10 @@ interface CurveFidelityConfig {
796
791
 
797
792
  ```typescript
798
793
  interface GeometryOptimizationOptions {
799
- enabled?: boolean; // Enable geometry optimization (default: true)
800
- areaThreshold?: number; // Min triangle area for Visvalingam-Whyatt (default: 1.0)
801
- colinearThreshold?: number; // Max angle for colinear removal in radians (default: 0.0087)
802
- minSegmentLength?: number; // Min segment length in font units (default: 10)
794
+ enabled?: boolean; // Enable optimization (default: true)
795
+ areaThreshold?: number; // Min triangle area in font units² (default: 1.0)
803
796
  }
804
- ````
797
+ ```
805
798
 
806
799
  #### TextGeometryInfo (Core)
807
800
 
@@ -827,7 +820,6 @@ interface TextGeometryInfo {
827
820
  trianglesGenerated: number;
828
821
  verticesGenerated: number;
829
822
  pointsRemovedByVisvalingam: number;
830
- pointsRemovedByColinear: number;
831
823
  originalPointCount: number;
832
824
  };
833
825
  query(options: TextQueryOptions): TextRange[];
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.3.5
2
+ * three-text v0.4.0
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -198,7 +198,7 @@ var FitnessClass;
198
198
  FitnessClass[FitnessClass["DECENT"] = 2] = "DECENT";
199
199
  FitnessClass[FitnessClass["TIGHT"] = 3] = "TIGHT"; // lines shrinking 0.5 to 1.0 of their shrinkability
200
200
  })(FitnessClass || (FitnessClass = {}));
201
- // Fast active node management with O(1) operations
201
+ // Active node management with Map for O(1) lookup by (position, fitness)
202
202
  class ActiveNodeList {
203
203
  constructor() {
204
204
  this.nodesByKey = new Map();
@@ -229,7 +229,6 @@ class ActiveNodeList {
229
229
  this.nodesByKey.set(key, node);
230
230
  return true;
231
231
  }
232
- // Swap-and-pop removal
233
232
  deactivate(node) {
234
233
  if (!node.active)
235
234
  return;
@@ -372,36 +371,66 @@ class LineBreak {
372
371
  const code = char.codePointAt(0);
373
372
  if (code === undefined)
374
373
  return false;
375
- return ((code >= 0x4e00 && code <= 0x9fff) ||
376
- (code >= 0x3400 && code <= 0x4dbf) ||
377
- (code >= 0x20000 && code <= 0x2a6df) ||
378
- (code >= 0x2a700 && code <= 0x2b73f) ||
379
- (code >= 0x2b740 && code <= 0x2b81f) ||
380
- (code >= 0x2b820 && code <= 0x2ceaf) ||
381
- (code >= 0xf900 && code <= 0xfaff) ||
382
- (code >= 0x3040 && code <= 0x309f) ||
383
- (code >= 0x30a0 && code <= 0x30ff) ||
384
- (code >= 0xac00 && code <= 0xd7af) ||
385
- (code >= 0x1100 && code <= 0x11ff) ||
386
- (code >= 0x3130 && code <= 0x318f) ||
387
- (code >= 0xa960 && code <= 0xa97f) ||
388
- (code >= 0xd7b0 && code <= 0xd7ff) ||
389
- (code >= 0xffa0 && code <= 0xffdc));
390
- }
374
+ return ((code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
375
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
376
+ (code >= 0x20000 && code <= 0x2a6df) || // CJK Extension B
377
+ (code >= 0x2a700 && code <= 0x2b73f) || // CJK Extension C
378
+ (code >= 0x2b740 && code <= 0x2b81f) || // CJK Extension D
379
+ (code >= 0x2b820 && code <= 0x2ceaf) || // CJK Extension E
380
+ (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
381
+ (code >= 0x3040 && code <= 0x309f) || // Hiragana
382
+ (code >= 0x30a0 && code <= 0x30ff) || // Katakana
383
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
384
+ (code >= 0x1100 && code <= 0x11ff) || // Hangul Jamo
385
+ (code >= 0x3130 && code <= 0x318f) || // Hangul Compatibility Jamo
386
+ (code >= 0xa960 && code <= 0xa97f) || // Hangul Jamo Extended-A
387
+ (code >= 0xd7b0 && code <= 0xd7ff) || // Hangul Jamo Extended-B
388
+ (code >= 0xffa0 && code <= 0xffdc) // Halfwidth Hangul
389
+ );
390
+ }
391
+ // Closing punctuation - no line break before (UAX #14 CL, JIS X 4051)
391
392
  static isCJClosingPunctuation(char) {
392
393
  const code = char.charCodeAt(0);
393
- return (code === 0x3001 || code === 0x3002 || code === 0xff0c || code === 0xff0e ||
394
- code === 0xff1a || code === 0xff1b || code === 0xff01 || code === 0xff1f ||
395
- code === 0xff09 || code === 0x3011 || code === 0xff5d || code === 0x300d ||
396
- code === 0x300f || code === 0x3009 || code === 0x300b || code === 0x3015 ||
397
- code === 0x3017 || code === 0x3019 || code === 0x301b || code === 0x30fc ||
398
- code === 0x2014 || code === 0x2026 || code === 0x2025);
399
- }
394
+ return (code === 0x3001 || //
395
+ code === 0x3002 || //
396
+ code === 0xff0c || //
397
+ code === 0xff0e || //
398
+ code === 0xff1a || //
399
+ code === 0xff1b || //
400
+ code === 0xff01 || // !
401
+ code === 0xff1f || // ?
402
+ code === 0xff09 || // )
403
+ code === 0x3011 || // 】
404
+ code === 0xff5d || // }
405
+ code === 0x300d || // 」
406
+ code === 0x300f || // 』
407
+ code === 0x3009 || // 〉
408
+ code === 0x300b || // 》
409
+ code === 0x3015 || // 〕
410
+ code === 0x3017 || // 〗
411
+ code === 0x3019 || // 〙
412
+ code === 0x301b || // 〛
413
+ code === 0x30fc || // ー
414
+ code === 0x2014 || // —
415
+ code === 0x2026 || // …
416
+ code === 0x2025 // ‥
417
+ );
418
+ }
419
+ // Opening punctuation - no line break after (UAX #14 OP, JIS X 4051)
400
420
  static isCJOpeningPunctuation(char) {
401
421
  const code = char.charCodeAt(0);
402
- return (code === 0xff08 || code === 0x3010 || code === 0xff5b || code === 0x300c ||
403
- code === 0x300e || code === 0x3008 || code === 0x300a || code === 0x3014 ||
404
- code === 0x3016 || code === 0x3018 || code === 0x301a);
422
+ return (code === 0xff08 || //
423
+ code === 0x3010 || //
424
+ code === 0xff5b || //
425
+ code === 0x300c || // 「
426
+ code === 0x300e || // 『
427
+ code === 0x3008 || // 〈
428
+ code === 0x300a || // 《
429
+ code === 0x3014 || // 〔
430
+ code === 0x3016 || // 〖
431
+ code === 0x3018 || // 〘
432
+ code === 0x301a // 〚
433
+ );
405
434
  }
406
435
  static isCJPunctuation(char) {
407
436
  return this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char);
@@ -3042,15 +3071,12 @@ class MinHeap {
3042
3071
 
3043
3072
  const DEFAULT_OPTIMIZATION_CONFIG = {
3044
3073
  enabled: true,
3045
- areaThreshold: 1.0, // Remove triangles smaller than 1 square font unit
3046
- colinearThreshold: 0.0087, // ~0.5 degrees in radians
3047
- minSegmentLength: 10
3074
+ areaThreshold: 1.0 // Remove triangles smaller than 1 square font unit
3048
3075
  };
3049
3076
  class PathOptimizer {
3050
3077
  constructor(config) {
3051
3078
  this.stats = {
3052
3079
  pointsRemovedByVisvalingam: 0,
3053
- pointsRemovedByColinear: 0,
3054
3080
  originalPointCount: 0
3055
3081
  };
3056
3082
  this.config = config;
@@ -3059,7 +3085,10 @@ class PathOptimizer {
3059
3085
  this.config = config;
3060
3086
  }
3061
3087
  optimizePath(path) {
3062
- if (!this.config.enabled || path.points.length <= 2) {
3088
+ if (path.points.length <= 2) {
3089
+ return path;
3090
+ }
3091
+ if (!this.config.enabled) {
3063
3092
  return path;
3064
3093
  }
3065
3094
  this.stats.originalPointCount += path.points.length;
@@ -3069,11 +3098,9 @@ class PathOptimizer {
3069
3098
  if (points.length < 5) {
3070
3099
  return path;
3071
3100
  }
3072
- let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
3073
- if (optimized.length < 3) {
3074
- return path;
3075
- }
3076
- optimized = this.removeColinearPoints(optimized, this.config.colinearThreshold);
3101
+ let optimized = points;
3102
+ // Visvalingam-Whyatt simplification
3103
+ optimized = this.simplifyPathVW(optimized, this.config.areaThreshold);
3077
3104
  if (optimized.length < 3) {
3078
3105
  return path;
3079
3106
  }
@@ -3110,17 +3137,6 @@ class PathOptimizer {
3110
3137
  if (!p || p.area > areaThreshold) {
3111
3138
  break;
3112
3139
  }
3113
- if (this.config.minSegmentLength > 0 && p.prev && p.next) {
3114
- const prevPoint = points[p.prev.index];
3115
- const currentPoint = points[p.index];
3116
- const nextPoint = points[p.next.index];
3117
- const len1 = prevPoint.distanceTo(currentPoint);
3118
- const len2 = currentPoint.distanceTo(nextPoint);
3119
- if (len1 < this.config.minSegmentLength ||
3120
- len2 < this.config.minSegmentLength) {
3121
- continue;
3122
- }
3123
- }
3124
3140
  if (p.prev)
3125
3141
  p.prev.next = p.next;
3126
3142
  if (p.next)
@@ -3145,32 +3161,6 @@ class PathOptimizer {
3145
3161
  this.stats.pointsRemovedByVisvalingam += pointsRemoved;
3146
3162
  return simplifiedPoints;
3147
3163
  }
3148
- removeColinearPoints(points, threshold) {
3149
- if (points.length <= 2)
3150
- return points;
3151
- const result = [points[0]];
3152
- for (let i = 1; i < points.length - 1; i++) {
3153
- const prev = points[i - 1];
3154
- const current = points[i];
3155
- const next = points[i + 1];
3156
- const v1x = current.x - prev.x;
3157
- const v1y = current.y - prev.y;
3158
- const v2x = next.x - current.x;
3159
- const v2y = next.y - current.y;
3160
- const angle = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
3161
- const v1LenSq = v1x * v1x + v1y * v1y;
3162
- const v2LenSq = v2x * v2x + v2y * v2y;
3163
- const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3164
- if (angle > threshold || v1LenSq < minLenSq || v2LenSq < minLenSq) {
3165
- result.push(current);
3166
- }
3167
- else {
3168
- this.stats.pointsRemovedByColinear++;
3169
- }
3170
- }
3171
- result.push(points[points.length - 1]);
3172
- return result;
3173
- }
3174
3164
  // Shoelace formula
3175
3165
  calculateTriangleArea(p1, p2, p3) {
3176
3166
  return Math.abs((p1.x * (p2.y - p3.y) + p2.x * (p3.y - p1.y) + p3.x * (p1.y - p2.y)) / 2);
@@ -3181,7 +3171,6 @@ class PathOptimizer {
3181
3171
  resetStats() {
3182
3172
  this.stats = {
3183
3173
  pointsRemovedByVisvalingam: 0,
3184
- pointsRemovedByColinear: 0,
3185
3174
  originalPointCount: 0
3186
3175
  };
3187
3176
  }
@@ -3232,6 +3221,7 @@ const COLLINEARITY_EPSILON = 1e-6;
3232
3221
  const RECURSION_LIMIT = 16;
3233
3222
  class Polygonizer {
3234
3223
  constructor(curveFidelityConfig) {
3224
+ this.curveSteps = null;
3235
3225
  this.curveFidelityConfig = {
3236
3226
  ...DEFAULT_CURVE_FIDELITY,
3237
3227
  ...curveFidelityConfig
@@ -3243,18 +3233,77 @@ class Polygonizer {
3243
3233
  ...curveFidelityConfig
3244
3234
  };
3245
3235
  }
3236
+ // Fixed-step subdivision; overrides adaptive curveFidelity when set
3237
+ setCurveSteps(curveSteps) {
3238
+ if (curveSteps === undefined || curveSteps === null) {
3239
+ this.curveSteps = null;
3240
+ return;
3241
+ }
3242
+ if (!Number.isFinite(curveSteps)) {
3243
+ this.curveSteps = null;
3244
+ return;
3245
+ }
3246
+ const stepsInt = Math.round(curveSteps);
3247
+ this.curveSteps = stepsInt >= 1 ? stepsInt : null;
3248
+ }
3246
3249
  polygonizeQuadratic(start, control, end) {
3250
+ if (this.curveSteps !== null) {
3251
+ return this.polygonizeQuadraticFixedSteps(start, control, end, this.curveSteps);
3252
+ }
3247
3253
  const points = [];
3248
3254
  this.recursiveQuadratic(start.x, start.y, control.x, control.y, end.x, end.y, points);
3249
3255
  this.addPoint(end.x, end.y, points);
3250
3256
  return points;
3251
3257
  }
3252
3258
  polygonizeCubic(start, control1, control2, end) {
3259
+ if (this.curveSteps !== null) {
3260
+ return this.polygonizeCubicFixedSteps(start, control1, control2, end, this.curveSteps);
3261
+ }
3253
3262
  const points = [];
3254
3263
  this.recursiveCubic(start.x, start.y, control1.x, control1.y, control2.x, control2.y, end.x, end.y, points);
3255
3264
  this.addPoint(end.x, end.y, points);
3256
3265
  return points;
3257
3266
  }
3267
+ lerp(a, b, t) {
3268
+ return a + (b - a) * t;
3269
+ }
3270
+ polygonizeQuadraticFixedSteps(start, control, end, steps) {
3271
+ const points = [];
3272
+ // Emit intermediate points; caller already has start
3273
+ for (let i = 1; i <= steps; i++) {
3274
+ const t = i / steps;
3275
+ const x12 = this.lerp(start.x, control.x, t);
3276
+ const y12 = this.lerp(start.y, control.y, t);
3277
+ const x23 = this.lerp(control.x, end.x, t);
3278
+ const y23 = this.lerp(control.y, end.y, t);
3279
+ const x = this.lerp(x12, x23, t);
3280
+ const y = this.lerp(y12, y23, t);
3281
+ this.addPoint(x, y, points);
3282
+ }
3283
+ return points;
3284
+ }
3285
+ polygonizeCubicFixedSteps(start, control1, control2, end, steps) {
3286
+ const points = [];
3287
+ // Emit intermediate points; caller already has start
3288
+ for (let i = 1; i <= steps; i++) {
3289
+ const t = i / steps;
3290
+ // De Casteljau
3291
+ const x12 = this.lerp(start.x, control1.x, t);
3292
+ const y12 = this.lerp(start.y, control1.y, t);
3293
+ const x23 = this.lerp(control1.x, control2.x, t);
3294
+ const y23 = this.lerp(control1.y, control2.y, t);
3295
+ const x34 = this.lerp(control2.x, end.x, t);
3296
+ const y34 = this.lerp(control2.y, end.y, t);
3297
+ const x123 = this.lerp(x12, x23, t);
3298
+ const y123 = this.lerp(y12, y23, t);
3299
+ const x234 = this.lerp(x23, x34, t);
3300
+ const y234 = this.lerp(y23, y34, t);
3301
+ const x = this.lerp(x123, x234, t);
3302
+ const y = this.lerp(y123, y234, t);
3303
+ this.addPoint(x, y, points);
3304
+ }
3305
+ return points;
3306
+ }
3258
3307
  recursiveQuadratic(x1, y1, x2, y2, x3, y3, points, level = 0) {
3259
3308
  if (level > RECURSION_LIMIT)
3260
3309
  return;
@@ -3281,8 +3330,8 @@ class Polygonizer {
3281
3330
  const angleTolerance = this.curveFidelityConfig.angleTolerance ??
3282
3331
  DEFAULT_CURVE_FIDELITY.angleTolerance;
3283
3332
  if (angleTolerance > 0) {
3284
- // Angle between segments (p1->p2) and (p2->p3).
3285
- // Using atan2(cross, dot) avoids computing 2 separate atan2() values + wrap logic.
3333
+ // Angle between segments (p1->p2) and (p2->p3)
3334
+ // atan2(cross, dot) avoids computing 2 separate atan2() values
3286
3335
  const v1x = x2 - x1;
3287
3336
  const v1y = y2 - y1;
3288
3337
  const v2x = x3 - x2;
@@ -3667,6 +3716,9 @@ class GlyphContourCollector {
3667
3716
  setCurveFidelityConfig(config) {
3668
3717
  this.polygonizer.setCurveFidelityConfig(config);
3669
3718
  }
3719
+ setCurveSteps(curveSteps) {
3720
+ this.polygonizer.setCurveSteps(curveSteps);
3721
+ }
3670
3722
  setGeometryOptimization(options) {
3671
3723
  this.pathOptimizer.setConfig({
3672
3724
  ...DEFAULT_OPTIMIZATION_CONFIG,
@@ -3820,6 +3872,21 @@ class GlyphGeometryBuilder {
3820
3872
  this.collector.setCurveFidelityConfig(config);
3821
3873
  this.updateCacheKeyPrefix();
3822
3874
  }
3875
+ setCurveSteps(curveSteps) {
3876
+ // Normalize: unset for undefined/null/non-finite/<=0
3877
+ if (curveSteps === undefined || curveSteps === null) {
3878
+ this.curveSteps = undefined;
3879
+ }
3880
+ else if (!Number.isFinite(curveSteps)) {
3881
+ this.curveSteps = undefined;
3882
+ }
3883
+ else {
3884
+ const stepsInt = Math.round(curveSteps);
3885
+ this.curveSteps = stepsInt >= 1 ? stepsInt : undefined;
3886
+ }
3887
+ this.collector.setCurveSteps(this.curveSteps);
3888
+ this.updateCacheKeyPrefix();
3889
+ }
3823
3890
  setGeometryOptimization(options) {
3824
3891
  this.geometryOptimizationOptions = options;
3825
3892
  this.collector.setGeometryOptimization(options);
@@ -3833,22 +3900,24 @@ class GlyphGeometryBuilder {
3833
3900
  this.cacheKeyPrefix = `${this.fontId}__${this.getGeometryConfigSignature()}`;
3834
3901
  }
3835
3902
  getGeometryConfigSignature() {
3836
- const distanceTolerance = this.curveFidelityConfig?.distanceTolerance ??
3837
- DEFAULT_CURVE_FIDELITY.distanceTolerance;
3838
- const angleTolerance = this.curveFidelityConfig?.angleTolerance ??
3839
- DEFAULT_CURVE_FIDELITY.angleTolerance;
3903
+ const curveSignature = (() => {
3904
+ if (this.curveSteps !== undefined) {
3905
+ return `cf:steps:${this.curveSteps}`;
3906
+ }
3907
+ const distanceTolerance = this.curveFidelityConfig?.distanceTolerance ??
3908
+ DEFAULT_CURVE_FIDELITY.distanceTolerance;
3909
+ const angleTolerance = this.curveFidelityConfig?.angleTolerance ??
3910
+ DEFAULT_CURVE_FIDELITY.angleTolerance;
3911
+ return `cf:${distanceTolerance.toFixed(4)},${angleTolerance.toFixed(4)}`;
3912
+ })();
3840
3913
  const enabled = this.geometryOptimizationOptions?.enabled ??
3841
3914
  DEFAULT_OPTIMIZATION_CONFIG.enabled;
3842
3915
  const areaThreshold = this.geometryOptimizationOptions?.areaThreshold ??
3843
3916
  DEFAULT_OPTIMIZATION_CONFIG.areaThreshold;
3844
- const colinearThreshold = this.geometryOptimizationOptions?.colinearThreshold ??
3845
- DEFAULT_OPTIMIZATION_CONFIG.colinearThreshold;
3846
- const minSegmentLength = this.geometryOptimizationOptions?.minSegmentLength ??
3847
- DEFAULT_OPTIMIZATION_CONFIG.minSegmentLength;
3848
3917
  // Use fixed precision to keep cache keys stable and avoid float noise
3849
3918
  return [
3850
- `cf:${distanceTolerance.toFixed(4)},${angleTolerance.toFixed(4)}`,
3851
- `opt:${enabled ? 1 : 0},${areaThreshold.toFixed(4)},${colinearThreshold.toFixed(6)},${minSegmentLength.toFixed(4)}`
3919
+ curveSignature,
3920
+ `opt:${enabled ? 1 : 0},${areaThreshold.toFixed(4)}`
3852
3921
  ].join('|');
3853
3922
  }
3854
3923
  // Build instanced geometry from glyph contours
@@ -5405,7 +5474,13 @@ class Text {
5405
5474
  this.geometryBuilder = new GlyphGeometryBuilder(globalGlyphCache, this.loadedFont);
5406
5475
  this.geometryBuilder.setFontId(this.currentFontId);
5407
5476
  }
5408
- this.geometryBuilder.setCurveFidelityConfig(options.curveFidelity);
5477
+ // Curve flattening: either use fixed-step De Casteljau (`curveSteps`) OR
5478
+ // adaptive AGG-style tolerances (`curveFidelity`)
5479
+ const useCurveSteps = options.curveSteps !== undefined &&
5480
+ options.curveSteps !== null &&
5481
+ options.curveSteps > 0;
5482
+ this.geometryBuilder.setCurveSteps(options.curveSteps);
5483
+ this.geometryBuilder.setCurveFidelityConfig(useCurveSteps ? undefined : options.curveFidelity);
5409
5484
  this.geometryBuilder.setGeometryOptimization(options.geometryOptimization);
5410
5485
  this.loadedFont.font.setScale(this.loadedFont.upem, this.loadedFont.upem);
5411
5486
  if (!this.textShaper) {
@@ -5778,7 +5853,6 @@ class Text {
5778
5853
  trianglesGenerated,
5779
5854
  verticesGenerated,
5780
5855
  pointsRemovedByVisvalingam: optimizationStats.pointsRemovedByVisvalingam,
5781
- pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5782
5856
  originalPointCount: optimizationStats.originalPointCount
5783
5857
  },
5784
5858
  query: (() => {
package/dist/index.d.ts CHANGED
@@ -236,7 +236,6 @@ interface TextGeometryInfo {
236
236
  trianglesGenerated: number;
237
237
  verticesGenerated: number;
238
238
  pointsRemovedByVisvalingam: number;
239
- pointsRemovedByColinear: number;
240
239
  originalPointCount: number;
241
240
  } & Partial<CacheStats & {
242
241
  hitRate: number;
@@ -300,6 +299,7 @@ interface TextOptions {
300
299
  };
301
300
  maxTextLength?: number;
302
301
  removeOverlaps?: boolean;
302
+ curveSteps?: number;
303
303
  curveFidelity?: CurveFidelityConfig;
304
304
  geometryOptimization?: GeometryOptimizationOptions;
305
305
  layout?: LayoutOptions;
@@ -315,8 +315,6 @@ interface CurveFidelityConfig {
315
315
  interface GeometryOptimizationOptions {
316
316
  enabled?: boolean;
317
317
  areaThreshold?: number;
318
- colinearThreshold?: number;
319
- minSegmentLength?: number;
320
318
  }
321
319
  interface LayoutOptions {
322
320
  width?: number;