three-text 0.3.4 → 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 +33 -39
- package/dist/index.cjs +214 -98
- package/dist/index.d.ts +1 -3
- package/dist/index.js +214 -98
- package/dist/index.min.cjs +434 -417
- package/dist/index.min.js +402 -385
- package/dist/index.umd.js +214 -98
- package/dist/index.umd.min.js +487 -470
- 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/cache/sharedCaches.d.ts +4 -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 +3 -3
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
361
|
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
|
@@ -637,6 +631,8 @@ const text = await Text.create({
|
|
|
637
631
|
|
|
638
632
|
Text matching occurs after layout processing, so patterns like "connection" will be found even if hyphenation splits them across lines. The `coloredRanges` property on the returned object contains the resolved color assignments for programmatic access to the colored parts of the geometry
|
|
639
633
|
|
|
634
|
+
When using selective coloring with `byText` or `byCharRange`, colored glyphs are kept geometrically separate from adjacent non-colored glyphs. This ensures accurate vertex coloring while still allowing overlap removal between glyphs of the same color status, e.g. two adjacent colored letters that overlap will still be properly merged
|
|
635
|
+
|
|
640
636
|
## API reference
|
|
641
637
|
|
|
642
638
|
The library's full TypeScript definitions are the most complete source of truth for the API. The core data structures and configuration options can be found in `src/core/types.ts`
|
|
@@ -738,6 +734,7 @@ interface TextOptions {
|
|
|
738
734
|
removeOverlaps?: boolean; // Override default overlap removal (auto-enabled for VF only)
|
|
739
735
|
perGlyphAttributes?: boolean; // Keep per-glyph identity and add per-glyph shader attributes
|
|
740
736
|
color?: [number, number, number] | ColorOptions; // Text coloring (simple or complex)
|
|
737
|
+
curveSteps?: number; // Fixed segments per curve; overrides curveFidelity when set
|
|
741
738
|
curveFidelity?: CurveFidelityConfig;
|
|
742
739
|
geometryOptimization?: GeometryOptimizationOptions;
|
|
743
740
|
layout?: LayoutOptions;
|
|
@@ -794,12 +791,10 @@ interface CurveFidelityConfig {
|
|
|
794
791
|
|
|
795
792
|
```typescript
|
|
796
793
|
interface GeometryOptimizationOptions {
|
|
797
|
-
enabled?: boolean;
|
|
798
|
-
areaThreshold?: number; // Min triangle area
|
|
799
|
-
colinearThreshold?: number; // Max angle for colinear removal in radians (default: 0.0087)
|
|
800
|
-
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)
|
|
801
796
|
}
|
|
802
|
-
|
|
797
|
+
```
|
|
803
798
|
|
|
804
799
|
#### TextGeometryInfo (Core)
|
|
805
800
|
|
|
@@ -825,7 +820,6 @@ interface TextGeometryInfo {
|
|
|
825
820
|
trianglesGenerated: number;
|
|
826
821
|
verticesGenerated: number;
|
|
827
822
|
pointsRemovedByVisvalingam: number;
|
|
828
|
-
pointsRemovedByColinear: number;
|
|
829
823
|
originalPointCount: number;
|
|
830
824
|
};
|
|
831
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
|
|
@@ -3902,15 +3971,20 @@ class GlyphGeometryBuilder {
|
|
|
3902
3971
|
boundaryGroups = [[0]];
|
|
3903
3972
|
}
|
|
3904
3973
|
else {
|
|
3905
|
-
// Check clustering cache (same text + glyph IDs = same overlap groups)
|
|
3974
|
+
// Check clustering cache (same text + glyph IDs + positions = same overlap groups)
|
|
3906
3975
|
// Key must be font-specific; glyph ids/bounds differ between fonts
|
|
3976
|
+
// Positions must match since overlap detection depends on relative glyph placement
|
|
3907
3977
|
const cacheKey = `${this.cacheKeyPrefix}_${cluster.text}`;
|
|
3908
3978
|
const cached = this.clusteringCache.get(cacheKey);
|
|
3909
3979
|
let isValid = false;
|
|
3910
3980
|
if (cached && cached.glyphIds.length === cluster.glyphs.length) {
|
|
3911
3981
|
isValid = true;
|
|
3912
3982
|
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3913
|
-
|
|
3983
|
+
const glyph = cluster.glyphs[i];
|
|
3984
|
+
const cachedPos = cached.positions[i];
|
|
3985
|
+
if (cached.glyphIds[i] !== glyph.g ||
|
|
3986
|
+
cachedPos.x !== (glyph.x ?? 0) ||
|
|
3987
|
+
cachedPos.y !== (glyph.y ?? 0)) {
|
|
3914
3988
|
isValid = false;
|
|
3915
3989
|
break;
|
|
3916
3990
|
}
|
|
@@ -3924,17 +3998,52 @@ class GlyphGeometryBuilder {
|
|
|
3924
3998
|
boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
|
|
3925
3999
|
this.clusteringCache.set(cacheKey, {
|
|
3926
4000
|
glyphIds: cluster.glyphs.map((g) => g.g),
|
|
4001
|
+
positions: cluster.glyphs.map((g) => ({
|
|
4002
|
+
x: g.x ?? 0,
|
|
4003
|
+
y: g.y ?? 0
|
|
4004
|
+
})),
|
|
3927
4005
|
groups: boundaryGroups
|
|
3928
4006
|
});
|
|
3929
4007
|
}
|
|
3930
4008
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
//
|
|
3934
|
-
|
|
4009
|
+
// Only force separate tessellation when explicitly requested via separateGlyphs
|
|
4010
|
+
const forceSeparate = separateGlyphs;
|
|
4011
|
+
// Split boundary groups so colored and non-colored glyphs don't merge together
|
|
4012
|
+
// This preserves overlap removal within each color class while keeping
|
|
4013
|
+
// geometry separate for accurate vertex coloring
|
|
4014
|
+
let finalGroups = boundaryGroups;
|
|
4015
|
+
if (coloredTextIndices && coloredTextIndices.size > 0) {
|
|
4016
|
+
finalGroups = [];
|
|
4017
|
+
for (const group of boundaryGroups) {
|
|
4018
|
+
if (group.length <= 1) {
|
|
4019
|
+
finalGroups.push(group);
|
|
4020
|
+
}
|
|
4021
|
+
else {
|
|
4022
|
+
// Split group into colored and non-colored sub-groups
|
|
4023
|
+
const coloredIndices = [];
|
|
4024
|
+
const nonColoredIndices = [];
|
|
4025
|
+
for (const idx of group) {
|
|
4026
|
+
const glyph = cluster.glyphs[idx];
|
|
4027
|
+
if (coloredTextIndices.has(glyph.absoluteTextIndex)) {
|
|
4028
|
+
coloredIndices.push(idx);
|
|
4029
|
+
}
|
|
4030
|
+
else {
|
|
4031
|
+
nonColoredIndices.push(idx);
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
// Add non-empty sub-groups
|
|
4035
|
+
if (coloredIndices.length > 0) {
|
|
4036
|
+
finalGroups.push(coloredIndices);
|
|
4037
|
+
}
|
|
4038
|
+
if (nonColoredIndices.length > 0) {
|
|
4039
|
+
finalGroups.push(nonColoredIndices);
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
3935
4044
|
// Iterate over the geometric groups identified by BoundaryClusterer
|
|
3936
4045
|
// logical groups (words) split into geometric sub-groups
|
|
3937
|
-
for (const groupIndices of
|
|
4046
|
+
for (const groupIndices of finalGroups) {
|
|
3938
4047
|
const isOverlappingGroup = groupIndices.length > 1;
|
|
3939
4048
|
const shouldCluster = isOverlappingGroup && !forceSeparate;
|
|
3940
4049
|
if (shouldCluster) {
|
|
@@ -5365,7 +5474,13 @@ class Text {
|
|
|
5365
5474
|
this.geometryBuilder = new GlyphGeometryBuilder(globalGlyphCache, this.loadedFont);
|
|
5366
5475
|
this.geometryBuilder.setFontId(this.currentFontId);
|
|
5367
5476
|
}
|
|
5368
|
-
|
|
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);
|
|
5369
5484
|
this.geometryBuilder.setGeometryOptimization(options.geometryOptimization);
|
|
5370
5485
|
this.loadedFont.font.setScale(this.loadedFont.upem, this.loadedFont.upem);
|
|
5371
5486
|
if (!this.textShaper) {
|
|
@@ -5595,6 +5710,7 @@ class Text {
|
|
|
5595
5710
|
else {
|
|
5596
5711
|
lineGroups.set(glyph.lineIndex, [glyph]);
|
|
5597
5712
|
}
|
|
5713
|
+
// Color vertices owned by this glyph
|
|
5598
5714
|
for (let v = 0; v < glyph.vertexCount; v++) {
|
|
5599
5715
|
const vertexIndex = (glyph.vertexStart + v) * 3;
|
|
5600
5716
|
if (vertexIndex >= 0 && vertexIndex < colors.length) {
|
|
@@ -5636,6 +5752,7 @@ class Text {
|
|
|
5636
5752
|
else {
|
|
5637
5753
|
lineGroups.set(glyph.lineIndex, [glyph]);
|
|
5638
5754
|
}
|
|
5755
|
+
// Color vertices owned by this glyph
|
|
5639
5756
|
for (let v = 0; v < glyph.vertexCount; v++) {
|
|
5640
5757
|
const vertexIndex = (glyph.vertexStart + v) * 3;
|
|
5641
5758
|
if (vertexIndex >= 0 && vertexIndex < colors.length) {
|
|
@@ -5736,7 +5853,6 @@ class Text {
|
|
|
5736
5853
|
trianglesGenerated,
|
|
5737
5854
|
verticesGenerated,
|
|
5738
5855
|
pointsRemovedByVisvalingam: optimizationStats.pointsRemovedByVisvalingam,
|
|
5739
|
-
pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
|
|
5740
5856
|
originalPointCount: optimizationStats.originalPointCount
|
|
5741
5857
|
},
|
|
5742
5858
|
query: (() => {
|