three-text 0.4.2 → 0.4.3

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.4.2
2
+ * three-text v0.4.3
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -2502,6 +2502,12 @@ class Tessellator {
2502
2502
  logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2503
2503
  return this.tessellate(valid, removeOverlaps, isCFF, needsExtrusionContours);
2504
2504
  }
2505
+ processContours(contours, removeOverlaps = true, isCFF = false, needsExtrusionContours = true) {
2506
+ if (contours.length === 0) {
2507
+ return { triangles: { vertices: [], indices: [] }, contours: [] };
2508
+ }
2509
+ return this.tessellateContours(contours, removeOverlaps, isCFF, needsExtrusionContours);
2510
+ }
2505
2511
  tessellate(paths, removeOverlaps, isCFF, needsExtrusionContours) {
2506
2512
  // libtess expects CCW winding; TTF outer contours are CW
2507
2513
  const needsWindingReversal = !isCFF && !removeOverlaps;
@@ -2566,6 +2572,68 @@ class Tessellator {
2566
2572
  contours: extrusionContours
2567
2573
  };
2568
2574
  }
2575
+ tessellateContours(contours, removeOverlaps, isCFF, needsExtrusionContours) {
2576
+ const needsWindingReversal = !isCFF && !removeOverlaps;
2577
+ let originalContours;
2578
+ let tessContours;
2579
+ if (needsWindingReversal) {
2580
+ tessContours = this.reverseContours(contours);
2581
+ if (removeOverlaps || needsExtrusionContours) {
2582
+ originalContours = contours;
2583
+ }
2584
+ }
2585
+ else {
2586
+ originalContours = contours;
2587
+ tessContours = contours;
2588
+ }
2589
+ let extrusionContours = needsExtrusionContours
2590
+ ? needsWindingReversal
2591
+ ? tessContours
2592
+ : (originalContours ?? contours)
2593
+ : [];
2594
+ if (removeOverlaps) {
2595
+ logger.log('Two-pass: boundary extraction then triangulation');
2596
+ perfLogger.start('Tessellator.boundaryPass', {
2597
+ contourCount: tessContours.length
2598
+ });
2599
+ const boundaryResult = this.performTessellation(originalContours, 'boundary');
2600
+ perfLogger.end('Tessellator.boundaryPass');
2601
+ if (!boundaryResult) {
2602
+ logger.warn('libtess returned empty result from boundary pass');
2603
+ return { triangles: { vertices: [], indices: [] }, contours: [] };
2604
+ }
2605
+ tessContours = this.boundaryToContours(boundaryResult);
2606
+ if (needsExtrusionContours) {
2607
+ extrusionContours = tessContours;
2608
+ }
2609
+ logger.log(`Boundary pass created ${tessContours.length} contours. Starting triangulation pass.`);
2610
+ }
2611
+ else {
2612
+ logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2613
+ }
2614
+ perfLogger.start('Tessellator.triangulationPass', {
2615
+ contourCount: tessContours.length
2616
+ });
2617
+ const triangleResult = this.performTessellation(tessContours, 'triangles');
2618
+ perfLogger.end('Tessellator.triangulationPass');
2619
+ if (!triangleResult) {
2620
+ const warning = removeOverlaps
2621
+ ? 'libtess returned empty result from triangulation pass'
2622
+ : 'libtess returned empty result from single-pass triangulation';
2623
+ logger.warn(warning);
2624
+ return {
2625
+ triangles: { vertices: [], indices: [] },
2626
+ contours: extrusionContours
2627
+ };
2628
+ }
2629
+ return {
2630
+ triangles: {
2631
+ vertices: triangleResult.vertices,
2632
+ indices: triangleResult.indices || []
2633
+ },
2634
+ contours: extrusionContours
2635
+ };
2636
+ }
2569
2637
  pathsToContours(paths, reversePoints = false) {
2570
2638
  const contours = new Array(paths.length);
2571
2639
  for (let p = 0; p < paths.length; p++) {
@@ -2603,6 +2671,35 @@ class Tessellator {
2603
2671
  }
2604
2672
  return contours;
2605
2673
  }
2674
+ reverseContours(contours) {
2675
+ const reversed = new Array(contours.length);
2676
+ for (let i = 0; i < contours.length; i++) {
2677
+ reversed[i] = this.reverseContour(contours[i]);
2678
+ }
2679
+ return reversed;
2680
+ }
2681
+ reverseContour(contour) {
2682
+ const len = contour.length;
2683
+ if (len === 0)
2684
+ return [];
2685
+ const isClosed = len >= 4 &&
2686
+ contour[0] === contour[len - 2] &&
2687
+ contour[1] === contour[len - 1];
2688
+ const end = isClosed ? len - 2 : len;
2689
+ if (end === 0)
2690
+ return [];
2691
+ const reversed = new Array(end + 2);
2692
+ let out = 0;
2693
+ for (let i = end - 2; i >= 0; i -= 2) {
2694
+ reversed[out++] = contour[i];
2695
+ reversed[out++] = contour[i + 1];
2696
+ }
2697
+ if (out >= 2) {
2698
+ reversed[out++] = reversed[0];
2699
+ reversed[out++] = reversed[1];
2700
+ }
2701
+ return reversed;
2702
+ }
2606
2703
  performTessellation(contours, mode) {
2607
2704
  const tess = new libtess_minExports.GluTesselator();
2608
2705
  tess.gluTessProperty(libtess_minExports.gluEnum.GLU_TESS_WINDING_RULE, libtess_minExports.windingRule.GLU_TESS_WINDING_NONZERO);
@@ -2729,80 +2826,86 @@ class Extruder {
2729
2826
  extrude(geometry, depth = 0, unitsPerEm) {
2730
2827
  const points = geometry.triangles.vertices;
2731
2828
  const triangleIndices = geometry.triangles.indices;
2732
- const numPoints = points.length / 2;
2733
- // Boundary edges are those that appear in exactly one triangle
2829
+ const contours = geometry.contours;
2830
+ const pointLen = points.length;
2831
+ const numPoints = pointLen / 2;
2832
+ // Prefer contours for side walls; fall back to triangle edges
2734
2833
  let boundaryEdges = [];
2834
+ let sideEdgeCount = 0;
2835
+ let useContours = false;
2735
2836
  if (depth !== 0) {
2736
- // Pack edge pair into integer key: (min << 16) | max
2737
- // Fits glyph vertex indices comfortably, good hash distribution
2738
- const edgeMap = new Map();
2739
- const triLen = triangleIndices.length;
2740
- for (let i = 0; i < triLen; i += 3) {
2741
- const a = triangleIndices[i];
2742
- const b = triangleIndices[i + 1];
2743
- const c = triangleIndices[i + 2];
2744
- let key, v0, v1;
2745
- if (a < b) {
2746
- key = (a << 16) | b;
2747
- v0 = a;
2748
- v1 = b;
2749
- }
2750
- else {
2751
- key = (b << 16) | a;
2752
- v0 = a;
2753
- v1 = b;
2754
- }
2755
- let data = edgeMap.get(key);
2756
- if (data) {
2757
- data[2]++;
2758
- }
2759
- else {
2760
- edgeMap.set(key, [v0, v1, 1]);
2761
- }
2762
- if (b < c) {
2763
- key = (b << 16) | c;
2764
- v0 = b;
2765
- v1 = c;
2766
- }
2767
- else {
2768
- key = (c << 16) | b;
2769
- v0 = b;
2770
- v1 = c;
2771
- }
2772
- data = edgeMap.get(key);
2773
- if (data) {
2774
- data[2]++;
2775
- }
2776
- else {
2777
- edgeMap.set(key, [v0, v1, 1]);
2778
- }
2779
- if (c < a) {
2780
- key = (c << 16) | a;
2781
- v0 = c;
2782
- v1 = a;
2783
- }
2784
- else {
2785
- key = (a << 16) | c;
2786
- v0 = c;
2787
- v1 = a;
2788
- }
2789
- data = edgeMap.get(key);
2790
- if (data) {
2791
- data[2]++;
2792
- }
2793
- else {
2794
- edgeMap.set(key, [v0, v1, 1]);
2837
+ if (contours.length > 0) {
2838
+ useContours = true;
2839
+ for (const contour of contours) {
2840
+ const contourPointCount = contour.length >> 1;
2841
+ if (contourPointCount >= 2) {
2842
+ sideEdgeCount += contourPointCount - 1;
2843
+ }
2795
2844
  }
2796
2845
  }
2797
- boundaryEdges = [];
2798
- for (const [v0, v1, count] of edgeMap.values()) {
2799
- if (count === 1) {
2800
- boundaryEdges.push([v0, v1]);
2846
+ else {
2847
+ // Use a directionless key (min/max) to detect shared edges
2848
+ // Store the directed edge (a->b) and mark as null when seen twice
2849
+ const edgeMap = new Map();
2850
+ const triLen = triangleIndices.length;
2851
+ for (let i = 0; i < triLen; i += 3) {
2852
+ const a = triangleIndices[i];
2853
+ const b = triangleIndices[i + 1];
2854
+ const c = triangleIndices[i + 2];
2855
+ let key, packed;
2856
+ if (a < b) {
2857
+ key = (a << 16) | b;
2858
+ }
2859
+ else {
2860
+ key = (b << 16) | a;
2861
+ }
2862
+ packed = (a << 16) | b;
2863
+ let data = edgeMap.get(key);
2864
+ if (data === undefined) {
2865
+ edgeMap.set(key, packed);
2866
+ }
2867
+ else if (data !== null) {
2868
+ edgeMap.set(key, null);
2869
+ }
2870
+ if (b < c) {
2871
+ key = (b << 16) | c;
2872
+ }
2873
+ else {
2874
+ key = (c << 16) | b;
2875
+ }
2876
+ packed = (b << 16) | c;
2877
+ data = edgeMap.get(key);
2878
+ if (data === undefined) {
2879
+ edgeMap.set(key, packed);
2880
+ }
2881
+ else if (data !== null) {
2882
+ edgeMap.set(key, null);
2883
+ }
2884
+ if (c < a) {
2885
+ key = (c << 16) | a;
2886
+ }
2887
+ else {
2888
+ key = (a << 16) | c;
2889
+ }
2890
+ packed = (c << 16) | a;
2891
+ data = edgeMap.get(key);
2892
+ if (data === undefined) {
2893
+ edgeMap.set(key, packed);
2894
+ }
2895
+ else if (data !== null) {
2896
+ edgeMap.set(key, null);
2897
+ }
2801
2898
  }
2899
+ boundaryEdges = [];
2900
+ for (const packedEdge of edgeMap.values()) {
2901
+ if (packedEdge === null)
2902
+ continue;
2903
+ boundaryEdges.push(packedEdge >>> 16, packedEdge & 0xffff);
2904
+ }
2905
+ sideEdgeCount = boundaryEdges.length >> 1;
2802
2906
  }
2803
2907
  }
2804
- const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2805
- const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2908
+ const sideVertexCount = sideEdgeCount * 4;
2806
2909
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2807
2910
  const vertexCount = baseVertexCount + sideVertexCount;
2808
2911
  const vertices = new Float32Array(vertexCount * 3);
@@ -2812,26 +2915,24 @@ class Extruder {
2812
2915
  : triangleIndices.length * 2 + sideEdgeCount * 6;
2813
2916
  const indices = new Uint32Array(indexCount);
2814
2917
  if (depth === 0) {
2815
- let vPos = 0;
2816
- for (let i = 0; i < points.length; i += 2) {
2817
- vertices[vPos] = points[i];
2818
- vertices[vPos + 1] = points[i + 1];
2918
+ for (let p = 0, vPos = 0; p < pointLen; p += 2, vPos += 3) {
2919
+ vertices[vPos] = points[p];
2920
+ vertices[vPos + 1] = points[p + 1];
2819
2921
  vertices[vPos + 2] = 0;
2820
2922
  normals[vPos] = 0;
2821
2923
  normals[vPos + 1] = 0;
2822
2924
  normals[vPos + 2] = 1;
2823
- vPos += 3;
2824
2925
  }
2825
2926
  indices.set(triangleIndices);
2826
2927
  return { vertices, normals, indices };
2827
2928
  }
2828
2929
  const minBackOffset = unitsPerEm * 0.000025;
2829
2930
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2830
- for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2931
+ const backOffset = numPoints * 3;
2932
+ for (let p = 0, vi = 0, base0 = 0; p < pointLen; p += 2, vi++, base0 += 3) {
2831
2933
  const x = points[p];
2832
2934
  const y = points[p + 1];
2833
2935
  // Cap at z=0
2834
- const base0 = vi * 3;
2835
2936
  vertices[base0] = x;
2836
2937
  vertices[base0 + 1] = y;
2837
2938
  vertices[base0 + 2] = 0;
@@ -2839,7 +2940,7 @@ class Extruder {
2839
2940
  normals[base0 + 1] = 0;
2840
2941
  normals[base0 + 2] = -1;
2841
2942
  // Cap at z=depth
2842
- const baseD = (numPoints + vi) * 3;
2943
+ const baseD = base0 + backOffset;
2843
2944
  vertices[baseD] = x;
2844
2945
  vertices[baseD + 1] = y;
2845
2946
  vertices[baseD + 2] = backZ;
@@ -2858,61 +2959,119 @@ class Extruder {
2858
2959
  }
2859
2960
  let nextVertex = numPoints * 2;
2860
2961
  let idxPos = triLen * 2;
2861
- const numEdges = boundaryEdges.length;
2862
- for (let e = 0; e < numEdges; e++) {
2863
- const edge = boundaryEdges[e];
2864
- const u = edge[0];
2865
- const v = edge[1];
2866
- const u2 = u << 1;
2867
- const v2 = v << 1;
2868
- const p0x = points[u2];
2869
- const p0y = points[u2 + 1];
2870
- const p1x = points[v2];
2871
- const p1y = points[v2 + 1];
2872
- const ex = p1x - p0x;
2873
- const ey = p1y - p0y;
2874
- const lenSq = ex * ex + ey * ey;
2875
- let nx = 0;
2876
- let ny = 0;
2877
- if (lenSq > 1e-10) {
2878
- const invLen = 1 / Math.sqrt(lenSq);
2879
- nx = ey * invLen;
2880
- ny = -ex * invLen;
2881
- }
2882
- const base = nextVertex * 3;
2883
- vertices[base] = p0x;
2884
- vertices[base + 1] = p0y;
2885
- vertices[base + 2] = 0;
2886
- vertices[base + 3] = p1x;
2887
- vertices[base + 4] = p1y;
2888
- vertices[base + 5] = 0;
2889
- vertices[base + 6] = p0x;
2890
- vertices[base + 7] = p0y;
2891
- vertices[base + 8] = backZ;
2892
- vertices[base + 9] = p1x;
2893
- vertices[base + 10] = p1y;
2894
- vertices[base + 11] = backZ;
2895
- normals[base] = nx;
2896
- normals[base + 1] = ny;
2897
- normals[base + 2] = 0;
2898
- normals[base + 3] = nx;
2899
- normals[base + 4] = ny;
2900
- normals[base + 5] = 0;
2901
- normals[base + 6] = nx;
2902
- normals[base + 7] = ny;
2903
- normals[base + 8] = 0;
2904
- normals[base + 9] = nx;
2905
- normals[base + 10] = ny;
2906
- normals[base + 11] = 0;
2907
- const baseVertex = nextVertex;
2908
- indices[idxPos] = baseVertex;
2909
- indices[idxPos + 1] = baseVertex + 1;
2910
- indices[idxPos + 2] = baseVertex + 2;
2911
- indices[idxPos + 3] = baseVertex + 1;
2912
- indices[idxPos + 4] = baseVertex + 3;
2913
- indices[idxPos + 5] = baseVertex + 2;
2914
- idxPos += 6;
2915
- nextVertex += 4;
2962
+ if (useContours) {
2963
+ for (const contour of contours) {
2964
+ const contourLen = contour.length;
2965
+ if (contourLen < 4)
2966
+ continue;
2967
+ for (let i = 0; i < contourLen - 2; i += 2) {
2968
+ const p0x = contour[i];
2969
+ const p0y = contour[i + 1];
2970
+ const p1x = contour[i + 2];
2971
+ const p1y = contour[i + 3];
2972
+ const ex = p1x - p0x;
2973
+ const ey = p1y - p0y;
2974
+ const lenSq = ex * ex + ey * ey;
2975
+ let nx = 0;
2976
+ let ny = 0;
2977
+ if (lenSq > 1e-10) {
2978
+ const invLen = 1 / Math.sqrt(lenSq);
2979
+ nx = ey * invLen;
2980
+ ny = -ex * invLen;
2981
+ }
2982
+ const base = nextVertex * 3;
2983
+ vertices[base] = p0x;
2984
+ vertices[base + 1] = p0y;
2985
+ vertices[base + 2] = 0;
2986
+ vertices[base + 3] = p1x;
2987
+ vertices[base + 4] = p1y;
2988
+ vertices[base + 5] = 0;
2989
+ vertices[base + 6] = p0x;
2990
+ vertices[base + 7] = p0y;
2991
+ vertices[base + 8] = backZ;
2992
+ vertices[base + 9] = p1x;
2993
+ vertices[base + 10] = p1y;
2994
+ vertices[base + 11] = backZ;
2995
+ normals[base] = nx;
2996
+ normals[base + 1] = ny;
2997
+ normals[base + 2] = 0;
2998
+ normals[base + 3] = nx;
2999
+ normals[base + 4] = ny;
3000
+ normals[base + 5] = 0;
3001
+ normals[base + 6] = nx;
3002
+ normals[base + 7] = ny;
3003
+ normals[base + 8] = 0;
3004
+ normals[base + 9] = nx;
3005
+ normals[base + 10] = ny;
3006
+ normals[base + 11] = 0;
3007
+ const baseVertex = nextVertex;
3008
+ indices[idxPos] = baseVertex;
3009
+ indices[idxPos + 1] = baseVertex + 1;
3010
+ indices[idxPos + 2] = baseVertex + 2;
3011
+ indices[idxPos + 3] = baseVertex + 1;
3012
+ indices[idxPos + 4] = baseVertex + 3;
3013
+ indices[idxPos + 5] = baseVertex + 2;
3014
+ idxPos += 6;
3015
+ nextVertex += 4;
3016
+ }
3017
+ }
3018
+ }
3019
+ else {
3020
+ for (let e = 0; e < sideEdgeCount; e++) {
3021
+ const edgeIndex = e << 1;
3022
+ const u = boundaryEdges[edgeIndex];
3023
+ const v = boundaryEdges[edgeIndex + 1];
3024
+ const u2 = u << 1;
3025
+ const v2 = v << 1;
3026
+ const p0x = points[u2];
3027
+ const p0y = points[u2 + 1];
3028
+ const p1x = points[v2];
3029
+ const p1y = points[v2 + 1];
3030
+ const ex = p1x - p0x;
3031
+ const ey = p1y - p0y;
3032
+ const lenSq = ex * ex + ey * ey;
3033
+ let nx = 0;
3034
+ let ny = 0;
3035
+ if (lenSq > 1e-10) {
3036
+ const invLen = 1 / Math.sqrt(lenSq);
3037
+ nx = ey * invLen;
3038
+ ny = -ex * invLen;
3039
+ }
3040
+ const base = nextVertex * 3;
3041
+ vertices[base] = p0x;
3042
+ vertices[base + 1] = p0y;
3043
+ vertices[base + 2] = 0;
3044
+ vertices[base + 3] = p1x;
3045
+ vertices[base + 4] = p1y;
3046
+ vertices[base + 5] = 0;
3047
+ vertices[base + 6] = p0x;
3048
+ vertices[base + 7] = p0y;
3049
+ vertices[base + 8] = backZ;
3050
+ vertices[base + 9] = p1x;
3051
+ vertices[base + 10] = p1y;
3052
+ vertices[base + 11] = backZ;
3053
+ normals[base] = nx;
3054
+ normals[base + 1] = ny;
3055
+ normals[base + 2] = 0;
3056
+ normals[base + 3] = nx;
3057
+ normals[base + 4] = ny;
3058
+ normals[base + 5] = 0;
3059
+ normals[base + 6] = nx;
3060
+ normals[base + 7] = ny;
3061
+ normals[base + 8] = 0;
3062
+ normals[base + 9] = nx;
3063
+ normals[base + 10] = ny;
3064
+ normals[base + 11] = 0;
3065
+ const baseVertex = nextVertex;
3066
+ indices[idxPos] = baseVertex;
3067
+ indices[idxPos + 1] = baseVertex + 1;
3068
+ indices[idxPos + 2] = baseVertex + 2;
3069
+ indices[idxPos + 3] = baseVertex + 1;
3070
+ indices[idxPos + 4] = baseVertex + 3;
3071
+ indices[idxPos + 5] = baseVertex + 2;
3072
+ idxPos += 6;
3073
+ nextVertex += 4;
3074
+ }
2916
3075
  }
2917
3076
  return { vertices, normals, indices };
2918
3077
  }
@@ -3899,6 +4058,9 @@ class GlyphGeometryBuilder {
3899
4058
  this.fontId = 'default';
3900
4059
  this.cacheKeyPrefix = 'default';
3901
4060
  this.emptyGlyphs = new Set();
4061
+ this.clusterPositions = [];
4062
+ this.clusterContoursScratch = [];
4063
+ this.taskScratch = [];
3902
4064
  this.cache = cache;
3903
4065
  this.loadedFont = loadedFont;
3904
4066
  this.tessellator = new Tessellator();
@@ -3984,14 +4146,28 @@ class GlyphGeometryBuilder {
3984
4146
  else {
3985
4147
  perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry');
3986
4148
  }
3987
- const tasks = [];
4149
+ const tasks = this.taskScratch;
4150
+ tasks.length = 0;
4151
+ let taskCount = 0;
3988
4152
  let totalVertexFloats = 0;
3989
4153
  let totalNormalFloats = 0;
3990
4154
  let totalIndexCount = 0;
3991
4155
  let vertexCursor = 0; // vertex offset (not float offset)
3992
4156
  const pushTask = (data, px, py, pz) => {
3993
4157
  const vertexStart = vertexCursor;
3994
- tasks.push({ data, px, py, pz, vertexStart });
4158
+ let task = tasks[taskCount];
4159
+ if (task) {
4160
+ task.data = data;
4161
+ task.px = px;
4162
+ task.py = py;
4163
+ task.pz = pz;
4164
+ task.vertexStart = vertexStart;
4165
+ }
4166
+ else {
4167
+ task = { data, px, py, pz, vertexStart };
4168
+ tasks[taskCount] = task;
4169
+ }
4170
+ taskCount++;
3995
4171
  totalVertexFloats += data.vertices.length;
3996
4172
  totalNormalFloats += data.normals.length;
3997
4173
  totalIndexCount += data.indices.length;
@@ -4041,8 +4217,21 @@ class GlyphGeometryBuilder {
4041
4217
  boundaryGroups = cached.groups;
4042
4218
  }
4043
4219
  else {
4044
- const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4045
- boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
4220
+ const glyphCount = cluster.glyphs.length;
4221
+ if (this.clusterPositions.length < glyphCount) {
4222
+ for (let i = this.clusterPositions.length; i < glyphCount; i++) {
4223
+ this.clusterPositions.push(new Vec3(0, 0, 0));
4224
+ }
4225
+ }
4226
+ this.clusterPositions.length = glyphCount;
4227
+ for (let i = 0; i < glyphCount; i++) {
4228
+ const glyph = cluster.glyphs[i];
4229
+ const pos = this.clusterPositions[i];
4230
+ pos.x = glyph.x ?? 0;
4231
+ pos.y = glyph.y ?? 0;
4232
+ pos.z = 0;
4233
+ }
4234
+ boundaryGroups = this.clusterer.cluster(clusterGlyphContours, this.clusterPositions);
4046
4235
  this.clusteringCache.set(cacheKey, {
4047
4236
  glyphIds: cluster.glyphs.map((g) => g.g),
4048
4237
  positions: cluster.glyphs.map((g) => ({
@@ -4099,7 +4288,8 @@ class GlyphGeometryBuilder {
4099
4288
  const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
4100
4289
  let cachedCluster = this.wordCache.get(clusterKey);
4101
4290
  if (!cachedCluster) {
4102
- const clusterPaths = [];
4291
+ const clusterContours = this.clusterContoursScratch;
4292
+ let contourIndex = 0;
4103
4293
  const refX = subClusterGlyphs[0].x ?? 0;
4104
4294
  const refY = subClusterGlyphs[0].y ?? 0;
4105
4295
  for (let i = 0; i < groupIndices.length; i++) {
@@ -4109,13 +4299,38 @@ class GlyphGeometryBuilder {
4109
4299
  const relX = (glyph.x ?? 0) - refX;
4110
4300
  const relY = (glyph.y ?? 0) - refY;
4111
4301
  for (const path of glyphContours.paths) {
4112
- clusterPaths.push({
4113
- ...path,
4114
- points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
4115
- });
4302
+ const points = path.points;
4303
+ const pointCount = points.length;
4304
+ if (pointCount < 3)
4305
+ continue;
4306
+ const isClosed = pointCount > 1 &&
4307
+ points[0].x === points[pointCount - 1].x &&
4308
+ points[0].y === points[pointCount - 1].y;
4309
+ const end = isClosed ? pointCount - 1 : pointCount;
4310
+ const needed = (end + 1) * 2;
4311
+ let contour = clusterContours[contourIndex];
4312
+ if (!contour || contour.length < needed) {
4313
+ contour = new Array(needed);
4314
+ clusterContours[contourIndex] = contour;
4315
+ }
4316
+ else {
4317
+ contour.length = needed;
4318
+ }
4319
+ let out = 0;
4320
+ for (let k = 0; k < end; k++) {
4321
+ const pt = points[k];
4322
+ contour[out++] = pt.x + relX;
4323
+ contour[out++] = pt.y + relY;
4324
+ }
4325
+ if (out >= 2) {
4326
+ contour[out++] = contour[0];
4327
+ contour[out++] = contour[1];
4328
+ }
4329
+ contourIndex++;
4116
4330
  }
4117
4331
  }
4118
- cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
4332
+ clusterContours.length = contourIndex;
4333
+ cachedCluster = this.tessellateGlyphCluster(clusterContours, depth, isCFF);
4119
4334
  this.wordCache.set(clusterKey, cachedCluster);
4120
4335
  }
4121
4336
  // Calculate the absolute position of this sub-cluster based on its first glyph
@@ -4170,6 +4385,7 @@ class GlyphGeometryBuilder {
4170
4385
  }
4171
4386
  }
4172
4387
  }
4388
+ tasks.length = taskCount;
4173
4389
  // Allocate exact-sized buffers and fill once
4174
4390
  const vertexArray = new Float32Array(totalVertexFloats);
4175
4391
  const normalArray = new Float32Array(totalNormalFloats);
@@ -4185,17 +4401,27 @@ class GlyphGeometryBuilder {
4185
4401
  const px = task.px;
4186
4402
  const py = task.py;
4187
4403
  const pz = task.pz;
4188
- for (let j = 0; j < v.length; j += 3) {
4189
- vertexArray[vertexPos++] = (v[j] + px) * scale;
4190
- vertexArray[vertexPos++] = (v[j + 1] + py) * scale;
4191
- vertexArray[vertexPos++] = (v[j + 2] + pz) * scale;
4192
- }
4404
+ const offsetX = px * scale;
4405
+ const offsetY = py * scale;
4406
+ const offsetZ = pz * scale;
4407
+ const vLen = v.length;
4408
+ let outPos = vertexPos;
4409
+ for (let j = 0; j < vLen; j += 3) {
4410
+ vertexArray[outPos] = v[j] * scale + offsetX;
4411
+ vertexArray[outPos + 1] = v[j + 1] * scale + offsetY;
4412
+ vertexArray[outPos + 2] = v[j + 2] * scale + offsetZ;
4413
+ outPos += 3;
4414
+ }
4415
+ vertexPos = outPos;
4193
4416
  normalArray.set(n, normalPos);
4194
4417
  normalPos += n.length;
4195
4418
  const vertexStart = task.vertexStart;
4196
- for (let j = 0; j < idx.length; j++) {
4197
- indexArray[indexPos++] = idx[j] + vertexStart;
4419
+ const idxLen = idx.length;
4420
+ let outIndexPos = indexPos;
4421
+ for (let j = 0; j < idxLen; j++) {
4422
+ indexArray[outIndexPos++] = idx[j] + vertexStart;
4198
4423
  }
4424
+ indexPos = outIndexPos;
4199
4425
  }
4200
4426
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4201
4427
  planeBounds.min.x *= scale;
@@ -4293,8 +4519,8 @@ class GlyphGeometryBuilder {
4293
4519
  this.contourCache.set(key, contours);
4294
4520
  return contours;
4295
4521
  }
4296
- tessellateGlyphCluster(paths, depth, isCFF) {
4297
- const processedGeometry = this.tessellator.process(paths, true, isCFF, depth !== 0);
4522
+ tessellateGlyphCluster(contours, depth, isCFF) {
4523
+ const processedGeometry = this.tessellator.processContours(contours, true, isCFF, depth !== 0);
4298
4524
  return this.extrudeAndPackage(processedGeometry, depth);
4299
4525
  }
4300
4526
  extrudeAndPackage(processedGeometry, depth) {