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 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.11
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();
@@ -3772,7 +3777,7 @@ class GlyphGeometryBuilder {
3772
3777
  this.fontId = fontId;
3773
3778
  }
3774
3779
  // Build instanced geometry from glyph contours
3775
- buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false) {
3780
+ buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3776
3781
  perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
3777
3782
  lineCount: clustersByLine.length,
3778
3783
  wordCount: clustersByLine.flat().length,
@@ -3796,64 +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);
3801
- const hasOverlaps = separateGlyphs
3802
- ? false
3803
- : boundaryGroups.some((group) => group.length > 1);
3804
- if (hasOverlaps) {
3805
- // Cluster-level caching
3806
- const clusterKey = `${this.fontId}_${cluster.text}_${depth}_${removeOverlaps}`;
3807
- let cachedCluster = this.wordCache.get(clusterKey);
3808
- if (!cachedCluster) {
3809
- const clusterPaths = [];
3810
- for (let i = 0; i < clusterGlyphContours.length; i++) {
3811
- const glyphContours = clusterGlyphContours[i];
3812
- const glyph = cluster.glyphs[i];
3813
- for (const path of glyphContours.paths) {
3814
- clusterPaths.push({
3815
- ...path,
3816
- points: path.points.map((p) => new Vec2(p.x + (glyph.x ?? 0), p.y + (glyph.y ?? 0)))
3817
- });
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;
3818
3819
  }
3819
3820
  }
3820
- cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
3821
- this.wordCache.set(clusterKey, cachedCluster);
3822
3821
  }
3823
- const vertexOffset = vertices.length / 3;
3824
- this.appendGeometry(vertices, normals, indices, cachedCluster, cluster.position, vertexOffset);
3825
- const clusterVertexCount = cachedCluster.vertices.length / 3;
3826
- for (let i = 0; i < cluster.glyphs.length; i++) {
3827
- const glyph = cluster.glyphs[i];
3828
- const glyphContours = clusterGlyphContours[i];
3829
- const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
3830
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
3831
- glyphInfos.push(glyphInfo);
3832
- this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
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
+ });
3833
3832
  }
3834
3833
  }
3835
- else {
3836
- // Glyph-level caching
3837
- for (let i = 0; i < cluster.glyphs.length; i++) {
3838
- const glyph = cluster.glyphs[i];
3839
- const glyphContours = clusterGlyphContours[i];
3840
- const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
3841
- // Skip glyphs with no paths (spaces, zero-width characters, etc.)
3842
- if (glyphContours.paths.length === 0) {
3843
- const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
3834
+ const clusterHasColoredGlyphs = coloredTextIndices &&
3835
+ cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
3836
+ // Force glyph-level caching if:
3837
+ // - separateGlyphs flag is set (for shader attributes), OR
3838
+ // - cluster contains selectively colored text (needs separate vertex ranges per glyph)
3839
+ const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
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
+ }
3867
+ }
3868
+ cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
3869
+ this.wordCache.set(clusterKey, cachedCluster);
3870
+ }
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);
3844
3886
  glyphInfos.push(glyphInfo);
3845
- continue;
3887
+ this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3846
3888
  }
3847
- let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
3848
- if (!cachedGlyph) {
3849
- cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
3850
- 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);
3851
3912
  }
3852
- const vertexOffset = vertices.length / 3;
3853
- this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
3854
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
3855
- glyphInfos.push(glyphInfo);
3856
- this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
3857
3913
  }
3858
3914
  }
3859
3915
  }
@@ -3870,6 +3926,21 @@ class GlyphGeometryBuilder {
3870
3926
  planeBounds
3871
3927
  };
3872
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
+ }
3873
3944
  appendGeometry(vertices, normals, indices, data, position, offset) {
3874
3945
  for (let j = 0; j < data.vertices.length; j += 3) {
3875
3946
  vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
@@ -3994,6 +4065,7 @@ class GlyphGeometryBuilder {
3994
4065
  clearCache() {
3995
4066
  this.cache.clear();
3996
4067
  this.wordCache.clear();
4068
+ this.clusteringCache.clear();
3997
4069
  }
3998
4070
  }
3999
4071
 
@@ -4008,30 +4080,14 @@ class TextShaper {
4008
4080
  perfLogger.start('TextShaper.shapeLines', {
4009
4081
  lineCount: lineInfos.length
4010
4082
  });
4011
- // Calculate color boundaries once for the entire text before line processing
4012
- const colorBoundaries = new Set();
4013
- if (color &&
4014
- typeof color === 'object' &&
4015
- 'byText' in color &&
4016
- color.byText &&
4017
- originalText) {
4018
- for (const textToColor of Object.keys(color.byText)) {
4019
- let index = 0;
4020
- while ((index = originalText.indexOf(textToColor, index)) !== -1) {
4021
- colorBoundaries.add(index);
4022
- colorBoundaries.add(index + textToColor.length);
4023
- index += textToColor.length;
4024
- }
4025
- }
4026
- }
4027
4083
  const clustersByLine = [];
4028
4084
  lineInfos.forEach((lineInfo, lineIndex) => {
4029
- const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction, colorBoundaries);
4085
+ const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
4030
4086
  clustersByLine.push(clusters);
4031
4087
  });
4032
4088
  return clustersByLine;
4033
4089
  }
4034
- shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction, colorBoundaries) {
4090
+ shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
4035
4091
  const buffer = this.loadedFont.hb.createBuffer();
4036
4092
  if (direction === 'rtl') {
4037
4093
  buffer.setDirection('rtl');
@@ -4064,8 +4120,9 @@ class TextShaper {
4064
4120
  glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
4065
4121
  }
4066
4122
  glyph.lineIndex = lineIndex;
4067
- const isBoundary = colorBoundaries.has(glyph.absoluteTextIndex);
4068
- if (isWhitespace || isBoundary) {
4123
+ // Cluster boundaries are based on whitespace only.
4124
+ // Coloring is applied later via vertex colors and must never affect shaping/kerning.
4125
+ if (isWhitespace) {
4069
4126
  if (currentClusterGlyphs.length > 0) {
4070
4127
  clusters.push({
4071
4128
  text: currentClusterText,
@@ -5252,7 +5309,35 @@ class Text {
5252
5309
  // Allow manual override via options.removeOverlaps
5253
5310
  const shouldRemoveOverlaps = options.removeOverlaps ?? this.loadedFont.isVariable ?? false;
5254
5311
  const clustersByLine = this.textShaper.shapeLines(layoutData.lines, layoutData.scaledLineHeight, layoutData.letterSpacing, layoutData.align, layoutData.direction, options.color, options.text);
5255
- const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false);
5312
+ // Pre-compute which character indices will be colored. This allows geometry building
5313
+ // to selectively use glyph-level caching (separate vertices) only for clusters containing
5314
+ // colored text, while non-colored clusters can still use fast cluster-level merging
5315
+ let coloredTextIndices;
5316
+ if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5317
+ if (options.color.byText || options.color.byCharRange) {
5318
+ // Build the set manually since glyphs don't exist yet
5319
+ coloredTextIndices = new Set();
5320
+ if (options.color.byText) {
5321
+ for (const pattern of Object.keys(options.color.byText)) {
5322
+ let index = 0;
5323
+ while ((index = options.text.indexOf(pattern, index)) !== -1) {
5324
+ for (let i = index; i < index + pattern.length; i++) {
5325
+ coloredTextIndices.add(i);
5326
+ }
5327
+ index += pattern.length;
5328
+ }
5329
+ }
5330
+ }
5331
+ if (options.color.byCharRange) {
5332
+ for (const range of options.color.byCharRange) {
5333
+ for (let i = range.start; i < range.end; i++) {
5334
+ coloredTextIndices.add(i);
5335
+ }
5336
+ }
5337
+ }
5338
+ }
5339
+ }
5340
+ const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
5256
5341
  const cacheStats = this.geometryBuilder.getCacheStats();
5257
5342
  const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
5258
5343
  if (options.separateGlyphsWithAttributes) {