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 +31 -39
- package/dist/index.cjs +165 -91
- package/dist/index.d.ts +1 -3
- package/dist/index.js +165 -91
- package/dist/index.min.cjs +429 -417
- package/dist/index.min.js +397 -385
- package/dist/index.umd.js +165 -91
- package/dist/index.umd.min.js +483 -471
- package/dist/three/react.d.ts +1 -3
- package/dist/types/core/cache/GlyphContourCollector.d.ts +1 -0
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +2 -0
- package/dist/types/core/geometry/PathOptimizer.d.ts +0 -4
- package/dist/types/core/geometry/Polygonizer.d.ts +5 -0
- package/dist/types/core/types.d.ts +1 -3
- package/package.json +2 -2
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
|
|
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**
|
|
252
|
-
- **troika-three-text**
|
|
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
|
|
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**:
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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;
|
|
800
|
-
areaThreshold?: number; // Min triangle area
|
|
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.
|
|
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
|
-
//
|
|
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 ||
|
|
394
|
-
code ===
|
|
395
|
-
code ===
|
|
396
|
-
code ===
|
|
397
|
-
code ===
|
|
398
|
-
code ===
|
|
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 ||
|
|
403
|
-
code ===
|
|
404
|
-
code ===
|
|
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
|
|
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 (
|
|
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 =
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
//
|
|
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
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
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
|
-
|
|
3851
|
-
`opt:${enabled ? 1 : 0},${areaThreshold.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
|
-
|
|
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;
|