three-text 0.2.17 → 0.2.18

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.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.17
2
+ * three-text v0.2.18
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -2727,7 +2727,9 @@ class Tessellator {
2727
2727
  tessContours = originalContours;
2728
2728
  }
2729
2729
  let extrusionContours = needsExtrusionContours
2730
- ? originalContours ?? this.pathsToContours(paths)
2730
+ ? needsWindingReversal
2731
+ ? tessContours
2732
+ : originalContours ?? this.pathsToContours(paths)
2731
2733
  : [];
2732
2734
  if (removeOverlaps) {
2733
2735
  logger.log('Two-pass: boundary extraction then triangulation');
@@ -2749,24 +2751,6 @@ class Tessellator {
2749
2751
  }
2750
2752
  else {
2751
2753
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2752
- // TTF contours may have inconsistent winding; check if we need normalization
2753
- if (needsExtrusionContours && !isCFF) {
2754
- const needsNormalization = this.needsWindingNormalization(extrusionContours);
2755
- if (needsNormalization) {
2756
- logger.log('Complex topology detected, running boundary pass for winding normalization');
2757
- perfLogger.start('Tessellator.windingNormalization', {
2758
- contourCount: extrusionContours.length
2759
- });
2760
- const boundaryResult = this.performTessellation(extrusionContours, 'boundary');
2761
- perfLogger.end('Tessellator.windingNormalization');
2762
- if (boundaryResult) {
2763
- extrusionContours = this.boundaryToContours(boundaryResult);
2764
- }
2765
- }
2766
- else {
2767
- logger.log('Simple topology, skipping winding normalization');
2768
- }
2769
- }
2770
2754
  }
2771
2755
  perfLogger.start('Tessellator.triangulationPass', {
2772
2756
  contourCount: tessContours.length
@@ -2948,28 +2932,58 @@ class Tessellator {
2948
2932
 
2949
2933
  class Extruder {
2950
2934
  constructor() { }
2935
+ packEdge(a, b) {
2936
+ const lo = a < b ? a : b;
2937
+ const hi = a < b ? b : a;
2938
+ return lo * 0x100000000 + hi;
2939
+ }
2951
2940
  extrude(geometry, depth = 0, unitsPerEm) {
2952
2941
  const points = geometry.triangles.vertices;
2953
2942
  const triangleIndices = geometry.triangles.indices;
2954
2943
  const numPoints = points.length / 2;
2955
- // Count side-wall segments (4 vertices + 6 indices per segment)
2956
- let sideSegments = 0;
2944
+ // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2945
+ let boundaryEdges = [];
2957
2946
  if (depth !== 0) {
2958
- for (const contour of geometry.contours) {
2959
- // Contours are closed (last point repeats first)
2960
- const contourPoints = contour.length / 2;
2961
- if (contourPoints >= 2)
2962
- sideSegments += contourPoints - 1;
2947
+ const counts = new Map();
2948
+ const oriented = new Map();
2949
+ for (let i = 0; i < triangleIndices.length; i += 3) {
2950
+ const a = triangleIndices[i];
2951
+ const b = triangleIndices[i + 1];
2952
+ const c = triangleIndices[i + 2];
2953
+ const k0 = this.packEdge(a, b);
2954
+ const n0 = (counts.get(k0) ?? 0) + 1;
2955
+ counts.set(k0, n0);
2956
+ if (n0 === 1)
2957
+ oriented.set(k0, [a, b]);
2958
+ const k1 = this.packEdge(b, c);
2959
+ const n1 = (counts.get(k1) ?? 0) + 1;
2960
+ counts.set(k1, n1);
2961
+ if (n1 === 1)
2962
+ oriented.set(k1, [b, c]);
2963
+ const k2 = this.packEdge(c, a);
2964
+ const n2 = (counts.get(k2) ?? 0) + 1;
2965
+ counts.set(k2, n2);
2966
+ if (n2 === 1)
2967
+ oriented.set(k2, [c, a]);
2968
+ }
2969
+ boundaryEdges = [];
2970
+ for (const [key, count] of counts) {
2971
+ if (count !== 1)
2972
+ continue;
2973
+ const edge = oriented.get(key);
2974
+ if (edge)
2975
+ boundaryEdges.push(edge);
2963
2976
  }
2964
2977
  }
2965
- const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2978
+ const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2979
+ const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2966
2980
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2967
2981
  const vertexCount = baseVertexCount + sideVertexCount;
2968
2982
  const vertices = new Float32Array(vertexCount * 3);
2969
2983
  const normals = new Float32Array(vertexCount * 3);
2970
2984
  const indexCount = depth === 0
2971
2985
  ? triangleIndices.length
2972
- : triangleIndices.length * 2 + sideSegments * 6;
2986
+ : triangleIndices.length * 2 + sideEdgeCount * 6;
2973
2987
  const indices = new Uint32Array(indexCount);
2974
2988
  if (depth === 0) {
2975
2989
  // Single-sided flat geometry at z=0
@@ -3025,60 +3039,62 @@ class Extruder {
3025
3039
  // Side walls
3026
3040
  let nextVertex = numPoints * 2;
3027
3041
  let idxPos = triangleIndices.length * 2;
3028
- for (const contour of geometry.contours) {
3029
- for (let i = 0; i < contour.length - 2; i += 2) {
3030
- const p0x = contour[i];
3031
- const p0y = contour[i + 1];
3032
- const p1x = contour[i + 2];
3033
- const p1y = contour[i + 3];
3034
- // Perpendicular normal for this wall segment
3035
- const ex = p1x - p0x;
3036
- const ey = p1y - p0y;
3037
- const lenSq = ex * ex + ey * ey;
3038
- let nx = 0;
3039
- let ny = 0;
3040
- if (lenSq > 0) {
3041
- const invLen = 1 / Math.sqrt(lenSq);
3042
- nx = ey * invLen;
3043
- ny = -ex * invLen;
3044
- }
3045
- const baseVertex = nextVertex;
3046
- const base = baseVertex * 3;
3047
- // Wall quad: front edge at z=0, back edge at z=depth
3048
- vertices[base] = p0x;
3049
- vertices[base + 1] = p0y;
3050
- vertices[base + 2] = 0;
3051
- vertices[base + 3] = p1x;
3052
- vertices[base + 4] = p1y;
3053
- vertices[base + 5] = 0;
3054
- vertices[base + 6] = p0x;
3055
- vertices[base + 7] = p0y;
3056
- vertices[base + 8] = backZ;
3057
- vertices[base + 9] = p1x;
3058
- vertices[base + 10] = p1y;
3059
- vertices[base + 11] = backZ;
3060
- // Wall normals point perpendicular to edge
3061
- normals[base] = nx;
3062
- normals[base + 1] = ny;
3063
- normals[base + 2] = 0;
3064
- normals[base + 3] = nx;
3065
- normals[base + 4] = ny;
3066
- normals[base + 5] = 0;
3067
- normals[base + 6] = nx;
3068
- normals[base + 7] = ny;
3069
- normals[base + 8] = 0;
3070
- normals[base + 9] = nx;
3071
- normals[base + 10] = ny;
3072
- normals[base + 11] = 0;
3073
- // Two triangles per wall segment
3074
- indices[idxPos++] = baseVertex;
3075
- indices[idxPos++] = baseVertex + 1;
3076
- indices[idxPos++] = baseVertex + 2;
3077
- indices[idxPos++] = baseVertex + 1;
3078
- indices[idxPos++] = baseVertex + 3;
3079
- indices[idxPos++] = baseVertex + 2;
3080
- nextVertex += 4;
3081
- }
3042
+ for (let e = 0; e < boundaryEdges.length; e++) {
3043
+ const [u, v] = boundaryEdges[e];
3044
+ const u2 = u * 2;
3045
+ const v2 = v * 2;
3046
+ const p0x = points[u2];
3047
+ const p0y = points[u2 + 1];
3048
+ const p1x = points[v2];
3049
+ const p1y = points[v2 + 1];
3050
+ // Perpendicular normal for this wall segment
3051
+ // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3052
+ const ex = p1x - p0x;
3053
+ const ey = p1y - p0y;
3054
+ const lenSq = ex * ex + ey * ey;
3055
+ let nx = 0;
3056
+ let ny = 0;
3057
+ if (lenSq > 0) {
3058
+ const invLen = 1 / Math.sqrt(lenSq);
3059
+ nx = ey * invLen;
3060
+ ny = -ex * invLen;
3061
+ }
3062
+ const baseVertex = nextVertex;
3063
+ const base = baseVertex * 3;
3064
+ // Wall quad: front edge at z=0, back edge at z=depth
3065
+ vertices[base] = p0x;
3066
+ vertices[base + 1] = p0y;
3067
+ vertices[base + 2] = 0;
3068
+ vertices[base + 3] = p1x;
3069
+ vertices[base + 4] = p1y;
3070
+ vertices[base + 5] = 0;
3071
+ vertices[base + 6] = p0x;
3072
+ vertices[base + 7] = p0y;
3073
+ vertices[base + 8] = backZ;
3074
+ vertices[base + 9] = p1x;
3075
+ vertices[base + 10] = p1y;
3076
+ vertices[base + 11] = backZ;
3077
+ // Wall normals point perpendicular to edge
3078
+ normals[base] = nx;
3079
+ normals[base + 1] = ny;
3080
+ normals[base + 2] = 0;
3081
+ normals[base + 3] = nx;
3082
+ normals[base + 4] = ny;
3083
+ normals[base + 5] = 0;
3084
+ normals[base + 6] = nx;
3085
+ normals[base + 7] = ny;
3086
+ normals[base + 8] = 0;
3087
+ normals[base + 9] = nx;
3088
+ normals[base + 10] = ny;
3089
+ normals[base + 11] = 0;
3090
+ // Two triangles per wall segment
3091
+ indices[idxPos++] = baseVertex;
3092
+ indices[idxPos++] = baseVertex + 1;
3093
+ indices[idxPos++] = baseVertex + 2;
3094
+ indices[idxPos++] = baseVertex + 1;
3095
+ indices[idxPos++] = baseVertex + 3;
3096
+ indices[idxPos++] = baseVertex + 2;
3097
+ nextVertex += 4;
3082
3098
  }
3083
3099
  return { vertices, normals, indices };
3084
3100
  }
@@ -4177,7 +4193,7 @@ class GlyphGeometryBuilder {
4177
4193
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4178
4194
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4179
4195
  // Iterate over the geometric groups identified by BoundaryClusterer
4180
- // logical groups (words) split into geometric sub-groups (e.g. "aa", "XX", "bb")
4196
+ // logical groups (words) split into geometric sub-groups
4181
4197
  for (const groupIndices of boundaryGroups) {
4182
4198
  const isOverlappingGroup = groupIndices.length > 1;
4183
4199
  const shouldCluster = isOverlappingGroup && !forceSeparate;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.17
2
+ * three-text v0.2.18
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -2724,7 +2724,9 @@ class Tessellator {
2724
2724
  tessContours = originalContours;
2725
2725
  }
2726
2726
  let extrusionContours = needsExtrusionContours
2727
- ? originalContours ?? this.pathsToContours(paths)
2727
+ ? needsWindingReversal
2728
+ ? tessContours
2729
+ : originalContours ?? this.pathsToContours(paths)
2728
2730
  : [];
2729
2731
  if (removeOverlaps) {
2730
2732
  logger.log('Two-pass: boundary extraction then triangulation');
@@ -2746,24 +2748,6 @@ class Tessellator {
2746
2748
  }
2747
2749
  else {
2748
2750
  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
2751
  }
2768
2752
  perfLogger.start('Tessellator.triangulationPass', {
2769
2753
  contourCount: tessContours.length
@@ -2945,28 +2929,58 @@ class Tessellator {
2945
2929
 
2946
2930
  class Extruder {
2947
2931
  constructor() { }
2932
+ packEdge(a, b) {
2933
+ const lo = a < b ? a : b;
2934
+ const hi = a < b ? b : a;
2935
+ return lo * 0x100000000 + hi;
2936
+ }
2948
2937
  extrude(geometry, depth = 0, unitsPerEm) {
2949
2938
  const points = geometry.triangles.vertices;
2950
2939
  const triangleIndices = geometry.triangles.indices;
2951
2940
  const numPoints = points.length / 2;
2952
- // Count side-wall segments (4 vertices + 6 indices per segment)
2953
- let sideSegments = 0;
2941
+ // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2942
+ let boundaryEdges = [];
2954
2943
  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;
2944
+ const counts = new Map();
2945
+ const oriented = new Map();
2946
+ for (let i = 0; i < triangleIndices.length; i += 3) {
2947
+ const a = triangleIndices[i];
2948
+ const b = triangleIndices[i + 1];
2949
+ const c = triangleIndices[i + 2];
2950
+ const k0 = this.packEdge(a, b);
2951
+ const n0 = (counts.get(k0) ?? 0) + 1;
2952
+ counts.set(k0, n0);
2953
+ if (n0 === 1)
2954
+ oriented.set(k0, [a, b]);
2955
+ const k1 = this.packEdge(b, c);
2956
+ const n1 = (counts.get(k1) ?? 0) + 1;
2957
+ counts.set(k1, n1);
2958
+ if (n1 === 1)
2959
+ oriented.set(k1, [b, c]);
2960
+ const k2 = this.packEdge(c, a);
2961
+ const n2 = (counts.get(k2) ?? 0) + 1;
2962
+ counts.set(k2, n2);
2963
+ if (n2 === 1)
2964
+ oriented.set(k2, [c, a]);
2965
+ }
2966
+ boundaryEdges = [];
2967
+ for (const [key, count] of counts) {
2968
+ if (count !== 1)
2969
+ continue;
2970
+ const edge = oriented.get(key);
2971
+ if (edge)
2972
+ boundaryEdges.push(edge);
2960
2973
  }
2961
2974
  }
2962
- const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2975
+ const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2976
+ const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2963
2977
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2964
2978
  const vertexCount = baseVertexCount + sideVertexCount;
2965
2979
  const vertices = new Float32Array(vertexCount * 3);
2966
2980
  const normals = new Float32Array(vertexCount * 3);
2967
2981
  const indexCount = depth === 0
2968
2982
  ? triangleIndices.length
2969
- : triangleIndices.length * 2 + sideSegments * 6;
2983
+ : triangleIndices.length * 2 + sideEdgeCount * 6;
2970
2984
  const indices = new Uint32Array(indexCount);
2971
2985
  if (depth === 0) {
2972
2986
  // Single-sided flat geometry at z=0
@@ -3022,60 +3036,62 @@ class Extruder {
3022
3036
  // Side walls
3023
3037
  let nextVertex = numPoints * 2;
3024
3038
  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
- }
3039
+ for (let e = 0; e < boundaryEdges.length; e++) {
3040
+ const [u, v] = boundaryEdges[e];
3041
+ const u2 = u * 2;
3042
+ const v2 = v * 2;
3043
+ const p0x = points[u2];
3044
+ const p0y = points[u2 + 1];
3045
+ const p1x = points[v2];
3046
+ const p1y = points[v2 + 1];
3047
+ // Perpendicular normal for this wall segment
3048
+ // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3049
+ const ex = p1x - p0x;
3050
+ const ey = p1y - p0y;
3051
+ const lenSq = ex * ex + ey * ey;
3052
+ let nx = 0;
3053
+ let ny = 0;
3054
+ if (lenSq > 0) {
3055
+ const invLen = 1 / Math.sqrt(lenSq);
3056
+ nx = ey * invLen;
3057
+ ny = -ex * invLen;
3058
+ }
3059
+ const baseVertex = nextVertex;
3060
+ const base = baseVertex * 3;
3061
+ // Wall quad: front edge at z=0, back edge at z=depth
3062
+ vertices[base] = p0x;
3063
+ vertices[base + 1] = p0y;
3064
+ vertices[base + 2] = 0;
3065
+ vertices[base + 3] = p1x;
3066
+ vertices[base + 4] = p1y;
3067
+ vertices[base + 5] = 0;
3068
+ vertices[base + 6] = p0x;
3069
+ vertices[base + 7] = p0y;
3070
+ vertices[base + 8] = backZ;
3071
+ vertices[base + 9] = p1x;
3072
+ vertices[base + 10] = p1y;
3073
+ vertices[base + 11] = backZ;
3074
+ // Wall normals point perpendicular to edge
3075
+ normals[base] = nx;
3076
+ normals[base + 1] = ny;
3077
+ normals[base + 2] = 0;
3078
+ normals[base + 3] = nx;
3079
+ normals[base + 4] = ny;
3080
+ normals[base + 5] = 0;
3081
+ normals[base + 6] = nx;
3082
+ normals[base + 7] = ny;
3083
+ normals[base + 8] = 0;
3084
+ normals[base + 9] = nx;
3085
+ normals[base + 10] = ny;
3086
+ normals[base + 11] = 0;
3087
+ // Two triangles per wall segment
3088
+ indices[idxPos++] = baseVertex;
3089
+ indices[idxPos++] = baseVertex + 1;
3090
+ indices[idxPos++] = baseVertex + 2;
3091
+ indices[idxPos++] = baseVertex + 1;
3092
+ indices[idxPos++] = baseVertex + 3;
3093
+ indices[idxPos++] = baseVertex + 2;
3094
+ nextVertex += 4;
3079
3095
  }
3080
3096
  return { vertices, normals, indices };
3081
3097
  }
@@ -4174,7 +4190,7 @@ class GlyphGeometryBuilder {
4174
4190
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4175
4191
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4176
4192
  // Iterate over the geometric groups identified by BoundaryClusterer
4177
- // logical groups (words) split into geometric sub-groups (e.g. "aa", "XX", "bb")
4193
+ // logical groups (words) split into geometric sub-groups
4178
4194
  for (const groupIndices of boundaryGroups) {
4179
4195
  const isOverlappingGroup = groupIndices.length > 1;
4180
4196
  const shouldCluster = isOverlappingGroup && !forceSeparate;