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