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.umd.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
@@ -83,7 +83,9 @@
83
83
  // Find the metric in reverse order (most recent first)
84
84
  for (let i = this.metrics.length - 1; i >= 0; i--) {
85
85
  const metric = this.metrics[i];
86
- if (metric.name === name && metric.startTime === startTime && !metric.endTime) {
86
+ if (metric.name === name &&
87
+ metric.startTime === startTime &&
88
+ !metric.endTime) {
87
89
  metric.endTime = endTime;
88
90
  metric.duration = duration;
89
91
  break;
@@ -471,7 +473,9 @@
471
473
  const char = chars[i];
472
474
  const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
473
475
  if (/\s/.test(char)) {
474
- const width = widths ? (widths[i] ?? measureText(char)) : measureText(char);
476
+ const width = widths
477
+ ? (widths[i] ?? measureText(char))
478
+ : measureText(char);
475
479
  items.push({
476
480
  type: ItemType.GLUE,
477
481
  width,
@@ -844,7 +848,9 @@
844
848
  if (breaks.length === 0) {
845
849
  // For first emergency attempt, use initialEmergencyStretch
846
850
  // For subsequent iterations (short line detection), progressively increase
847
- currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
851
+ currentEmergencyStretch =
852
+ initialEmergencyStretch +
853
+ iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
848
854
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
849
855
  }
850
856
  // Last resort: allow higher badness (but not infinite)
@@ -1725,12 +1731,12 @@
1725
1731
  try {
1726
1732
  if (gsubTableOffset) {
1727
1733
  const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1728
- gsubData.features.forEach(f => features.add(f));
1734
+ gsubData.features.forEach((f) => features.add(f));
1729
1735
  Object.assign(featureNames, gsubData.names);
1730
1736
  }
1731
1737
  if (gposTableOffset) {
1732
1738
  const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1733
- gposData.features.forEach(f => features.add(f));
1739
+ gposData.features.forEach((f) => features.add(f));
1734
1740
  Object.assign(featureNames, gposData.names);
1735
1741
  }
1736
1742
  }
@@ -2731,7 +2737,9 @@
2731
2737
  tessContours = originalContours;
2732
2738
  }
2733
2739
  let extrusionContours = needsExtrusionContours
2734
- ? originalContours ?? this.pathsToContours(paths)
2740
+ ? needsWindingReversal
2741
+ ? tessContours
2742
+ : (originalContours ?? this.pathsToContours(paths))
2735
2743
  : [];
2736
2744
  if (removeOverlaps) {
2737
2745
  logger.log('Two-pass: boundary extraction then triangulation');
@@ -2753,24 +2761,6 @@
2753
2761
  }
2754
2762
  else {
2755
2763
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2756
- // TTF contours may have inconsistent winding; check if we need normalization
2757
- if (needsExtrusionContours && !isCFF) {
2758
- const needsNormalization = this.needsWindingNormalization(extrusionContours);
2759
- if (needsNormalization) {
2760
- logger.log('Complex topology detected, running boundary pass for winding normalization');
2761
- perfLogger.start('Tessellator.windingNormalization', {
2762
- contourCount: extrusionContours.length
2763
- });
2764
- const boundaryResult = this.performTessellation(extrusionContours, 'boundary');
2765
- perfLogger.end('Tessellator.windingNormalization');
2766
- if (boundaryResult) {
2767
- extrusionContours = this.boundaryToContours(boundaryResult);
2768
- }
2769
- }
2770
- else {
2771
- logger.log('Simple topology, skipping winding normalization');
2772
- }
2773
- }
2774
2764
  }
2775
2765
  perfLogger.start('Tessellator.triangulationPass', {
2776
2766
  contourCount: tessContours.length
@@ -2782,7 +2772,10 @@
2782
2772
  ? 'libtess returned empty result from triangulation pass'
2783
2773
  : 'libtess returned empty result from single-pass triangulation';
2784
2774
  logger.warn(warning);
2785
- return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2775
+ return {
2776
+ triangles: { vertices: [], indices: [] },
2777
+ contours: extrusionContours
2778
+ };
2786
2779
  }
2787
2780
  return {
2788
2781
  triangles: {
@@ -2952,28 +2945,58 @@
2952
2945
 
2953
2946
  class Extruder {
2954
2947
  constructor() { }
2948
+ packEdge(a, b) {
2949
+ const lo = a < b ? a : b;
2950
+ const hi = a < b ? b : a;
2951
+ return lo * 0x100000000 + hi;
2952
+ }
2955
2953
  extrude(geometry, depth = 0, unitsPerEm) {
2956
2954
  const points = geometry.triangles.vertices;
2957
2955
  const triangleIndices = geometry.triangles.indices;
2958
2956
  const numPoints = points.length / 2;
2959
- // Count side-wall segments (4 vertices + 6 indices per segment)
2960
- let sideSegments = 0;
2957
+ // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2958
+ let boundaryEdges = [];
2961
2959
  if (depth !== 0) {
2962
- for (const contour of geometry.contours) {
2963
- // Contours are closed (last point repeats first)
2964
- const contourPoints = contour.length / 2;
2965
- if (contourPoints >= 2)
2966
- sideSegments += contourPoints - 1;
2960
+ const counts = new Map();
2961
+ const oriented = new Map();
2962
+ for (let i = 0; i < triangleIndices.length; i += 3) {
2963
+ const a = triangleIndices[i];
2964
+ const b = triangleIndices[i + 1];
2965
+ const c = triangleIndices[i + 2];
2966
+ const k0 = this.packEdge(a, b);
2967
+ const n0 = (counts.get(k0) ?? 0) + 1;
2968
+ counts.set(k0, n0);
2969
+ if (n0 === 1)
2970
+ oriented.set(k0, [a, b]);
2971
+ const k1 = this.packEdge(b, c);
2972
+ const n1 = (counts.get(k1) ?? 0) + 1;
2973
+ counts.set(k1, n1);
2974
+ if (n1 === 1)
2975
+ oriented.set(k1, [b, c]);
2976
+ const k2 = this.packEdge(c, a);
2977
+ const n2 = (counts.get(k2) ?? 0) + 1;
2978
+ counts.set(k2, n2);
2979
+ if (n2 === 1)
2980
+ oriented.set(k2, [c, a]);
2981
+ }
2982
+ boundaryEdges = [];
2983
+ for (const [key, count] of counts) {
2984
+ if (count !== 1)
2985
+ continue;
2986
+ const edge = oriented.get(key);
2987
+ if (edge)
2988
+ boundaryEdges.push(edge);
2967
2989
  }
2968
2990
  }
2969
- const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2991
+ const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2992
+ const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2970
2993
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2971
2994
  const vertexCount = baseVertexCount + sideVertexCount;
2972
2995
  const vertices = new Float32Array(vertexCount * 3);
2973
2996
  const normals = new Float32Array(vertexCount * 3);
2974
2997
  const indexCount = depth === 0
2975
2998
  ? triangleIndices.length
2976
- : triangleIndices.length * 2 + sideSegments * 6;
2999
+ : triangleIndices.length * 2 + sideEdgeCount * 6;
2977
3000
  const indices = new Uint32Array(indexCount);
2978
3001
  if (depth === 0) {
2979
3002
  // Single-sided flat geometry at z=0
@@ -3029,60 +3052,62 @@
3029
3052
  // Side walls
3030
3053
  let nextVertex = numPoints * 2;
3031
3054
  let idxPos = triangleIndices.length * 2;
3032
- for (const contour of geometry.contours) {
3033
- for (let i = 0; i < contour.length - 2; i += 2) {
3034
- const p0x = contour[i];
3035
- const p0y = contour[i + 1];
3036
- const p1x = contour[i + 2];
3037
- const p1y = contour[i + 3];
3038
- // Perpendicular normal for this wall segment
3039
- const ex = p1x - p0x;
3040
- const ey = p1y - p0y;
3041
- const lenSq = ex * ex + ey * ey;
3042
- let nx = 0;
3043
- let ny = 0;
3044
- if (lenSq > 0) {
3045
- const invLen = 1 / Math.sqrt(lenSq);
3046
- nx = ey * invLen;
3047
- ny = -ex * invLen;
3048
- }
3049
- const baseVertex = nextVertex;
3050
- const base = baseVertex * 3;
3051
- // Wall quad: front edge at z=0, back edge at z=depth
3052
- vertices[base] = p0x;
3053
- vertices[base + 1] = p0y;
3054
- vertices[base + 2] = 0;
3055
- vertices[base + 3] = p1x;
3056
- vertices[base + 4] = p1y;
3057
- vertices[base + 5] = 0;
3058
- vertices[base + 6] = p0x;
3059
- vertices[base + 7] = p0y;
3060
- vertices[base + 8] = backZ;
3061
- vertices[base + 9] = p1x;
3062
- vertices[base + 10] = p1y;
3063
- vertices[base + 11] = backZ;
3064
- // Wall normals point perpendicular to edge
3065
- normals[base] = nx;
3066
- normals[base + 1] = ny;
3067
- normals[base + 2] = 0;
3068
- normals[base + 3] = nx;
3069
- normals[base + 4] = ny;
3070
- normals[base + 5] = 0;
3071
- normals[base + 6] = nx;
3072
- normals[base + 7] = ny;
3073
- normals[base + 8] = 0;
3074
- normals[base + 9] = nx;
3075
- normals[base + 10] = ny;
3076
- normals[base + 11] = 0;
3077
- // Two triangles per wall segment
3078
- indices[idxPos++] = baseVertex;
3079
- indices[idxPos++] = baseVertex + 1;
3080
- indices[idxPos++] = baseVertex + 2;
3081
- indices[idxPos++] = baseVertex + 1;
3082
- indices[idxPos++] = baseVertex + 3;
3083
- indices[idxPos++] = baseVertex + 2;
3084
- nextVertex += 4;
3085
- }
3055
+ for (let e = 0; e < boundaryEdges.length; e++) {
3056
+ const [u, v] = boundaryEdges[e];
3057
+ const u2 = u * 2;
3058
+ const v2 = v * 2;
3059
+ const p0x = points[u2];
3060
+ const p0y = points[u2 + 1];
3061
+ const p1x = points[v2];
3062
+ const p1y = points[v2 + 1];
3063
+ // Perpendicular normal for this wall segment
3064
+ // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3065
+ const ex = p1x - p0x;
3066
+ const ey = p1y - p0y;
3067
+ const lenSq = ex * ex + ey * ey;
3068
+ let nx = 0;
3069
+ let ny = 0;
3070
+ if (lenSq > 0) {
3071
+ const invLen = 1 / Math.sqrt(lenSq);
3072
+ nx = ey * invLen;
3073
+ ny = -ex * invLen;
3074
+ }
3075
+ const baseVertex = nextVertex;
3076
+ const base = baseVertex * 3;
3077
+ // Wall quad: front edge at z=0, back edge at z=depth
3078
+ vertices[base] = p0x;
3079
+ vertices[base + 1] = p0y;
3080
+ vertices[base + 2] = 0;
3081
+ vertices[base + 3] = p1x;
3082
+ vertices[base + 4] = p1y;
3083
+ vertices[base + 5] = 0;
3084
+ vertices[base + 6] = p0x;
3085
+ vertices[base + 7] = p0y;
3086
+ vertices[base + 8] = backZ;
3087
+ vertices[base + 9] = p1x;
3088
+ vertices[base + 10] = p1y;
3089
+ vertices[base + 11] = backZ;
3090
+ // Wall normals point perpendicular to edge
3091
+ normals[base] = nx;
3092
+ normals[base + 1] = ny;
3093
+ normals[base + 2] = 0;
3094
+ normals[base + 3] = nx;
3095
+ normals[base + 4] = ny;
3096
+ normals[base + 5] = 0;
3097
+ normals[base + 6] = nx;
3098
+ normals[base + 7] = ny;
3099
+ normals[base + 8] = 0;
3100
+ normals[base + 9] = nx;
3101
+ normals[base + 10] = ny;
3102
+ normals[base + 11] = 0;
3103
+ // Two triangles per wall segment
3104
+ indices[idxPos++] = baseVertex;
3105
+ indices[idxPos++] = baseVertex + 1;
3106
+ indices[idxPos++] = baseVertex + 2;
3107
+ indices[idxPos++] = baseVertex + 1;
3108
+ indices[idxPos++] = baseVertex + 3;
3109
+ indices[idxPos++] = baseVertex + 2;
3110
+ nextVertex += 4;
3086
3111
  }
3087
3112
  return { vertices, normals, indices };
3088
3113
  }
@@ -3407,9 +3432,7 @@
3407
3432
  const v1LenSq = v1x * v1x + v1y * v1y;
3408
3433
  const v2LenSq = v2x * v2x + v2y * v2y;
3409
3434
  const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3410
- if (angle > threshold ||
3411
- v1LenSq < minLenSq ||
3412
- v2LenSq < minLenSq) {
3435
+ if (angle > threshold || v1LenSq < minLenSq || v2LenSq < minLenSq) {
3413
3436
  result.push(current);
3414
3437
  }
3415
3438
  else {
@@ -4171,7 +4194,7 @@
4171
4194
  const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4172
4195
  boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
4173
4196
  this.clusteringCache.set(cacheKey, {
4174
- glyphIds: cluster.glyphs.map(g => g.g),
4197
+ glyphIds: cluster.glyphs.map((g) => g.g),
4175
4198
  groups: boundaryGroups
4176
4199
  });
4177
4200
  }
@@ -4181,7 +4204,7 @@
4181
4204
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4182
4205
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4183
4206
  // Iterate over the geometric groups identified by BoundaryClusterer
4184
- // logical groups (words) split into geometric sub-groups (e.g. "aa", "XX", "bb")
4207
+ // logical groups (words) split into geometric sub-groups
4185
4208
  for (const groupIndices of boundaryGroups) {
4186
4209
  const isOverlappingGroup = groupIndices.length > 1;
4187
4210
  const shouldCluster = isOverlappingGroup && !forceSeparate;
@@ -4561,7 +4584,8 @@
4561
4584
  if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4562
4585
  shouldApply = false;
4563
4586
  }
4564
- if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4587
+ if (LineBreak.isCJPunctuation(currentChar) &&
4588
+ LineBreak.isCJPunctuation(nextChar)) {
4565
4589
  shouldApply = false;
4566
4590
  }
4567
4591
  if (shouldApply) {
@@ -5365,7 +5389,7 @@
5365
5389
  // Stringify with sorted keys for cache stability
5366
5390
  static stableStringify(obj) {
5367
5391
  const keys = Object.keys(obj).sort();
5368
- const pairs = keys.map(k => `${k}:${obj[k]}`);
5392
+ const pairs = keys.map((k) => `${k}:${obj[k]}`);
5369
5393
  return pairs.join(',');
5370
5394
  }
5371
5395
  constructor() {
@@ -5603,7 +5627,9 @@
5603
5627
  // to selectively use glyph-level caching (separate vertices) only for clusters containing
5604
5628
  // colored text, while non-colored clusters can still use fast cluster-level merging
5605
5629
  let coloredTextIndices;
5606
- if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5630
+ if (options.color &&
5631
+ typeof options.color === 'object' &&
5632
+ !Array.isArray(options.color)) {
5607
5633
  if (options.color.byText || options.color.byCharRange) {
5608
5634
  // Build the set manually since glyphs don't exist yet
5609
5635
  coloredTextIndices = new Set();
@@ -5708,7 +5734,9 @@
5708
5734
  const depthScale = this.loadedFont.upem / size;
5709
5735
  const rawDepthInFontUnits = depth * depthScale;
5710
5736
  const minExtrudeDepth = this.loadedFont.upem * 0.000025;
5711
- const depthInFontUnits = rawDepthInFontUnits <= 0 ? 0 : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5737
+ const depthInFontUnits = rawDepthInFontUnits <= 0
5738
+ ? 0
5739
+ : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5712
5740
  if (!this.textLayout) {
5713
5741
  this.textLayout = new TextLayout(this.loadedFont);
5714
5742
  }
@@ -5880,10 +5908,12 @@
5880
5908
  planeBounds.max.z *= finalScale;
5881
5909
  for (let i = 0; i < glyphInfoArray.length; i++) {
5882
5910
  const glyphInfo = glyphInfoArray[i];
5883
- glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5911
+ glyphInfo.bounds.min.x =
5912
+ glyphInfo.bounds.min.x * finalScale + offsetScaled;
5884
5913
  glyphInfo.bounds.min.y *= finalScale;
5885
5914
  glyphInfo.bounds.min.z *= finalScale;
5886
- glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5915
+ glyphInfo.bounds.max.x =
5916
+ glyphInfo.bounds.max.x * finalScale + offsetScaled;
5887
5917
  glyphInfo.bounds.max.y *= finalScale;
5888
5918
  glyphInfo.bounds.max.z *= finalScale;
5889
5919
  }
@@ -5946,13 +5976,11 @@
5946
5976
  static registerPattern(language, pattern) {
5947
5977
  Text.patternCache.set(language, pattern);
5948
5978
  }
5949
- static clearFontCache() {
5950
- Text.fontCache.clear();
5951
- Text.fontCacheMemoryBytes = 0;
5952
- }
5953
5979
  static setMaxFontCacheMemoryMB(limitMB) {
5954
5980
  Text.maxFontCacheMemoryBytes =
5955
- limitMB === Infinity ? Infinity : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5981
+ limitMB === Infinity
5982
+ ? Infinity
5983
+ : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5956
5984
  Text.enforceFontCacheMemoryLimit();
5957
5985
  }
5958
5986
  getLoadedFont() {