three-text 0.4.1 → 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.1
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
@@ -218,6 +218,9 @@ class ActiveNodeList {
218
218
  existing.previous = node.previous;
219
219
  existing.hyphenated = node.hyphenated;
220
220
  existing.line = node.line;
221
+ existing.cumWidth = node.cumWidth;
222
+ existing.cumStretch = node.cumStretch;
223
+ existing.cumShrink = node.cumShrink;
221
224
  return true;
222
225
  }
223
226
  return false;
@@ -294,27 +297,6 @@ class LineBreak {
294
297
  return FitnessClass.LOOSE; // stretching 0.5-1.0
295
298
  return FitnessClass.VERY_LOOSE; // stretching > 1.0
296
299
  }
297
- // Build prefix sums so we can quickly compute the width/stretch/shrink
298
- // of any range [a, b] as cumulative[b] - cumulative[a]
299
- static computeCumulativeWidths(items) {
300
- const n = items.length + 1;
301
- const width = new Float64Array(n);
302
- const stretch = new Float64Array(n);
303
- const shrink = new Float64Array(n);
304
- for (let i = 0; i < items.length; i++) {
305
- const item = items[i];
306
- width[i + 1] = width[i] + item.width;
307
- if (item.type === ItemType.GLUE) {
308
- stretch[i + 1] = stretch[i] + item.stretch;
309
- shrink[i + 1] = shrink[i] + item.shrink;
310
- }
311
- else {
312
- stretch[i + 1] = stretch[i];
313
- shrink[i + 1] = shrink[i];
314
- }
315
- }
316
- return { width, stretch, shrink };
317
- }
318
300
  static findHyphenationPoints(word, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN) {
319
301
  let patternTrie;
320
302
  if (availablePatterns && availablePatterns[language]) {
@@ -734,9 +716,8 @@ class LineBreak {
734
716
  // TeX: line_break inner loop (tex.web lines 16169-17256)
735
717
  // Finds optimal breakpoints using Knuth-Plass algorithm
736
718
  static lineBreak(items, lineWidth, threshold, emergencyStretch, context) {
737
- const cumulative = this.computeCumulativeWidths(items);
738
719
  const activeNodes = new ActiveNodeList();
739
- // Start node
720
+ // Start node with zero cumulative width
740
721
  activeNodes.insert({
741
722
  position: 0,
742
723
  line: 0,
@@ -745,8 +726,15 @@ class LineBreak {
745
726
  previous: null,
746
727
  hyphenated: false,
747
728
  active: true,
748
- activeIndex: 0
729
+ activeIndex: 0,
730
+ cumWidth: 0,
731
+ cumStretch: 0,
732
+ cumShrink: 0
749
733
  });
734
+ // Cumulative width from paragraph start, representing items[0..i-1]
735
+ let cumWidth = 0;
736
+ let cumStretch = 0;
737
+ let cumShrink = 0;
750
738
  // Process each item
751
739
  for (let i = 0; i < items.length; i++) {
752
740
  const item = items[i];
@@ -757,8 +745,22 @@ class LineBreak {
757
745
  (item.type === ItemType.GLUE &&
758
746
  i > 0 &&
759
747
  items[i - 1].type === ItemType.BOX);
760
- if (!isBreakpoint)
748
+ if (!isBreakpoint) {
749
+ // Accumulate width for non-breakpoint items
750
+ if (item.type === ItemType.BOX) {
751
+ cumWidth += item.width;
752
+ }
753
+ else if (item.type === ItemType.GLUE) {
754
+ const glue = item;
755
+ cumWidth += glue.width;
756
+ cumStretch += glue.stretch;
757
+ cumShrink += glue.shrink;
758
+ }
759
+ else if (item.type === ItemType.DISCRETIONARY) {
760
+ cumWidth += item.width;
761
+ }
761
762
  continue;
763
+ }
762
764
  // Get penalty and flagged status
763
765
  let pi = 0;
764
766
  let flagged = false;
@@ -780,18 +782,14 @@ class LineBreak {
780
782
  const bestDemerits = [Infinity, Infinity, Infinity, Infinity];
781
783
  // Nodes to deactivate
782
784
  const toDeactivate = [];
783
- // Current cumulative values at position i
784
- const curWidth = cumulative.width[i];
785
- const curStretch = cumulative.stretch[i];
786
- const curShrink = cumulative.shrink[i];
787
785
  // Try each active node as predecessor
788
786
  const active = activeNodes.getActive();
789
787
  for (let j = 0; j < active.length; j++) {
790
788
  const a = active[j];
791
- // Line width from a to i using cumulative arrays
792
- const lineW = curWidth - cumulative.width[a.position] + breakWidth;
793
- const lineStretch = curStretch - cumulative.stretch[a.position];
794
- const lineShrink = curShrink - cumulative.shrink[a.position];
789
+ // Line width from a to i
790
+ const lineW = cumWidth - a.cumWidth + breakWidth;
791
+ const lineStretch = cumStretch - a.cumStretch;
792
+ const lineShrink = cumShrink - a.cumShrink;
795
793
  const shortfall = lineWidth - lineW;
796
794
  let ratio;
797
795
  if (shortfall > 0) {
@@ -847,7 +845,10 @@ class LineBreak {
847
845
  previous: a,
848
846
  hyphenated: flagged,
849
847
  active: true,
850
- activeIndex: -1
848
+ activeIndex: -1,
849
+ cumWidth: cumWidth,
850
+ cumStretch: cumStretch,
851
+ cumShrink: cumShrink
851
852
  };
852
853
  }
853
854
  }
@@ -864,6 +865,19 @@ class LineBreak {
864
865
  if (activeNodes.size() === 0 && pi !== EJECT_PENALTY) {
865
866
  return null;
866
867
  }
868
+ // Accumulate width after evaluating this breakpoint
869
+ if (item.type === ItemType.BOX) {
870
+ cumWidth += item.width;
871
+ }
872
+ else if (item.type === ItemType.GLUE) {
873
+ const glue = item;
874
+ cumWidth += glue.width;
875
+ cumStretch += glue.stretch;
876
+ cumShrink += glue.shrink;
877
+ }
878
+ else if (item.type === ItemType.DISCRETIONARY) {
879
+ cumWidth += item.width;
880
+ }
867
881
  }
868
882
  // Find best solution
869
883
  let best = null;
@@ -2488,6 +2502,12 @@ class Tessellator {
2488
2502
  logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2489
2503
  return this.tessellate(valid, removeOverlaps, isCFF, needsExtrusionContours);
2490
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
+ }
2491
2511
  tessellate(paths, removeOverlaps, isCFF, needsExtrusionContours) {
2492
2512
  // libtess expects CCW winding; TTF outer contours are CW
2493
2513
  const needsWindingReversal = !isCFF && !removeOverlaps;
@@ -2552,6 +2572,68 @@ class Tessellator {
2552
2572
  contours: extrusionContours
2553
2573
  };
2554
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
+ }
2555
2637
  pathsToContours(paths, reversePoints = false) {
2556
2638
  const contours = new Array(paths.length);
2557
2639
  for (let p = 0; p < paths.length; p++) {
@@ -2589,6 +2671,35 @@ class Tessellator {
2589
2671
  }
2590
2672
  return contours;
2591
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
+ }
2592
2703
  performTessellation(contours, mode) {
2593
2704
  const tess = new libtess_minExports.GluTesselator();
2594
2705
  tess.gluTessProperty(libtess_minExports.gluEnum.GLU_TESS_WINDING_RULE, libtess_minExports.windingRule.GLU_TESS_WINDING_NONZERO);
@@ -2715,80 +2826,86 @@ class Extruder {
2715
2826
  extrude(geometry, depth = 0, unitsPerEm) {
2716
2827
  const points = geometry.triangles.vertices;
2717
2828
  const triangleIndices = geometry.triangles.indices;
2718
- const numPoints = points.length / 2;
2719
- // 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
2720
2833
  let boundaryEdges = [];
2834
+ let sideEdgeCount = 0;
2835
+ let useContours = false;
2721
2836
  if (depth !== 0) {
2722
- // Pack edge pair into integer key: (min << 16) | max
2723
- // Fits glyph vertex indices comfortably, good hash distribution
2724
- const edgeMap = new Map();
2725
- const triLen = triangleIndices.length;
2726
- for (let i = 0; i < triLen; i += 3) {
2727
- const a = triangleIndices[i];
2728
- const b = triangleIndices[i + 1];
2729
- const c = triangleIndices[i + 2];
2730
- let key, v0, v1;
2731
- if (a < b) {
2732
- key = (a << 16) | b;
2733
- v0 = a;
2734
- v1 = b;
2735
- }
2736
- else {
2737
- key = (b << 16) | a;
2738
- v0 = a;
2739
- v1 = b;
2740
- }
2741
- let data = edgeMap.get(key);
2742
- if (data) {
2743
- data[2]++;
2744
- }
2745
- else {
2746
- edgeMap.set(key, [v0, v1, 1]);
2747
- }
2748
- if (b < c) {
2749
- key = (b << 16) | c;
2750
- v0 = b;
2751
- v1 = c;
2752
- }
2753
- else {
2754
- key = (c << 16) | b;
2755
- v0 = b;
2756
- v1 = c;
2757
- }
2758
- data = edgeMap.get(key);
2759
- if (data) {
2760
- data[2]++;
2761
- }
2762
- else {
2763
- edgeMap.set(key, [v0, v1, 1]);
2764
- }
2765
- if (c < a) {
2766
- key = (c << 16) | a;
2767
- v0 = c;
2768
- v1 = a;
2769
- }
2770
- else {
2771
- key = (a << 16) | c;
2772
- v0 = c;
2773
- v1 = a;
2774
- }
2775
- data = edgeMap.get(key);
2776
- if (data) {
2777
- data[2]++;
2778
- }
2779
- else {
2780
- 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
+ }
2781
2844
  }
2782
2845
  }
2783
- boundaryEdges = [];
2784
- for (const [v0, v1, count] of edgeMap.values()) {
2785
- if (count === 1) {
2786
- 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
+ }
2898
+ }
2899
+ boundaryEdges = [];
2900
+ for (const packedEdge of edgeMap.values()) {
2901
+ if (packedEdge === null)
2902
+ continue;
2903
+ boundaryEdges.push(packedEdge >>> 16, packedEdge & 0xffff);
2787
2904
  }
2905
+ sideEdgeCount = boundaryEdges.length >> 1;
2788
2906
  }
2789
2907
  }
2790
- const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2791
- const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2908
+ const sideVertexCount = sideEdgeCount * 4;
2792
2909
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2793
2910
  const vertexCount = baseVertexCount + sideVertexCount;
2794
2911
  const vertices = new Float32Array(vertexCount * 3);
@@ -2798,26 +2915,24 @@ class Extruder {
2798
2915
  : triangleIndices.length * 2 + sideEdgeCount * 6;
2799
2916
  const indices = new Uint32Array(indexCount);
2800
2917
  if (depth === 0) {
2801
- let vPos = 0;
2802
- for (let i = 0; i < points.length; i += 2) {
2803
- vertices[vPos] = points[i];
2804
- 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];
2805
2921
  vertices[vPos + 2] = 0;
2806
2922
  normals[vPos] = 0;
2807
2923
  normals[vPos + 1] = 0;
2808
2924
  normals[vPos + 2] = 1;
2809
- vPos += 3;
2810
2925
  }
2811
2926
  indices.set(triangleIndices);
2812
2927
  return { vertices, normals, indices };
2813
2928
  }
2814
2929
  const minBackOffset = unitsPerEm * 0.000025;
2815
2930
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2816
- 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) {
2817
2933
  const x = points[p];
2818
2934
  const y = points[p + 1];
2819
2935
  // Cap at z=0
2820
- const base0 = vi * 3;
2821
2936
  vertices[base0] = x;
2822
2937
  vertices[base0 + 1] = y;
2823
2938
  vertices[base0 + 2] = 0;
@@ -2825,7 +2940,7 @@ class Extruder {
2825
2940
  normals[base0 + 1] = 0;
2826
2941
  normals[base0 + 2] = -1;
2827
2942
  // Cap at z=depth
2828
- const baseD = (numPoints + vi) * 3;
2943
+ const baseD = base0 + backOffset;
2829
2944
  vertices[baseD] = x;
2830
2945
  vertices[baseD + 1] = y;
2831
2946
  vertices[baseD + 2] = backZ;
@@ -2844,61 +2959,119 @@ class Extruder {
2844
2959
  }
2845
2960
  let nextVertex = numPoints * 2;
2846
2961
  let idxPos = triLen * 2;
2847
- const numEdges = boundaryEdges.length;
2848
- for (let e = 0; e < numEdges; e++) {
2849
- const edge = boundaryEdges[e];
2850
- const u = edge[0];
2851
- const v = edge[1];
2852
- const u2 = u << 1;
2853
- const v2 = v << 1;
2854
- const p0x = points[u2];
2855
- const p0y = points[u2 + 1];
2856
- const p1x = points[v2];
2857
- const p1y = points[v2 + 1];
2858
- const ex = p1x - p0x;
2859
- const ey = p1y - p0y;
2860
- const lenSq = ex * ex + ey * ey;
2861
- let nx = 0;
2862
- let ny = 0;
2863
- if (lenSq > 1e-10) {
2864
- const invLen = 1 / Math.sqrt(lenSq);
2865
- nx = ey * invLen;
2866
- ny = -ex * invLen;
2867
- }
2868
- const base = nextVertex * 3;
2869
- vertices[base] = p0x;
2870
- vertices[base + 1] = p0y;
2871
- vertices[base + 2] = 0;
2872
- vertices[base + 3] = p1x;
2873
- vertices[base + 4] = p1y;
2874
- vertices[base + 5] = 0;
2875
- vertices[base + 6] = p0x;
2876
- vertices[base + 7] = p0y;
2877
- vertices[base + 8] = backZ;
2878
- vertices[base + 9] = p1x;
2879
- vertices[base + 10] = p1y;
2880
- vertices[base + 11] = backZ;
2881
- normals[base] = nx;
2882
- normals[base + 1] = ny;
2883
- normals[base + 2] = 0;
2884
- normals[base + 3] = nx;
2885
- normals[base + 4] = ny;
2886
- normals[base + 5] = 0;
2887
- normals[base + 6] = nx;
2888
- normals[base + 7] = ny;
2889
- normals[base + 8] = 0;
2890
- normals[base + 9] = nx;
2891
- normals[base + 10] = ny;
2892
- normals[base + 11] = 0;
2893
- const baseVertex = nextVertex;
2894
- indices[idxPos] = baseVertex;
2895
- indices[idxPos + 1] = baseVertex + 1;
2896
- indices[idxPos + 2] = baseVertex + 2;
2897
- indices[idxPos + 3] = baseVertex + 1;
2898
- indices[idxPos + 4] = baseVertex + 3;
2899
- indices[idxPos + 5] = baseVertex + 2;
2900
- idxPos += 6;
2901
- 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
+ }
2902
3075
  }
2903
3076
  return { vertices, normals, indices };
2904
3077
  }
@@ -3885,6 +4058,9 @@ class GlyphGeometryBuilder {
3885
4058
  this.fontId = 'default';
3886
4059
  this.cacheKeyPrefix = 'default';
3887
4060
  this.emptyGlyphs = new Set();
4061
+ this.clusterPositions = [];
4062
+ this.clusterContoursScratch = [];
4063
+ this.taskScratch = [];
3888
4064
  this.cache = cache;
3889
4065
  this.loadedFont = loadedFont;
3890
4066
  this.tessellator = new Tessellator();
@@ -3970,14 +4146,28 @@ class GlyphGeometryBuilder {
3970
4146
  else {
3971
4147
  perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry');
3972
4148
  }
3973
- const tasks = [];
4149
+ const tasks = this.taskScratch;
4150
+ tasks.length = 0;
4151
+ let taskCount = 0;
3974
4152
  let totalVertexFloats = 0;
3975
4153
  let totalNormalFloats = 0;
3976
4154
  let totalIndexCount = 0;
3977
4155
  let vertexCursor = 0; // vertex offset (not float offset)
3978
4156
  const pushTask = (data, px, py, pz) => {
3979
4157
  const vertexStart = vertexCursor;
3980
- 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++;
3981
4171
  totalVertexFloats += data.vertices.length;
3982
4172
  totalNormalFloats += data.normals.length;
3983
4173
  totalIndexCount += data.indices.length;
@@ -4027,8 +4217,21 @@ class GlyphGeometryBuilder {
4027
4217
  boundaryGroups = cached.groups;
4028
4218
  }
4029
4219
  else {
4030
- const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4031
- 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);
4032
4235
  this.clusteringCache.set(cacheKey, {
4033
4236
  glyphIds: cluster.glyphs.map((g) => g.g),
4034
4237
  positions: cluster.glyphs.map((g) => ({
@@ -4085,7 +4288,8 @@ class GlyphGeometryBuilder {
4085
4288
  const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
4086
4289
  let cachedCluster = this.wordCache.get(clusterKey);
4087
4290
  if (!cachedCluster) {
4088
- const clusterPaths = [];
4291
+ const clusterContours = this.clusterContoursScratch;
4292
+ let contourIndex = 0;
4089
4293
  const refX = subClusterGlyphs[0].x ?? 0;
4090
4294
  const refY = subClusterGlyphs[0].y ?? 0;
4091
4295
  for (let i = 0; i < groupIndices.length; i++) {
@@ -4095,13 +4299,38 @@ class GlyphGeometryBuilder {
4095
4299
  const relX = (glyph.x ?? 0) - refX;
4096
4300
  const relY = (glyph.y ?? 0) - refY;
4097
4301
  for (const path of glyphContours.paths) {
4098
- clusterPaths.push({
4099
- ...path,
4100
- points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
4101
- });
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++;
4102
4330
  }
4103
4331
  }
4104
- cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
4332
+ clusterContours.length = contourIndex;
4333
+ cachedCluster = this.tessellateGlyphCluster(clusterContours, depth, isCFF);
4105
4334
  this.wordCache.set(clusterKey, cachedCluster);
4106
4335
  }
4107
4336
  // Calculate the absolute position of this sub-cluster based on its first glyph
@@ -4156,6 +4385,7 @@ class GlyphGeometryBuilder {
4156
4385
  }
4157
4386
  }
4158
4387
  }
4388
+ tasks.length = taskCount;
4159
4389
  // Allocate exact-sized buffers and fill once
4160
4390
  const vertexArray = new Float32Array(totalVertexFloats);
4161
4391
  const normalArray = new Float32Array(totalNormalFloats);
@@ -4171,17 +4401,27 @@ class GlyphGeometryBuilder {
4171
4401
  const px = task.px;
4172
4402
  const py = task.py;
4173
4403
  const pz = task.pz;
4174
- for (let j = 0; j < v.length; j += 3) {
4175
- vertexArray[vertexPos++] = (v[j] + px) * scale;
4176
- vertexArray[vertexPos++] = (v[j + 1] + py) * scale;
4177
- vertexArray[vertexPos++] = (v[j + 2] + pz) * scale;
4178
- }
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;
4179
4416
  normalArray.set(n, normalPos);
4180
4417
  normalPos += n.length;
4181
4418
  const vertexStart = task.vertexStart;
4182
- for (let j = 0; j < idx.length; j++) {
4183
- 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;
4184
4423
  }
4424
+ indexPos = outIndexPos;
4185
4425
  }
4186
4426
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4187
4427
  planeBounds.min.x *= scale;
@@ -4279,8 +4519,8 @@ class GlyphGeometryBuilder {
4279
4519
  this.contourCache.set(key, contours);
4280
4520
  return contours;
4281
4521
  }
4282
- tessellateGlyphCluster(paths, depth, isCFF) {
4283
- 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);
4284
4524
  return this.extrudeAndPackage(processedGeometry, depth);
4285
4525
  }
4286
4526
  extrudeAndPackage(processedGeometry, depth) {