three-text 0.2.16 → 0.2.17

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.17
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,124 @@ 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
+ ? originalContours ?? this.pathsToContours(paths)
2728
+ : [];
2642
2729
  if (removeOverlaps) {
2643
2730
  logger.log('Two-pass: boundary extraction then triangulation');
2644
- // Extract boundaries to remove overlaps
2645
2731
  perfLogger.start('Tessellator.boundaryPass', {
2646
- contourCount: contours.length
2732
+ contourCount: tessContours.length
2647
2733
  });
2648
- const boundaryResult = this.performTessellation(contours, 'boundary');
2734
+ const boundaryResult = this.performTessellation(originalContours, 'boundary');
2649
2735
  perfLogger.end('Tessellator.boundaryPass');
2650
2736
  if (!boundaryResult) {
2651
2737
  logger.warn('libtess returned empty result from boundary pass');
2652
2738
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2653
2739
  }
2654
- // Convert boundary elements back to contours
2655
- contours = this.boundaryToContours(boundaryResult);
2656
- logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2740
+ // Boundary pass normalizes winding (outer CCW, holes CW)
2741
+ tessContours = this.boundaryToContours(boundaryResult);
2742
+ if (needsExtrusionContours) {
2743
+ extrusionContours = tessContours;
2744
+ }
2745
+ logger.log(`Boundary pass created ${tessContours.length} contours. Starting triangulation pass.`);
2657
2746
  }
2658
2747
  else {
2659
2748
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2749
+ // TTF contours may have inconsistent winding; check if we need normalization
2750
+ if (needsExtrusionContours && !isCFF) {
2751
+ const needsNormalization = this.needsWindingNormalization(extrusionContours);
2752
+ if (needsNormalization) {
2753
+ logger.log('Complex topology detected, running boundary pass for winding normalization');
2754
+ perfLogger.start('Tessellator.windingNormalization', {
2755
+ contourCount: extrusionContours.length
2756
+ });
2757
+ const boundaryResult = this.performTessellation(extrusionContours, 'boundary');
2758
+ perfLogger.end('Tessellator.windingNormalization');
2759
+ if (boundaryResult) {
2760
+ extrusionContours = this.boundaryToContours(boundaryResult);
2761
+ }
2762
+ }
2763
+ else {
2764
+ logger.log('Simple topology, skipping winding normalization');
2765
+ }
2766
+ }
2660
2767
  }
2661
- // Triangulate the contours
2662
2768
  perfLogger.start('Tessellator.triangulationPass', {
2663
- contourCount: contours.length
2769
+ contourCount: tessContours.length
2664
2770
  });
2665
- const triangleResult = this.performTessellation(contours, 'triangles');
2771
+ const triangleResult = this.performTessellation(tessContours, 'triangles');
2666
2772
  perfLogger.end('Tessellator.triangulationPass');
2667
2773
  if (!triangleResult) {
2668
2774
  const warning = removeOverlaps
2669
2775
  ? 'libtess returned empty result from triangulation pass'
2670
2776
  : 'libtess returned empty result from single-pass triangulation';
2671
2777
  logger.warn(warning);
2672
- return { triangles: { vertices: [], indices: [] }, contours };
2778
+ return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2673
2779
  }
2674
2780
  return {
2675
2781
  triangles: {
2676
2782
  vertices: triangleResult.vertices,
2677
2783
  indices: triangleResult.indices || []
2678
2784
  },
2679
- contours
2785
+ contours: extrusionContours
2680
2786
  };
2681
2787
  }
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);
2788
+ pathsToContours(paths, reversePoints = false) {
2789
+ const contours = new Array(paths.length);
2790
+ for (let p = 0; p < paths.length; p++) {
2791
+ const points = paths[p].points;
2792
+ const pointCount = points.length;
2793
+ // Clipper-style paths can be explicitly closed by repeating the first point at the end
2794
+ // Normalize to a single closing vertex for stable side wall generation
2795
+ const isClosed = pointCount > 1 &&
2796
+ points[0].x === points[pointCount - 1].x &&
2797
+ points[0].y === points[pointCount - 1].y;
2798
+ const end = isClosed ? pointCount - 1 : pointCount;
2799
+ // +1 to append a closing vertex
2800
+ const contour = new Array((end + 1) * 2);
2801
+ let i = 0;
2802
+ if (reversePoints) {
2803
+ for (let k = end - 1; k >= 0; k--) {
2804
+ const pt = points[k];
2805
+ contour[i++] = pt.x;
2806
+ contour[i++] = pt.y;
2807
+ }
2687
2808
  }
2688
- return contour;
2689
- });
2809
+ else {
2810
+ for (let k = 0; k < end; k++) {
2811
+ const pt = points[k];
2812
+ contour[i++] = pt.x;
2813
+ contour[i++] = pt.y;
2814
+ }
2815
+ }
2816
+ // Some glyphs omit closePath, leaving gaps in extruded side walls
2817
+ if (i >= 2) {
2818
+ contour[i++] = contour[0];
2819
+ contour[i++] = contour[1];
2820
+ }
2821
+ contours[p] = contour;
2822
+ }
2823
+ return contours;
2690
2824
  }
2691
2825
  performTessellation(contours, mode) {
2692
2826
  const tess = new libtess_minExports.GluTesselator();
2693
- // Set winding rule to NON-ZERO
2694
2827
  tess.gluTessProperty(libtess_minExports.gluEnum.GLU_TESS_WINDING_RULE, libtess_minExports.windingRule.GLU_TESS_WINDING_NONZERO);
2695
2828
  const vertices = [];
2696
2829
  const indices = [];
@@ -2713,7 +2846,7 @@ class Tessellator {
2713
2846
  });
2714
2847
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_END, () => {
2715
2848
  if (currentContour.length > 0) {
2716
- contourIndices.push([...currentContour]);
2849
+ contourIndices.push(currentContour);
2717
2850
  }
2718
2851
  });
2719
2852
  }
@@ -2758,7 +2891,6 @@ class Tessellator {
2758
2891
  const vertIdx = idx * 2;
2759
2892
  contour.push(boundaryResult.vertices[vertIdx], boundaryResult.vertices[vertIdx + 1]);
2760
2893
  }
2761
- // Ensure contour is closed for side wall generation
2762
2894
  if (contour.length > 2) {
2763
2895
  if (contour[0] !== contour[contour.length - 2] ||
2764
2896
  contour[1] !== contour[contour.length - 1]) {
@@ -2769,11 +2901,45 @@ class Tessellator {
2769
2901
  }
2770
2902
  return contours;
2771
2903
  }
2772
- reverseWinding(path) {
2773
- return {
2774
- ...path,
2775
- points: [...path.points].reverse()
2776
- };
2904
+ // Check if contours need winding normalization via boundary pass
2905
+ // Returns false if topology is simple enough to skip the expensive pass
2906
+ needsWindingNormalization(contours) {
2907
+ if (contours.length === 0)
2908
+ return false;
2909
+ // Heuristic 1: Single contour never needs normalization
2910
+ if (contours.length === 1)
2911
+ return false;
2912
+ // Heuristic 2: All same winding = all outers, no holes
2913
+ // Compute signed areas
2914
+ let firstSign = null;
2915
+ for (const contour of contours) {
2916
+ const area = this.signedArea(contour);
2917
+ const sign = area >= 0 ? 1 : -1;
2918
+ if (firstSign === null) {
2919
+ firstSign = sign;
2920
+ }
2921
+ else if (sign !== firstSign) {
2922
+ // Mixed winding detected → might have holes or complex topology
2923
+ return true;
2924
+ }
2925
+ }
2926
+ // All same winding → simple topology, no normalization needed
2927
+ return false;
2928
+ }
2929
+ // Compute signed area (CCW = positive, CW = negative)
2930
+ signedArea(contour) {
2931
+ let area = 0;
2932
+ const len = contour.length;
2933
+ if (len < 6)
2934
+ return 0; // Need at least 3 points
2935
+ for (let i = 0; i < len; i += 2) {
2936
+ const x1 = contour[i];
2937
+ const y1 = contour[i + 1];
2938
+ const x2 = contour[(i + 2) % len];
2939
+ const y2 = contour[(i + 3) % len];
2940
+ area += x1 * y2 - x2 * y1;
2941
+ }
2942
+ return area / 2;
2777
2943
  }
2778
2944
  }
2779
2945
 
@@ -2823,25 +2989,26 @@ class Extruder {
2823
2989
  // Extruded geometry: front at z=0, back at z=depth
2824
2990
  const minBackOffset = unitsPerEm * 0.000025;
2825
2991
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2826
- // Cap at z=0, back face
2827
- 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
2992
+ // Generate both caps in one pass
2837
2993
  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;
2994
+ const x = points[p];
2995
+ const y = points[p + 1];
2996
+ // Cap at z=0
2997
+ const base0 = vi * 3;
2998
+ vertices[base0] = x;
2999
+ vertices[base0 + 1] = y;
3000
+ vertices[base0 + 2] = 0;
3001
+ normals[base0] = 0;
3002
+ normals[base0 + 1] = 0;
3003
+ normals[base0 + 2] = -1;
3004
+ // Cap at z=depth
3005
+ const baseD = (numPoints + vi) * 3;
3006
+ vertices[baseD] = x;
3007
+ vertices[baseD + 1] = y;
3008
+ vertices[baseD + 2] = backZ;
3009
+ normals[baseD] = 0;
3010
+ normals[baseD + 1] = 0;
3011
+ normals[baseD + 2] = 1;
2845
3012
  }
2846
3013
  // libtess outputs CCW triangles (viewed from +Z)
2847
3014
  // Z=0 cap faces -Z, reverse winding
@@ -3135,21 +3302,23 @@ class PathOptimizer {
3135
3302
  return path;
3136
3303
  }
3137
3304
  this.stats.originalPointCount += path.points.length;
3138
- let points = [...path.points];
3305
+ // Most paths are already immutable after collection; avoid copying large point arrays
3306
+ // The optimizers below never mutate the input `points` array
3307
+ const points = path.points;
3139
3308
  if (points.length < 5) {
3140
3309
  return path;
3141
3310
  }
3142
- points = this.simplifyPathVW(points, this.config.areaThreshold);
3143
- if (points.length < 3) {
3311
+ let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
3312
+ if (optimized.length < 3) {
3144
3313
  return path;
3145
3314
  }
3146
- points = this.removeColinearPoints(points, this.config.colinearThreshold);
3147
- if (points.length < 3) {
3315
+ optimized = this.removeColinearPoints(optimized, this.config.colinearThreshold);
3316
+ if (optimized.length < 3) {
3148
3317
  return path;
3149
3318
  }
3150
3319
  return {
3151
3320
  ...path,
3152
- points
3321
+ points: optimized
3153
3322
  };
3154
3323
  }
3155
3324
  // Visvalingam-Whyatt algorithm
@@ -3603,7 +3772,7 @@ class GlyphContourCollector {
3603
3772
  if (this.currentGlyphPaths.length > 0) {
3604
3773
  this.collectedGlyphs.push({
3605
3774
  glyphId: this.currentGlyphId,
3606
- paths: [...this.currentGlyphPaths],
3775
+ paths: this.currentGlyphPaths,
3607
3776
  bounds: {
3608
3777
  min: {
3609
3778
  x: this.currentGlyphBounds.min.x,
@@ -3655,11 +3824,10 @@ class GlyphContourCollector {
3655
3824
  return;
3656
3825
  }
3657
3826
  const flattenedPoints = this.polygonizer.polygonizeQuadratic(start, control, end);
3658
- for (const point of flattenedPoints) {
3659
- this.updateBounds(point);
3660
- }
3661
3827
  for (let i = 0; i < flattenedPoints.length; i++) {
3662
- this.currentPath.points.push(flattenedPoints[i]);
3828
+ const pt = flattenedPoints[i];
3829
+ this.updateBounds(pt);
3830
+ this.currentPath.points.push(pt);
3663
3831
  }
3664
3832
  this.currentPoint = end;
3665
3833
  }
@@ -3679,11 +3847,10 @@ class GlyphContourCollector {
3679
3847
  return;
3680
3848
  }
3681
3849
  const flattenedPoints = this.polygonizer.polygonizeCubic(start, control1, control2, end);
3682
- for (const point of flattenedPoints) {
3683
- this.updateBounds(point);
3684
- }
3685
3850
  for (let i = 0; i < flattenedPoints.length; i++) {
3686
- this.currentPath.points.push(flattenedPoints[i]);
3851
+ const pt = flattenedPoints[i];
3852
+ this.updateBounds(pt);
3853
+ this.currentPath.points.push(pt);
3687
3854
  }
3688
3855
  this.currentPoint = end;
3689
3856
  }
@@ -3873,6 +4040,7 @@ class GlyphGeometryBuilder {
3873
4040
  constructor(cache, loadedFont) {
3874
4041
  this.fontId = 'default';
3875
4042
  this.cacheKeyPrefix = 'default';
4043
+ this.emptyGlyphs = new Set();
3876
4044
  this.cache = cache;
3877
4045
  this.loadedFont = loadedFont;
3878
4046
  this.tessellator = new Tessellator();
@@ -3926,63 +4094,34 @@ class GlyphGeometryBuilder {
3926
4094
  }
3927
4095
  // Build instanced geometry from glyph contours
3928
4096
  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
- }
4097
+ if (isLogEnabled) {
4098
+ let wordCount = 0;
4099
+ for (let i = 0; i < clustersByLine.length; i++) {
4100
+ wordCount += clustersByLine[i].length;
4101
+ }
4102
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
4103
+ lineCount: clustersByLine.length,
4104
+ wordCount,
4105
+ depth,
4106
+ removeOverlaps
4107
+ });
4108
+ }
4109
+ else {
4110
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry');
4111
+ }
4112
+ const tasks = [];
4113
+ let totalVertexFloats = 0;
4114
+ let totalNormalFloats = 0;
4115
+ let totalIndexCount = 0;
4116
+ let vertexCursor = 0; // vertex offset (not float offset)
4117
+ const pushTask = (data, px, py, pz) => {
4118
+ const vertexStart = vertexCursor;
4119
+ tasks.push({ data, px, py, pz, vertexStart });
4120
+ totalVertexFloats += data.vertices.length;
4121
+ totalNormalFloats += data.normals.length;
4122
+ totalIndexCount += data.indices.length;
4123
+ vertexCursor += data.vertices.length / 3;
4124
+ return vertexStart;
3986
4125
  };
3987
4126
  const glyphInfos = [];
3988
4127
  const planeBounds = {
@@ -3992,6 +4131,9 @@ class GlyphGeometryBuilder {
3992
4131
  for (let lineIndex = 0; lineIndex < clustersByLine.length; lineIndex++) {
3993
4132
  const line = clustersByLine[lineIndex];
3994
4133
  for (const cluster of line) {
4134
+ const clusterX = cluster.position.x;
4135
+ const clusterY = cluster.position.y;
4136
+ const clusterZ = cluster.position.z;
3995
4137
  const clusterGlyphContours = [];
3996
4138
  for (const glyph of cluster.glyphs) {
3997
4139
  clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
@@ -4032,7 +4174,7 @@ class GlyphGeometryBuilder {
4032
4174
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4033
4175
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4034
4176
  // Iterate over the geometric groups identified by BoundaryClusterer
4035
- // logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
4177
+ // logical groups (words) split into geometric sub-groups (e.g. "aa", "XX", "bb")
4036
4178
  for (const groupIndices of boundaryGroups) {
4037
4179
  const isOverlappingGroup = groupIndices.length > 1;
4038
4180
  const shouldCluster = isOverlappingGroup && !forceSeparate;
@@ -4064,16 +4206,19 @@ class GlyphGeometryBuilder {
4064
4206
  // Calculate the absolute position of this sub-cluster based on its first glyph
4065
4207
  // (since the cached geometry is relative to that first glyph)
4066
4208
  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);
4209
+ const groupPosX = clusterX + (firstGlyphInGroup.x ?? 0);
4210
+ const groupPosY = clusterY + (firstGlyphInGroup.y ?? 0);
4211
+ const groupPosZ = clusterZ;
4212
+ const vertexStart = pushTask(cachedCluster, groupPosX, groupPosY, groupPosZ);
4070
4213
  const clusterVertexCount = cachedCluster.vertices.length / 3;
4071
4214
  for (let i = 0; i < groupIndices.length; i++) {
4072
4215
  const originalIndex = groupIndices[i];
4073
4216
  const glyph = cluster.glyphs[originalIndex];
4074
4217
  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);
4218
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4219
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4220
+ const glyphPosZ = clusterZ;
4221
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, clusterVertexCount, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4077
4222
  glyphInfos.push(glyphInfo);
4078
4223
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4079
4224
  }
@@ -4083,24 +4228,26 @@ class GlyphGeometryBuilder {
4083
4228
  for (const i of groupIndices) {
4084
4229
  const glyph = cluster.glyphs[i];
4085
4230
  const glyphContours = clusterGlyphContours[i];
4086
- const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4231
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4232
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4233
+ const glyphPosZ = clusterZ;
4087
4234
  // Skip glyphs with no paths (spaces, zero-width characters, etc.)
4088
4235
  if (glyphContours.paths.length === 0) {
4089
- const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
4236
+ const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4090
4237
  glyphInfos.push(glyphInfo);
4091
4238
  continue;
4092
4239
  }
4093
- let cachedGlyph = this.cache.get(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps));
4240
+ const glyphCacheKey = getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps);
4241
+ let cachedGlyph = this.cache.get(glyphCacheKey);
4094
4242
  if (!cachedGlyph) {
4095
4243
  cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
4096
- this.cache.set(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps), cachedGlyph);
4244
+ this.cache.set(glyphCacheKey, cachedGlyph);
4097
4245
  }
4098
4246
  else {
4099
4247
  cachedGlyph.useCount++;
4100
4248
  }
4101
- const vertexOffset = vertexPos / 3;
4102
- appendGeometryToBuffers(cachedGlyph, glyphPosition, vertexOffset);
4103
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
4249
+ const vertexStart = pushTask(cachedGlyph, glyphPosX, glyphPosY, glyphPosZ);
4250
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, cachedGlyph.vertices.length / 3, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4104
4251
  glyphInfos.push(glyphInfo);
4105
4252
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4106
4253
  }
@@ -4108,10 +4255,33 @@ class GlyphGeometryBuilder {
4108
4255
  }
4109
4256
  }
4110
4257
  }
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);
4258
+ // Allocate exact-sized buffers and fill once
4259
+ const vertexArray = new Float32Array(totalVertexFloats);
4260
+ const normalArray = new Float32Array(totalNormalFloats);
4261
+ const indexArray = new Uint32Array(totalIndexCount);
4262
+ let vertexPos = 0; // float index (multiple of 3)
4263
+ let normalPos = 0; // float index (multiple of 3)
4264
+ let indexPos = 0; // index count
4265
+ for (let t = 0; t < tasks.length; t++) {
4266
+ const task = tasks[t];
4267
+ const v = task.data.vertices;
4268
+ const n = task.data.normals;
4269
+ const idx = task.data.indices;
4270
+ const px = task.px;
4271
+ const py = task.py;
4272
+ const pz = task.pz;
4273
+ for (let j = 0; j < v.length; j += 3) {
4274
+ vertexArray[vertexPos++] = v[j] + px;
4275
+ vertexArray[vertexPos++] = v[j + 1] + py;
4276
+ vertexArray[vertexPos++] = v[j + 2] + pz;
4277
+ }
4278
+ normalArray.set(n, normalPos);
4279
+ normalPos += n.length;
4280
+ const vertexStart = task.vertexStart;
4281
+ for (let j = 0; j < idx.length; j++) {
4282
+ indexArray[indexPos++] = idx[j] + vertexStart;
4283
+ }
4284
+ }
4115
4285
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4116
4286
  return {
4117
4287
  vertices: vertexArray,
@@ -4136,7 +4306,7 @@ class GlyphGeometryBuilder {
4136
4306
  const roundedDepth = Math.round(depth * 1000) / 1000;
4137
4307
  return `${this.cacheKeyPrefix}_${ids}_${roundedDepth}_${removeOverlaps}`;
4138
4308
  }
4139
- createGlyphInfo(glyph, vertexStart, vertexCount, position, contours, depth) {
4309
+ createGlyphInfo(glyph, vertexStart, vertexCount, positionX, positionY, positionZ, contours, depth) {
4140
4310
  return {
4141
4311
  textIndex: glyph.absoluteTextIndex,
4142
4312
  lineIndex: glyph.lineIndex,
@@ -4144,19 +4314,30 @@ class GlyphGeometryBuilder {
4144
4314
  vertexCount,
4145
4315
  bounds: {
4146
4316
  min: {
4147
- x: contours.bounds.min.x + position.x,
4148
- y: contours.bounds.min.y + position.y,
4149
- z: position.z
4317
+ x: contours.bounds.min.x + positionX,
4318
+ y: contours.bounds.min.y + positionY,
4319
+ z: positionZ
4150
4320
  },
4151
4321
  max: {
4152
- x: contours.bounds.max.x + position.x,
4153
- y: contours.bounds.max.y + position.y,
4154
- z: position.z + depth
4322
+ x: contours.bounds.max.x + positionX,
4323
+ y: contours.bounds.max.y + positionY,
4324
+ z: positionZ + depth
4155
4325
  }
4156
4326
  }
4157
4327
  };
4158
4328
  }
4159
4329
  getContoursForGlyph(glyphId) {
4330
+ // Fast path: skip HarfBuzz draw for known-empty glyphs (spaces, zero-width, etc)
4331
+ if (this.emptyGlyphs.has(glyphId)) {
4332
+ return {
4333
+ glyphId,
4334
+ paths: [],
4335
+ bounds: {
4336
+ min: { x: 0, y: 0 },
4337
+ max: { x: 0, y: 0 }
4338
+ }
4339
+ };
4340
+ }
4160
4341
  const key = `${this.cacheKeyPrefix}_${glyphId}`;
4161
4342
  const cached = this.contourCache.get(key);
4162
4343
  if (cached) {
@@ -4177,11 +4358,15 @@ class GlyphGeometryBuilder {
4177
4358
  max: { x: 0, y: 0 }
4178
4359
  }
4179
4360
  };
4361
+ // Mark glyph as empty for future fast-path
4362
+ if (contours.paths.length === 0) {
4363
+ this.emptyGlyphs.add(glyphId);
4364
+ }
4180
4365
  this.contourCache.set(key, contours);
4181
4366
  return contours;
4182
4367
  }
4183
4368
  tessellateGlyphCluster(paths, depth, isCFF) {
4184
- const processedGeometry = this.tessellator.process(paths, true, isCFF);
4369
+ const processedGeometry = this.tessellator.process(paths, true, isCFF, depth !== 0);
4185
4370
  return this.extrudeAndPackage(processedGeometry, depth);
4186
4371
  }
4187
4372
  extrudeAndPackage(processedGeometry, depth) {
@@ -4229,7 +4414,7 @@ class GlyphGeometryBuilder {
4229
4414
  glyphId: glyphContours.glyphId,
4230
4415
  pathCount: glyphContours.paths.length
4231
4416
  });
4232
- const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
4417
+ const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF, depth !== 0);
4233
4418
  perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
4234
4419
  return this.extrudeAndPackage(processedGeometry, depth);
4235
4420
  }
@@ -4299,8 +4484,11 @@ class TextShaper {
4299
4484
  const clusters = [];
4300
4485
  let currentClusterGlyphs = [];
4301
4486
  let currentClusterText = '';
4302
- let clusterStartPosition = new Vec3();
4303
- let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4487
+ let clusterStartX = 0;
4488
+ let clusterStartY = 0;
4489
+ let cursorX = lineInfo.xOffset;
4490
+ let cursorY = -lineIndex * scaledLineHeight;
4491
+ const cursorZ = 0;
4304
4492
  // Apply letter spacing after each glyph to match width measurements used during line breaking
4305
4493
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4306
4494
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
@@ -4325,31 +4513,31 @@ class TextShaper {
4325
4513
  clusters.push({
4326
4514
  text: currentClusterText,
4327
4515
  glyphs: currentClusterGlyphs,
4328
- position: clusterStartPosition.clone()
4516
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4329
4517
  });
4330
4518
  currentClusterGlyphs = [];
4331
4519
  currentClusterText = '';
4332
4520
  }
4333
4521
  }
4334
- const absoluteGlyphPosition = cursor
4335
- .clone()
4336
- .add(new Vec3(glyph.dx, glyph.dy, 0));
4522
+ const absoluteGlyphX = cursorX + glyph.dx;
4523
+ const absoluteGlyphY = cursorY + glyph.dy;
4337
4524
  if (!isWhitespace) {
4338
4525
  if (currentClusterGlyphs.length === 0) {
4339
- clusterStartPosition.copy(absoluteGlyphPosition);
4526
+ clusterStartX = absoluteGlyphX;
4527
+ clusterStartY = absoluteGlyphY;
4340
4528
  }
4341
- glyph.x = absoluteGlyphPosition.x - clusterStartPosition.x;
4342
- glyph.y = absoluteGlyphPosition.y - clusterStartPosition.y;
4529
+ glyph.x = absoluteGlyphX - clusterStartX;
4530
+ glyph.y = absoluteGlyphY - clusterStartY;
4343
4531
  currentClusterGlyphs.push(glyph);
4344
4532
  currentClusterText += lineInfo.text[glyph.cl];
4345
4533
  }
4346
- cursor.x += glyph.ax;
4347
- cursor.y += glyph.ay;
4534
+ cursorX += glyph.ax;
4535
+ cursorY += glyph.ay;
4348
4536
  if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
4349
- cursor.x += letterSpacingFU;
4537
+ cursorX += letterSpacingFU;
4350
4538
  }
4351
4539
  if (isWhitespace) {
4352
- cursor.x += spaceAdjustment;
4540
+ cursorX += spaceAdjustment;
4353
4541
  }
4354
4542
  // CJK glue adjustment (must match exactly where LineBreak adds glue)
4355
4543
  if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
@@ -4370,7 +4558,7 @@ class TextShaper {
4370
4558
  shouldApply = false;
4371
4559
  }
4372
4560
  if (shouldApply) {
4373
- cursor.x += cjkAdjustment;
4561
+ cursorX += cjkAdjustment;
4374
4562
  }
4375
4563
  }
4376
4564
  }
@@ -4379,7 +4567,7 @@ class TextShaper {
4379
4567
  clusters.push({
4380
4568
  text: currentClusterText,
4381
4569
  glyphs: currentClusterGlyphs,
4382
- position: clusterStartPosition.clone()
4570
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4383
4571
  });
4384
4572
  }
4385
4573
  return clusters;
@@ -5206,9 +5394,8 @@ class Text {
5206
5394
  const loadedFont = await Text.resolveFont(options);
5207
5395
  const text = new Text();
5208
5396
  text.setLoadedFont(loadedFont);
5209
- // Initial creation
5210
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
5211
- const result = await text.createGeometry(geometryOptions);
5397
+ // Pass full options so createGeometry honors maxCacheSizeMB etc
5398
+ const result = await text.createGeometry(options);
5212
5399
  // Recursive update function
5213
5400
  const update = async (newOptions) => {
5214
5401
  // Merge options - preserve font from original options if not provided
@@ -5230,8 +5417,7 @@ class Text {
5230
5417
  }
5231
5418
  // Update closure options for next time
5232
5419
  options = mergedOptions;
5233
- const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5234
- const newResult = await text.createGeometry(currentGeometryOptions);
5420
+ const newResult = await text.createGeometry(options);
5235
5421
  return {
5236
5422
  ...newResult,
5237
5423
  getLoadedFont: () => text.getLoadedFont(),
@@ -5656,7 +5842,7 @@ class Text {
5656
5842
  if (!this.textLayout) {
5657
5843
  this.textLayout = new TextLayout(this.loadedFont);
5658
5844
  }
5659
- const alignmentResult = this.textLayout.applyAlignment(vertices, {
5845
+ const alignmentResult = this.textLayout.computeAlignmentOffset({
5660
5846
  width,
5661
5847
  align,
5662
5848
  planeBounds
@@ -5665,9 +5851,19 @@ class Text {
5665
5851
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5666
5852
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5667
5853
  const finalScale = size / this.loadedFont.upem;
5854
+ const offsetScaled = offset * finalScale;
5668
5855
  // Scale vertices only (normals are unit vectors, don't scale)
5669
- for (let i = 0; i < vertices.length; i++) {
5670
- vertices[i] *= finalScale;
5856
+ if (offsetScaled === 0) {
5857
+ for (let i = 0; i < vertices.length; i++) {
5858
+ vertices[i] *= finalScale;
5859
+ }
5860
+ }
5861
+ else {
5862
+ for (let i = 0; i < vertices.length; i += 3) {
5863
+ vertices[i] = vertices[i] * finalScale + offsetScaled;
5864
+ vertices[i + 1] *= finalScale;
5865
+ vertices[i + 2] *= finalScale;
5866
+ }
5671
5867
  }
5672
5868
  planeBounds.min.x *= finalScale;
5673
5869
  planeBounds.min.y *= finalScale;
@@ -5677,14 +5873,10 @@ class Text {
5677
5873
  planeBounds.max.z *= finalScale;
5678
5874
  for (let i = 0; i < glyphInfoArray.length; i++) {
5679
5875
  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;
5876
+ glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5685
5877
  glyphInfo.bounds.min.y *= finalScale;
5686
5878
  glyphInfo.bounds.min.z *= finalScale;
5687
- glyphInfo.bounds.max.x *= finalScale;
5879
+ glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5688
5880
  glyphInfo.bounds.max.y *= finalScale;
5689
5881
  glyphInfo.bounds.max.z *= finalScale;
5690
5882
  }