three-text 0.2.11 → 0.2.13
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 +1 -1
- package/dist/index.cjs +168 -83
- package/dist/index.js +168 -83
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +168 -83
- package/dist/index.umd.min.js +2 -2
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +3 -1
- package/package.json +2 -1
package/dist/index.umd.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.13
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -2590,7 +2590,6 @@
|
|
|
2590
2590
|
vertices.push(points[i], points[i + 1], 0);
|
|
2591
2591
|
normals.push(0, 0, -1);
|
|
2592
2592
|
}
|
|
2593
|
-
// Add triangle indices
|
|
2594
2593
|
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2595
2594
|
indices.push(baseIndex + triangleIndices[i]);
|
|
2596
2595
|
}
|
|
@@ -2641,18 +2640,21 @@
|
|
|
2641
2640
|
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2642
2641
|
glyphCount: glyphContoursList.length
|
|
2643
2642
|
});
|
|
2644
|
-
|
|
2643
|
+
const n = glyphContoursList.length;
|
|
2644
|
+
if (n === 0) {
|
|
2645
2645
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2646
2646
|
return [];
|
|
2647
2647
|
}
|
|
2648
|
+
if (n === 1) {
|
|
2649
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2650
|
+
return [[0]];
|
|
2651
|
+
}
|
|
2648
2652
|
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2649
2653
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2650
2654
|
return result;
|
|
2651
2655
|
}
|
|
2652
2656
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2653
2657
|
const n = glyphContoursList.length;
|
|
2654
|
-
if (n <= 1)
|
|
2655
|
-
return n === 0 ? [] : [[0]];
|
|
2656
2658
|
const bounds = new Array(n);
|
|
2657
2659
|
const events = new Array(2 * n);
|
|
2658
2660
|
let eventIndex = 0;
|
|
@@ -2672,7 +2674,6 @@
|
|
|
2672
2674
|
const py = find(y);
|
|
2673
2675
|
if (px === py)
|
|
2674
2676
|
return;
|
|
2675
|
-
// Union by rank, attach smaller tree under larger tree
|
|
2676
2677
|
if (rank[px] < rank[py]) {
|
|
2677
2678
|
parent[px] = py;
|
|
2678
2679
|
}
|
|
@@ -2688,8 +2689,6 @@
|
|
|
2688
2689
|
for (const [, eventType, glyphIndex] of events) {
|
|
2689
2690
|
if (eventType === 0) {
|
|
2690
2691
|
const bounds1 = bounds[glyphIndex];
|
|
2691
|
-
// Check y-overlap with all currently active glyphs
|
|
2692
|
-
// (x-overlap is guaranteed by the sweep line)
|
|
2693
2692
|
for (const activeIndex of active) {
|
|
2694
2693
|
const bounds2 = bounds[activeIndex];
|
|
2695
2694
|
if (bounds1.minY < bounds2.maxY + OVERLAP_EPSILON &&
|
|
@@ -2706,10 +2705,12 @@
|
|
|
2706
2705
|
const clusters = new Map();
|
|
2707
2706
|
for (let i = 0; i < n; i++) {
|
|
2708
2707
|
const root = find(i);
|
|
2709
|
-
|
|
2710
|
-
|
|
2708
|
+
let list = clusters.get(root);
|
|
2709
|
+
if (!list) {
|
|
2710
|
+
list = [];
|
|
2711
|
+
clusters.set(root, list);
|
|
2711
2712
|
}
|
|
2712
|
-
|
|
2713
|
+
list.push(i);
|
|
2713
2714
|
}
|
|
2714
2715
|
return Array.from(clusters.values());
|
|
2715
2716
|
}
|
|
@@ -3762,6 +3763,10 @@
|
|
|
3762
3763
|
return size;
|
|
3763
3764
|
}
|
|
3764
3765
|
});
|
|
3766
|
+
this.clusteringCache = new LRUCache({
|
|
3767
|
+
maxEntries: 2000,
|
|
3768
|
+
calculateSize: () => 1
|
|
3769
|
+
});
|
|
3765
3770
|
}
|
|
3766
3771
|
getOptimizationStats() {
|
|
3767
3772
|
return this.collector.getOptimizationStats();
|
|
@@ -3776,7 +3781,7 @@
|
|
|
3776
3781
|
this.fontId = fontId;
|
|
3777
3782
|
}
|
|
3778
3783
|
// Build instanced geometry from glyph contours
|
|
3779
|
-
buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false) {
|
|
3784
|
+
buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
|
|
3780
3785
|
perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
|
|
3781
3786
|
lineCount: clustersByLine.length,
|
|
3782
3787
|
wordCount: clustersByLine.flat().length,
|
|
@@ -3800,64 +3805,115 @@
|
|
|
3800
3805
|
clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
|
|
3801
3806
|
}
|
|
3802
3807
|
// Step 2: Check for overlaps within the cluster
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
const
|
|
3811
|
-
let
|
|
3812
|
-
if (
|
|
3813
|
-
|
|
3814
|
-
for (let i = 0; i <
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
clusterPaths.push({
|
|
3819
|
-
...path,
|
|
3820
|
-
points: path.points.map((p) => new Vec2(p.x + (glyph.x ?? 0), p.y + (glyph.y ?? 0)))
|
|
3821
|
-
});
|
|
3808
|
+
let boundaryGroups;
|
|
3809
|
+
if (cluster.glyphs.length <= 1) {
|
|
3810
|
+
boundaryGroups = [[0]];
|
|
3811
|
+
}
|
|
3812
|
+
else {
|
|
3813
|
+
// Check clustering cache (same text + glyph IDs = same overlap groups)
|
|
3814
|
+
const cacheKey = cluster.text;
|
|
3815
|
+
const cached = this.clusteringCache.get(cacheKey);
|
|
3816
|
+
let isValid = false;
|
|
3817
|
+
if (cached && cached.glyphIds.length === cluster.glyphs.length) {
|
|
3818
|
+
isValid = true;
|
|
3819
|
+
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3820
|
+
if (cached.glyphIds[i] !== cluster.glyphs[i].g) {
|
|
3821
|
+
isValid = false;
|
|
3822
|
+
break;
|
|
3822
3823
|
}
|
|
3823
3824
|
}
|
|
3824
|
-
cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
|
|
3825
|
-
this.wordCache.set(clusterKey, cachedCluster);
|
|
3826
3825
|
}
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
const
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3826
|
+
if (isValid && cached) {
|
|
3827
|
+
boundaryGroups = cached.groups;
|
|
3828
|
+
}
|
|
3829
|
+
else {
|
|
3830
|
+
const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
|
|
3831
|
+
boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
|
|
3832
|
+
this.clusteringCache.set(cacheKey, {
|
|
3833
|
+
glyphIds: cluster.glyphs.map(g => g.g),
|
|
3834
|
+
groups: boundaryGroups
|
|
3835
|
+
});
|
|
3837
3836
|
}
|
|
3838
3837
|
}
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3838
|
+
const clusterHasColoredGlyphs = coloredTextIndices &&
|
|
3839
|
+
cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
|
|
3840
|
+
// Force glyph-level caching if:
|
|
3841
|
+
// - separateGlyphs flag is set (for shader attributes), OR
|
|
3842
|
+
// - cluster contains selectively colored text (needs separate vertex ranges per glyph)
|
|
3843
|
+
const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
|
|
3844
|
+
// Iterate over the geometric groups identified by BoundaryClusterer
|
|
3845
|
+
// logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
|
|
3846
|
+
for (const groupIndices of boundaryGroups) {
|
|
3847
|
+
const isOverlappingGroup = groupIndices.length > 1;
|
|
3848
|
+
const shouldCluster = isOverlappingGroup && !forceSeparate;
|
|
3849
|
+
if (shouldCluster) {
|
|
3850
|
+
// Cluster-level caching for this specific group of overlapping glyphs
|
|
3851
|
+
const subClusterGlyphs = groupIndices.map((i) => cluster.glyphs[i]);
|
|
3852
|
+
const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
|
|
3853
|
+
let cachedCluster = this.wordCache.get(clusterKey);
|
|
3854
|
+
if (!cachedCluster) {
|
|
3855
|
+
const clusterPaths = [];
|
|
3856
|
+
const refX = subClusterGlyphs[0].x ?? 0;
|
|
3857
|
+
const refY = subClusterGlyphs[0].y ?? 0;
|
|
3858
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3859
|
+
const originalIndex = groupIndices[i];
|
|
3860
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3861
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3862
|
+
// Position relative to the sub-cluster start
|
|
3863
|
+
const relX = (glyph.x ?? 0) - refX;
|
|
3864
|
+
const relY = (glyph.y ?? 0) - refY;
|
|
3865
|
+
for (const path of glyphContours.paths) {
|
|
3866
|
+
clusterPaths.push({
|
|
3867
|
+
...path,
|
|
3868
|
+
points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
|
|
3869
|
+
});
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
|
|
3873
|
+
this.wordCache.set(clusterKey, cachedCluster);
|
|
3874
|
+
}
|
|
3875
|
+
// Calculate the absolute position of this sub-cluster based on its first glyph
|
|
3876
|
+
// (since the cached geometry is relative to that first glyph)
|
|
3877
|
+
const firstGlyphInGroup = subClusterGlyphs[0];
|
|
3878
|
+
const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
|
|
3879
|
+
const vertexOffset = vertices.length / 3;
|
|
3880
|
+
this.appendGeometry(vertices, normals, indices, cachedCluster, groupPosition, vertexOffset);
|
|
3881
|
+
const clusterVertexCount = cachedCluster.vertices.length / 3;
|
|
3882
|
+
// Register glyph infos for all glyphs in this sub-cluster
|
|
3883
|
+
// They all point to the same merged geometry
|
|
3884
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3885
|
+
const originalIndex = groupIndices[i];
|
|
3886
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3887
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3888
|
+
const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3889
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
|
|
3848
3890
|
glyphInfos.push(glyphInfo);
|
|
3849
|
-
|
|
3891
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3850
3892
|
}
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3893
|
+
}
|
|
3894
|
+
else {
|
|
3895
|
+
// Glyph-level caching (standard path for isolated glyphs or when forced separate)
|
|
3896
|
+
for (const i of groupIndices) {
|
|
3897
|
+
const glyph = cluster.glyphs[i];
|
|
3898
|
+
const glyphContours = clusterGlyphContours[i];
|
|
3899
|
+
const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3900
|
+
// Skip glyphs with no paths (spaces, zero-width characters, etc.)
|
|
3901
|
+
if (glyphContours.paths.length === 0) {
|
|
3902
|
+
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
|
|
3903
|
+
glyphInfos.push(glyphInfo);
|
|
3904
|
+
continue;
|
|
3905
|
+
}
|
|
3906
|
+
let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
|
|
3907
|
+
if (!cachedGlyph) {
|
|
3908
|
+
cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
|
|
3909
|
+
this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
|
|
3910
|
+
}
|
|
3911
|
+
const vertexOffset = vertices.length / 3;
|
|
3912
|
+
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3913
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3914
|
+
glyphInfos.push(glyphInfo);
|
|
3915
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3855
3916
|
}
|
|
3856
|
-
const vertexOffset = vertices.length / 3;
|
|
3857
|
-
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3858
|
-
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3859
|
-
glyphInfos.push(glyphInfo);
|
|
3860
|
-
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3861
3917
|
}
|
|
3862
3918
|
}
|
|
3863
3919
|
}
|
|
@@ -3874,6 +3930,21 @@
|
|
|
3874
3930
|
planeBounds
|
|
3875
3931
|
};
|
|
3876
3932
|
}
|
|
3933
|
+
getClusterKey(glyphs, depth, removeOverlaps) {
|
|
3934
|
+
if (glyphs.length === 0)
|
|
3935
|
+
return '';
|
|
3936
|
+
// Normalize positions relative to the first glyph in the cluster
|
|
3937
|
+
const refX = glyphs[0].x ?? 0;
|
|
3938
|
+
const refY = glyphs[0].y ?? 0;
|
|
3939
|
+
const parts = glyphs.map((g) => {
|
|
3940
|
+
const relX = (g.x ?? 0) - refX;
|
|
3941
|
+
const relY = (g.y ?? 0) - refY;
|
|
3942
|
+
return `${g.g}:${relX},${relY}`;
|
|
3943
|
+
});
|
|
3944
|
+
const ids = parts.join('|');
|
|
3945
|
+
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
3946
|
+
return `${this.fontId}_${ids}_${roundedDepth}_${removeOverlaps}`;
|
|
3947
|
+
}
|
|
3877
3948
|
appendGeometry(vertices, normals, indices, data, position, offset) {
|
|
3878
3949
|
for (let j = 0; j < data.vertices.length; j += 3) {
|
|
3879
3950
|
vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
|
|
@@ -3998,6 +4069,7 @@
|
|
|
3998
4069
|
clearCache() {
|
|
3999
4070
|
this.cache.clear();
|
|
4000
4071
|
this.wordCache.clear();
|
|
4072
|
+
this.clusteringCache.clear();
|
|
4001
4073
|
}
|
|
4002
4074
|
}
|
|
4003
4075
|
|
|
@@ -4012,30 +4084,14 @@
|
|
|
4012
4084
|
perfLogger.start('TextShaper.shapeLines', {
|
|
4013
4085
|
lineCount: lineInfos.length
|
|
4014
4086
|
});
|
|
4015
|
-
// Calculate color boundaries once for the entire text before line processing
|
|
4016
|
-
const colorBoundaries = new Set();
|
|
4017
|
-
if (color &&
|
|
4018
|
-
typeof color === 'object' &&
|
|
4019
|
-
'byText' in color &&
|
|
4020
|
-
color.byText &&
|
|
4021
|
-
originalText) {
|
|
4022
|
-
for (const textToColor of Object.keys(color.byText)) {
|
|
4023
|
-
let index = 0;
|
|
4024
|
-
while ((index = originalText.indexOf(textToColor, index)) !== -1) {
|
|
4025
|
-
colorBoundaries.add(index);
|
|
4026
|
-
colorBoundaries.add(index + textToColor.length);
|
|
4027
|
-
index += textToColor.length;
|
|
4028
|
-
}
|
|
4029
|
-
}
|
|
4030
|
-
}
|
|
4031
4087
|
const clustersByLine = [];
|
|
4032
4088
|
lineInfos.forEach((lineInfo, lineIndex) => {
|
|
4033
|
-
const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction
|
|
4089
|
+
const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
|
|
4034
4090
|
clustersByLine.push(clusters);
|
|
4035
4091
|
});
|
|
4036
4092
|
return clustersByLine;
|
|
4037
4093
|
}
|
|
4038
|
-
shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction
|
|
4094
|
+
shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
|
|
4039
4095
|
const buffer = this.loadedFont.hb.createBuffer();
|
|
4040
4096
|
if (direction === 'rtl') {
|
|
4041
4097
|
buffer.setDirection('rtl');
|
|
@@ -4068,8 +4124,9 @@
|
|
|
4068
4124
|
glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
|
|
4069
4125
|
}
|
|
4070
4126
|
glyph.lineIndex = lineIndex;
|
|
4071
|
-
|
|
4072
|
-
|
|
4127
|
+
// Cluster boundaries are based on whitespace only.
|
|
4128
|
+
// Coloring is applied later via vertex colors and must never affect shaping/kerning.
|
|
4129
|
+
if (isWhitespace) {
|
|
4073
4130
|
if (currentClusterGlyphs.length > 0) {
|
|
4074
4131
|
clusters.push({
|
|
4075
4132
|
text: currentClusterText,
|
|
@@ -5256,7 +5313,35 @@
|
|
|
5256
5313
|
// Allow manual override via options.removeOverlaps
|
|
5257
5314
|
const shouldRemoveOverlaps = options.removeOverlaps ?? this.loadedFont.isVariable ?? false;
|
|
5258
5315
|
const clustersByLine = this.textShaper.shapeLines(layoutData.lines, layoutData.scaledLineHeight, layoutData.letterSpacing, layoutData.align, layoutData.direction, options.color, options.text);
|
|
5259
|
-
|
|
5316
|
+
// Pre-compute which character indices will be colored. This allows geometry building
|
|
5317
|
+
// to selectively use glyph-level caching (separate vertices) only for clusters containing
|
|
5318
|
+
// colored text, while non-colored clusters can still use fast cluster-level merging
|
|
5319
|
+
let coloredTextIndices;
|
|
5320
|
+
if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
|
|
5321
|
+
if (options.color.byText || options.color.byCharRange) {
|
|
5322
|
+
// Build the set manually since glyphs don't exist yet
|
|
5323
|
+
coloredTextIndices = new Set();
|
|
5324
|
+
if (options.color.byText) {
|
|
5325
|
+
for (const pattern of Object.keys(options.color.byText)) {
|
|
5326
|
+
let index = 0;
|
|
5327
|
+
while ((index = options.text.indexOf(pattern, index)) !== -1) {
|
|
5328
|
+
for (let i = index; i < index + pattern.length; i++) {
|
|
5329
|
+
coloredTextIndices.add(i);
|
|
5330
|
+
}
|
|
5331
|
+
index += pattern.length;
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
if (options.color.byCharRange) {
|
|
5336
|
+
for (const range of options.color.byCharRange) {
|
|
5337
|
+
for (let i = range.start; i < range.end; i++) {
|
|
5338
|
+
coloredTextIndices.add(i);
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
|
|
5260
5345
|
const cacheStats = this.geometryBuilder.getCacheStats();
|
|
5261
5346
|
const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
|
|
5262
5347
|
if (options.separateGlyphsWithAttributes) {
|