three-text 0.2.15 → 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.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.15
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
@@ -196,7 +196,7 @@ var FitnessClass;
196
196
  FitnessClass[FitnessClass["LOOSE"] = 2] = "LOOSE";
197
197
  FitnessClass[FitnessClass["VERY_LOOSE"] = 3] = "VERY_LOOSE";
198
198
  })(FitnessClass || (FitnessClass = {}));
199
- // ActiveNodeList maintains all currently viable breakpoints as we scan through the text.
199
+ // ActiveNodeList maintains all currently viable breakpoints as we scan through the text
200
200
  // Each node represents a potential break with accumulated demerits (total "cost" from start)
201
201
  //
202
202
  // Demerits = cumulative penalty score from text start to this break, calculated as:
@@ -338,9 +338,9 @@ class LineBreak {
338
338
  // Converts text into items (boxes, glues, penalties) for line breaking
339
339
  // The measureText function should return widths that include any letter spacing
340
340
  static itemizeText(text, measureText, // function to measure text width
341
- hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
341
+ measureTextWidths, hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
342
342
  const items = [];
343
- items.push(...this.itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context));
343
+ items.push(...this.itemizeParagraph(text, measureText, measureTextWidths, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context));
344
344
  // Final glue and penalty to end the paragraph
345
345
  // Use infinite stretch to fill the last line
346
346
  items.push({
@@ -445,9 +445,10 @@ class LineBreak {
445
445
  return (this.isCJClosingPunctuation(char) || this.isCJOpeningPunctuation(char));
446
446
  }
447
447
  // CJK (Chinese/Japanese/Korean) character-level itemization with inter-character glue
448
- static itemizeCJKText(text, measureText, context, startOffset = 0, glueParams) {
448
+ static itemizeCJKText(text, measureText, measureTextWidths, context, startOffset = 0, glueParams) {
449
449
  const items = [];
450
450
  const chars = Array.from(text);
451
+ const widths = measureTextWidths ? measureTextWidths(text) : null;
451
452
  let textPosition = startOffset;
452
453
  // Inter-character glue parameters
453
454
  let glueWidth;
@@ -468,7 +469,7 @@ class LineBreak {
468
469
  const char = chars[i];
469
470
  const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
470
471
  if (/\s/.test(char)) {
471
- const width = measureText(char);
472
+ const width = widths ? (widths[i] ?? measureText(char)) : measureText(char);
472
473
  items.push({
473
474
  type: ItemType.GLUE,
474
475
  width,
@@ -482,7 +483,7 @@ class LineBreak {
482
483
  }
483
484
  items.push({
484
485
  type: ItemType.BOX,
485
- width: measureText(char),
486
+ width: widths ? (widths[i] ?? measureText(char)) : measureText(char),
486
487
  text: char,
487
488
  originIndex: textPosition
488
489
  });
@@ -513,15 +514,21 @@ class LineBreak {
513
514
  }
514
515
  return items;
515
516
  }
516
- static itemizeParagraph(text, measureText, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
517
+ static itemizeParagraph(text, measureText, measureTextWidths, hyphenate, language, availablePatterns, lefthyphenmin, righthyphenmin, context) {
517
518
  const items = [];
518
519
  const chars = Array.from(text);
519
- // Calculate CJK glue parameters once for consistency across all segments
520
- const baseCharWidth = measureText('字');
521
- const cjkGlueParams = {
522
- width: 0,
523
- stretch: baseCharWidth * 0.04,
524
- shrink: baseCharWidth * 0.04
520
+ // Calculate CJK glue parameters lazily and once for consistency across all segments
521
+ let cjkGlueParams;
522
+ const getCjkGlueParams = () => {
523
+ if (!cjkGlueParams) {
524
+ const baseCharWidth = measureText('字');
525
+ cjkGlueParams = {
526
+ width: 0,
527
+ stretch: baseCharWidth * 0.04,
528
+ shrink: baseCharWidth * 0.04
529
+ };
530
+ }
531
+ return cjkGlueParams;
525
532
  };
526
533
  let buffer = '';
527
534
  let bufferStart = 0;
@@ -531,7 +538,7 @@ class LineBreak {
531
538
  if (buffer.length === 0)
532
539
  return;
533
540
  if (bufferScript === 'cjk') {
534
- const cjkItems = this.itemizeCJKText(buffer, measureText, context, bufferStart, cjkGlueParams);
541
+ const cjkItems = this.itemizeCJKText(buffer, measureText, measureTextWidths, context, bufferStart, getCjkGlueParams());
535
542
  items.push(...cjkItems);
536
543
  }
537
544
  else {
@@ -724,7 +731,7 @@ class LineBreak {
724
731
  align: options.align || 'left',
725
732
  hyphenate: options.hyphenate || false
726
733
  });
727
- 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;
734
+ 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;
728
735
  // Handle multiple paragraphs by processing each independently
729
736
  if (respectExistingBreaks && text.includes('\n')) {
730
737
  const paragraphs = text.split('\n');
@@ -787,9 +794,9 @@ class LineBreak {
787
794
  exHyphenPenalty: exhyphenpenalty,
788
795
  currentAlign: align,
789
796
  unitsPerEm,
790
- // measureText() includes trailing letter spacing after the final glyph of a token.
797
+ // measureText() includes trailing letter spacing after the final glyph of a token
791
798
  // Shaping applies letter spacing only between glyphs, so we subtract one
792
- // trailing letterSpacingFU per line segment (see computeAdjustmentRatio/createLines).
799
+ // trailing letterSpacingFU per line segment (see computeAdjustmentRatio/createLines)
793
800
  letterSpacingFU: unitsPerEm ? letterSpacing * unitsPerEm : 0
794
801
  };
795
802
  if (!width || width === Infinity) {
@@ -808,7 +815,7 @@ class LineBreak {
808
815
  ];
809
816
  }
810
817
  // Itemize without hyphenation first (TeX approach: only compute if needed)
811
- const allItems = LineBreak.itemizeText(text, measureText, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
818
+ const allItems = LineBreak.itemizeText(text, measureText, measureTextWidths, false, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
812
819
  if (allItems.length === 0) {
813
820
  return [];
814
821
  }
@@ -827,7 +834,7 @@ class LineBreak {
827
834
  let breaks = LineBreak.findBreakpoints(currentItems, width, pretolerance, looseness, false, 0, context);
828
835
  // Second pass: with hyphenation if first pass failed
829
836
  if (breaks.length === 0 && useHyphenation) {
830
- const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
837
+ const itemsWithHyphenation = LineBreak.itemizeText(text, measureText, measureTextWidths, true, language, hyphenationPatterns, lefthyphenmin, righthyphenmin, context);
831
838
  currentItems = itemsWithHyphenation;
832
839
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, false, 0, context);
833
840
  }
@@ -1141,9 +1148,9 @@ class LineBreak {
1141
1148
  ? items[lineEnd].width
1142
1149
  : items[lineEnd].preBreakWidth;
1143
1150
  }
1144
- // Correct for trailing letter spacing at the end of the line segment.
1151
+ // Correct for trailing letter spacing at the end of the line segment
1145
1152
  // Our token measurement includes letter spacing after the final glyph;
1146
- // shaping does not add letter spacing after the final glyph in a line.
1153
+ // shaping does not add letter spacing after the final glyph in a line
1147
1154
  if (context?.letterSpacingFU && totalWidth !== 0) {
1148
1155
  totalWidth -= context.letterSpacingFU;
1149
1156
  }
@@ -1309,7 +1316,7 @@ class LineBreak {
1309
1316
  }
1310
1317
  }
1311
1318
  const lineText = lineTextParts.join('');
1312
- // Correct for trailing letter spacing at the end of the line.
1319
+ // Correct for trailing letter spacing at the end of the line
1313
1320
  if (context?.letterSpacingFU && naturalWidth !== 0) {
1314
1321
  naturalWidth -= context.letterSpacingFU;
1315
1322
  }
@@ -1366,7 +1373,7 @@ class LineBreak {
1366
1373
  finalNaturalWidth += item.width;
1367
1374
  }
1368
1375
  const finalLineText = finalLineTextParts.join('');
1369
- // Correct for trailing letter spacing at the end of the final line.
1376
+ // Correct for trailing letter spacing at the end of the final line
1370
1377
  if (context?.letterSpacingFU && finalNaturalWidth !== 0) {
1371
1378
  finalNaturalWidth -= context.letterSpacingFU;
1372
1379
  }
@@ -1403,12 +1410,21 @@ class LineBreak {
1403
1410
  }
1404
1411
  }
1405
1412
 
1413
+ // Memoize conversion per feature-object identity to avoid rebuilding the same
1414
+ // comma-separated string on every HarfBuzz shape call
1415
+ const featureStringCache = new WeakMap();
1406
1416
  // Convert feature objects to HarfBuzz comma-separated format
1407
1417
  function convertFontFeaturesToString(features) {
1408
1418
  if (!features || Object.keys(features).length === 0) {
1409
1419
  return undefined;
1410
1420
  }
1421
+ const cached = featureStringCache.get(features);
1422
+ if (cached !== undefined) {
1423
+ return cached ?? undefined;
1424
+ }
1411
1425
  const featureStrings = [];
1426
+ // Preserve insertion order of the input object
1427
+ // (The public API/tests expect this to be stable and predictable)
1412
1428
  for (const [tag, value] of Object.entries(features)) {
1413
1429
  if (!/^[a-zA-Z0-9]{4}$/.test(tag)) {
1414
1430
  logger.warn(`Invalid OpenType feature tag: "${tag}". Tags must be exactly 4 alphanumeric characters.`);
@@ -1427,10 +1443,63 @@ function convertFontFeaturesToString(features) {
1427
1443
  logger.warn(`Invalid value for feature "${tag}": ${value}. Expected boolean or positive number.`);
1428
1444
  }
1429
1445
  }
1430
- return featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1446
+ const result = featureStrings.length > 0 ? featureStrings.join(',') : undefined;
1447
+ featureStringCache.set(features, result ?? null);
1448
+ return result;
1431
1449
  }
1432
1450
 
1433
1451
  class TextMeasurer {
1452
+ // Shape once and return per-codepoint widths aligned with Array.from(text)
1453
+ // Groups glyph advances by HarfBuzz cluster (cl)
1454
+ // Includes trailing per-glyph letter spacing like measureTextWidth
1455
+ static measureTextWidths(loadedFont, text, letterSpacing = 0) {
1456
+ const chars = Array.from(text);
1457
+ if (chars.length === 0)
1458
+ return [];
1459
+ // HarfBuzz clusters are UTF-16 code unit indices
1460
+ const startToCharIndex = new Map();
1461
+ let codeUnitIndex = 0;
1462
+ for (let i = 0; i < chars.length; i++) {
1463
+ startToCharIndex.set(codeUnitIndex, i);
1464
+ codeUnitIndex += chars[i].length;
1465
+ }
1466
+ const widths = new Array(chars.length).fill(0);
1467
+ const buffer = loadedFont.hb.createBuffer();
1468
+ try {
1469
+ buffer.addText(text);
1470
+ buffer.guessSegmentProperties();
1471
+ const featuresString = convertFontFeaturesToString(loadedFont.fontFeatures);
1472
+ loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
1473
+ const glyphInfos = buffer.json(loadedFont.font);
1474
+ const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
1475
+ for (let i = 0; i < glyphInfos.length; i++) {
1476
+ const glyph = glyphInfos[i];
1477
+ const cl = glyph.cl ?? 0;
1478
+ let charIndex = startToCharIndex.get(cl);
1479
+ // Fallback if cl lands mid-codepoint
1480
+ if (charIndex === undefined) {
1481
+ // Find the closest start <= cl
1482
+ for (let back = cl; back >= 0; back--) {
1483
+ const candidate = startToCharIndex.get(back);
1484
+ if (candidate !== undefined) {
1485
+ charIndex = candidate;
1486
+ break;
1487
+ }
1488
+ }
1489
+ }
1490
+ if (charIndex === undefined)
1491
+ continue;
1492
+ widths[charIndex] += glyph.ax;
1493
+ if (letterSpacingInFontUnits !== 0) {
1494
+ widths[charIndex] += letterSpacingInFontUnits;
1495
+ }
1496
+ }
1497
+ return widths;
1498
+ }
1499
+ finally {
1500
+ buffer.destroy();
1501
+ }
1502
+ }
1434
1503
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1435
1504
  const buffer = loadedFont.hb.createBuffer();
1436
1505
  buffer.addText(text);
@@ -1487,7 +1556,8 @@ class TextLayout {
1487
1556
  unitsPerEm: this.loadedFont.upem,
1488
1557
  letterSpacing,
1489
1558
  measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1490
- )
1559
+ ),
1560
+ measureTextWidths: (textToMeasure) => TextMeasurer.measureTextWidths(this.loadedFont, textToMeasure, letterSpacing)
1491
1561
  });
1492
1562
  }
1493
1563
  else {
@@ -1509,6 +1579,15 @@ class TextLayout {
1509
1579
  return { lines };
1510
1580
  }
1511
1581
  applyAlignment(vertices, options) {
1582
+ const { offset, adjustedBounds } = this.computeAlignmentOffset(options);
1583
+ if (offset !== 0) {
1584
+ for (let i = 0; i < vertices.length; i += 3) {
1585
+ vertices[i] += offset;
1586
+ }
1587
+ }
1588
+ return { offset, adjustedBounds };
1589
+ }
1590
+ computeAlignmentOffset(options) {
1512
1591
  const { width, align, planeBounds } = options;
1513
1592
  let offset = 0;
1514
1593
  const adjustedBounds = {
@@ -1520,17 +1599,13 @@ class TextLayout {
1520
1599
  if (align === 'center') {
1521
1600
  offset = (width - lineWidth) / 2 - planeBounds.min.x;
1522
1601
  }
1523
- else if (align === 'right') {
1602
+ else {
1524
1603
  offset = width - planeBounds.max.x;
1525
1604
  }
1526
- if (offset !== 0) {
1527
- // Translate vertices
1528
- for (let i = 0; i < vertices.length; i += 3) {
1529
- vertices[i] += offset;
1530
- }
1531
- adjustedBounds.min.x += offset;
1532
- adjustedBounds.max.x += offset;
1533
- }
1605
+ }
1606
+ if (offset !== 0) {
1607
+ adjustedBounds.min.x += offset;
1608
+ adjustedBounds.max.x += offset;
1534
1609
  }
1535
1610
  return { offset, adjustedBounds };
1536
1611
  }
@@ -2625,7 +2700,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
2625
2700
  var libtess_minExports = libtess_min.exports;
2626
2701
 
2627
2702
  class Tessellator {
2628
- process(paths, removeOverlaps = true, isCFF = false) {
2703
+ process(paths, removeOverlaps = true, isCFF = false, needsExtrusionContours = true) {
2629
2704
  if (paths.length === 0) {
2630
2705
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2631
2706
  }
@@ -2634,66 +2709,124 @@ class Tessellator {
2634
2709
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2635
2710
  }
2636
2711
  logger.log(`Tessellator: removeOverlaps=${removeOverlaps}, processing ${valid.length} paths`);
2637
- return this.tessellate(valid, removeOverlaps, isCFF);
2638
- }
2639
- tessellate(paths, removeOverlaps, isCFF) {
2640
- // TTF fonts have opposite winding from tessellator expectations
2641
- const normalizedPaths = !isCFF && !removeOverlaps
2642
- ? paths.map((p) => this.reverseWinding(p))
2643
- : paths;
2644
- let contours = this.pathsToContours(normalizedPaths);
2712
+ return this.tessellate(valid, removeOverlaps, isCFF, needsExtrusionContours);
2713
+ }
2714
+ tessellate(paths, removeOverlaps, isCFF, needsExtrusionContours) {
2715
+ // libtess expects CCW winding; TTF outer contours are CW
2716
+ const needsWindingReversal = !isCFF && !removeOverlaps;
2717
+ let originalContours;
2718
+ let tessContours;
2719
+ if (needsWindingReversal) {
2720
+ tessContours = this.pathsToContours(paths, true);
2721
+ if (removeOverlaps || needsExtrusionContours) {
2722
+ originalContours = this.pathsToContours(paths);
2723
+ }
2724
+ }
2725
+ else {
2726
+ originalContours = this.pathsToContours(paths);
2727
+ tessContours = originalContours;
2728
+ }
2729
+ let extrusionContours = needsExtrusionContours
2730
+ ? originalContours ?? this.pathsToContours(paths)
2731
+ : [];
2645
2732
  if (removeOverlaps) {
2646
2733
  logger.log('Two-pass: boundary extraction then triangulation');
2647
- // Extract boundaries to remove overlaps
2648
2734
  perfLogger.start('Tessellator.boundaryPass', {
2649
- contourCount: contours.length
2735
+ contourCount: tessContours.length
2650
2736
  });
2651
- const boundaryResult = this.performTessellation(contours, 'boundary');
2737
+ const boundaryResult = this.performTessellation(originalContours, 'boundary');
2652
2738
  perfLogger.end('Tessellator.boundaryPass');
2653
2739
  if (!boundaryResult) {
2654
2740
  logger.warn('libtess returned empty result from boundary pass');
2655
2741
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2656
2742
  }
2657
- // Convert boundary elements back to contours
2658
- contours = this.boundaryToContours(boundaryResult);
2659
- logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2743
+ // Boundary pass normalizes winding (outer CCW, holes CW)
2744
+ tessContours = this.boundaryToContours(boundaryResult);
2745
+ if (needsExtrusionContours) {
2746
+ extrusionContours = tessContours;
2747
+ }
2748
+ logger.log(`Boundary pass created ${tessContours.length} contours. Starting triangulation pass.`);
2660
2749
  }
2661
2750
  else {
2662
2751
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2752
+ // TTF contours may have inconsistent winding; check if we need normalization
2753
+ if (needsExtrusionContours && !isCFF) {
2754
+ const needsNormalization = this.needsWindingNormalization(extrusionContours);
2755
+ if (needsNormalization) {
2756
+ logger.log('Complex topology detected, running boundary pass for winding normalization');
2757
+ perfLogger.start('Tessellator.windingNormalization', {
2758
+ contourCount: extrusionContours.length
2759
+ });
2760
+ const boundaryResult = this.performTessellation(extrusionContours, 'boundary');
2761
+ perfLogger.end('Tessellator.windingNormalization');
2762
+ if (boundaryResult) {
2763
+ extrusionContours = this.boundaryToContours(boundaryResult);
2764
+ }
2765
+ }
2766
+ else {
2767
+ logger.log('Simple topology, skipping winding normalization');
2768
+ }
2769
+ }
2663
2770
  }
2664
- // Triangulate the contours
2665
2771
  perfLogger.start('Tessellator.triangulationPass', {
2666
- contourCount: contours.length
2772
+ contourCount: tessContours.length
2667
2773
  });
2668
- const triangleResult = this.performTessellation(contours, 'triangles');
2774
+ const triangleResult = this.performTessellation(tessContours, 'triangles');
2669
2775
  perfLogger.end('Tessellator.triangulationPass');
2670
2776
  if (!triangleResult) {
2671
2777
  const warning = removeOverlaps
2672
2778
  ? 'libtess returned empty result from triangulation pass'
2673
2779
  : 'libtess returned empty result from single-pass triangulation';
2674
2780
  logger.warn(warning);
2675
- return { triangles: { vertices: [], indices: [] }, contours };
2781
+ return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2676
2782
  }
2677
2783
  return {
2678
2784
  triangles: {
2679
2785
  vertices: triangleResult.vertices,
2680
2786
  indices: triangleResult.indices || []
2681
2787
  },
2682
- contours
2788
+ contours: extrusionContours
2683
2789
  };
2684
2790
  }
2685
- pathsToContours(paths) {
2686
- return paths.map((path) => {
2687
- const contour = [];
2688
- for (const point of path.points) {
2689
- contour.push(point.x, point.y);
2791
+ pathsToContours(paths, reversePoints = false) {
2792
+ const contours = new Array(paths.length);
2793
+ for (let p = 0; p < paths.length; p++) {
2794
+ const points = paths[p].points;
2795
+ const pointCount = points.length;
2796
+ // Clipper-style paths can be explicitly closed by repeating the first point at the end
2797
+ // Normalize to a single closing vertex for stable side wall generation
2798
+ const isClosed = pointCount > 1 &&
2799
+ points[0].x === points[pointCount - 1].x &&
2800
+ points[0].y === points[pointCount - 1].y;
2801
+ const end = isClosed ? pointCount - 1 : pointCount;
2802
+ // +1 to append a closing vertex
2803
+ const contour = new Array((end + 1) * 2);
2804
+ let i = 0;
2805
+ if (reversePoints) {
2806
+ for (let k = end - 1; k >= 0; k--) {
2807
+ const pt = points[k];
2808
+ contour[i++] = pt.x;
2809
+ contour[i++] = pt.y;
2810
+ }
2690
2811
  }
2691
- return contour;
2692
- });
2812
+ else {
2813
+ for (let k = 0; k < end; k++) {
2814
+ const pt = points[k];
2815
+ contour[i++] = pt.x;
2816
+ contour[i++] = pt.y;
2817
+ }
2818
+ }
2819
+ // Some glyphs omit closePath, leaving gaps in extruded side walls
2820
+ if (i >= 2) {
2821
+ contour[i++] = contour[0];
2822
+ contour[i++] = contour[1];
2823
+ }
2824
+ contours[p] = contour;
2825
+ }
2826
+ return contours;
2693
2827
  }
2694
2828
  performTessellation(contours, mode) {
2695
2829
  const tess = new libtess_minExports.GluTesselator();
2696
- // Set winding rule to NON-ZERO
2697
2830
  tess.gluTessProperty(libtess_minExports.gluEnum.GLU_TESS_WINDING_RULE, libtess_minExports.windingRule.GLU_TESS_WINDING_NONZERO);
2698
2831
  const vertices = [];
2699
2832
  const indices = [];
@@ -2716,7 +2849,7 @@ class Tessellator {
2716
2849
  });
2717
2850
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_END, () => {
2718
2851
  if (currentContour.length > 0) {
2719
- contourIndices.push([...currentContour]);
2852
+ contourIndices.push(currentContour);
2720
2853
  }
2721
2854
  });
2722
2855
  }
@@ -2761,7 +2894,6 @@ class Tessellator {
2761
2894
  const vertIdx = idx * 2;
2762
2895
  contour.push(boundaryResult.vertices[vertIdx], boundaryResult.vertices[vertIdx + 1]);
2763
2896
  }
2764
- // Ensure contour is closed for side wall generation
2765
2897
  if (contour.length > 2) {
2766
2898
  if (contour[0] !== contour[contour.length - 2] ||
2767
2899
  contour[1] !== contour[contour.length - 1]) {
@@ -2772,11 +2904,45 @@ class Tessellator {
2772
2904
  }
2773
2905
  return contours;
2774
2906
  }
2775
- reverseWinding(path) {
2776
- return {
2777
- ...path,
2778
- points: [...path.points].reverse()
2779
- };
2907
+ // Check if contours need winding normalization via boundary pass
2908
+ // Returns false if topology is simple enough to skip the expensive pass
2909
+ needsWindingNormalization(contours) {
2910
+ if (contours.length === 0)
2911
+ return false;
2912
+ // Heuristic 1: Single contour never needs normalization
2913
+ if (contours.length === 1)
2914
+ return false;
2915
+ // Heuristic 2: All same winding = all outers, no holes
2916
+ // Compute signed areas
2917
+ let firstSign = null;
2918
+ for (const contour of contours) {
2919
+ const area = this.signedArea(contour);
2920
+ const sign = area >= 0 ? 1 : -1;
2921
+ if (firstSign === null) {
2922
+ firstSign = sign;
2923
+ }
2924
+ else if (sign !== firstSign) {
2925
+ // Mixed winding detected → might have holes or complex topology
2926
+ return true;
2927
+ }
2928
+ }
2929
+ // All same winding → simple topology, no normalization needed
2930
+ return false;
2931
+ }
2932
+ // Compute signed area (CCW = positive, CW = negative)
2933
+ signedArea(contour) {
2934
+ let area = 0;
2935
+ const len = contour.length;
2936
+ if (len < 6)
2937
+ return 0; // Need at least 3 points
2938
+ for (let i = 0; i < len; i += 2) {
2939
+ const x1 = contour[i];
2940
+ const y1 = contour[i + 1];
2941
+ const x2 = contour[(i + 2) % len];
2942
+ const y2 = contour[(i + 3) % len];
2943
+ area += x1 * y2 - x2 * y1;
2944
+ }
2945
+ return area / 2;
2780
2946
  }
2781
2947
  }
2782
2948
 
@@ -2786,12 +2952,11 @@ class Extruder {
2786
2952
  const points = geometry.triangles.vertices;
2787
2953
  const triangleIndices = geometry.triangles.indices;
2788
2954
  const numPoints = points.length / 2;
2789
- // Count side-wall segments (each segment emits 4 vertices + 6 indices)
2955
+ // Count side-wall segments (4 vertices + 6 indices per segment)
2790
2956
  let sideSegments = 0;
2791
2957
  if (depth !== 0) {
2792
2958
  for (const contour of geometry.contours) {
2793
- // Each contour is a flat [x0,y0,x1,y1,...] array; side walls connect consecutive points
2794
- // Contours are expected to be closed (last point repeats first), so segments = (nPoints - 1)
2959
+ // Contours are closed (last point repeats first)
2795
2960
  const contourPoints = contour.length / 2;
2796
2961
  if (contourPoints >= 2)
2797
2962
  sideSegments += contourPoints - 1;
@@ -2807,7 +2972,7 @@ class Extruder {
2807
2972
  : triangleIndices.length * 2 + sideSegments * 6;
2808
2973
  const indices = new Uint32Array(indexCount);
2809
2974
  if (depth === 0) {
2810
- // Flat faces only
2975
+ // Single-sided flat geometry at z=0
2811
2976
  let vPos = 0;
2812
2977
  for (let i = 0; i < points.length; i += 2) {
2813
2978
  vertices[vPos] = points[i];
@@ -2818,42 +2983,44 @@ class Extruder {
2818
2983
  normals[vPos + 2] = 1;
2819
2984
  vPos += 3;
2820
2985
  }
2986
+ // libtess outputs CCW, use as-is for +Z facing geometry
2821
2987
  for (let i = 0; i < triangleIndices.length; i++) {
2822
2988
  indices[i] = triangleIndices[i];
2823
2989
  }
2824
2990
  return { vertices, normals, indices };
2825
2991
  }
2826
- // Front/back faces
2992
+ // Extruded geometry: front at z=0, back at z=depth
2827
2993
  const minBackOffset = unitsPerEm * 0.000025;
2828
2994
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2829
- // Fill front vertices/normals (0..numPoints-1)
2830
- for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2831
- const base = vi * 3;
2832
- vertices[base] = points[p];
2833
- vertices[base + 1] = points[p + 1];
2834
- vertices[base + 2] = 0;
2835
- normals[base] = 0;
2836
- normals[base + 1] = 0;
2837
- normals[base + 2] = 1;
2838
- }
2839
- // Fill back vertices/normals (numPoints..2*numPoints-1)
2995
+ // Generate both caps in one pass
2840
2996
  for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
2841
- const base = (numPoints + vi) * 3;
2842
- vertices[base] = points[p];
2843
- vertices[base + 1] = points[p + 1];
2844
- vertices[base + 2] = backZ;
2845
- normals[base] = 0;
2846
- normals[base + 1] = 0;
2847
- normals[base + 2] = -1;
2848
- }
2849
- // Front indices
2997
+ const x = points[p];
2998
+ const y = points[p + 1];
2999
+ // Cap at z=0
3000
+ const base0 = vi * 3;
3001
+ vertices[base0] = x;
3002
+ vertices[base0 + 1] = y;
3003
+ vertices[base0 + 2] = 0;
3004
+ normals[base0] = 0;
3005
+ normals[base0 + 1] = 0;
3006
+ normals[base0 + 2] = -1;
3007
+ // Cap at z=depth
3008
+ const baseD = (numPoints + vi) * 3;
3009
+ vertices[baseD] = x;
3010
+ vertices[baseD + 1] = y;
3011
+ vertices[baseD + 2] = backZ;
3012
+ normals[baseD] = 0;
3013
+ normals[baseD + 1] = 0;
3014
+ normals[baseD + 2] = 1;
3015
+ }
3016
+ // libtess outputs CCW triangles (viewed from +Z)
3017
+ // Z=0 cap faces -Z, reverse winding
2850
3018
  for (let i = 0; i < triangleIndices.length; i++) {
2851
- indices[i] = triangleIndices[i];
3019
+ indices[i] = triangleIndices[triangleIndices.length - 1 - i];
2852
3020
  }
2853
- // Back indices (reverse winding + offset)
3021
+ // Z=depth cap faces +Z, use original winding
2854
3022
  for (let i = 0; i < triangleIndices.length; i++) {
2855
- indices[triangleIndices.length + i] =
2856
- triangleIndices[triangleIndices.length - 1 - i] + numPoints;
3023
+ indices[triangleIndices.length + i] = triangleIndices[i] + numPoints;
2857
3024
  }
2858
3025
  // Side walls
2859
3026
  let nextVertex = numPoints * 2;
@@ -2864,7 +3031,7 @@ class Extruder {
2864
3031
  const p0y = contour[i + 1];
2865
3032
  const p1x = contour[i + 2];
2866
3033
  const p1y = contour[i + 3];
2867
- // Unit normal for the wall quad (per-edge)
3034
+ // Perpendicular normal for this wall segment
2868
3035
  const ex = p1x - p0x;
2869
3036
  const ey = p1y - p0y;
2870
3037
  const lenSq = ex * ex + ey * ey;
@@ -2877,7 +3044,7 @@ class Extruder {
2877
3044
  }
2878
3045
  const baseVertex = nextVertex;
2879
3046
  const base = baseVertex * 3;
2880
- // 4 vertices (two at z=0, two at z=depth)
3047
+ // Wall quad: front edge at z=0, back edge at z=depth
2881
3048
  vertices[base] = p0x;
2882
3049
  vertices[base + 1] = p0y;
2883
3050
  vertices[base + 2] = 0;
@@ -2890,7 +3057,7 @@ class Extruder {
2890
3057
  vertices[base + 9] = p1x;
2891
3058
  vertices[base + 10] = p1y;
2892
3059
  vertices[base + 11] = backZ;
2893
- // Normals (same for all 4 wall vertices)
3060
+ // Wall normals point perpendicular to edge
2894
3061
  normals[base] = nx;
2895
3062
  normals[base + 1] = ny;
2896
3063
  normals[base + 2] = 0;
@@ -2903,7 +3070,7 @@ class Extruder {
2903
3070
  normals[base + 9] = nx;
2904
3071
  normals[base + 10] = ny;
2905
3072
  normals[base + 11] = 0;
2906
- // Indices (two triangles)
3073
+ // Two triangles per wall segment
2907
3074
  indices[idxPos++] = baseVertex;
2908
3075
  indices[idxPos++] = baseVertex + 1;
2909
3076
  indices[idxPos++] = baseVertex + 2;
@@ -3138,21 +3305,23 @@ class PathOptimizer {
3138
3305
  return path;
3139
3306
  }
3140
3307
  this.stats.originalPointCount += path.points.length;
3141
- let points = [...path.points];
3308
+ // Most paths are already immutable after collection; avoid copying large point arrays
3309
+ // The optimizers below never mutate the input `points` array
3310
+ const points = path.points;
3142
3311
  if (points.length < 5) {
3143
3312
  return path;
3144
3313
  }
3145
- points = this.simplifyPathVW(points, this.config.areaThreshold);
3146
- if (points.length < 3) {
3314
+ let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
3315
+ if (optimized.length < 3) {
3147
3316
  return path;
3148
3317
  }
3149
- points = this.removeColinearPoints(points, this.config.colinearThreshold);
3150
- if (points.length < 3) {
3318
+ optimized = this.removeColinearPoints(optimized, this.config.colinearThreshold);
3319
+ if (optimized.length < 3) {
3151
3320
  return path;
3152
3321
  }
3153
3322
  return {
3154
3323
  ...path,
3155
- points
3324
+ points: optimized
3156
3325
  };
3157
3326
  }
3158
3327
  // Visvalingam-Whyatt algorithm
@@ -3606,7 +3775,7 @@ class GlyphContourCollector {
3606
3775
  if (this.currentGlyphPaths.length > 0) {
3607
3776
  this.collectedGlyphs.push({
3608
3777
  glyphId: this.currentGlyphId,
3609
- paths: [...this.currentGlyphPaths],
3778
+ paths: this.currentGlyphPaths,
3610
3779
  bounds: {
3611
3780
  min: {
3612
3781
  x: this.currentGlyphBounds.min.x,
@@ -3658,11 +3827,10 @@ class GlyphContourCollector {
3658
3827
  return;
3659
3828
  }
3660
3829
  const flattenedPoints = this.polygonizer.polygonizeQuadratic(start, control, end);
3661
- for (const point of flattenedPoints) {
3662
- this.updateBounds(point);
3663
- }
3664
3830
  for (let i = 0; i < flattenedPoints.length; i++) {
3665
- this.currentPath.points.push(flattenedPoints[i]);
3831
+ const pt = flattenedPoints[i];
3832
+ this.updateBounds(pt);
3833
+ this.currentPath.points.push(pt);
3666
3834
  }
3667
3835
  this.currentPoint = end;
3668
3836
  }
@@ -3682,11 +3850,10 @@ class GlyphContourCollector {
3682
3850
  return;
3683
3851
  }
3684
3852
  const flattenedPoints = this.polygonizer.polygonizeCubic(start, control1, control2, end);
3685
- for (const point of flattenedPoints) {
3686
- this.updateBounds(point);
3687
- }
3688
3853
  for (let i = 0; i < flattenedPoints.length; i++) {
3689
- this.currentPath.points.push(flattenedPoints[i]);
3854
+ const pt = flattenedPoints[i];
3855
+ this.updateBounds(pt);
3856
+ this.currentPath.points.push(pt);
3690
3857
  }
3691
3858
  this.currentPoint = end;
3692
3859
  }
@@ -3876,6 +4043,7 @@ class GlyphGeometryBuilder {
3876
4043
  constructor(cache, loadedFont) {
3877
4044
  this.fontId = 'default';
3878
4045
  this.cacheKeyPrefix = 'default';
4046
+ this.emptyGlyphs = new Set();
3879
4047
  this.cache = cache;
3880
4048
  this.loadedFont = loadedFont;
3881
4049
  this.tessellator = new Tessellator();
@@ -3929,63 +4097,34 @@ class GlyphGeometryBuilder {
3929
4097
  }
3930
4098
  // Build instanced geometry from glyph contours
3931
4099
  buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3932
- perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
3933
- lineCount: clustersByLine.length,
3934
- wordCount: clustersByLine.flat().length,
3935
- depth,
3936
- removeOverlaps
3937
- });
3938
- // Growable typed arrays; slice to final size at end
3939
- let vertexBuffer = new Float32Array(1024);
3940
- let normalBuffer = new Float32Array(1024);
3941
- let indexBuffer = new Uint32Array(1024);
3942
- let vertexPos = 0; // float index (multiple of 3)
3943
- let normalPos = 0; // float index (multiple of 3)
3944
- let indexPos = 0; // index count
3945
- const ensureFloatCapacity = (buffer, needed) => {
3946
- if (needed <= buffer.length)
3947
- return buffer;
3948
- let nextSize = buffer.length;
3949
- while (nextSize < needed)
3950
- nextSize *= 2;
3951
- const next = new Float32Array(nextSize);
3952
- next.set(buffer);
3953
- return next;
3954
- };
3955
- const ensureIndexCapacity = (buffer, needed) => {
3956
- if (needed <= buffer.length)
3957
- return buffer;
3958
- let nextSize = buffer.length;
3959
- while (nextSize < needed)
3960
- nextSize *= 2;
3961
- const next = new Uint32Array(nextSize);
3962
- next.set(buffer);
3963
- return next;
3964
- };
3965
- const appendGeometryToBuffers = (data, position, vertexOffset) => {
3966
- const v = data.vertices;
3967
- const n = data.normals;
3968
- const idx = data.indices;
3969
- // Grow buffers as needed
3970
- vertexBuffer = ensureFloatCapacity(vertexBuffer, vertexPos + v.length);
3971
- normalBuffer = ensureFloatCapacity(normalBuffer, normalPos + n.length);
3972
- indexBuffer = ensureIndexCapacity(indexBuffer, indexPos + idx.length);
3973
- // Vertices: translate by position
3974
- const px = position.x;
3975
- const py = position.y;
3976
- const pz = position.z;
3977
- for (let j = 0; j < v.length; j += 3) {
3978
- vertexBuffer[vertexPos++] = v[j] + px;
3979
- vertexBuffer[vertexPos++] = v[j + 1] + py;
3980
- vertexBuffer[vertexPos++] = v[j + 2] + pz;
3981
- }
3982
- // Normals: straight copy
3983
- normalBuffer.set(n, normalPos);
3984
- normalPos += n.length;
3985
- // Indices: copy with vertex offset
3986
- for (let j = 0; j < idx.length; j++) {
3987
- indexBuffer[indexPos++] = idx[j] + vertexOffset;
3988
- }
4100
+ if (isLogEnabled) {
4101
+ let wordCount = 0;
4102
+ for (let i = 0; i < clustersByLine.length; i++) {
4103
+ wordCount += clustersByLine[i].length;
4104
+ }
4105
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
4106
+ lineCount: clustersByLine.length,
4107
+ wordCount,
4108
+ depth,
4109
+ removeOverlaps
4110
+ });
4111
+ }
4112
+ else {
4113
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry');
4114
+ }
4115
+ const tasks = [];
4116
+ let totalVertexFloats = 0;
4117
+ let totalNormalFloats = 0;
4118
+ let totalIndexCount = 0;
4119
+ let vertexCursor = 0; // vertex offset (not float offset)
4120
+ const pushTask = (data, px, py, pz) => {
4121
+ const vertexStart = vertexCursor;
4122
+ tasks.push({ data, px, py, pz, vertexStart });
4123
+ totalVertexFloats += data.vertices.length;
4124
+ totalNormalFloats += data.normals.length;
4125
+ totalIndexCount += data.indices.length;
4126
+ vertexCursor += data.vertices.length / 3;
4127
+ return vertexStart;
3989
4128
  };
3990
4129
  const glyphInfos = [];
3991
4130
  const planeBounds = {
@@ -3995,6 +4134,9 @@ class GlyphGeometryBuilder {
3995
4134
  for (let lineIndex = 0; lineIndex < clustersByLine.length; lineIndex++) {
3996
4135
  const line = clustersByLine[lineIndex];
3997
4136
  for (const cluster of line) {
4137
+ const clusterX = cluster.position.x;
4138
+ const clusterY = cluster.position.y;
4139
+ const clusterZ = cluster.position.z;
3998
4140
  const clusterGlyphContours = [];
3999
4141
  for (const glyph of cluster.glyphs) {
4000
4142
  clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
@@ -4035,7 +4177,7 @@ class GlyphGeometryBuilder {
4035
4177
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4036
4178
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4037
4179
  // Iterate over the geometric groups identified by BoundaryClusterer
4038
- // logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
4180
+ // logical groups (words) split into geometric sub-groups (e.g. "aa", "XX", "bb")
4039
4181
  for (const groupIndices of boundaryGroups) {
4040
4182
  const isOverlappingGroup = groupIndices.length > 1;
4041
4183
  const shouldCluster = isOverlappingGroup && !forceSeparate;
@@ -4067,16 +4209,19 @@ class GlyphGeometryBuilder {
4067
4209
  // Calculate the absolute position of this sub-cluster based on its first glyph
4068
4210
  // (since the cached geometry is relative to that first glyph)
4069
4211
  const firstGlyphInGroup = subClusterGlyphs[0];
4070
- const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
4071
- const vertexOffset = vertexPos / 3;
4072
- appendGeometryToBuffers(cachedCluster, groupPosition, vertexOffset);
4212
+ const groupPosX = clusterX + (firstGlyphInGroup.x ?? 0);
4213
+ const groupPosY = clusterY + (firstGlyphInGroup.y ?? 0);
4214
+ const groupPosZ = clusterZ;
4215
+ const vertexStart = pushTask(cachedCluster, groupPosX, groupPosY, groupPosZ);
4073
4216
  const clusterVertexCount = cachedCluster.vertices.length / 3;
4074
4217
  for (let i = 0; i < groupIndices.length; i++) {
4075
4218
  const originalIndex = groupIndices[i];
4076
4219
  const glyph = cluster.glyphs[originalIndex];
4077
4220
  const glyphContours = clusterGlyphContours[originalIndex];
4078
- const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4079
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
4221
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4222
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4223
+ const glyphPosZ = clusterZ;
4224
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, clusterVertexCount, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4080
4225
  glyphInfos.push(glyphInfo);
4081
4226
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4082
4227
  }
@@ -4086,24 +4231,26 @@ class GlyphGeometryBuilder {
4086
4231
  for (const i of groupIndices) {
4087
4232
  const glyph = cluster.glyphs[i];
4088
4233
  const glyphContours = clusterGlyphContours[i];
4089
- const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4234
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4235
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4236
+ const glyphPosZ = clusterZ;
4090
4237
  // Skip glyphs with no paths (spaces, zero-width characters, etc.)
4091
4238
  if (glyphContours.paths.length === 0) {
4092
- const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
4239
+ const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4093
4240
  glyphInfos.push(glyphInfo);
4094
4241
  continue;
4095
4242
  }
4096
- let cachedGlyph = this.cache.get(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps));
4243
+ const glyphCacheKey = getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps);
4244
+ let cachedGlyph = this.cache.get(glyphCacheKey);
4097
4245
  if (!cachedGlyph) {
4098
4246
  cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
4099
- this.cache.set(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps), cachedGlyph);
4247
+ this.cache.set(glyphCacheKey, cachedGlyph);
4100
4248
  }
4101
4249
  else {
4102
4250
  cachedGlyph.useCount++;
4103
4251
  }
4104
- const vertexOffset = vertexPos / 3;
4105
- appendGeometryToBuffers(cachedGlyph, glyphPosition, vertexOffset);
4106
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
4252
+ const vertexStart = pushTask(cachedGlyph, glyphPosX, glyphPosY, glyphPosZ);
4253
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, cachedGlyph.vertices.length / 3, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4107
4254
  glyphInfos.push(glyphInfo);
4108
4255
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4109
4256
  }
@@ -4111,10 +4258,33 @@ class GlyphGeometryBuilder {
4111
4258
  }
4112
4259
  }
4113
4260
  }
4114
- // Slice to used lengths (avoid returning oversized buffers)
4115
- const vertexArray = vertexBuffer.slice(0, vertexPos);
4116
- const normalArray = normalBuffer.slice(0, normalPos);
4117
- const indexArray = indexBuffer.slice(0, indexPos);
4261
+ // Allocate exact-sized buffers and fill once
4262
+ const vertexArray = new Float32Array(totalVertexFloats);
4263
+ const normalArray = new Float32Array(totalNormalFloats);
4264
+ const indexArray = new Uint32Array(totalIndexCount);
4265
+ let vertexPos = 0; // float index (multiple of 3)
4266
+ let normalPos = 0; // float index (multiple of 3)
4267
+ let indexPos = 0; // index count
4268
+ for (let t = 0; t < tasks.length; t++) {
4269
+ const task = tasks[t];
4270
+ const v = task.data.vertices;
4271
+ const n = task.data.normals;
4272
+ const idx = task.data.indices;
4273
+ const px = task.px;
4274
+ const py = task.py;
4275
+ const pz = task.pz;
4276
+ for (let j = 0; j < v.length; j += 3) {
4277
+ vertexArray[vertexPos++] = v[j] + px;
4278
+ vertexArray[vertexPos++] = v[j + 1] + py;
4279
+ vertexArray[vertexPos++] = v[j + 2] + pz;
4280
+ }
4281
+ normalArray.set(n, normalPos);
4282
+ normalPos += n.length;
4283
+ const vertexStart = task.vertexStart;
4284
+ for (let j = 0; j < idx.length; j++) {
4285
+ indexArray[indexPos++] = idx[j] + vertexStart;
4286
+ }
4287
+ }
4118
4288
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4119
4289
  return {
4120
4290
  vertices: vertexArray,
@@ -4139,7 +4309,7 @@ class GlyphGeometryBuilder {
4139
4309
  const roundedDepth = Math.round(depth * 1000) / 1000;
4140
4310
  return `${this.cacheKeyPrefix}_${ids}_${roundedDepth}_${removeOverlaps}`;
4141
4311
  }
4142
- createGlyphInfo(glyph, vertexStart, vertexCount, position, contours, depth) {
4312
+ createGlyphInfo(glyph, vertexStart, vertexCount, positionX, positionY, positionZ, contours, depth) {
4143
4313
  return {
4144
4314
  textIndex: glyph.absoluteTextIndex,
4145
4315
  lineIndex: glyph.lineIndex,
@@ -4147,19 +4317,30 @@ class GlyphGeometryBuilder {
4147
4317
  vertexCount,
4148
4318
  bounds: {
4149
4319
  min: {
4150
- x: contours.bounds.min.x + position.x,
4151
- y: contours.bounds.min.y + position.y,
4152
- z: position.z
4320
+ x: contours.bounds.min.x + positionX,
4321
+ y: contours.bounds.min.y + positionY,
4322
+ z: positionZ
4153
4323
  },
4154
4324
  max: {
4155
- x: contours.bounds.max.x + position.x,
4156
- y: contours.bounds.max.y + position.y,
4157
- z: position.z + depth
4325
+ x: contours.bounds.max.x + positionX,
4326
+ y: contours.bounds.max.y + positionY,
4327
+ z: positionZ + depth
4158
4328
  }
4159
4329
  }
4160
4330
  };
4161
4331
  }
4162
4332
  getContoursForGlyph(glyphId) {
4333
+ // Fast path: skip HarfBuzz draw for known-empty glyphs (spaces, zero-width, etc)
4334
+ if (this.emptyGlyphs.has(glyphId)) {
4335
+ return {
4336
+ glyphId,
4337
+ paths: [],
4338
+ bounds: {
4339
+ min: { x: 0, y: 0 },
4340
+ max: { x: 0, y: 0 }
4341
+ }
4342
+ };
4343
+ }
4163
4344
  const key = `${this.cacheKeyPrefix}_${glyphId}`;
4164
4345
  const cached = this.contourCache.get(key);
4165
4346
  if (cached) {
@@ -4180,11 +4361,15 @@ class GlyphGeometryBuilder {
4180
4361
  max: { x: 0, y: 0 }
4181
4362
  }
4182
4363
  };
4364
+ // Mark glyph as empty for future fast-path
4365
+ if (contours.paths.length === 0) {
4366
+ this.emptyGlyphs.add(glyphId);
4367
+ }
4183
4368
  this.contourCache.set(key, contours);
4184
4369
  return contours;
4185
4370
  }
4186
4371
  tessellateGlyphCluster(paths, depth, isCFF) {
4187
- const processedGeometry = this.tessellator.process(paths, true, isCFF);
4372
+ const processedGeometry = this.tessellator.process(paths, true, isCFF, depth !== 0);
4188
4373
  return this.extrudeAndPackage(processedGeometry, depth);
4189
4374
  }
4190
4375
  extrudeAndPackage(processedGeometry, depth) {
@@ -4232,7 +4417,7 @@ class GlyphGeometryBuilder {
4232
4417
  glyphId: glyphContours.glyphId,
4233
4418
  pathCount: glyphContours.paths.length
4234
4419
  });
4235
- const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
4420
+ const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF, depth !== 0);
4236
4421
  perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
4237
4422
  return this.extrudeAndPackage(processedGeometry, depth);
4238
4423
  }
@@ -4302,8 +4487,11 @@ class TextShaper {
4302
4487
  const clusters = [];
4303
4488
  let currentClusterGlyphs = [];
4304
4489
  let currentClusterText = '';
4305
- let clusterStartPosition = new Vec3();
4306
- let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4490
+ let clusterStartX = 0;
4491
+ let clusterStartY = 0;
4492
+ let cursorX = lineInfo.xOffset;
4493
+ let cursorY = -lineIndex * scaledLineHeight;
4494
+ const cursorZ = 0;
4307
4495
  // Apply letter spacing after each glyph to match width measurements used during line breaking
4308
4496
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4309
4497
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
@@ -4328,31 +4516,31 @@ class TextShaper {
4328
4516
  clusters.push({
4329
4517
  text: currentClusterText,
4330
4518
  glyphs: currentClusterGlyphs,
4331
- position: clusterStartPosition.clone()
4519
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4332
4520
  });
4333
4521
  currentClusterGlyphs = [];
4334
4522
  currentClusterText = '';
4335
4523
  }
4336
4524
  }
4337
- const absoluteGlyphPosition = cursor
4338
- .clone()
4339
- .add(new Vec3(glyph.dx, glyph.dy, 0));
4525
+ const absoluteGlyphX = cursorX + glyph.dx;
4526
+ const absoluteGlyphY = cursorY + glyph.dy;
4340
4527
  if (!isWhitespace) {
4341
4528
  if (currentClusterGlyphs.length === 0) {
4342
- clusterStartPosition.copy(absoluteGlyphPosition);
4529
+ clusterStartX = absoluteGlyphX;
4530
+ clusterStartY = absoluteGlyphY;
4343
4531
  }
4344
- glyph.x = absoluteGlyphPosition.x - clusterStartPosition.x;
4345
- glyph.y = absoluteGlyphPosition.y - clusterStartPosition.y;
4532
+ glyph.x = absoluteGlyphX - clusterStartX;
4533
+ glyph.y = absoluteGlyphY - clusterStartY;
4346
4534
  currentClusterGlyphs.push(glyph);
4347
4535
  currentClusterText += lineInfo.text[glyph.cl];
4348
4536
  }
4349
- cursor.x += glyph.ax;
4350
- cursor.y += glyph.ay;
4537
+ cursorX += glyph.ax;
4538
+ cursorY += glyph.ay;
4351
4539
  if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
4352
- cursor.x += letterSpacingFU;
4540
+ cursorX += letterSpacingFU;
4353
4541
  }
4354
4542
  if (isWhitespace) {
4355
- cursor.x += spaceAdjustment;
4543
+ cursorX += spaceAdjustment;
4356
4544
  }
4357
4545
  // CJK glue adjustment (must match exactly where LineBreak adds glue)
4358
4546
  if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
@@ -4373,7 +4561,7 @@ class TextShaper {
4373
4561
  shouldApply = false;
4374
4562
  }
4375
4563
  if (shouldApply) {
4376
- cursor.x += cjkAdjustment;
4564
+ cursorX += cjkAdjustment;
4377
4565
  }
4378
4566
  }
4379
4567
  }
@@ -4382,7 +4570,7 @@ class TextShaper {
4382
4570
  clusters.push({
4383
4571
  text: currentClusterText,
4384
4572
  glyphs: currentClusterGlyphs,
4385
- position: clusterStartPosition.clone()
4573
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4386
4574
  });
4387
4575
  }
4388
4576
  return clusters;
@@ -5209,9 +5397,8 @@ class Text {
5209
5397
  const loadedFont = await Text.resolveFont(options);
5210
5398
  const text = new Text();
5211
5399
  text.setLoadedFont(loadedFont);
5212
- // Initial creation
5213
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
5214
- const result = await text.createGeometry(geometryOptions);
5400
+ // Pass full options so createGeometry honors maxCacheSizeMB etc
5401
+ const result = await text.createGeometry(options);
5215
5402
  // Recursive update function
5216
5403
  const update = async (newOptions) => {
5217
5404
  // Merge options - preserve font from original options if not provided
@@ -5233,8 +5420,7 @@ class Text {
5233
5420
  }
5234
5421
  // Update closure options for next time
5235
5422
  options = mergedOptions;
5236
- const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5237
- const newResult = await text.createGeometry(currentGeometryOptions);
5423
+ const newResult = await text.createGeometry(options);
5238
5424
  return {
5239
5425
  ...newResult,
5240
5426
  getLoadedFont: () => text.getLoadedFont(),
@@ -5659,7 +5845,7 @@ class Text {
5659
5845
  if (!this.textLayout) {
5660
5846
  this.textLayout = new TextLayout(this.loadedFont);
5661
5847
  }
5662
- const alignmentResult = this.textLayout.applyAlignment(vertices, {
5848
+ const alignmentResult = this.textLayout.computeAlignmentOffset({
5663
5849
  width,
5664
5850
  align,
5665
5851
  planeBounds
@@ -5668,9 +5854,19 @@ class Text {
5668
5854
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5669
5855
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5670
5856
  const finalScale = size / this.loadedFont.upem;
5857
+ const offsetScaled = offset * finalScale;
5671
5858
  // Scale vertices only (normals are unit vectors, don't scale)
5672
- for (let i = 0; i < vertices.length; i++) {
5673
- vertices[i] *= finalScale;
5859
+ if (offsetScaled === 0) {
5860
+ for (let i = 0; i < vertices.length; i++) {
5861
+ vertices[i] *= finalScale;
5862
+ }
5863
+ }
5864
+ else {
5865
+ for (let i = 0; i < vertices.length; i += 3) {
5866
+ vertices[i] = vertices[i] * finalScale + offsetScaled;
5867
+ vertices[i + 1] *= finalScale;
5868
+ vertices[i + 2] *= finalScale;
5869
+ }
5674
5870
  }
5675
5871
  planeBounds.min.x *= finalScale;
5676
5872
  planeBounds.min.y *= finalScale;
@@ -5680,14 +5876,10 @@ class Text {
5680
5876
  planeBounds.max.z *= finalScale;
5681
5877
  for (let i = 0; i < glyphInfoArray.length; i++) {
5682
5878
  const glyphInfo = glyphInfoArray[i];
5683
- if (offset !== 0) {
5684
- glyphInfo.bounds.min.x += offset;
5685
- glyphInfo.bounds.max.x += offset;
5686
- }
5687
- glyphInfo.bounds.min.x *= finalScale;
5879
+ glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5688
5880
  glyphInfo.bounds.min.y *= finalScale;
5689
5881
  glyphInfo.bounds.min.z *= finalScale;
5690
- glyphInfo.bounds.max.x *= finalScale;
5882
+ glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5691
5883
  glyphInfo.bounds.max.y *= finalScale;
5692
5884
  glyphInfo.bounds.max.z *= finalScale;
5693
5885
  }