three-text 0.2.17 → 0.2.19

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.17
2
+ * three-text v0.2.19
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -78,7 +78,9 @@ class PerformanceLogger {
78
78
  // Find the metric in reverse order (most recent first)
79
79
  for (let i = this.metrics.length - 1; i >= 0; i--) {
80
80
  const metric = this.metrics[i];
81
- if (metric.name === name && metric.startTime === startTime && !metric.endTime) {
81
+ if (metric.name === name &&
82
+ metric.startTime === startTime &&
83
+ !metric.endTime) {
82
84
  metric.endTime = endTime;
83
85
  metric.duration = duration;
84
86
  break;
@@ -466,7 +468,9 @@ class LineBreak {
466
468
  const char = chars[i];
467
469
  const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
468
470
  if (/\s/.test(char)) {
469
- const width = widths ? (widths[i] ?? measureText(char)) : measureText(char);
471
+ const width = widths
472
+ ? (widths[i] ?? measureText(char))
473
+ : measureText(char);
470
474
  items.push({
471
475
  type: ItemType.GLUE,
472
476
  width,
@@ -839,7 +843,9 @@ class LineBreak {
839
843
  if (breaks.length === 0) {
840
844
  // For first emergency attempt, use initialEmergencyStretch
841
845
  // For subsequent iterations (short line detection), progressively increase
842
- currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
846
+ currentEmergencyStretch =
847
+ initialEmergencyStretch +
848
+ iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
843
849
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
844
850
  }
845
851
  // Last resort: allow higher badness (but not infinite)
@@ -1720,12 +1726,12 @@ class FontMetadataExtractor {
1720
1726
  try {
1721
1727
  if (gsubTableOffset) {
1722
1728
  const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1723
- gsubData.features.forEach(f => features.add(f));
1729
+ gsubData.features.forEach((f) => features.add(f));
1724
1730
  Object.assign(featureNames, gsubData.names);
1725
1731
  }
1726
1732
  if (gposTableOffset) {
1727
1733
  const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1728
- gposData.features.forEach(f => features.add(f));
1734
+ gposData.features.forEach((f) => features.add(f));
1729
1735
  Object.assign(featureNames, gposData.names);
1730
1736
  }
1731
1737
  }
@@ -2724,7 +2730,9 @@ class Tessellator {
2724
2730
  tessContours = originalContours;
2725
2731
  }
2726
2732
  let extrusionContours = needsExtrusionContours
2727
- ? originalContours ?? this.pathsToContours(paths)
2733
+ ? needsWindingReversal
2734
+ ? tessContours
2735
+ : (originalContours ?? this.pathsToContours(paths))
2728
2736
  : [];
2729
2737
  if (removeOverlaps) {
2730
2738
  logger.log('Two-pass: boundary extraction then triangulation');
@@ -2746,24 +2754,6 @@ class Tessellator {
2746
2754
  }
2747
2755
  else {
2748
2756
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2749
- // TTF contours may have inconsistent winding; check if we need normalization
2750
- if (needsExtrusionContours && !isCFF) {
2751
- const needsNormalization = this.needsWindingNormalization(extrusionContours);
2752
- if (needsNormalization) {
2753
- logger.log('Complex topology detected, running boundary pass for winding normalization');
2754
- perfLogger.start('Tessellator.windingNormalization', {
2755
- contourCount: extrusionContours.length
2756
- });
2757
- const boundaryResult = this.performTessellation(extrusionContours, 'boundary');
2758
- perfLogger.end('Tessellator.windingNormalization');
2759
- if (boundaryResult) {
2760
- extrusionContours = this.boundaryToContours(boundaryResult);
2761
- }
2762
- }
2763
- else {
2764
- logger.log('Simple topology, skipping winding normalization');
2765
- }
2766
- }
2767
2757
  }
2768
2758
  perfLogger.start('Tessellator.triangulationPass', {
2769
2759
  contourCount: tessContours.length
@@ -2775,7 +2765,10 @@ class Tessellator {
2775
2765
  ? 'libtess returned empty result from triangulation pass'
2776
2766
  : 'libtess returned empty result from single-pass triangulation';
2777
2767
  logger.warn(warning);
2778
- return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2768
+ return {
2769
+ triangles: { vertices: [], indices: [] },
2770
+ contours: extrusionContours
2771
+ };
2779
2772
  }
2780
2773
  return {
2781
2774
  triangles: {
@@ -2945,28 +2938,58 @@ class Tessellator {
2945
2938
 
2946
2939
  class Extruder {
2947
2940
  constructor() { }
2941
+ packEdge(a, b) {
2942
+ const lo = a < b ? a : b;
2943
+ const hi = a < b ? b : a;
2944
+ return lo * 0x100000000 + hi;
2945
+ }
2948
2946
  extrude(geometry, depth = 0, unitsPerEm) {
2949
2947
  const points = geometry.triangles.vertices;
2950
2948
  const triangleIndices = geometry.triangles.indices;
2951
2949
  const numPoints = points.length / 2;
2952
- // Count side-wall segments (4 vertices + 6 indices per segment)
2953
- let sideSegments = 0;
2950
+ // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2951
+ let boundaryEdges = [];
2954
2952
  if (depth !== 0) {
2955
- for (const contour of geometry.contours) {
2956
- // Contours are closed (last point repeats first)
2957
- const contourPoints = contour.length / 2;
2958
- if (contourPoints >= 2)
2959
- sideSegments += contourPoints - 1;
2953
+ const counts = new Map();
2954
+ const oriented = new Map();
2955
+ for (let i = 0; i < triangleIndices.length; i += 3) {
2956
+ const a = triangleIndices[i];
2957
+ const b = triangleIndices[i + 1];
2958
+ const c = triangleIndices[i + 2];
2959
+ const k0 = this.packEdge(a, b);
2960
+ const n0 = (counts.get(k0) ?? 0) + 1;
2961
+ counts.set(k0, n0);
2962
+ if (n0 === 1)
2963
+ oriented.set(k0, [a, b]);
2964
+ const k1 = this.packEdge(b, c);
2965
+ const n1 = (counts.get(k1) ?? 0) + 1;
2966
+ counts.set(k1, n1);
2967
+ if (n1 === 1)
2968
+ oriented.set(k1, [b, c]);
2969
+ const k2 = this.packEdge(c, a);
2970
+ const n2 = (counts.get(k2) ?? 0) + 1;
2971
+ counts.set(k2, n2);
2972
+ if (n2 === 1)
2973
+ oriented.set(k2, [c, a]);
2974
+ }
2975
+ boundaryEdges = [];
2976
+ for (const [key, count] of counts) {
2977
+ if (count !== 1)
2978
+ continue;
2979
+ const edge = oriented.get(key);
2980
+ if (edge)
2981
+ boundaryEdges.push(edge);
2960
2982
  }
2961
2983
  }
2962
- const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2984
+ const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2985
+ const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2963
2986
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2964
2987
  const vertexCount = baseVertexCount + sideVertexCount;
2965
2988
  const vertices = new Float32Array(vertexCount * 3);
2966
2989
  const normals = new Float32Array(vertexCount * 3);
2967
2990
  const indexCount = depth === 0
2968
2991
  ? triangleIndices.length
2969
- : triangleIndices.length * 2 + sideSegments * 6;
2992
+ : triangleIndices.length * 2 + sideEdgeCount * 6;
2970
2993
  const indices = new Uint32Array(indexCount);
2971
2994
  if (depth === 0) {
2972
2995
  // Single-sided flat geometry at z=0
@@ -3022,60 +3045,62 @@ class Extruder {
3022
3045
  // Side walls
3023
3046
  let nextVertex = numPoints * 2;
3024
3047
  let idxPos = triangleIndices.length * 2;
3025
- for (const contour of geometry.contours) {
3026
- for (let i = 0; i < contour.length - 2; i += 2) {
3027
- const p0x = contour[i];
3028
- const p0y = contour[i + 1];
3029
- const p1x = contour[i + 2];
3030
- const p1y = contour[i + 3];
3031
- // Perpendicular normal for this wall segment
3032
- const ex = p1x - p0x;
3033
- const ey = p1y - p0y;
3034
- const lenSq = ex * ex + ey * ey;
3035
- let nx = 0;
3036
- let ny = 0;
3037
- if (lenSq > 0) {
3038
- const invLen = 1 / Math.sqrt(lenSq);
3039
- nx = ey * invLen;
3040
- ny = -ex * invLen;
3041
- }
3042
- const baseVertex = nextVertex;
3043
- const base = baseVertex * 3;
3044
- // Wall quad: front edge at z=0, back edge at z=depth
3045
- vertices[base] = p0x;
3046
- vertices[base + 1] = p0y;
3047
- vertices[base + 2] = 0;
3048
- vertices[base + 3] = p1x;
3049
- vertices[base + 4] = p1y;
3050
- vertices[base + 5] = 0;
3051
- vertices[base + 6] = p0x;
3052
- vertices[base + 7] = p0y;
3053
- vertices[base + 8] = backZ;
3054
- vertices[base + 9] = p1x;
3055
- vertices[base + 10] = p1y;
3056
- vertices[base + 11] = backZ;
3057
- // Wall normals point perpendicular to edge
3058
- normals[base] = nx;
3059
- normals[base + 1] = ny;
3060
- normals[base + 2] = 0;
3061
- normals[base + 3] = nx;
3062
- normals[base + 4] = ny;
3063
- normals[base + 5] = 0;
3064
- normals[base + 6] = nx;
3065
- normals[base + 7] = ny;
3066
- normals[base + 8] = 0;
3067
- normals[base + 9] = nx;
3068
- normals[base + 10] = ny;
3069
- normals[base + 11] = 0;
3070
- // Two triangles per wall segment
3071
- indices[idxPos++] = baseVertex;
3072
- indices[idxPos++] = baseVertex + 1;
3073
- indices[idxPos++] = baseVertex + 2;
3074
- indices[idxPos++] = baseVertex + 1;
3075
- indices[idxPos++] = baseVertex + 3;
3076
- indices[idxPos++] = baseVertex + 2;
3077
- nextVertex += 4;
3078
- }
3048
+ for (let e = 0; e < boundaryEdges.length; e++) {
3049
+ const [u, v] = boundaryEdges[e];
3050
+ const u2 = u * 2;
3051
+ const v2 = v * 2;
3052
+ const p0x = points[u2];
3053
+ const p0y = points[u2 + 1];
3054
+ const p1x = points[v2];
3055
+ const p1y = points[v2 + 1];
3056
+ // Perpendicular normal for this wall segment
3057
+ // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3058
+ const ex = p1x - p0x;
3059
+ const ey = p1y - p0y;
3060
+ const lenSq = ex * ex + ey * ey;
3061
+ let nx = 0;
3062
+ let ny = 0;
3063
+ if (lenSq > 0) {
3064
+ const invLen = 1 / Math.sqrt(lenSq);
3065
+ nx = ey * invLen;
3066
+ ny = -ex * invLen;
3067
+ }
3068
+ const baseVertex = nextVertex;
3069
+ const base = baseVertex * 3;
3070
+ // Wall quad: front edge at z=0, back edge at z=depth
3071
+ vertices[base] = p0x;
3072
+ vertices[base + 1] = p0y;
3073
+ vertices[base + 2] = 0;
3074
+ vertices[base + 3] = p1x;
3075
+ vertices[base + 4] = p1y;
3076
+ vertices[base + 5] = 0;
3077
+ vertices[base + 6] = p0x;
3078
+ vertices[base + 7] = p0y;
3079
+ vertices[base + 8] = backZ;
3080
+ vertices[base + 9] = p1x;
3081
+ vertices[base + 10] = p1y;
3082
+ vertices[base + 11] = backZ;
3083
+ // Wall normals point perpendicular to edge
3084
+ normals[base] = nx;
3085
+ normals[base + 1] = ny;
3086
+ normals[base + 2] = 0;
3087
+ normals[base + 3] = nx;
3088
+ normals[base + 4] = ny;
3089
+ normals[base + 5] = 0;
3090
+ normals[base + 6] = nx;
3091
+ normals[base + 7] = ny;
3092
+ normals[base + 8] = 0;
3093
+ normals[base + 9] = nx;
3094
+ normals[base + 10] = ny;
3095
+ normals[base + 11] = 0;
3096
+ // Two triangles per wall segment
3097
+ indices[idxPos++] = baseVertex;
3098
+ indices[idxPos++] = baseVertex + 1;
3099
+ indices[idxPos++] = baseVertex + 2;
3100
+ indices[idxPos++] = baseVertex + 1;
3101
+ indices[idxPos++] = baseVertex + 3;
3102
+ indices[idxPos++] = baseVertex + 2;
3103
+ nextVertex += 4;
3079
3104
  }
3080
3105
  return { vertices, normals, indices };
3081
3106
  }
@@ -3400,9 +3425,7 @@ class PathOptimizer {
3400
3425
  const v1LenSq = v1x * v1x + v1y * v1y;
3401
3426
  const v2LenSq = v2x * v2x + v2y * v2y;
3402
3427
  const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3403
- if (angle > threshold ||
3404
- v1LenSq < minLenSq ||
3405
- v2LenSq < minLenSq) {
3428
+ if (angle > threshold || v1LenSq < minLenSq || v2LenSq < minLenSq) {
3406
3429
  result.push(current);
3407
3430
  }
3408
3431
  else {
@@ -4164,7 +4187,7 @@ class GlyphGeometryBuilder {
4164
4187
  const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4165
4188
  boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
4166
4189
  this.clusteringCache.set(cacheKey, {
4167
- glyphIds: cluster.glyphs.map(g => g.g),
4190
+ glyphIds: cluster.glyphs.map((g) => g.g),
4168
4191
  groups: boundaryGroups
4169
4192
  });
4170
4193
  }
@@ -4174,7 +4197,7 @@ class GlyphGeometryBuilder {
4174
4197
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4175
4198
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4176
4199
  // Iterate over the geometric groups identified by BoundaryClusterer
4177
- // logical groups (words) split into geometric sub-groups (e.g. "aa", "XX", "bb")
4200
+ // logical groups (words) split into geometric sub-groups
4178
4201
  for (const groupIndices of boundaryGroups) {
4179
4202
  const isOverlappingGroup = groupIndices.length > 1;
4180
4203
  const shouldCluster = isOverlappingGroup && !forceSeparate;
@@ -4554,7 +4577,8 @@ class TextShaper {
4554
4577
  if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4555
4578
  shouldApply = false;
4556
4579
  }
4557
- if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4580
+ if (LineBreak.isCJPunctuation(currentChar) &&
4581
+ LineBreak.isCJPunctuation(nextChar)) {
4558
4582
  shouldApply = false;
4559
4583
  }
4560
4584
  if (shouldApply) {
@@ -5358,7 +5382,7 @@ class Text {
5358
5382
  // Stringify with sorted keys for cache stability
5359
5383
  static stableStringify(obj) {
5360
5384
  const keys = Object.keys(obj).sort();
5361
- const pairs = keys.map(k => `${k}:${obj[k]}`);
5385
+ const pairs = keys.map((k) => `${k}:${obj[k]}`);
5362
5386
  return pairs.join(',');
5363
5387
  }
5364
5388
  constructor() {
@@ -5596,7 +5620,9 @@ class Text {
5596
5620
  // to selectively use glyph-level caching (separate vertices) only for clusters containing
5597
5621
  // colored text, while non-colored clusters can still use fast cluster-level merging
5598
5622
  let coloredTextIndices;
5599
- if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5623
+ if (options.color &&
5624
+ typeof options.color === 'object' &&
5625
+ !Array.isArray(options.color)) {
5600
5626
  if (options.color.byText || options.color.byCharRange) {
5601
5627
  // Build the set manually since glyphs don't exist yet
5602
5628
  coloredTextIndices = new Set();
@@ -5701,7 +5727,9 @@ class Text {
5701
5727
  const depthScale = this.loadedFont.upem / size;
5702
5728
  const rawDepthInFontUnits = depth * depthScale;
5703
5729
  const minExtrudeDepth = this.loadedFont.upem * 0.000025;
5704
- const depthInFontUnits = rawDepthInFontUnits <= 0 ? 0 : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5730
+ const depthInFontUnits = rawDepthInFontUnits <= 0
5731
+ ? 0
5732
+ : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5705
5733
  if (!this.textLayout) {
5706
5734
  this.textLayout = new TextLayout(this.loadedFont);
5707
5735
  }
@@ -5873,10 +5901,12 @@ class Text {
5873
5901
  planeBounds.max.z *= finalScale;
5874
5902
  for (let i = 0; i < glyphInfoArray.length; i++) {
5875
5903
  const glyphInfo = glyphInfoArray[i];
5876
- glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5904
+ glyphInfo.bounds.min.x =
5905
+ glyphInfo.bounds.min.x * finalScale + offsetScaled;
5877
5906
  glyphInfo.bounds.min.y *= finalScale;
5878
5907
  glyphInfo.bounds.min.z *= finalScale;
5879
- glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5908
+ glyphInfo.bounds.max.x =
5909
+ glyphInfo.bounds.max.x * finalScale + offsetScaled;
5880
5910
  glyphInfo.bounds.max.y *= finalScale;
5881
5911
  glyphInfo.bounds.max.z *= finalScale;
5882
5912
  }
@@ -5939,13 +5969,11 @@ class Text {
5939
5969
  static registerPattern(language, pattern) {
5940
5970
  Text.patternCache.set(language, pattern);
5941
5971
  }
5942
- static clearFontCache() {
5943
- Text.fontCache.clear();
5944
- Text.fontCacheMemoryBytes = 0;
5945
- }
5946
5972
  static setMaxFontCacheMemoryMB(limitMB) {
5947
5973
  Text.maxFontCacheMemoryBytes =
5948
- limitMB === Infinity ? Infinity : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5974
+ limitMB === Infinity
5975
+ ? Infinity
5976
+ : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5949
5977
  Text.enforceFontCacheMemoryLimit();
5950
5978
  }
5951
5979
  getLoadedFont() {