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 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. When an overlap within a word is found, the entire word is treated as a single unit and escalated to a word-level cache. All of its glyphs are tessellated together to correctly resolve the overlaps, and the resulting geometry for the word is cached
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.12
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
- if (glyphContoursList.length === 0) {
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
- if (!clusters.has(root)) {
2706
- clusters.set(root, []);
2704
+ let list = clusters.get(root);
2705
+ if (!list) {
2706
+ list = [];
2707
+ clusters.set(root, list);
2707
2708
  }
2708
- clusters.get(root).push(i);
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
- const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
3800
- const boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
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
- const hasOverlaps = forceSeparate
3808
- ? false
3809
- : boundaryGroups.some((group) => group.length > 1);
3810
- if (hasOverlaps) {
3811
- // Cluster-level caching
3812
- const clusterKey = `${this.fontId}_${cluster.text}_${depth}_${removeOverlaps}`;
3813
- let cachedCluster = this.wordCache.get(clusterKey);
3814
- if (!cachedCluster) {
3815
- const clusterPaths = [];
3816
- for (let i = 0; i < clusterGlyphContours.length; i++) {
3817
- const glyphContours = clusterGlyphContours[i];
3818
- const glyph = cluster.glyphs[i];
3819
- for (const path of glyphContours.paths) {
3820
- clusterPaths.push({
3821
- ...path,
3822
- points: path.points.map((p) => new Vec2(p.x + (glyph.x ?? 0), p.y + (glyph.y ?? 0)))
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
- cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
3827
- this.wordCache.set(clusterKey, cachedCluster);
3828
- }
3829
- const vertexOffset = vertices.length / 3;
3830
- this.appendGeometry(vertices, normals, indices, cachedCluster, cluster.position, vertexOffset);
3831
- const clusterVertexCount = cachedCluster.vertices.length / 3;
3832
- for (let i = 0; i < cluster.glyphs.length; i++) {
3833
- const glyph = cluster.glyphs[i];
3834
- const glyphContours = clusterGlyphContours[i];
3835
- const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
3836
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
3837
- glyphInfos.push(glyphInfo);
3838
- this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
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
- continue;
3887
+ this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3852
3888
  }
3853
- let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
3854
- if (!cachedGlyph) {
3855
- cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
3856
- this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
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.12
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
- if (glyphContoursList.length === 0) {
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
- if (!clusters.has(root)) {
2703
- clusters.set(root, []);
2701
+ let list = clusters.get(root);
2702
+ if (!list) {
2703
+ list = [];
2704
+ clusters.set(root, list);
2704
2705
  }
2705
- clusters.get(root).push(i);
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
- const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
3797
- const boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
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
- const hasOverlaps = forceSeparate
3805
- ? false
3806
- : boundaryGroups.some((group) => group.length > 1);
3807
- if (hasOverlaps) {
3808
- // Cluster-level caching
3809
- const clusterKey = `${this.fontId}_${cluster.text}_${depth}_${removeOverlaps}`;
3810
- let cachedCluster = this.wordCache.get(clusterKey);
3811
- if (!cachedCluster) {
3812
- const clusterPaths = [];
3813
- for (let i = 0; i < clusterGlyphContours.length; i++) {
3814
- const glyphContours = clusterGlyphContours[i];
3815
- const glyph = cluster.glyphs[i];
3816
- for (const path of glyphContours.paths) {
3817
- clusterPaths.push({
3818
- ...path,
3819
- points: path.points.map((p) => new Vec2(p.x + (glyph.x ?? 0), p.y + (glyph.y ?? 0)))
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
- cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
3824
- this.wordCache.set(clusterKey, cachedCluster);
3825
- }
3826
- const vertexOffset = vertices.length / 3;
3827
- this.appendGeometry(vertices, normals, indices, cachedCluster, cluster.position, vertexOffset);
3828
- const clusterVertexCount = cachedCluster.vertices.length / 3;
3829
- for (let i = 0; i < cluster.glyphs.length; i++) {
3830
- const glyph = cluster.glyphs[i];
3831
- const glyphContours = clusterGlyphContours[i];
3832
- const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
3833
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
3834
- glyphInfos.push(glyphInfo);
3835
- this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
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
- continue;
3884
+ this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3849
3885
  }
3850
- let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
3851
- if (!cachedGlyph) {
3852
- cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
3853
- this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
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