three-text 0.2.12 → 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 +130 -64
- package/dist/index.js +130 -64
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +130 -64
- package/dist/index.umd.min.js +2 -2
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -317,7 +317,7 @@ The library uses a hybrid caching strategy to maximize performance while ensurin
|
|
|
317
317
|
|
|
318
318
|
By default, it operates with glyph-level cache. The geometry for each unique character (`a`, `b`, `c`...) is generated only once and stored for reuse, avoiding redundant computation
|
|
319
319
|
|
|
320
|
-
For text with tight tracking, connected scripts, or complex kerning pairs, individual glyphs can overlap.
|
|
320
|
+
For text with tight tracking, connected scripts, or complex kerning pairs, individual glyphs can overlap. The system detects overlaps within each word and handles them at the sub-cluster level: only the specific glyphs that overlap are tessellated together as a group, while non-overlapping glyphs in the same word continue to use individual glyph caching
|
|
321
321
|
|
|
322
322
|
|
|
323
323
|
#### Flat geometry mode
|
package/dist/index.cjs
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
|
|
@@ -2586,7 +2586,6 @@ class Extruder {
|
|
|
2586
2586
|
vertices.push(points[i], points[i + 1], 0);
|
|
2587
2587
|
normals.push(0, 0, -1);
|
|
2588
2588
|
}
|
|
2589
|
-
// Add triangle indices
|
|
2590
2589
|
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2591
2590
|
indices.push(baseIndex + triangleIndices[i]);
|
|
2592
2591
|
}
|
|
@@ -2637,18 +2636,21 @@ class BoundaryClusterer {
|
|
|
2637
2636
|
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2638
2637
|
glyphCount: glyphContoursList.length
|
|
2639
2638
|
});
|
|
2640
|
-
|
|
2639
|
+
const n = glyphContoursList.length;
|
|
2640
|
+
if (n === 0) {
|
|
2641
2641
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2642
2642
|
return [];
|
|
2643
2643
|
}
|
|
2644
|
+
if (n === 1) {
|
|
2645
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2646
|
+
return [[0]];
|
|
2647
|
+
}
|
|
2644
2648
|
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2645
2649
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2646
2650
|
return result;
|
|
2647
2651
|
}
|
|
2648
2652
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2649
2653
|
const n = glyphContoursList.length;
|
|
2650
|
-
if (n <= 1)
|
|
2651
|
-
return n === 0 ? [] : [[0]];
|
|
2652
2654
|
const bounds = new Array(n);
|
|
2653
2655
|
const events = new Array(2 * n);
|
|
2654
2656
|
let eventIndex = 0;
|
|
@@ -2668,7 +2670,6 @@ class BoundaryClusterer {
|
|
|
2668
2670
|
const py = find(y);
|
|
2669
2671
|
if (px === py)
|
|
2670
2672
|
return;
|
|
2671
|
-
// Union by rank, attach smaller tree under larger tree
|
|
2672
2673
|
if (rank[px] < rank[py]) {
|
|
2673
2674
|
parent[px] = py;
|
|
2674
2675
|
}
|
|
@@ -2684,8 +2685,6 @@ class BoundaryClusterer {
|
|
|
2684
2685
|
for (const [, eventType, glyphIndex] of events) {
|
|
2685
2686
|
if (eventType === 0) {
|
|
2686
2687
|
const bounds1 = bounds[glyphIndex];
|
|
2687
|
-
// Check y-overlap with all currently active glyphs
|
|
2688
|
-
// (x-overlap is guaranteed by the sweep line)
|
|
2689
2688
|
for (const activeIndex of active) {
|
|
2690
2689
|
const bounds2 = bounds[activeIndex];
|
|
2691
2690
|
if (bounds1.minY < bounds2.maxY + OVERLAP_EPSILON &&
|
|
@@ -2702,10 +2701,12 @@ class BoundaryClusterer {
|
|
|
2702
2701
|
const clusters = new Map();
|
|
2703
2702
|
for (let i = 0; i < n; i++) {
|
|
2704
2703
|
const root = find(i);
|
|
2705
|
-
|
|
2706
|
-
|
|
2704
|
+
let list = clusters.get(root);
|
|
2705
|
+
if (!list) {
|
|
2706
|
+
list = [];
|
|
2707
|
+
clusters.set(root, list);
|
|
2707
2708
|
}
|
|
2708
|
-
|
|
2709
|
+
list.push(i);
|
|
2709
2710
|
}
|
|
2710
2711
|
return Array.from(clusters.values());
|
|
2711
2712
|
}
|
|
@@ -3758,6 +3759,10 @@ class GlyphGeometryBuilder {
|
|
|
3758
3759
|
return size;
|
|
3759
3760
|
}
|
|
3760
3761
|
});
|
|
3762
|
+
this.clusteringCache = new LRUCache({
|
|
3763
|
+
maxEntries: 2000,
|
|
3764
|
+
calculateSize: () => 1
|
|
3765
|
+
});
|
|
3761
3766
|
}
|
|
3762
3767
|
getOptimizationStats() {
|
|
3763
3768
|
return this.collector.getOptimizationStats();
|
|
@@ -3796,70 +3801,115 @@ class GlyphGeometryBuilder {
|
|
|
3796
3801
|
clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
|
|
3797
3802
|
}
|
|
3798
3803
|
// Step 2: Check for overlaps within the cluster
|
|
3799
|
-
|
|
3800
|
-
|
|
3804
|
+
let boundaryGroups;
|
|
3805
|
+
if (cluster.glyphs.length <= 1) {
|
|
3806
|
+
boundaryGroups = [[0]];
|
|
3807
|
+
}
|
|
3808
|
+
else {
|
|
3809
|
+
// Check clustering cache (same text + glyph IDs = same overlap groups)
|
|
3810
|
+
const cacheKey = cluster.text;
|
|
3811
|
+
const cached = this.clusteringCache.get(cacheKey);
|
|
3812
|
+
let isValid = false;
|
|
3813
|
+
if (cached && cached.glyphIds.length === cluster.glyphs.length) {
|
|
3814
|
+
isValid = true;
|
|
3815
|
+
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3816
|
+
if (cached.glyphIds[i] !== cluster.glyphs[i].g) {
|
|
3817
|
+
isValid = false;
|
|
3818
|
+
break;
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
if (isValid && cached) {
|
|
3823
|
+
boundaryGroups = cached.groups;
|
|
3824
|
+
}
|
|
3825
|
+
else {
|
|
3826
|
+
const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
|
|
3827
|
+
boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
|
|
3828
|
+
this.clusteringCache.set(cacheKey, {
|
|
3829
|
+
glyphIds: cluster.glyphs.map(g => g.g),
|
|
3830
|
+
groups: boundaryGroups
|
|
3831
|
+
});
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3801
3834
|
const clusterHasColoredGlyphs = coloredTextIndices &&
|
|
3802
3835
|
cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
|
|
3803
3836
|
// Force glyph-level caching if:
|
|
3804
3837
|
// - separateGlyphs flag is set (for shader attributes), OR
|
|
3805
3838
|
// - cluster contains selectively colored text (needs separate vertex ranges per glyph)
|
|
3806
3839
|
const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
const
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
const
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3840
|
+
// Iterate over the geometric groups identified by BoundaryClusterer
|
|
3841
|
+
// logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
|
|
3842
|
+
for (const groupIndices of boundaryGroups) {
|
|
3843
|
+
const isOverlappingGroup = groupIndices.length > 1;
|
|
3844
|
+
const shouldCluster = isOverlappingGroup && !forceSeparate;
|
|
3845
|
+
if (shouldCluster) {
|
|
3846
|
+
// Cluster-level caching for this specific group of overlapping glyphs
|
|
3847
|
+
const subClusterGlyphs = groupIndices.map((i) => cluster.glyphs[i]);
|
|
3848
|
+
const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
|
|
3849
|
+
let cachedCluster = this.wordCache.get(clusterKey);
|
|
3850
|
+
if (!cachedCluster) {
|
|
3851
|
+
const clusterPaths = [];
|
|
3852
|
+
const refX = subClusterGlyphs[0].x ?? 0;
|
|
3853
|
+
const refY = subClusterGlyphs[0].y ?? 0;
|
|
3854
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3855
|
+
const originalIndex = groupIndices[i];
|
|
3856
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3857
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3858
|
+
// Position relative to the sub-cluster start
|
|
3859
|
+
const relX = (glyph.x ?? 0) - refX;
|
|
3860
|
+
const relY = (glyph.y ?? 0) - refY;
|
|
3861
|
+
for (const path of glyphContours.paths) {
|
|
3862
|
+
clusterPaths.push({
|
|
3863
|
+
...path,
|
|
3864
|
+
points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3824
3867
|
}
|
|
3868
|
+
cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
|
|
3869
|
+
this.wordCache.set(clusterKey, cachedCluster);
|
|
3825
3870
|
}
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
else {
|
|
3842
|
-
// Glyph-level caching
|
|
3843
|
-
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3844
|
-
const glyph = cluster.glyphs[i];
|
|
3845
|
-
const glyphContours = clusterGlyphContours[i];
|
|
3846
|
-
const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3847
|
-
// Skip glyphs with no paths (spaces, zero-width characters, etc.)
|
|
3848
|
-
if (glyphContours.paths.length === 0) {
|
|
3849
|
-
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
|
|
3871
|
+
// Calculate the absolute position of this sub-cluster based on its first glyph
|
|
3872
|
+
// (since the cached geometry is relative to that first glyph)
|
|
3873
|
+
const firstGlyphInGroup = subClusterGlyphs[0];
|
|
3874
|
+
const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
|
|
3875
|
+
const vertexOffset = vertices.length / 3;
|
|
3876
|
+
this.appendGeometry(vertices, normals, indices, cachedCluster, groupPosition, vertexOffset);
|
|
3877
|
+
const clusterVertexCount = cachedCluster.vertices.length / 3;
|
|
3878
|
+
// Register glyph infos for all glyphs in this sub-cluster
|
|
3879
|
+
// They all point to the same merged geometry
|
|
3880
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3881
|
+
const originalIndex = groupIndices[i];
|
|
3882
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3883
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3884
|
+
const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3885
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
|
|
3850
3886
|
glyphInfos.push(glyphInfo);
|
|
3851
|
-
|
|
3887
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3852
3888
|
}
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3889
|
+
}
|
|
3890
|
+
else {
|
|
3891
|
+
// Glyph-level caching (standard path for isolated glyphs or when forced separate)
|
|
3892
|
+
for (const i of groupIndices) {
|
|
3893
|
+
const glyph = cluster.glyphs[i];
|
|
3894
|
+
const glyphContours = clusterGlyphContours[i];
|
|
3895
|
+
const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3896
|
+
// Skip glyphs with no paths (spaces, zero-width characters, etc.)
|
|
3897
|
+
if (glyphContours.paths.length === 0) {
|
|
3898
|
+
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
|
|
3899
|
+
glyphInfos.push(glyphInfo);
|
|
3900
|
+
continue;
|
|
3901
|
+
}
|
|
3902
|
+
let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
|
|
3903
|
+
if (!cachedGlyph) {
|
|
3904
|
+
cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
|
|
3905
|
+
this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
|
|
3906
|
+
}
|
|
3907
|
+
const vertexOffset = vertices.length / 3;
|
|
3908
|
+
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3909
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3910
|
+
glyphInfos.push(glyphInfo);
|
|
3911
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3857
3912
|
}
|
|
3858
|
-
const vertexOffset = vertices.length / 3;
|
|
3859
|
-
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3860
|
-
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3861
|
-
glyphInfos.push(glyphInfo);
|
|
3862
|
-
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3863
3913
|
}
|
|
3864
3914
|
}
|
|
3865
3915
|
}
|
|
@@ -3876,6 +3926,21 @@ class GlyphGeometryBuilder {
|
|
|
3876
3926
|
planeBounds
|
|
3877
3927
|
};
|
|
3878
3928
|
}
|
|
3929
|
+
getClusterKey(glyphs, depth, removeOverlaps) {
|
|
3930
|
+
if (glyphs.length === 0)
|
|
3931
|
+
return '';
|
|
3932
|
+
// Normalize positions relative to the first glyph in the cluster
|
|
3933
|
+
const refX = glyphs[0].x ?? 0;
|
|
3934
|
+
const refY = glyphs[0].y ?? 0;
|
|
3935
|
+
const parts = glyphs.map((g) => {
|
|
3936
|
+
const relX = (g.x ?? 0) - refX;
|
|
3937
|
+
const relY = (g.y ?? 0) - refY;
|
|
3938
|
+
return `${g.g}:${relX},${relY}`;
|
|
3939
|
+
});
|
|
3940
|
+
const ids = parts.join('|');
|
|
3941
|
+
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
3942
|
+
return `${this.fontId}_${ids}_${roundedDepth}_${removeOverlaps}`;
|
|
3943
|
+
}
|
|
3879
3944
|
appendGeometry(vertices, normals, indices, data, position, offset) {
|
|
3880
3945
|
for (let j = 0; j < data.vertices.length; j += 3) {
|
|
3881
3946
|
vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
|
|
@@ -4000,6 +4065,7 @@ class GlyphGeometryBuilder {
|
|
|
4000
4065
|
clearCache() {
|
|
4001
4066
|
this.cache.clear();
|
|
4002
4067
|
this.wordCache.clear();
|
|
4068
|
+
this.clusteringCache.clear();
|
|
4003
4069
|
}
|
|
4004
4070
|
}
|
|
4005
4071
|
|
package/dist/index.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
|
|
@@ -2583,7 +2583,6 @@ class Extruder {
|
|
|
2583
2583
|
vertices.push(points[i], points[i + 1], 0);
|
|
2584
2584
|
normals.push(0, 0, -1);
|
|
2585
2585
|
}
|
|
2586
|
-
// Add triangle indices
|
|
2587
2586
|
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2588
2587
|
indices.push(baseIndex + triangleIndices[i]);
|
|
2589
2588
|
}
|
|
@@ -2634,18 +2633,21 @@ class BoundaryClusterer {
|
|
|
2634
2633
|
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2635
2634
|
glyphCount: glyphContoursList.length
|
|
2636
2635
|
});
|
|
2637
|
-
|
|
2636
|
+
const n = glyphContoursList.length;
|
|
2637
|
+
if (n === 0) {
|
|
2638
2638
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2639
2639
|
return [];
|
|
2640
2640
|
}
|
|
2641
|
+
if (n === 1) {
|
|
2642
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2643
|
+
return [[0]];
|
|
2644
|
+
}
|
|
2641
2645
|
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2642
2646
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2643
2647
|
return result;
|
|
2644
2648
|
}
|
|
2645
2649
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2646
2650
|
const n = glyphContoursList.length;
|
|
2647
|
-
if (n <= 1)
|
|
2648
|
-
return n === 0 ? [] : [[0]];
|
|
2649
2651
|
const bounds = new Array(n);
|
|
2650
2652
|
const events = new Array(2 * n);
|
|
2651
2653
|
let eventIndex = 0;
|
|
@@ -2665,7 +2667,6 @@ class BoundaryClusterer {
|
|
|
2665
2667
|
const py = find(y);
|
|
2666
2668
|
if (px === py)
|
|
2667
2669
|
return;
|
|
2668
|
-
// Union by rank, attach smaller tree under larger tree
|
|
2669
2670
|
if (rank[px] < rank[py]) {
|
|
2670
2671
|
parent[px] = py;
|
|
2671
2672
|
}
|
|
@@ -2681,8 +2682,6 @@ class BoundaryClusterer {
|
|
|
2681
2682
|
for (const [, eventType, glyphIndex] of events) {
|
|
2682
2683
|
if (eventType === 0) {
|
|
2683
2684
|
const bounds1 = bounds[glyphIndex];
|
|
2684
|
-
// Check y-overlap with all currently active glyphs
|
|
2685
|
-
// (x-overlap is guaranteed by the sweep line)
|
|
2686
2685
|
for (const activeIndex of active) {
|
|
2687
2686
|
const bounds2 = bounds[activeIndex];
|
|
2688
2687
|
if (bounds1.minY < bounds2.maxY + OVERLAP_EPSILON &&
|
|
@@ -2699,10 +2698,12 @@ class BoundaryClusterer {
|
|
|
2699
2698
|
const clusters = new Map();
|
|
2700
2699
|
for (let i = 0; i < n; i++) {
|
|
2701
2700
|
const root = find(i);
|
|
2702
|
-
|
|
2703
|
-
|
|
2701
|
+
let list = clusters.get(root);
|
|
2702
|
+
if (!list) {
|
|
2703
|
+
list = [];
|
|
2704
|
+
clusters.set(root, list);
|
|
2704
2705
|
}
|
|
2705
|
-
|
|
2706
|
+
list.push(i);
|
|
2706
2707
|
}
|
|
2707
2708
|
return Array.from(clusters.values());
|
|
2708
2709
|
}
|
|
@@ -3755,6 +3756,10 @@ class GlyphGeometryBuilder {
|
|
|
3755
3756
|
return size;
|
|
3756
3757
|
}
|
|
3757
3758
|
});
|
|
3759
|
+
this.clusteringCache = new LRUCache({
|
|
3760
|
+
maxEntries: 2000,
|
|
3761
|
+
calculateSize: () => 1
|
|
3762
|
+
});
|
|
3758
3763
|
}
|
|
3759
3764
|
getOptimizationStats() {
|
|
3760
3765
|
return this.collector.getOptimizationStats();
|
|
@@ -3793,70 +3798,115 @@ class GlyphGeometryBuilder {
|
|
|
3793
3798
|
clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
|
|
3794
3799
|
}
|
|
3795
3800
|
// Step 2: Check for overlaps within the cluster
|
|
3796
|
-
|
|
3797
|
-
|
|
3801
|
+
let boundaryGroups;
|
|
3802
|
+
if (cluster.glyphs.length <= 1) {
|
|
3803
|
+
boundaryGroups = [[0]];
|
|
3804
|
+
}
|
|
3805
|
+
else {
|
|
3806
|
+
// Check clustering cache (same text + glyph IDs = same overlap groups)
|
|
3807
|
+
const cacheKey = cluster.text;
|
|
3808
|
+
const cached = this.clusteringCache.get(cacheKey);
|
|
3809
|
+
let isValid = false;
|
|
3810
|
+
if (cached && cached.glyphIds.length === cluster.glyphs.length) {
|
|
3811
|
+
isValid = true;
|
|
3812
|
+
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3813
|
+
if (cached.glyphIds[i] !== cluster.glyphs[i].g) {
|
|
3814
|
+
isValid = false;
|
|
3815
|
+
break;
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
if (isValid && cached) {
|
|
3820
|
+
boundaryGroups = cached.groups;
|
|
3821
|
+
}
|
|
3822
|
+
else {
|
|
3823
|
+
const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
|
|
3824
|
+
boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
|
|
3825
|
+
this.clusteringCache.set(cacheKey, {
|
|
3826
|
+
glyphIds: cluster.glyphs.map(g => g.g),
|
|
3827
|
+
groups: boundaryGroups
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3798
3831
|
const clusterHasColoredGlyphs = coloredTextIndices &&
|
|
3799
3832
|
cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
|
|
3800
3833
|
// Force glyph-level caching if:
|
|
3801
3834
|
// - separateGlyphs flag is set (for shader attributes), OR
|
|
3802
3835
|
// - cluster contains selectively colored text (needs separate vertex ranges per glyph)
|
|
3803
3836
|
const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
const
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
const
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3837
|
+
// Iterate over the geometric groups identified by BoundaryClusterer
|
|
3838
|
+
// logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
|
|
3839
|
+
for (const groupIndices of boundaryGroups) {
|
|
3840
|
+
const isOverlappingGroup = groupIndices.length > 1;
|
|
3841
|
+
const shouldCluster = isOverlappingGroup && !forceSeparate;
|
|
3842
|
+
if (shouldCluster) {
|
|
3843
|
+
// Cluster-level caching for this specific group of overlapping glyphs
|
|
3844
|
+
const subClusterGlyphs = groupIndices.map((i) => cluster.glyphs[i]);
|
|
3845
|
+
const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
|
|
3846
|
+
let cachedCluster = this.wordCache.get(clusterKey);
|
|
3847
|
+
if (!cachedCluster) {
|
|
3848
|
+
const clusterPaths = [];
|
|
3849
|
+
const refX = subClusterGlyphs[0].x ?? 0;
|
|
3850
|
+
const refY = subClusterGlyphs[0].y ?? 0;
|
|
3851
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3852
|
+
const originalIndex = groupIndices[i];
|
|
3853
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3854
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3855
|
+
// Position relative to the sub-cluster start
|
|
3856
|
+
const relX = (glyph.x ?? 0) - refX;
|
|
3857
|
+
const relY = (glyph.y ?? 0) - refY;
|
|
3858
|
+
for (const path of glyphContours.paths) {
|
|
3859
|
+
clusterPaths.push({
|
|
3860
|
+
...path,
|
|
3861
|
+
points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3821
3864
|
}
|
|
3865
|
+
cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
|
|
3866
|
+
this.wordCache.set(clusterKey, cachedCluster);
|
|
3822
3867
|
}
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
else {
|
|
3839
|
-
// Glyph-level caching
|
|
3840
|
-
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3841
|
-
const glyph = cluster.glyphs[i];
|
|
3842
|
-
const glyphContours = clusterGlyphContours[i];
|
|
3843
|
-
const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3844
|
-
// Skip glyphs with no paths (spaces, zero-width characters, etc.)
|
|
3845
|
-
if (glyphContours.paths.length === 0) {
|
|
3846
|
-
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
|
|
3868
|
+
// Calculate the absolute position of this sub-cluster based on its first glyph
|
|
3869
|
+
// (since the cached geometry is relative to that first glyph)
|
|
3870
|
+
const firstGlyphInGroup = subClusterGlyphs[0];
|
|
3871
|
+
const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
|
|
3872
|
+
const vertexOffset = vertices.length / 3;
|
|
3873
|
+
this.appendGeometry(vertices, normals, indices, cachedCluster, groupPosition, vertexOffset);
|
|
3874
|
+
const clusterVertexCount = cachedCluster.vertices.length / 3;
|
|
3875
|
+
// Register glyph infos for all glyphs in this sub-cluster
|
|
3876
|
+
// They all point to the same merged geometry
|
|
3877
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3878
|
+
const originalIndex = groupIndices[i];
|
|
3879
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3880
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3881
|
+
const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3882
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
|
|
3847
3883
|
glyphInfos.push(glyphInfo);
|
|
3848
|
-
|
|
3884
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3849
3885
|
}
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3886
|
+
}
|
|
3887
|
+
else {
|
|
3888
|
+
// Glyph-level caching (standard path for isolated glyphs or when forced separate)
|
|
3889
|
+
for (const i of groupIndices) {
|
|
3890
|
+
const glyph = cluster.glyphs[i];
|
|
3891
|
+
const glyphContours = clusterGlyphContours[i];
|
|
3892
|
+
const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3893
|
+
// Skip glyphs with no paths (spaces, zero-width characters, etc.)
|
|
3894
|
+
if (glyphContours.paths.length === 0) {
|
|
3895
|
+
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
|
|
3896
|
+
glyphInfos.push(glyphInfo);
|
|
3897
|
+
continue;
|
|
3898
|
+
}
|
|
3899
|
+
let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
|
|
3900
|
+
if (!cachedGlyph) {
|
|
3901
|
+
cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
|
|
3902
|
+
this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
|
|
3903
|
+
}
|
|
3904
|
+
const vertexOffset = vertices.length / 3;
|
|
3905
|
+
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3906
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3907
|
+
glyphInfos.push(glyphInfo);
|
|
3908
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3854
3909
|
}
|
|
3855
|
-
const vertexOffset = vertices.length / 3;
|
|
3856
|
-
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3857
|
-
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3858
|
-
glyphInfos.push(glyphInfo);
|
|
3859
|
-
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3860
3910
|
}
|
|
3861
3911
|
}
|
|
3862
3912
|
}
|
|
@@ -3873,6 +3923,21 @@ class GlyphGeometryBuilder {
|
|
|
3873
3923
|
planeBounds
|
|
3874
3924
|
};
|
|
3875
3925
|
}
|
|
3926
|
+
getClusterKey(glyphs, depth, removeOverlaps) {
|
|
3927
|
+
if (glyphs.length === 0)
|
|
3928
|
+
return '';
|
|
3929
|
+
// Normalize positions relative to the first glyph in the cluster
|
|
3930
|
+
const refX = glyphs[0].x ?? 0;
|
|
3931
|
+
const refY = glyphs[0].y ?? 0;
|
|
3932
|
+
const parts = glyphs.map((g) => {
|
|
3933
|
+
const relX = (g.x ?? 0) - refX;
|
|
3934
|
+
const relY = (g.y ?? 0) - refY;
|
|
3935
|
+
return `${g.g}:${relX},${relY}`;
|
|
3936
|
+
});
|
|
3937
|
+
const ids = parts.join('|');
|
|
3938
|
+
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
3939
|
+
return `${this.fontId}_${ids}_${roundedDepth}_${removeOverlaps}`;
|
|
3940
|
+
}
|
|
3876
3941
|
appendGeometry(vertices, normals, indices, data, position, offset) {
|
|
3877
3942
|
for (let j = 0; j < data.vertices.length; j += 3) {
|
|
3878
3943
|
vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
|
|
@@ -3997,6 +4062,7 @@ class GlyphGeometryBuilder {
|
|
|
3997
4062
|
clearCache() {
|
|
3998
4063
|
this.cache.clear();
|
|
3999
4064
|
this.wordCache.clear();
|
|
4065
|
+
this.clusteringCache.clear();
|
|
4000
4066
|
}
|
|
4001
4067
|
}
|
|
4002
4068
|
|