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