three-text 0.2.16 → 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.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.16
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
@@ -193,7 +193,7 @@ var FitnessClass;
193
193
  FitnessClass[FitnessClass["LOOSE"] = 2] = "LOOSE";
194
194
  FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
195
195
  })(FitnessClass || (FitnessClass = {}));
196
- // ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
196
+ // ActiveNodeList maintains all currently viable breakpoints as we scan through the text
197
197
  // Each node represents a potential break with accumulated demerits (total "cost" from start)
198
198
  //
199
199
  // Demerits = cumulative penalty score from text start to this break, calculated as:
@@ -335,9 +335,9 @@ class LineBreak {
335
335
  // Converts text into items (boxes, glues, penalties) for line breaking
336
336
  // The measureText function should return widths that include any letter spacing
337
337
  static itemizeText(text, measureText, // function to measure text width
338
- hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
338
+ measureTextWidths, hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
339
339
  const items = [];
340
- items.push(...this.itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context));
340
+ items.push(...this.itemizeParagraph(text, measureText, measureTextWidths, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context));
341
341
  // Final glue and penalty to end the paragraph
342
342
  // Use infinite stretch to fill the last line
343
343
  items.push({
@@ -442,9 +442,10 @@ class LineBreak {
442
442
  return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
443
443
  }
444
444
  // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
445
- static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
445
+ static itemizeCJKText(text, measureText, measureTextWidths, context, startOffset = 0, glueParams) {
446
446
  const items = [];
447
447
  const chars = Array.from(text);
448
+ const widths = measureTextWidths ? measureTextWidths(text) : null;
448
449
  let textPosition = startOffset;
449
450
  // Inter-character glue parameters
450
451
  let glueWidth;
@@ -465,7 +466,7 @@ class LineBreak {
465
466
  const char = chars[i];
466
467
  const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
467
468
  if (/\s/.test(char)) {
468
- const width = measureText(char);
469
+ const width = widths ? (widths[i] ?? measureText(char)) : measureText(char);
469
470
  items.push({
470
471
  type: ItemType.GLUE,
471
472
  width,
@@ -479,7 +480,7 @@ class LineBreak {
479
480
  }
480
481
  items.push({
481
482
  type: ItemType.BOX,
482
- width: measureText(char),
483
+ width: widths ? (widths[i] ?? measureText(char)) : measureText(char),
483
484
  text: char,
484
485
  originIndex: textPosition
485
486
  });
@@ -510,15 +511,21 @@ class LineBreak {
510
511
  }
511
512
  return items;
512
513
  }
513
- static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
514
+ static itemizeParagraph(text, measureText, measureTextWidths, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
514
515
  const items = [];
515
516
  const chars = Array.from(text);
516
- // Calculate CJK glue parameters once for consistency across all segments
517
- const baseCharWidth = measureText('字');
518
- const cjkGlueParams = {
519
- width: 0,
520
- stretch: baseCharWidth * 0.04,
521
- shrink: baseCharWidth * 0.04
517
+ // Calculate CJK glue parameters lazily and once for consistency across all segments
518
+ let cjkGlueParams;
519
+ const getCjkGlueParams = () => {
520
+ if (!cjkGlueParams) {
521
+ const baseCharWidth = measureText('字');
522
+ cjkGlueParams = {
523
+ width: 0,
524
+ stretch: baseCharWidth * 0.04,
525
+ shrink: baseCharWidth * 0.04
526
+ };
527
+ }
528
+ return cjkGlueParams;
522
529
  };
523
530
  let buffer = '';
524
531
  let bufferStart = 0;
@@ -528,7 +535,7 @@ class LineBreak {
528
535
  if (buffer.length === 0)
529
536
  return;
530
537
  if (bufferScript === 'cjk') {
531
- const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
538
+ const cjkItems = this.itemizeCJKText(buffer, measureText, measureTextWidths, context, bufferStart, getCjkGlueParams());
532
539
  items.push(...cjkItems);
533
540
  }
534
541
  else {
@@ -721,7 +728,7 @@ class LineBreak {
721
728
  align: options.align || 'left',
722
729
  hyphenate: options.hyphenate || false
723
730
  });
724
- const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, letterSpacing = 0, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
731
+ const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, measureTextWidths, hyphenationPatterns, unitsPerEm, letterSpacing = 0, tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, linepenalty = DEFAULT_LINE_PENALTY, adjdemerits = DEFAULT_FITNESS_DIFF_DEMERITS, hyphenpenalty = DEFAULT_HYPHEN_PENALTY, exhyphenpenalty = DEFAULT_EX_HYPHEN_PENALTY, doublehyphendemerits = DEFAULT_DOUBLE_HYPHEN_DEMERITS, looseness = 0, disableShortLineDetection = false, shortLineThreshold = SHORT_LINE_WIDTH_THRESHOLD } = options;
725
732
  // Handle multiple paragraphs by processing each independently
726
733
  if (respectExistingBreaks && text.includes('\n')) {
727
734
  const paragraphs = text.split('\n');
@@ -784,9 +791,9 @@ class LineBreak {
784
791
  exHyphenPenalty: exhyphenpenalty,
785
792
  currentAlign: align,
786
793
  unitsPerEm,
787
- // measureText() includes trailing letter spacing after the final glyph of a token.
794
+ // measureText() includes trailing letter spacing after the final glyph of a token
788
795
  // Shaping applies letter spacing only between glyphs, so we subtract one
789
- // trailing letterSpacingFU per line segment (see computeAdjustmentRatio/createLines).
796
+ // trailing letterSpacingFU per line segment (see computeAdjustmentRatio/createLines)
790
797
  letterSpacingFU: unitsPerEm ? letterSpacing * unitsPerEm : 0
791
798
  };
792
799
  if (!width || width === Infinity) {
@@ -805,7 +812,7 @@ class LineBreak {
805
812
  ];
806
813
  }
807
814
  // Itemize without hyphenation first (TeX approach: only compute if needed)
808
- const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
815
+ const allItems = LineBreak.itemizeText(text, measureText, measureTextWidths, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
809
816
  if (allItems.length === 0) {
810
817
  return [];
811
818
  }
@@ -824,7 +831,7 @@ class LineBreak {
824
831
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
825
832
  // Second pass: with hyphenation if first pass failed
826
833
  if (breaks.length === 0 && useHyphenation) {
827
- const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
834
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, measureTextWidths, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
828
835
  currentItems = itemsWithHyphenation;
829
836
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
830
837
  }
@@ -1138,9 +1145,9 @@ class LineBreak {
1138
1145
  ? items[lineEnd].width
1139
1146
  : items[lineEnd].preBreakWidth;
1140
1147
  }
1141
- // Correct for trailing letter spacing at the end of the line segment.
1148
+ // Correct for trailing letter spacing at the end of the line segment
1142
1149
  // Our token measurement includes letter spacing after the final glyph;
1143
- // shaping does not add letter spacing after the final glyph in a line.
1150
+ // shaping does not add letter spacing after the final glyph in a line
1144
1151
  if (context?.letterSpacingFU && totalWidth !== 0) {
1145
1152
  totalWidth -= context.letterSpacingFU;
1146
1153
  }
@@ -1306,7 +1313,7 @@ class LineBreak {
1306
1313
  }
1307
1314
  }
1308
1315
  const lineText = lineTextParts.join('');
1309
- // Correct for trailing letter spacing at the end of the line.
1316
+ // Correct for trailing letter spacing at the end of the line
1310
1317
  if (context?.letterSpacingFU && naturalWidth !== 0) {
1311
1318
  naturalWidth -= context.letterSpacingFU;
1312
1319
  }
@@ -1363,7 +1370,7 @@ class LineBreak {
1363
1370
  finalNaturalWidth += item.width;
1364
1371
  }
1365
1372
  const finalLineText = finalLineTextParts.join('');
1366
- // Correct for trailing letter spacing at the end of the final line.
1373
+ // Correct for trailing letter spacing at the end of the final line
1367
1374
  if (context?.letterSpacingFU && finalNaturalWidth !== 0) {
1368
1375
  finalNaturalWidth -= context.letterSpacingFU;
1369
1376
  }
@@ -1400,12 +1407,21 @@ class LineBreak {
1400
1407
  }
1401
1408
  }
1402
1409
 
1410
+ // Memoize conversion per feature-object identity to avoid rebuilding the same
1411
+ // comma-separated string on every HarfBuzz shape call
1412
+ const featureStringCache = new WeakMap();
1403
1413
  // Convert feature objects to HarfBuzz comma-separated format
1404
1414
  function convertFontFeaturesToString(features) {
1405
1415
  if (!features || Object.keys(features).length === 0) {
1406
1416
  return undefined;
1407
1417
  }
1418
+ const cached = featureStringCache.get(features);
1419
+ if (cached !== undefined) {
1420
+ return cached ?? undefined;
1421
+ }
1408
1422
  const featureStrings = [];
1423
+ // Preserve insertion order of the input object
1424
+ // (The public API/tests expect this to be stable and predictable)
1409
1425
  for (const [tag, value] of Object.entries(features)) {
1410
1426
  if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1411
1427
  logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
@@ -1424,10 +1440,63 @@ function convertFontFeaturesToString(features) {
1424
1440
  logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1425
1441
  }
1426
1442
  }
1427
- return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1443
+ const result = featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1444
+ featureStringCache.set(features, result ?? null);
1445
+ return result;
1428
1446
  }
1429
1447
 
1430
1448
  class TextMeasurer {
1449
+ // Shape once and return per-codepoint widths aligned with Array.from(text)
1450
+ // Groups glyph advances by HarfBuzz cluster (cl)
1451
+ // Includes trailing per-glyph letter spacing like measureTextWidth
1452
+ static measureTextWidths(loadedFont, text, letterSpacing = 0) {
1453
+ const chars = Array.from(text);
1454
+ if (chars.length === 0)
1455
+ return [];
1456
+ // HarfBuzz clusters are UTF-16 code unit indices
1457
+ const startToCharIndex = new Map();
1458
+ let codeUnitIndex = 0;
1459
+ for (let i = 0; i < chars.length; i++) {
1460
+ startToCharIndex.set(codeUnitIndex, i);
1461
+ codeUnitIndex += chars[i].length;
1462
+ }
1463
+ const widths = new Array(chars.length).fill(0);
1464
+ const buffer = loadedFont.hb.createBuffer();
1465
+ try {
1466
+ buffer.addText(text);
1467
+ buffer.guessSegmentProperties();
1468
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1469
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1470
+ const glyphInfos = buffer.json(loadedFont.font);
1471
+ const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1472
+ for (let i = 0; i < glyphInfos.length; i++) {
1473
+ const glyph = glyphInfos[i];
1474
+ const cl = glyph.cl ?? 0;
1475
+ let charIndex = startToCharIndex.get(cl);
1476
+ // Fallback if cl lands mid-codepoint
1477
+ if (charIndex === undefined) {
1478
+ // Find the closest start <= cl
1479
+ for (let back = cl; back >= 0; back--) {
1480
+ const candidate = startToCharIndex.get(back);
1481
+ if (candidate !== undefined) {
1482
+ charIndex = candidate;
1483
+ break;
1484
+ }
1485
+ }
1486
+ }
1487
+ if (charIndex === undefined)
1488
+ continue;
1489
+ widths[charIndex] += glyph.ax;
1490
+ if (letterSpacingInFontUnits !== 0) {
1491
+ widths[charIndex] += letterSpacingInFontUnits;
1492
+ }
1493
+ }
1494
+ return widths;
1495
+ }
1496
+ finally {
1497
+ buffer.destroy();
1498
+ }
1499
+ }
1431
1500
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1432
1501
  const buffer = loadedFont.hb.createBuffer();
1433
1502
  buffer.addText(text);
@@ -1484,7 +1553,8 @@ class TextLayout {
1484
1553
  unitsPerEm: this.loadedFont.upem,
1485
1554
  letterSpacing,
1486
1555
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1487
- )
1556
+ ),
1557
+ measureTextWidths: (textToMeasure) => TextMeasurer.measureTextWidths(this.loadedFont, textToMeasure, letterSpacing)
1488
1558
  });
1489
1559
  }
1490
1560
  else {
@@ -1506,6 +1576,15 @@ class TextLayout {
1506
1576
  return { lines };
1507
1577
  }
1508
1578
  applyAlignment(vertices, options) {
1579
+ const { offset, adjustedBounds } = this.computeAlignmentOffset(options);
1580
+ if (offset !== 0) {
1581
+ for (let i = 0; i < vertices.length; i += 3) {
1582
+ vertices[i] += offset;
1583
+ }
1584
+ }
1585
+ return { offset, adjustedBounds };
1586
+ }
1587
+ computeAlignmentOffset(options) {
1509
1588
  const { width, align, planeBounds } = options;
1510
1589
  let offset = 0;
1511
1590
  const adjustedBounds = {
@@ -1517,17 +1596,13 @@ class TextLayout {
1517
1596
  if (align === 'center') {
1518
1597
  offset = (width - lineWidth) / 2 - planeBounds.min.x;
1519
1598
  }
1520
- else if (align === 'right') {
1599
+ else {
1521
1600
  offset = width - planeBounds.max.x;
1522
1601
  }
1523
- if (offset !== 0) {
1524
- // Translate vertices
1525
- for (let i = 0; i < vertices.length; i += 3) {
1526
- vertices[i] += offset;
1527
- }
1528
- adjustedBounds.min.x += offset;
1529
- adjustedBounds.max.x += offset;
1530
- }
1602
+ }
1603
+ if (offset !== 0) {
1604
+ adjustedBounds.min.x += offset;
1605
+ adjustedBounds.max.x += offset;
1531
1606
  }
1532
1607
  return { offset, adjustedBounds };
1533
1608
  }
@@ -2622,7 +2697,7 @@ var n;function t(a,b){return a.b===b.b&&a.a===b.a}function u(a,b){return a.b<b.b
2622
2697
  var libtess_minExports = libtess_min.exports;
2623
2698
 
2624
2699
  class Tessellator {
2625
- process(paths, removeOverlaps = true, isCFF = false) {
2700
+ process(paths, removeOverlaps = true, isCFF = false, needsExtrusionContours = true) {
2626
2701
  if (paths.length === 0) {
2627
2702
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2628
2703
  }
@@ -2631,66 +2706,108 @@ class Tessellator {
2631
2706
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2632
2707
  }
2633
2708
  logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2634
- return this.tessellate(valid, removeOverlaps, isCFF);
2635
- }
2636
- tessellate(paths, removeOverlaps, isCFF) {
2637
- // TTF fonts have opposite winding from tessellator expectations
2638
- const normalizedPaths = !isCFF && !removeOverlaps
2639
- ? paths.map((p) => this.reverseWinding(p))
2640
- : paths;
2641
- let contours = this.pathsToContours(normalizedPaths);
2709
+ return this.tessellate(valid, removeOverlaps, isCFF, needsExtrusionContours);
2710
+ }
2711
+ tessellate(paths, removeOverlaps, isCFF, needsExtrusionContours) {
2712
+ // libtess expects CCW winding; TTF outer contours are CW
2713
+ const needsWindingReversal = !isCFF && !removeOverlaps;
2714
+ let originalContours;
2715
+ let tessContours;
2716
+ if (needsWindingReversal) {
2717
+ tessContours = this.pathsToContours(paths, true);
2718
+ if (removeOverlaps || needsExtrusionContours) {
2719
+ originalContours = this.pathsToContours(paths);
2720
+ }
2721
+ }
2722
+ else {
2723
+ originalContours = this.pathsToContours(paths);
2724
+ tessContours = originalContours;
2725
+ }
2726
+ let extrusionContours = needsExtrusionContours
2727
+ ? needsWindingReversal
2728
+ ? tessContours
2729
+ : originalContours ?? this.pathsToContours(paths)
2730
+ : [];
2642
2731
  if (removeOverlaps) {
2643
2732
  logger.log('Two-pass: boundary extraction then triangulation');
2644
- // Extract boundaries to remove overlaps
2645
2733
  perfLogger.start('Tessellator.boundaryPass', {
2646
- contourCount: contours.length
2734
+ contourCount: tessContours.length
2647
2735
  });
2648
- const boundaryResult = this.performTessellation(contours, 'boundary');
2736
+ const boundaryResult = this.performTessellation(originalContours, 'boundary');
2649
2737
  perfLogger.end('Tessellator.boundaryPass');
2650
2738
  if (!boundaryResult) {
2651
2739
  logger.warn('libtess returned empty result from boundary pass');
2652
2740
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2653
2741
  }
2654
- // Convert boundary elements back to contours
2655
- contours = this.boundaryToContours(boundaryResult);
2656
- logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2742
+ // Boundary pass normalizes winding (outer CCW, holes CW)
2743
+ tessContours = this.boundaryToContours(boundaryResult);
2744
+ if (needsExtrusionContours) {
2745
+ extrusionContours = tessContours;
2746
+ }
2747
+ logger.log(`Boundary pass created ${tessContours.length} contours. Starting triangulation pass.`);
2657
2748
  }
2658
2749
  else {
2659
2750
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2660
2751
  }
2661
- // Triangulate the contours
2662
2752
  perfLogger.start('Tessellator.triangulationPass', {
2663
- contourCount: contours.length
2753
+ contourCount: tessContours.length
2664
2754
  });
2665
- const triangleResult = this.performTessellation(contours, 'triangles');
2755
+ const triangleResult = this.performTessellation(tessContours, 'triangles');
2666
2756
  perfLogger.end('Tessellator.triangulationPass');
2667
2757
  if (!triangleResult) {
2668
2758
  const warning = removeOverlaps
2669
2759
  ? 'libtess returned empty result from triangulation pass'
2670
2760
  : 'libtess returned empty result from single-pass triangulation';
2671
2761
  logger.warn(warning);
2672
- return { triangles: { vertices: [], indices: [] }, contours };
2762
+ return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2673
2763
  }
2674
2764
  return {
2675
2765
  triangles: {
2676
2766
  vertices: triangleResult.vertices,
2677
2767
  indices: triangleResult.indices || []
2678
2768
  },
2679
- contours
2769
+ contours: extrusionContours
2680
2770
  };
2681
2771
  }
2682
- pathsToContours(paths) {
2683
- return paths.map((path) => {
2684
- const contour = [];
2685
- for (const point of path.points) {
2686
- contour.push(point.x, point.y);
2772
+ pathsToContours(paths, reversePoints = false) {
2773
+ const contours = new Array(paths.length);
2774
+ for (let p = 0; p < paths.length; p++) {
2775
+ const points = paths[p].points;
2776
+ const pointCount = points.length;
2777
+ // Clipper-style paths can be explicitly closed by repeating the first point at the end
2778
+ // Normalize to a single closing vertex for stable side wall generation
2779
+ const isClosed = pointCount > 1 &&
2780
+ points[0].x === points[pointCount - 1].x &&
2781
+ points[0].y === points[pointCount - 1].y;
2782
+ const end = isClosed ? pointCount - 1 : pointCount;
2783
+ // +1 to append a closing vertex
2784
+ const contour = new Array((end + 1) * 2);
2785
+ let i = 0;
2786
+ if (reversePoints) {
2787
+ for (let k = end - 1; k >= 0; k--) {
2788
+ const pt = points[k];
2789
+ contour[i++] = pt.x;
2790
+ contour[i++] = pt.y;
2791
+ }
2687
2792
  }
2688
- return contour;
2689
- });
2793
+ else {
2794
+ for (let k = 0; k < end; k++) {
2795
+ const pt = points[k];
2796
+ contour[i++] = pt.x;
2797
+ contour[i++] = pt.y;
2798
+ }
2799
+ }
2800
+ // Some glyphs omit closePath, leaving gaps in extruded side walls
2801
+ if (i >= 2) {
2802
+ contour[i++] = contour[0];
2803
+ contour[i++] = contour[1];
2804
+ }
2805
+ contours[p] = contour;
2806
+ }
2807
+ return contours;
2690
2808
  }
2691
2809
  performTessellation(contours, mode) {
2692
2810
  const tess = new libtess_minExports.GluTesselator();
2693
- // Set winding rule to NON-ZERO
2694
2811
  tess.gluTessProperty(libtess_minExports.gluEnum.GLU_TESS_WINDING_RULE, libtess_minExports.windingRule.GLU_TESS_WINDING_NONZERO);
2695
2812
  const vertices = [];
2696
2813
  const indices = [];
@@ -2713,7 +2830,7 @@ class Tessellator {
2713
2830
  });
2714
2831
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_END, () => {
2715
2832
  if (currentContour.length > 0) {
2716
- contourIndices.push([...currentContour]);
2833
+ contourIndices.push(currentContour);
2717
2834
  }
2718
2835
  });
2719
2836
  }
@@ -2758,7 +2875,6 @@ class Tessellator {
2758
2875
  const vertIdx = idx * 2;
2759
2876
  contour.push(boundaryResult.vertices[vertIdx], boundaryResult.vertices[vertIdx + 1]);
2760
2877
  }
2761
- // Ensure contour is closed for side wall generation
2762
2878
  if (contour.length > 2) {
2763
2879
  if (contour[0] !== contour[contour.length - 2] ||
2764
2880
  contour[1] !== contour[contour.length - 1]) {
@@ -2769,38 +2885,102 @@ class Tessellator {
2769
2885
  }
2770
2886
  return contours;
2771
2887
  }
2772
- reverseWinding(path) {
2773
- return {
2774
- ...path,
2775
- points: [...path.points].reverse()
2776
- };
2888
+ // Check if contours need winding normalization via boundary pass
2889
+ // Returns false if topology is simple enough to skip the expensive pass
2890
+ needsWindingNormalization(contours) {
2891
+ if (contours.length === 0)
2892
+ return false;
2893
+ // Heuristic 1: Single contour never needs normalization
2894
+ if (contours.length === 1)
2895
+ return false;
2896
+ // Heuristic 2: All same winding = all outers, no holes
2897
+ // Compute signed areas
2898
+ let firstSign = null;
2899
+ for (const contour of contours) {
2900
+ const area = this.signedArea(contour);
2901
+ const sign = area >= 0 ? 1 : -1;
2902
+ if (firstSign === null) {
2903
+ firstSign = sign;
2904
+ }
2905
+ else if (sign !== firstSign) {
2906
+ // Mixed winding detected → might have holes or complex topology
2907
+ return true;
2908
+ }
2909
+ }
2910
+ // All same winding → simple topology, no normalization needed
2911
+ return false;
2912
+ }
2913
+ // Compute signed area (CCW = positive, CW = negative)
2914
+ signedArea(contour) {
2915
+ let area = 0;
2916
+ const len = contour.length;
2917
+ if (len < 6)
2918
+ return 0; // Need at least 3 points
2919
+ for (let i = 0; i < len; i += 2) {
2920
+ const x1 = contour[i];
2921
+ const y1 = contour[i + 1];
2922
+ const x2 = contour[(i + 2) % len];
2923
+ const y2 = contour[(i + 3) % len];
2924
+ area += x1 * y2 - x2 * y1;
2925
+ }
2926
+ return area / 2;
2777
2927
  }
2778
2928
  }
2779
2929
 
2780
2930
  class Extruder {
2781
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
+ }
2782
2937
  extrude(geometry, depth = 0, unitsPerEm) {
2783
2938
  const points = geometry.triangles.vertices;
2784
2939
  const triangleIndices = geometry.triangles.indices;
2785
2940
  const numPoints = points.length / 2;
2786
- // Count side-wall segments (4 vertices + 6 indices per segment)
2787
- let sideSegments = 0;
2941
+ // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2942
+ let boundaryEdges = [];
2788
2943
  if (depth !== 0) {
2789
- for (const contour of geometry.contours) {
2790
- // Contours are closed (last point repeats first)
2791
- const contourPoints = contour.length / 2;
2792
- if (contourPoints >= 2)
2793
- 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);
2794
2973
  }
2795
2974
  }
2796
- const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2975
+ const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2976
+ const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2797
2977
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2798
2978
  const vertexCount = baseVertexCount + sideVertexCount;
2799
2979
  const vertices = new Float32Array(vertexCount * 3);
2800
2980
  const normals = new Float32Array(vertexCount * 3);
2801
2981
  const indexCount = depth === 0
2802
2982
  ? triangleIndices.length
2803
- : triangleIndices.length * 2 + sideSegments * 6;
2983
+ : triangleIndices.length * 2 + sideEdgeCount * 6;
2804
2984
  const indices = new Uint32Array(indexCount);
2805
2985
  if (depth === 0) {
2806
2986
  // Single-sided flat geometry at z=0
@@ -2823,25 +3003,26 @@ class Extruder {
2823
3003
  // Extruded geometry: front at z=0, back at z=depth
2824
3004
  const minBackOffset = unitsPerEm * 0.000025;
2825
3005
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2826
- // Cap at z=0, back face
3006
+ // Generate both caps in one pass
2827
3007
  for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2828
- const base = vi * 3;
2829
- vertices[base] = points[p];
2830
- vertices[base + 1] = points[p + 1];
2831
- vertices[base + 2] = 0;
2832
- normals[base] = 0;
2833
- normals[base + 1] = 0;
2834
- normals[base + 2] = -1;
2835
- }
2836
- // Cap at z=depth, front face
2837
- for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2838
- const base = (numPoints + vi) * 3;
2839
- vertices[base] = points[p];
2840
- vertices[base + 1] = points[p + 1];
2841
- vertices[base + 2] = backZ;
2842
- normals[base] = 0;
2843
- normals[base + 1] = 0;
2844
- normals[base + 2] = 1;
3008
+ const x = points[p];
3009
+ const y = points[p + 1];
3010
+ // Cap at z=0
3011
+ const base0 = vi * 3;
3012
+ vertices[base0] = x;
3013
+ vertices[base0 + 1] = y;
3014
+ vertices[base0 + 2] = 0;
3015
+ normals[base0] = 0;
3016
+ normals[base0 + 1] = 0;
3017
+ normals[base0 + 2] = -1;
3018
+ // Cap at z=depth
3019
+ const baseD = (numPoints + vi) * 3;
3020
+ vertices[baseD] = x;
3021
+ vertices[baseD + 1] = y;
3022
+ vertices[baseD + 2] = backZ;
3023
+ normals[baseD] = 0;
3024
+ normals[baseD + 1] = 0;
3025
+ normals[baseD + 2] = 1;
2845
3026
  }
2846
3027
  // libtess outputs CCW triangles (viewed from +Z)
2847
3028
  // Z=0 cap faces -Z, reverse winding
@@ -2855,60 +3036,62 @@ class Extruder {
2855
3036
  // Side walls
2856
3037
  let nextVertex = numPoints * 2;
2857
3038
  let idxPos = triangleIndices.length * 2;
2858
- for (const contour of geometry.contours) {
2859
- for (let i = 0; i < contour.length - 2; i += 2) {
2860
- const p0x = contour[i];
2861
- const p0y = contour[i + 1];
2862
- const p1x = contour[i + 2];
2863
- const p1y = contour[i + 3];
2864
- // Perpendicular normal for this wall segment
2865
- const ex = p1x - p0x;
2866
- const ey = p1y - p0y;
2867
- const lenSq = ex * ex + ey * ey;
2868
- let nx = 0;
2869
- let ny = 0;
2870
- if (lenSq > 0) {
2871
- const invLen = 1 / Math.sqrt(lenSq);
2872
- nx = ey * invLen;
2873
- ny = -ex * invLen;
2874
- }
2875
- const baseVertex = nextVertex;
2876
- const base = baseVertex * 3;
2877
- // Wall quad: front edge at z=0, back edge at z=depth
2878
- vertices[base] = p0x;
2879
- vertices[base + 1] = p0y;
2880
- vertices[base + 2] = 0;
2881
- vertices[base + 3] = p1x;
2882
- vertices[base + 4] = p1y;
2883
- vertices[base + 5] = 0;
2884
- vertices[base + 6] = p0x;
2885
- vertices[base + 7] = p0y;
2886
- vertices[base + 8] = backZ;
2887
- vertices[base + 9] = p1x;
2888
- vertices[base + 10] = p1y;
2889
- vertices[base + 11] = backZ;
2890
- // Wall normals point perpendicular to edge
2891
- normals[base] = nx;
2892
- normals[base + 1] = ny;
2893
- normals[base + 2] = 0;
2894
- normals[base + 3] = nx;
2895
- normals[base + 4] = ny;
2896
- normals[base + 5] = 0;
2897
- normals[base + 6] = nx;
2898
- normals[base + 7] = ny;
2899
- normals[base + 8] = 0;
2900
- normals[base + 9] = nx;
2901
- normals[base + 10] = ny;
2902
- normals[base + 11] = 0;
2903
- // Two triangles per wall segment
2904
- indices[idxPos++] = baseVertex;
2905
- indices[idxPos++] = baseVertex + 1;
2906
- indices[idxPos++] = baseVertex + 2;
2907
- indices[idxPos++] = baseVertex + 1;
2908
- indices[idxPos++] = baseVertex + 3;
2909
- indices[idxPos++] = baseVertex + 2;
2910
- nextVertex += 4;
2911
- }
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;
2912
3095
  }
2913
3096
  return { vertices, normals, indices };
2914
3097
  }
@@ -3135,21 +3318,23 @@ class PathOptimizer {
3135
3318
  return path;
3136
3319
  }
3137
3320
  this.stats.originalPointCount += path.points.length;
3138
- let points = [...path.points];
3321
+ // Most paths are already immutable after collection; avoid copying large point arrays
3322
+ // The optimizers below never mutate the input `points` array
3323
+ const points = path.points;
3139
3324
  if (points.length < 5) {
3140
3325
  return path;
3141
3326
  }
3142
- points = this.simplifyPathVW(points, this.config.areaThreshold);
3143
- if (points.length < 3) {
3327
+ let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
3328
+ if (optimized.length < 3) {
3144
3329
  return path;
3145
3330
  }
3146
- points = this.removeColinearPoints(points, this.config.colinearThreshold);
3147
- if (points.length < 3) {
3331
+ optimized = this.removeColinearPoints(optimized, this.config.colinearThreshold);
3332
+ if (optimized.length < 3) {
3148
3333
  return path;
3149
3334
  }
3150
3335
  return {
3151
3336
  ...path,
3152
- points
3337
+ points: optimized
3153
3338
  };
3154
3339
  }
3155
3340
  // Visvalingam-Whyatt algorithm
@@ -3603,7 +3788,7 @@ class GlyphContourCollector {
3603
3788
  if (this.currentGlyphPaths.length > 0) {
3604
3789
  this.collectedGlyphs.push({
3605
3790
  glyphId: this.currentGlyphId,
3606
- paths: [...this.currentGlyphPaths],
3791
+ paths: this.currentGlyphPaths,
3607
3792
  bounds: {
3608
3793
  min: {
3609
3794
  x: this.currentGlyphBounds.min.x,
@@ -3655,11 +3840,10 @@ class GlyphContourCollector {
3655
3840
  return;
3656
3841
  }
3657
3842
  const flattenedPoints = this.polygonizer.polygonizeQuadratic(start, control, end);
3658
- for (const point of flattenedPoints) {
3659
- this.updateBounds(point);
3660
- }
3661
3843
  for (let i = 0; i < flattenedPoints.length; i++) {
3662
- this.currentPath.points.push(flattenedPoints[i]);
3844
+ const pt = flattenedPoints[i];
3845
+ this.updateBounds(pt);
3846
+ this.currentPath.points.push(pt);
3663
3847
  }
3664
3848
  this.currentPoint = end;
3665
3849
  }
@@ -3679,11 +3863,10 @@ class GlyphContourCollector {
3679
3863
  return;
3680
3864
  }
3681
3865
  const flattenedPoints = this.polygonizer.polygonizeCubic(start, control1, control2, end);
3682
- for (const point of flattenedPoints) {
3683
- this.updateBounds(point);
3684
- }
3685
3866
  for (let i = 0; i < flattenedPoints.length; i++) {
3686
- this.currentPath.points.push(flattenedPoints[i]);
3867
+ const pt = flattenedPoints[i];
3868
+ this.updateBounds(pt);
3869
+ this.currentPath.points.push(pt);
3687
3870
  }
3688
3871
  this.currentPoint = end;
3689
3872
  }
@@ -3873,6 +4056,7 @@ class GlyphGeometryBuilder {
3873
4056
  constructor(cache, loadedFont) {
3874
4057
  this.fontId = 'default';
3875
4058
  this.cacheKeyPrefix = 'default';
4059
+ this.emptyGlyphs = new Set();
3876
4060
  this.cache = cache;
3877
4061
  this.loadedFont = loadedFont;
3878
4062
  this.tessellator = new Tessellator();
@@ -3926,63 +4110,34 @@ class GlyphGeometryBuilder {
3926
4110
  }
3927
4111
  // Build instanced geometry from glyph contours
3928
4112
  buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3929
- perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
3930
- lineCount: clustersByLine.length,
3931
- wordCount: clustersByLine.flat().length,
3932
- depth,
3933
- removeOverlaps
3934
- });
3935
- // Growable typed arrays; slice to final size at end
3936
- let vertexBuffer = new Float32Array(1024);
3937
- let normalBuffer = new Float32Array(1024);
3938
- let indexBuffer = new Uint32Array(1024);
3939
- let vertexPos = 0; // float index (multiple of 3)
3940
- let normalPos = 0; // float index (multiple of 3)
3941
- let indexPos = 0; // index count
3942
- const ensureFloatCapacity = (buffer, needed) => {
3943
- if (needed <= buffer.length)
3944
- return buffer;
3945
- let nextSize = buffer.length;
3946
- while (nextSize < needed)
3947
- nextSize *= 2;
3948
- const next = new Float32Array(nextSize);
3949
- next.set(buffer);
3950
- return next;
3951
- };
3952
- const ensureIndexCapacity = (buffer, needed) => {
3953
- if (needed <= buffer.length)
3954
- return buffer;
3955
- let nextSize = buffer.length;
3956
- while (nextSize < needed)
3957
- nextSize *= 2;
3958
- const next = new Uint32Array(nextSize);
3959
- next.set(buffer);
3960
- return next;
3961
- };
3962
- const appendGeometryToBuffers = (data, position, vertexOffset) => {
3963
- const v = data.vertices;
3964
- const n = data.normals;
3965
- const idx = data.indices;
3966
- // Grow buffers as needed
3967
- vertexBuffer = ensureFloatCapacity(vertexBuffer, vertexPos + v.length);
3968
- normalBuffer = ensureFloatCapacity(normalBuffer, normalPos + n.length);
3969
- indexBuffer = ensureIndexCapacity(indexBuffer, indexPos + idx.length);
3970
- // Vertices: translate by position
3971
- const px = position.x;
3972
- const py = position.y;
3973
- const pz = position.z;
3974
- for (let j = 0; j < v.length; j += 3) {
3975
- vertexBuffer[vertexPos++] = v[j] + px;
3976
- vertexBuffer[vertexPos++] = v[j + 1] + py;
3977
- vertexBuffer[vertexPos++] = v[j + 2] + pz;
3978
- }
3979
- // Normals: straight copy
3980
- normalBuffer.set(n, normalPos);
3981
- normalPos += n.length;
3982
- // Indices: copy with vertex offset
3983
- for (let j = 0; j < idx.length; j++) {
3984
- indexBuffer[indexPos++] = idx[j] + vertexOffset;
3985
- }
4113
+ if (isLogEnabled) {
4114
+ let wordCount = 0;
4115
+ for (let i = 0; i < clustersByLine.length; i++) {
4116
+ wordCount += clustersByLine[i].length;
4117
+ }
4118
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
4119
+ lineCount: clustersByLine.length,
4120
+ wordCount,
4121
+ depth,
4122
+ removeOverlaps
4123
+ });
4124
+ }
4125
+ else {
4126
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry');
4127
+ }
4128
+ const tasks = [];
4129
+ let totalVertexFloats = 0;
4130
+ let totalNormalFloats = 0;
4131
+ let totalIndexCount = 0;
4132
+ let vertexCursor = 0; // vertex offset (not float offset)
4133
+ const pushTask = (data, px, py, pz) => {
4134
+ const vertexStart = vertexCursor;
4135
+ tasks.push({ data, px, py, pz, vertexStart });
4136
+ totalVertexFloats += data.vertices.length;
4137
+ totalNormalFloats += data.normals.length;
4138
+ totalIndexCount += data.indices.length;
4139
+ vertexCursor += data.vertices.length / 3;
4140
+ return vertexStart;
3986
4141
  };
3987
4142
  const glyphInfos = [];
3988
4143
  const planeBounds = {
@@ -3992,6 +4147,9 @@ class GlyphGeometryBuilder {
3992
4147
  for (let lineIndex = 0; lineIndex < clustersByLine.length; lineIndex++) {
3993
4148
  const line = clustersByLine[lineIndex];
3994
4149
  for (const cluster of line) {
4150
+ const clusterX = cluster.position.x;
4151
+ const clusterY = cluster.position.y;
4152
+ const clusterZ = cluster.position.z;
3995
4153
  const clusterGlyphContours = [];
3996
4154
  for (const glyph of cluster.glyphs) {
3997
4155
  clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
@@ -4032,7 +4190,7 @@ class GlyphGeometryBuilder {
4032
4190
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4033
4191
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4034
4192
  // Iterate over the geometric groups identified by BoundaryClusterer
4035
- // logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
4193
+ // logical groups (words) split into geometric sub-groups
4036
4194
  for (const groupIndices of boundaryGroups) {
4037
4195
  const isOverlappingGroup = groupIndices.length > 1;
4038
4196
  const shouldCluster = isOverlappingGroup && !forceSeparate;
@@ -4064,16 +4222,19 @@ class GlyphGeometryBuilder {
4064
4222
  // Calculate the absolute position of this sub-cluster based on its first glyph
4065
4223
  // (since the cached geometry is relative to that first glyph)
4066
4224
  const firstGlyphInGroup = subClusterGlyphs[0];
4067
- const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
4068
- const vertexOffset = vertexPos / 3;
4069
- appendGeometryToBuffers(cachedCluster, groupPosition, vertexOffset);
4225
+ const groupPosX = clusterX + (firstGlyphInGroup.x ?? 0);
4226
+ const groupPosY = clusterY + (firstGlyphInGroup.y ?? 0);
4227
+ const groupPosZ = clusterZ;
4228
+ const vertexStart = pushTask(cachedCluster, groupPosX, groupPosY, groupPosZ);
4070
4229
  const clusterVertexCount = cachedCluster.vertices.length / 3;
4071
4230
  for (let i = 0; i < groupIndices.length; i++) {
4072
4231
  const originalIndex = groupIndices[i];
4073
4232
  const glyph = cluster.glyphs[originalIndex];
4074
4233
  const glyphContours = clusterGlyphContours[originalIndex];
4075
- const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4076
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
4234
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4235
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4236
+ const glyphPosZ = clusterZ;
4237
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, clusterVertexCount, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4077
4238
  glyphInfos.push(glyphInfo);
4078
4239
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4079
4240
  }
@@ -4083,24 +4244,26 @@ class GlyphGeometryBuilder {
4083
4244
  for (const i of groupIndices) {
4084
4245
  const glyph = cluster.glyphs[i];
4085
4246
  const glyphContours = clusterGlyphContours[i];
4086
- const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4247
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4248
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4249
+ const glyphPosZ = clusterZ;
4087
4250
  // Skip glyphs with no paths (spaces, zero-width characters, etc.)
4088
4251
  if (glyphContours.paths.length === 0) {
4089
- const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
4252
+ const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4090
4253
  glyphInfos.push(glyphInfo);
4091
4254
  continue;
4092
4255
  }
4093
- let cachedGlyph = this.cache.get(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps));
4256
+ const glyphCacheKey = getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps);
4257
+ let cachedGlyph = this.cache.get(glyphCacheKey);
4094
4258
  if (!cachedGlyph) {
4095
4259
  cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
4096
- this.cache.set(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps), cachedGlyph);
4260
+ this.cache.set(glyphCacheKey, cachedGlyph);
4097
4261
  }
4098
4262
  else {
4099
4263
  cachedGlyph.useCount++;
4100
4264
  }
4101
- const vertexOffset = vertexPos / 3;
4102
- appendGeometryToBuffers(cachedGlyph, glyphPosition, vertexOffset);
4103
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
4265
+ const vertexStart = pushTask(cachedGlyph, glyphPosX, glyphPosY, glyphPosZ);
4266
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, cachedGlyph.vertices.length / 3, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4104
4267
  glyphInfos.push(glyphInfo);
4105
4268
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4106
4269
  }
@@ -4108,10 +4271,33 @@ class GlyphGeometryBuilder {
4108
4271
  }
4109
4272
  }
4110
4273
  }
4111
- // Slice to used lengths (avoid returning oversized buffers)
4112
- const vertexArray = vertexBuffer.slice(0, vertexPos);
4113
- const normalArray = normalBuffer.slice(0, normalPos);
4114
- const indexArray = indexBuffer.slice(0, indexPos);
4274
+ // Allocate exact-sized buffers and fill once
4275
+ const vertexArray = new Float32Array(totalVertexFloats);
4276
+ const normalArray = new Float32Array(totalNormalFloats);
4277
+ const indexArray = new Uint32Array(totalIndexCount);
4278
+ let vertexPos = 0; // float index (multiple of 3)
4279
+ let normalPos = 0; // float index (multiple of 3)
4280
+ let indexPos = 0; // index count
4281
+ for (let t = 0; t < tasks.length; t++) {
4282
+ const task = tasks[t];
4283
+ const v = task.data.vertices;
4284
+ const n = task.data.normals;
4285
+ const idx = task.data.indices;
4286
+ const px = task.px;
4287
+ const py = task.py;
4288
+ const pz = task.pz;
4289
+ for (let j = 0; j < v.length; j += 3) {
4290
+ vertexArray[vertexPos++] = v[j] + px;
4291
+ vertexArray[vertexPos++] = v[j + 1] + py;
4292
+ vertexArray[vertexPos++] = v[j + 2] + pz;
4293
+ }
4294
+ normalArray.set(n, normalPos);
4295
+ normalPos += n.length;
4296
+ const vertexStart = task.vertexStart;
4297
+ for (let j = 0; j < idx.length; j++) {
4298
+ indexArray[indexPos++] = idx[j] + vertexStart;
4299
+ }
4300
+ }
4115
4301
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4116
4302
  return {
4117
4303
  vertices: vertexArray,
@@ -4136,7 +4322,7 @@ class GlyphGeometryBuilder {
4136
4322
  const roundedDepth = Math.round(depth * 1000) / 1000;
4137
4323
  return `${this.cacheKeyPrefix}_${ids}_${roundedDepth}_${removeOverlaps}`;
4138
4324
  }
4139
- createGlyphInfo(glyph, vertexStart, vertexCount, position, contours, depth) {
4325
+ createGlyphInfo(glyph, vertexStart, vertexCount, positionX, positionY, positionZ, contours, depth) {
4140
4326
  return {
4141
4327
  textIndex: glyph.absoluteTextIndex,
4142
4328
  lineIndex: glyph.lineIndex,
@@ -4144,19 +4330,30 @@ class GlyphGeometryBuilder {
4144
4330
  vertexCount,
4145
4331
  bounds: {
4146
4332
  min: {
4147
- x: contours.bounds.min.x + position.x,
4148
- y: contours.bounds.min.y + position.y,
4149
- z: position.z
4333
+ x: contours.bounds.min.x + positionX,
4334
+ y: contours.bounds.min.y + positionY,
4335
+ z: positionZ
4150
4336
  },
4151
4337
  max: {
4152
- x: contours.bounds.max.x + position.x,
4153
- y: contours.bounds.max.y + position.y,
4154
- z: position.z + depth
4338
+ x: contours.bounds.max.x + positionX,
4339
+ y: contours.bounds.max.y + positionY,
4340
+ z: positionZ + depth
4155
4341
  }
4156
4342
  }
4157
4343
  };
4158
4344
  }
4159
4345
  getContoursForGlyph(glyphId) {
4346
+ // Fast path: skip HarfBuzz draw for known-empty glyphs (spaces, zero-width, etc)
4347
+ if (this.emptyGlyphs.has(glyphId)) {
4348
+ return {
4349
+ glyphId,
4350
+ paths: [],
4351
+ bounds: {
4352
+ min: { x: 0, y: 0 },
4353
+ max: { x: 0, y: 0 }
4354
+ }
4355
+ };
4356
+ }
4160
4357
  const key = `${this.cacheKeyPrefix}_${glyphId}`;
4161
4358
  const cached = this.contourCache.get(key);
4162
4359
  if (cached) {
@@ -4177,11 +4374,15 @@ class GlyphGeometryBuilder {
4177
4374
  max: { x: 0, y: 0 }
4178
4375
  }
4179
4376
  };
4377
+ // Mark glyph as empty for future fast-path
4378
+ if (contours.paths.length === 0) {
4379
+ this.emptyGlyphs.add(glyphId);
4380
+ }
4180
4381
  this.contourCache.set(key, contours);
4181
4382
  return contours;
4182
4383
  }
4183
4384
  tessellateGlyphCluster(paths, depth, isCFF) {
4184
- const processedGeometry = this.tessellator.process(paths, true, isCFF);
4385
+ const processedGeometry = this.tessellator.process(paths, true, isCFF, depth !== 0);
4185
4386
  return this.extrudeAndPackage(processedGeometry, depth);
4186
4387
  }
4187
4388
  extrudeAndPackage(processedGeometry, depth) {
@@ -4229,7 +4430,7 @@ class GlyphGeometryBuilder {
4229
4430
  glyphId: glyphContours.glyphId,
4230
4431
  pathCount: glyphContours.paths.length
4231
4432
  });
4232
- const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
4433
+ const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF, depth !== 0);
4233
4434
  perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
4234
4435
  return this.extrudeAndPackage(processedGeometry, depth);
4235
4436
  }
@@ -4299,8 +4500,11 @@ class TextShaper {
4299
4500
  const clusters = [];
4300
4501
  let currentClusterGlyphs = [];
4301
4502
  let currentClusterText = '';
4302
- let clusterStartPosition = new Vec3();
4303
- let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4503
+ let clusterStartX = 0;
4504
+ let clusterStartY = 0;
4505
+ let cursorX = lineInfo.xOffset;
4506
+ let cursorY = -lineIndex * scaledLineHeight;
4507
+ const cursorZ = 0;
4304
4508
  // Apply letter spacing after each glyph to match width measurements used during line breaking
4305
4509
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4306
4510
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
@@ -4325,31 +4529,31 @@ class TextShaper {
4325
4529
  clusters.push({
4326
4530
  text: currentClusterText,
4327
4531
  glyphs: currentClusterGlyphs,
4328
- position: clusterStartPosition.clone()
4532
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4329
4533
  });
4330
4534
  currentClusterGlyphs = [];
4331
4535
  currentClusterText = '';
4332
4536
  }
4333
4537
  }
4334
- const absoluteGlyphPosition = cursor
4335
- .clone()
4336
- .add(new Vec3(glyph.dx, glyph.dy, 0));
4538
+ const absoluteGlyphX = cursorX + glyph.dx;
4539
+ const absoluteGlyphY = cursorY + glyph.dy;
4337
4540
  if (!isWhitespace) {
4338
4541
  if (currentClusterGlyphs.length === 0) {
4339
- clusterStartPosition.copy(absoluteGlyphPosition);
4542
+ clusterStartX = absoluteGlyphX;
4543
+ clusterStartY = absoluteGlyphY;
4340
4544
  }
4341
- glyph.x = absoluteGlyphPosition.x - clusterStartPosition.x;
4342
- glyph.y = absoluteGlyphPosition.y - clusterStartPosition.y;
4545
+ glyph.x = absoluteGlyphX - clusterStartX;
4546
+ glyph.y = absoluteGlyphY - clusterStartY;
4343
4547
  currentClusterGlyphs.push(glyph);
4344
4548
  currentClusterText += lineInfo.text[glyph.cl];
4345
4549
  }
4346
- cursor.x += glyph.ax;
4347
- cursor.y += glyph.ay;
4550
+ cursorX += glyph.ax;
4551
+ cursorY += glyph.ay;
4348
4552
  if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
4349
- cursor.x += letterSpacingFU;
4553
+ cursorX += letterSpacingFU;
4350
4554
  }
4351
4555
  if (isWhitespace) {
4352
- cursor.x += spaceAdjustment;
4556
+ cursorX += spaceAdjustment;
4353
4557
  }
4354
4558
  // CJK glue adjustment (must match exactly where LineBreak adds glue)
4355
4559
  if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
@@ -4370,7 +4574,7 @@ class TextShaper {
4370
4574
  shouldApply = false;
4371
4575
  }
4372
4576
  if (shouldApply) {
4373
- cursor.x += cjkAdjustment;
4577
+ cursorX += cjkAdjustment;
4374
4578
  }
4375
4579
  }
4376
4580
  }
@@ -4379,7 +4583,7 @@ class TextShaper {
4379
4583
  clusters.push({
4380
4584
  text: currentClusterText,
4381
4585
  glyphs: currentClusterGlyphs,
4382
- position: clusterStartPosition.clone()
4586
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4383
4587
  });
4384
4588
  }
4385
4589
  return clusters;
@@ -5206,9 +5410,8 @@ class Text {
5206
5410
  const loadedFont = await Text.resolveFont(options);
5207
5411
  const text = new Text();
5208
5412
  text.setLoadedFont(loadedFont);
5209
- // Initial creation
5210
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
5211
- const result = await text.createGeometry(geometryOptions);
5413
+ // Pass full options so createGeometry honors maxCacheSizeMB etc
5414
+ const result = await text.createGeometry(options);
5212
5415
  // Recursive update function
5213
5416
  const update = async (newOptions) => {
5214
5417
  // Merge options - preserve font from original options if not provided
@@ -5230,8 +5433,7 @@ class Text {
5230
5433
  }
5231
5434
  // Update closure options for next time
5232
5435
  options = mergedOptions;
5233
- const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5234
- const newResult = await text.createGeometry(currentGeometryOptions);
5436
+ const newResult = await text.createGeometry(options);
5235
5437
  return {
5236
5438
  ...newResult,
5237
5439
  getLoadedFont: () => text.getLoadedFont(),
@@ -5656,7 +5858,7 @@ class Text {
5656
5858
  if (!this.textLayout) {
5657
5859
  this.textLayout = new TextLayout(this.loadedFont);
5658
5860
  }
5659
- const alignmentResult = this.textLayout.applyAlignment(vertices, {
5861
+ const alignmentResult = this.textLayout.computeAlignmentOffset({
5660
5862
  width,
5661
5863
  align,
5662
5864
  planeBounds
@@ -5665,9 +5867,19 @@ class Text {
5665
5867
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5666
5868
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5667
5869
  const finalScale = size / this.loadedFont.upem;
5870
+ const offsetScaled = offset * finalScale;
5668
5871
  // Scale vertices only (normals are unit vectors, don't scale)
5669
- for (let i = 0; i < vertices.length; i++) {
5670
- vertices[i] *= finalScale;
5872
+ if (offsetScaled === 0) {
5873
+ for (let i = 0; i < vertices.length; i++) {
5874
+ vertices[i] *= finalScale;
5875
+ }
5876
+ }
5877
+ else {
5878
+ for (let i = 0; i < vertices.length; i += 3) {
5879
+ vertices[i] = vertices[i] * finalScale + offsetScaled;
5880
+ vertices[i + 1] *= finalScale;
5881
+ vertices[i + 2] *= finalScale;
5882
+ }
5671
5883
  }
5672
5884
  planeBounds.min.x *= finalScale;
5673
5885
  planeBounds.min.y *= finalScale;
@@ -5677,14 +5889,10 @@ class Text {
5677
5889
  planeBounds.max.z *= finalScale;
5678
5890
  for (let i = 0; i < glyphInfoArray.length; i++) {
5679
5891
  const glyphInfo = glyphInfoArray[i];
5680
- if (offset !== 0) {
5681
- glyphInfo.bounds.min.x += offset;
5682
- glyphInfo.bounds.max.x += offset;
5683
- }
5684
- glyphInfo.bounds.min.x *= finalScale;
5892
+ glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5685
5893
  glyphInfo.bounds.min.y *= finalScale;
5686
5894
  glyphInfo.bounds.min.z *= finalScale;
5687
- glyphInfo.bounds.max.x *= finalScale;
5895
+ glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5688
5896
  glyphInfo.bounds.max.y *= finalScale;
5689
5897
  glyphInfo.bounds.max.z *= finalScale;
5690
5898
  }