three-text 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.16
2
+ * three-text v0.2.18
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -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,108 @@ 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
+ ? needsWindingReversal
2731
+ ? tessContours
2732
+ : originalContours ?? this.pathsToContours(paths)
2733
+ : [];
2645
2734
  if (removeOverlaps) {
2646
2735
  logger.log('Two-pass: boundary extraction then triangulation');
2647
- // Extract boundaries to remove overlaps
2648
2736
  perfLogger.start('Tessellator.boundaryPass', {
2649
- contourCount: contours.length
2737
+ contourCount: tessContours.length
2650
2738
  });
2651
- const boundaryResult = this.performTessellation(contours, 'boundary');
2739
+ const boundaryResult = this.performTessellation(originalContours, 'boundary');
2652
2740
  perfLogger.end('Tessellator.boundaryPass');
2653
2741
  if (!boundaryResult) {
2654
2742
  logger.warn('libtess returned empty result from boundary pass');
2655
2743
  return { triangles: { vertices: [], indices: [] }, contours: [] };
2656
2744
  }
2657
- // Convert boundary elements back to contours
2658
- contours = this.boundaryToContours(boundaryResult);
2659
- logger.log(`Boundary pass created ${contours.length} contours. Starting triangulation pass.`);
2745
+ // Boundary pass normalizes winding (outer CCW, holes CW)
2746
+ tessContours = this.boundaryToContours(boundaryResult);
2747
+ if (needsExtrusionContours) {
2748
+ extrusionContours = tessContours;
2749
+ }
2750
+ logger.log(`Boundary pass created ${tessContours.length} contours. Starting triangulation pass.`);
2660
2751
  }
2661
2752
  else {
2662
2753
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2663
2754
  }
2664
- // Triangulate the contours
2665
2755
  perfLogger.start('Tessellator.triangulationPass', {
2666
- contourCount: contours.length
2756
+ contourCount: tessContours.length
2667
2757
  });
2668
- const triangleResult = this.performTessellation(contours, 'triangles');
2758
+ const triangleResult = this.performTessellation(tessContours, 'triangles');
2669
2759
  perfLogger.end('Tessellator.triangulationPass');
2670
2760
  if (!triangleResult) {
2671
2761
  const warning = removeOverlaps
2672
2762
  ? 'libtess returned empty result from triangulation pass'
2673
2763
  : 'libtess returned empty result from single-pass triangulation';
2674
2764
  logger.warn(warning);
2675
- return { triangles: { vertices: [], indices: [] }, contours };
2765
+ return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2676
2766
  }
2677
2767
  return {
2678
2768
  triangles: {
2679
2769
  vertices: triangleResult.vertices,
2680
2770
  indices: triangleResult.indices || []
2681
2771
  },
2682
- contours
2772
+ contours: extrusionContours
2683
2773
  };
2684
2774
  }
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);
2775
+ pathsToContours(paths, reversePoints = false) {
2776
+ const contours = new Array(paths.length);
2777
+ for (let p = 0; p < paths.length; p++) {
2778
+ const points = paths[p].points;
2779
+ const pointCount = points.length;
2780
+ // Clipper-style paths can be explicitly closed by repeating the first point at the end
2781
+ // Normalize to a single closing vertex for stable side wall generation
2782
+ const isClosed = pointCount > 1 &&
2783
+ points[0].x === points[pointCount - 1].x &&
2784
+ points[0].y === points[pointCount - 1].y;
2785
+ const end = isClosed ? pointCount - 1 : pointCount;
2786
+ // +1 to append a closing vertex
2787
+ const contour = new Array((end + 1) * 2);
2788
+ let i = 0;
2789
+ if (reversePoints) {
2790
+ for (let k = end - 1; k >= 0; k--) {
2791
+ const pt = points[k];
2792
+ contour[i++] = pt.x;
2793
+ contour[i++] = pt.y;
2794
+ }
2690
2795
  }
2691
- return contour;
2692
- });
2796
+ else {
2797
+ for (let k = 0; k < end; k++) {
2798
+ const pt = points[k];
2799
+ contour[i++] = pt.x;
2800
+ contour[i++] = pt.y;
2801
+ }
2802
+ }
2803
+ // Some glyphs omit closePath, leaving gaps in extruded side walls
2804
+ if (i >= 2) {
2805
+ contour[i++] = contour[0];
2806
+ contour[i++] = contour[1];
2807
+ }
2808
+ contours[p] = contour;
2809
+ }
2810
+ return contours;
2693
2811
  }
2694
2812
  performTessellation(contours, mode) {
2695
2813
  const tess = new libtess_minExports.GluTesselator();
2696
- // Set winding rule to NON-ZERO
2697
2814
  tess.gluTessProperty(libtess_minExports.gluEnum.GLU_TESS_WINDING_RULE, libtess_minExports.windingRule.GLU_TESS_WINDING_NONZERO);
2698
2815
  const vertices = [];
2699
2816
  const indices = [];
@@ -2716,7 +2833,7 @@ class Tessellator {
2716
2833
  });
2717
2834
  tess.gluTessCallback(libtess_minExports.gluEnum.GLU_TESS_END, () => {
2718
2835
  if (currentContour.length > 0) {
2719
- contourIndices.push([...currentContour]);
2836
+ contourIndices.push(currentContour);
2720
2837
  }
2721
2838
  });
2722
2839
  }
@@ -2761,7 +2878,6 @@ class Tessellator {
2761
2878
  const vertIdx = idx * 2;
2762
2879
  contour.push(boundaryResult.vertices[vertIdx], boundaryResult.vertices[vertIdx + 1]);
2763
2880
  }
2764
- // Ensure contour is closed for side wall generation
2765
2881
  if (contour.length > 2) {
2766
2882
  if (contour[0] !== contour[contour.length - 2] ||
2767
2883
  contour[1] !== contour[contour.length - 1]) {
@@ -2772,38 +2888,102 @@ class Tessellator {
2772
2888
  }
2773
2889
  return contours;
2774
2890
  }
2775
- reverseWinding(path) {
2776
- return {
2777
- ...path,
2778
- points: [...path.points].reverse()
2779
- };
2891
+ // Check if contours need winding normalization via boundary pass
2892
+ // Returns false if topology is simple enough to skip the expensive pass
2893
+ needsWindingNormalization(contours) {
2894
+ if (contours.length === 0)
2895
+ return false;
2896
+ // Heuristic 1: Single contour never needs normalization
2897
+ if (contours.length === 1)
2898
+ return false;
2899
+ // Heuristic 2: All same winding = all outers, no holes
2900
+ // Compute signed areas
2901
+ let firstSign = null;
2902
+ for (const contour of contours) {
2903
+ const area = this.signedArea(contour);
2904
+ const sign = area >= 0 ? 1 : -1;
2905
+ if (firstSign === null) {
2906
+ firstSign = sign;
2907
+ }
2908
+ else if (sign !== firstSign) {
2909
+ // Mixed winding detected → might have holes or complex topology
2910
+ return true;
2911
+ }
2912
+ }
2913
+ // All same winding → simple topology, no normalization needed
2914
+ return false;
2915
+ }
2916
+ // Compute signed area (CCW = positive, CW = negative)
2917
+ signedArea(contour) {
2918
+ let area = 0;
2919
+ const len = contour.length;
2920
+ if (len < 6)
2921
+ return 0; // Need at least 3 points
2922
+ for (let i = 0; i < len; i += 2) {
2923
+ const x1 = contour[i];
2924
+ const y1 = contour[i + 1];
2925
+ const x2 = contour[(i + 2) % len];
2926
+ const y2 = contour[(i + 3) % len];
2927
+ area += x1 * y2 - x2 * y1;
2928
+ }
2929
+ return area / 2;
2780
2930
  }
2781
2931
  }
2782
2932
 
2783
2933
  class Extruder {
2784
2934
  constructor() { }
2935
+ packEdge(a, b) {
2936
+ const lo = a < b ? a : b;
2937
+ const hi = a < b ? b : a;
2938
+ return lo * 0x100000000 + hi;
2939
+ }
2785
2940
  extrude(geometry, depth = 0, unitsPerEm) {
2786
2941
  const points = geometry.triangles.vertices;
2787
2942
  const triangleIndices = geometry.triangles.indices;
2788
2943
  const numPoints = points.length / 2;
2789
- // Count side-wall segments (4 vertices + 6 indices per segment)
2790
- let sideSegments = 0;
2944
+ // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2945
+ let boundaryEdges = [];
2791
2946
  if (depth !== 0) {
2792
- for (const contour of geometry.contours) {
2793
- // Contours are closed (last point repeats first)
2794
- const contourPoints = contour.length / 2;
2795
- if (contourPoints >= 2)
2796
- sideSegments += contourPoints - 1;
2947
+ const counts = new Map();
2948
+ const oriented = new Map();
2949
+ for (let i = 0; i < triangleIndices.length; i += 3) {
2950
+ const a = triangleIndices[i];
2951
+ const b = triangleIndices[i + 1];
2952
+ const c = triangleIndices[i + 2];
2953
+ const k0 = this.packEdge(a, b);
2954
+ const n0 = (counts.get(k0) ?? 0) + 1;
2955
+ counts.set(k0, n0);
2956
+ if (n0 === 1)
2957
+ oriented.set(k0, [a, b]);
2958
+ const k1 = this.packEdge(b, c);
2959
+ const n1 = (counts.get(k1) ?? 0) + 1;
2960
+ counts.set(k1, n1);
2961
+ if (n1 === 1)
2962
+ oriented.set(k1, [b, c]);
2963
+ const k2 = this.packEdge(c, a);
2964
+ const n2 = (counts.get(k2) ?? 0) + 1;
2965
+ counts.set(k2, n2);
2966
+ if (n2 === 1)
2967
+ oriented.set(k2, [c, a]);
2968
+ }
2969
+ boundaryEdges = [];
2970
+ for (const [key, count] of counts) {
2971
+ if (count !== 1)
2972
+ continue;
2973
+ const edge = oriented.get(key);
2974
+ if (edge)
2975
+ boundaryEdges.push(edge);
2797
2976
  }
2798
2977
  }
2799
- const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
2978
+ const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
2979
+ const sideVertexCount = depth === 0 ? 0 : sideEdgeCount * 4;
2800
2980
  const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
2801
2981
  const vertexCount = baseVertexCount + sideVertexCount;
2802
2982
  const vertices = new Float32Array(vertexCount * 3);
2803
2983
  const normals = new Float32Array(vertexCount * 3);
2804
2984
  const indexCount = depth === 0
2805
2985
  ? triangleIndices.length
2806
- : triangleIndices.length * 2 + sideSegments * 6;
2986
+ : triangleIndices.length * 2 + sideEdgeCount * 6;
2807
2987
  const indices = new Uint32Array(indexCount);
2808
2988
  if (depth === 0) {
2809
2989
  // Single-sided flat geometry at z=0
@@ -2826,25 +3006,26 @@ class Extruder {
2826
3006
  // Extruded geometry: front at z=0, back at z=depth
2827
3007
  const minBackOffset = unitsPerEm * 0.000025;
2828
3008
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
2829
- // Cap at z=0, back face
3009
+ // Generate both caps in one pass
2830
3010
  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
- // Cap at z=depth, front face
2840
- 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;
3011
+ const x = points[p];
3012
+ const y = points[p + 1];
3013
+ // Cap at z=0
3014
+ const base0 = vi * 3;
3015
+ vertices[base0] = x;
3016
+ vertices[base0 + 1] = y;
3017
+ vertices[base0 + 2] = 0;
3018
+ normals[base0] = 0;
3019
+ normals[base0 + 1] = 0;
3020
+ normals[base0 + 2] = -1;
3021
+ // Cap at z=depth
3022
+ const baseD = (numPoints + vi) * 3;
3023
+ vertices[baseD] = x;
3024
+ vertices[baseD + 1] = y;
3025
+ vertices[baseD + 2] = backZ;
3026
+ normals[baseD] = 0;
3027
+ normals[baseD + 1] = 0;
3028
+ normals[baseD + 2] = 1;
2848
3029
  }
2849
3030
  // libtess outputs CCW triangles (viewed from +Z)
2850
3031
  // Z=0 cap faces -Z, reverse winding
@@ -2858,60 +3039,62 @@ class Extruder {
2858
3039
  // Side walls
2859
3040
  let nextVertex = numPoints * 2;
2860
3041
  let idxPos = triangleIndices.length * 2;
2861
- for (const contour of geometry.contours) {
2862
- for (let i = 0; i < contour.length - 2; i += 2) {
2863
- const p0x = contour[i];
2864
- const p0y = contour[i + 1];
2865
- const p1x = contour[i + 2];
2866
- const p1y = contour[i + 3];
2867
- // Perpendicular normal for this wall segment
2868
- const ex = p1x - p0x;
2869
- const ey = p1y - p0y;
2870
- const lenSq = ex * ex + ey * ey;
2871
- let nx = 0;
2872
- let ny = 0;
2873
- if (lenSq > 0) {
2874
- const invLen = 1 / Math.sqrt(lenSq);
2875
- nx = ey * invLen;
2876
- ny = -ex * invLen;
2877
- }
2878
- const baseVertex = nextVertex;
2879
- const base = baseVertex * 3;
2880
- // Wall quad: front edge at z=0, back edge at z=depth
2881
- vertices[base] = p0x;
2882
- vertices[base + 1] = p0y;
2883
- vertices[base + 2] = 0;
2884
- vertices[base + 3] = p1x;
2885
- vertices[base + 4] = p1y;
2886
- vertices[base + 5] = 0;
2887
- vertices[base + 6] = p0x;
2888
- vertices[base + 7] = p0y;
2889
- vertices[base + 8] = backZ;
2890
- vertices[base + 9] = p1x;
2891
- vertices[base + 10] = p1y;
2892
- vertices[base + 11] = backZ;
2893
- // Wall normals point perpendicular to edge
2894
- normals[base] = nx;
2895
- normals[base + 1] = ny;
2896
- normals[base + 2] = 0;
2897
- normals[base + 3] = nx;
2898
- normals[base + 4] = ny;
2899
- normals[base + 5] = 0;
2900
- normals[base + 6] = nx;
2901
- normals[base + 7] = ny;
2902
- normals[base + 8] = 0;
2903
- normals[base + 9] = nx;
2904
- normals[base + 10] = ny;
2905
- normals[base + 11] = 0;
2906
- // Two triangles per wall segment
2907
- indices[idxPos++] = baseVertex;
2908
- indices[idxPos++] = baseVertex + 1;
2909
- indices[idxPos++] = baseVertex + 2;
2910
- indices[idxPos++] = baseVertex + 1;
2911
- indices[idxPos++] = baseVertex + 3;
2912
- indices[idxPos++] = baseVertex + 2;
2913
- nextVertex += 4;
2914
- }
3042
+ for (let e = 0; e < boundaryEdges.length; e++) {
3043
+ const [u, v] = boundaryEdges[e];
3044
+ const u2 = u * 2;
3045
+ const v2 = v * 2;
3046
+ const p0x = points[u2];
3047
+ const p0y = points[u2 + 1];
3048
+ const p1x = points[v2];
3049
+ const p1y = points[v2 + 1];
3050
+ // Perpendicular normal for this wall segment
3051
+ // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3052
+ const ex = p1x - p0x;
3053
+ const ey = p1y - p0y;
3054
+ const lenSq = ex * ex + ey * ey;
3055
+ let nx = 0;
3056
+ let ny = 0;
3057
+ if (lenSq > 0) {
3058
+ const invLen = 1 / Math.sqrt(lenSq);
3059
+ nx = ey * invLen;
3060
+ ny = -ex * invLen;
3061
+ }
3062
+ const baseVertex = nextVertex;
3063
+ const base = baseVertex * 3;
3064
+ // Wall quad: front edge at z=0, back edge at z=depth
3065
+ vertices[base] = p0x;
3066
+ vertices[base + 1] = p0y;
3067
+ vertices[base + 2] = 0;
3068
+ vertices[base + 3] = p1x;
3069
+ vertices[base + 4] = p1y;
3070
+ vertices[base + 5] = 0;
3071
+ vertices[base + 6] = p0x;
3072
+ vertices[base + 7] = p0y;
3073
+ vertices[base + 8] = backZ;
3074
+ vertices[base + 9] = p1x;
3075
+ vertices[base + 10] = p1y;
3076
+ vertices[base + 11] = backZ;
3077
+ // Wall normals point perpendicular to edge
3078
+ normals[base] = nx;
3079
+ normals[base + 1] = ny;
3080
+ normals[base + 2] = 0;
3081
+ normals[base + 3] = nx;
3082
+ normals[base + 4] = ny;
3083
+ normals[base + 5] = 0;
3084
+ normals[base + 6] = nx;
3085
+ normals[base + 7] = ny;
3086
+ normals[base + 8] = 0;
3087
+ normals[base + 9] = nx;
3088
+ normals[base + 10] = ny;
3089
+ normals[base + 11] = 0;
3090
+ // Two triangles per wall segment
3091
+ indices[idxPos++] = baseVertex;
3092
+ indices[idxPos++] = baseVertex + 1;
3093
+ indices[idxPos++] = baseVertex + 2;
3094
+ indices[idxPos++] = baseVertex + 1;
3095
+ indices[idxPos++] = baseVertex + 3;
3096
+ indices[idxPos++] = baseVertex + 2;
3097
+ nextVertex += 4;
2915
3098
  }
2916
3099
  return { vertices, normals, indices };
2917
3100
  }
@@ -3138,21 +3321,23 @@ class PathOptimizer {
3138
3321
  return path;
3139
3322
  }
3140
3323
  this.stats.originalPointCount += path.points.length;
3141
- let points = [...path.points];
3324
+ // Most paths are already immutable after collection; avoid copying large point arrays
3325
+ // The optimizers below never mutate the input `points` array
3326
+ const points = path.points;
3142
3327
  if (points.length < 5) {
3143
3328
  return path;
3144
3329
  }
3145
- points = this.simplifyPathVW(points, this.config.areaThreshold);
3146
- if (points.length < 3) {
3330
+ let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
3331
+ if (optimized.length < 3) {
3147
3332
  return path;
3148
3333
  }
3149
- points = this.removeColinearPoints(points, this.config.colinearThreshold);
3150
- if (points.length < 3) {
3334
+ optimized = this.removeColinearPoints(optimized, this.config.colinearThreshold);
3335
+ if (optimized.length < 3) {
3151
3336
  return path;
3152
3337
  }
3153
3338
  return {
3154
3339
  ...path,
3155
- points
3340
+ points: optimized
3156
3341
  };
3157
3342
  }
3158
3343
  // Visvalingam-Whyatt algorithm
@@ -3606,7 +3791,7 @@ class GlyphContourCollector {
3606
3791
  if (this.currentGlyphPaths.length > 0) {
3607
3792
  this.collectedGlyphs.push({
3608
3793
  glyphId: this.currentGlyphId,
3609
- paths: [...this.currentGlyphPaths],
3794
+ paths: this.currentGlyphPaths,
3610
3795
  bounds: {
3611
3796
  min: {
3612
3797
  x: this.currentGlyphBounds.min.x,
@@ -3658,11 +3843,10 @@ class GlyphContourCollector {
3658
3843
  return;
3659
3844
  }
3660
3845
  const flattenedPoints = this.polygonizer.polygonizeQuadratic(start, control, end);
3661
- for (const point of flattenedPoints) {
3662
- this.updateBounds(point);
3663
- }
3664
3846
  for (let i = 0; i < flattenedPoints.length; i++) {
3665
- this.currentPath.points.push(flattenedPoints[i]);
3847
+ const pt = flattenedPoints[i];
3848
+ this.updateBounds(pt);
3849
+ this.currentPath.points.push(pt);
3666
3850
  }
3667
3851
  this.currentPoint = end;
3668
3852
  }
@@ -3682,11 +3866,10 @@ class GlyphContourCollector {
3682
3866
  return;
3683
3867
  }
3684
3868
  const flattenedPoints = this.polygonizer.polygonizeCubic(start, control1, control2, end);
3685
- for (const point of flattenedPoints) {
3686
- this.updateBounds(point);
3687
- }
3688
3869
  for (let i = 0; i < flattenedPoints.length; i++) {
3689
- this.currentPath.points.push(flattenedPoints[i]);
3870
+ const pt = flattenedPoints[i];
3871
+ this.updateBounds(pt);
3872
+ this.currentPath.points.push(pt);
3690
3873
  }
3691
3874
  this.currentPoint = end;
3692
3875
  }
@@ -3876,6 +4059,7 @@ class GlyphGeometryBuilder {
3876
4059
  constructor(cache, loadedFont) {
3877
4060
  this.fontId = 'default';
3878
4061
  this.cacheKeyPrefix = 'default';
4062
+ this.emptyGlyphs = new Set();
3879
4063
  this.cache = cache;
3880
4064
  this.loadedFont = loadedFont;
3881
4065
  this.tessellator = new Tessellator();
@@ -3929,63 +4113,34 @@ class GlyphGeometryBuilder {
3929
4113
  }
3930
4114
  // Build instanced geometry from glyph contours
3931
4115
  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
- }
4116
+ if (isLogEnabled) {
4117
+ let wordCount = 0;
4118
+ for (let i = 0; i < clustersByLine.length; i++) {
4119
+ wordCount += clustersByLine[i].length;
4120
+ }
4121
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
4122
+ lineCount: clustersByLine.length,
4123
+ wordCount,
4124
+ depth,
4125
+ removeOverlaps
4126
+ });
4127
+ }
4128
+ else {
4129
+ perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry');
4130
+ }
4131
+ const tasks = [];
4132
+ let totalVertexFloats = 0;
4133
+ let totalNormalFloats = 0;
4134
+ let totalIndexCount = 0;
4135
+ let vertexCursor = 0; // vertex offset (not float offset)
4136
+ const pushTask = (data, px, py, pz) => {
4137
+ const vertexStart = vertexCursor;
4138
+ tasks.push({ data, px, py, pz, vertexStart });
4139
+ totalVertexFloats += data.vertices.length;
4140
+ totalNormalFloats += data.normals.length;
4141
+ totalIndexCount += data.indices.length;
4142
+ vertexCursor += data.vertices.length / 3;
4143
+ return vertexStart;
3989
4144
  };
3990
4145
  const glyphInfos = [];
3991
4146
  const planeBounds = {
@@ -3995,6 +4150,9 @@ class GlyphGeometryBuilder {
3995
4150
  for (let lineIndex = 0; lineIndex < clustersByLine.length; lineIndex++) {
3996
4151
  const line = clustersByLine[lineIndex];
3997
4152
  for (const cluster of line) {
4153
+ const clusterX = cluster.position.x;
4154
+ const clusterY = cluster.position.y;
4155
+ const clusterZ = cluster.position.z;
3998
4156
  const clusterGlyphContours = [];
3999
4157
  for (const glyph of cluster.glyphs) {
4000
4158
  clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
@@ -4035,7 +4193,7 @@ class GlyphGeometryBuilder {
4035
4193
  // Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
4036
4194
  const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
4037
4195
  // Iterate over the geometric groups identified by BoundaryClusterer
4038
- // logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
4196
+ // logical groups (words) split into geometric sub-groups
4039
4197
  for (const groupIndices of boundaryGroups) {
4040
4198
  const isOverlappingGroup = groupIndices.length > 1;
4041
4199
  const shouldCluster = isOverlappingGroup && !forceSeparate;
@@ -4067,16 +4225,19 @@ class GlyphGeometryBuilder {
4067
4225
  // Calculate the absolute position of this sub-cluster based on its first glyph
4068
4226
  // (since the cached geometry is relative to that first glyph)
4069
4227
  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);
4228
+ const groupPosX = clusterX + (firstGlyphInGroup.x ?? 0);
4229
+ const groupPosY = clusterY + (firstGlyphInGroup.y ?? 0);
4230
+ const groupPosZ = clusterZ;
4231
+ const vertexStart = pushTask(cachedCluster, groupPosX, groupPosY, groupPosZ);
4073
4232
  const clusterVertexCount = cachedCluster.vertices.length / 3;
4074
4233
  for (let i = 0; i < groupIndices.length; i++) {
4075
4234
  const originalIndex = groupIndices[i];
4076
4235
  const glyph = cluster.glyphs[originalIndex];
4077
4236
  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);
4237
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4238
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4239
+ const glyphPosZ = clusterZ;
4240
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, clusterVertexCount, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4080
4241
  glyphInfos.push(glyphInfo);
4081
4242
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4082
4243
  }
@@ -4086,24 +4247,26 @@ class GlyphGeometryBuilder {
4086
4247
  for (const i of groupIndices) {
4087
4248
  const glyph = cluster.glyphs[i];
4088
4249
  const glyphContours = clusterGlyphContours[i];
4089
- const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
4250
+ const glyphPosX = clusterX + (glyph.x ?? 0);
4251
+ const glyphPosY = clusterY + (glyph.y ?? 0);
4252
+ const glyphPosZ = clusterZ;
4090
4253
  // Skip glyphs with no paths (spaces, zero-width characters, etc.)
4091
4254
  if (glyphContours.paths.length === 0) {
4092
- const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
4255
+ const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4093
4256
  glyphInfos.push(glyphInfo);
4094
4257
  continue;
4095
4258
  }
4096
- let cachedGlyph = this.cache.get(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps));
4259
+ const glyphCacheKey = getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps);
4260
+ let cachedGlyph = this.cache.get(glyphCacheKey);
4097
4261
  if (!cachedGlyph) {
4098
4262
  cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
4099
- this.cache.set(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps), cachedGlyph);
4263
+ this.cache.set(glyphCacheKey, cachedGlyph);
4100
4264
  }
4101
4265
  else {
4102
4266
  cachedGlyph.useCount++;
4103
4267
  }
4104
- const vertexOffset = vertexPos / 3;
4105
- appendGeometryToBuffers(cachedGlyph, glyphPosition, vertexOffset);
4106
- const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
4268
+ const vertexStart = pushTask(cachedGlyph, glyphPosX, glyphPosY, glyphPosZ);
4269
+ const glyphInfo = this.createGlyphInfo(glyph, vertexStart, cachedGlyph.vertices.length / 3, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
4107
4270
  glyphInfos.push(glyphInfo);
4108
4271
  this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
4109
4272
  }
@@ -4111,10 +4274,33 @@ class GlyphGeometryBuilder {
4111
4274
  }
4112
4275
  }
4113
4276
  }
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);
4277
+ // Allocate exact-sized buffers and fill once
4278
+ const vertexArray = new Float32Array(totalVertexFloats);
4279
+ const normalArray = new Float32Array(totalNormalFloats);
4280
+ const indexArray = new Uint32Array(totalIndexCount);
4281
+ let vertexPos = 0; // float index (multiple of 3)
4282
+ let normalPos = 0; // float index (multiple of 3)
4283
+ let indexPos = 0; // index count
4284
+ for (let t = 0; t < tasks.length; t++) {
4285
+ const task = tasks[t];
4286
+ const v = task.data.vertices;
4287
+ const n = task.data.normals;
4288
+ const idx = task.data.indices;
4289
+ const px = task.px;
4290
+ const py = task.py;
4291
+ const pz = task.pz;
4292
+ for (let j = 0; j < v.length; j += 3) {
4293
+ vertexArray[vertexPos++] = v[j] + px;
4294
+ vertexArray[vertexPos++] = v[j + 1] + py;
4295
+ vertexArray[vertexPos++] = v[j + 2] + pz;
4296
+ }
4297
+ normalArray.set(n, normalPos);
4298
+ normalPos += n.length;
4299
+ const vertexStart = task.vertexStart;
4300
+ for (let j = 0; j < idx.length; j++) {
4301
+ indexArray[indexPos++] = idx[j] + vertexStart;
4302
+ }
4303
+ }
4118
4304
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4119
4305
  return {
4120
4306
  vertices: vertexArray,
@@ -4139,7 +4325,7 @@ class GlyphGeometryBuilder {
4139
4325
  const roundedDepth = Math.round(depth * 1000) / 1000;
4140
4326
  return `${this.cacheKeyPrefix}_${ids}_${roundedDepth}_${removeOverlaps}`;
4141
4327
  }
4142
- createGlyphInfo(glyph, vertexStart, vertexCount, position, contours, depth) {
4328
+ createGlyphInfo(glyph, vertexStart, vertexCount, positionX, positionY, positionZ, contours, depth) {
4143
4329
  return {
4144
4330
  textIndex: glyph.absoluteTextIndex,
4145
4331
  lineIndex: glyph.lineIndex,
@@ -4147,19 +4333,30 @@ class GlyphGeometryBuilder {
4147
4333
  vertexCount,
4148
4334
  bounds: {
4149
4335
  min: {
4150
- x: contours.bounds.min.x + position.x,
4151
- y: contours.bounds.min.y + position.y,
4152
- z: position.z
4336
+ x: contours.bounds.min.x + positionX,
4337
+ y: contours.bounds.min.y + positionY,
4338
+ z: positionZ
4153
4339
  },
4154
4340
  max: {
4155
- x: contours.bounds.max.x + position.x,
4156
- y: contours.bounds.max.y + position.y,
4157
- z: position.z + depth
4341
+ x: contours.bounds.max.x + positionX,
4342
+ y: contours.bounds.max.y + positionY,
4343
+ z: positionZ + depth
4158
4344
  }
4159
4345
  }
4160
4346
  };
4161
4347
  }
4162
4348
  getContoursForGlyph(glyphId) {
4349
+ // Fast path: skip HarfBuzz draw for known-empty glyphs (spaces, zero-width, etc)
4350
+ if (this.emptyGlyphs.has(glyphId)) {
4351
+ return {
4352
+ glyphId,
4353
+ paths: [],
4354
+ bounds: {
4355
+ min: { x: 0, y: 0 },
4356
+ max: { x: 0, y: 0 }
4357
+ }
4358
+ };
4359
+ }
4163
4360
  const key = `${this.cacheKeyPrefix}_${glyphId}`;
4164
4361
  const cached = this.contourCache.get(key);
4165
4362
  if (cached) {
@@ -4180,11 +4377,15 @@ class GlyphGeometryBuilder {
4180
4377
  max: { x: 0, y: 0 }
4181
4378
  }
4182
4379
  };
4380
+ // Mark glyph as empty for future fast-path
4381
+ if (contours.paths.length === 0) {
4382
+ this.emptyGlyphs.add(glyphId);
4383
+ }
4183
4384
  this.contourCache.set(key, contours);
4184
4385
  return contours;
4185
4386
  }
4186
4387
  tessellateGlyphCluster(paths, depth, isCFF) {
4187
- const processedGeometry = this.tessellator.process(paths, true, isCFF);
4388
+ const processedGeometry = this.tessellator.process(paths, true, isCFF, depth !== 0);
4188
4389
  return this.extrudeAndPackage(processedGeometry, depth);
4189
4390
  }
4190
4391
  extrudeAndPackage(processedGeometry, depth) {
@@ -4232,7 +4433,7 @@ class GlyphGeometryBuilder {
4232
4433
  glyphId: glyphContours.glyphId,
4233
4434
  pathCount: glyphContours.paths.length
4234
4435
  });
4235
- const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
4436
+ const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF, depth !== 0);
4236
4437
  perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
4237
4438
  return this.extrudeAndPackage(processedGeometry, depth);
4238
4439
  }
@@ -4302,8 +4503,11 @@ class TextShaper {
4302
4503
  const clusters = [];
4303
4504
  let currentClusterGlyphs = [];
4304
4505
  let currentClusterText = '';
4305
- let clusterStartPosition = new Vec3();
4306
- let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
4506
+ let clusterStartX = 0;
4507
+ let clusterStartY = 0;
4508
+ let cursorX = lineInfo.xOffset;
4509
+ let cursorY = -lineIndex * scaledLineHeight;
4510
+ const cursorZ = 0;
4307
4511
  // Apply letter spacing after each glyph to match width measurements used during line breaking
4308
4512
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4309
4513
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
@@ -4328,31 +4532,31 @@ class TextShaper {
4328
4532
  clusters.push({
4329
4533
  text: currentClusterText,
4330
4534
  glyphs: currentClusterGlyphs,
4331
- position: clusterStartPosition.clone()
4535
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4332
4536
  });
4333
4537
  currentClusterGlyphs = [];
4334
4538
  currentClusterText = '';
4335
4539
  }
4336
4540
  }
4337
- const absoluteGlyphPosition = cursor
4338
- .clone()
4339
- .add(new Vec3(glyph.dx, glyph.dy, 0));
4541
+ const absoluteGlyphX = cursorX + glyph.dx;
4542
+ const absoluteGlyphY = cursorY + glyph.dy;
4340
4543
  if (!isWhitespace) {
4341
4544
  if (currentClusterGlyphs.length === 0) {
4342
- clusterStartPosition.copy(absoluteGlyphPosition);
4545
+ clusterStartX = absoluteGlyphX;
4546
+ clusterStartY = absoluteGlyphY;
4343
4547
  }
4344
- glyph.x = absoluteGlyphPosition.x - clusterStartPosition.x;
4345
- glyph.y = absoluteGlyphPosition.y - clusterStartPosition.y;
4548
+ glyph.x = absoluteGlyphX - clusterStartX;
4549
+ glyph.y = absoluteGlyphY - clusterStartY;
4346
4550
  currentClusterGlyphs.push(glyph);
4347
4551
  currentClusterText += lineInfo.text[glyph.cl];
4348
4552
  }
4349
- cursor.x += glyph.ax;
4350
- cursor.y += glyph.ay;
4553
+ cursorX += glyph.ax;
4554
+ cursorY += glyph.ay;
4351
4555
  if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
4352
- cursor.x += letterSpacingFU;
4556
+ cursorX += letterSpacingFU;
4353
4557
  }
4354
4558
  if (isWhitespace) {
4355
- cursor.x += spaceAdjustment;
4559
+ cursorX += spaceAdjustment;
4356
4560
  }
4357
4561
  // CJK glue adjustment (must match exactly where LineBreak adds glue)
4358
4562
  if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
@@ -4373,7 +4577,7 @@ class TextShaper {
4373
4577
  shouldApply = false;
4374
4578
  }
4375
4579
  if (shouldApply) {
4376
- cursor.x += cjkAdjustment;
4580
+ cursorX += cjkAdjustment;
4377
4581
  }
4378
4582
  }
4379
4583
  }
@@ -4382,7 +4586,7 @@ class TextShaper {
4382
4586
  clusters.push({
4383
4587
  text: currentClusterText,
4384
4588
  glyphs: currentClusterGlyphs,
4385
- position: clusterStartPosition.clone()
4589
+ position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4386
4590
  });
4387
4591
  }
4388
4592
  return clusters;
@@ -5209,9 +5413,8 @@ class Text {
5209
5413
  const loadedFont = await Text.resolveFont(options);
5210
5414
  const text = new Text();
5211
5415
  text.setLoadedFont(loadedFont);
5212
- // Initial creation
5213
- const { font, maxCacheSizeMB, ...geometryOptions } = options;
5214
- const result = await text.createGeometry(geometryOptions);
5416
+ // Pass full options so createGeometry honors maxCacheSizeMB etc
5417
+ const result = await text.createGeometry(options);
5215
5418
  // Recursive update function
5216
5419
  const update = async (newOptions) => {
5217
5420
  // Merge options - preserve font from original options if not provided
@@ -5233,8 +5436,7 @@ class Text {
5233
5436
  }
5234
5437
  // Update closure options for next time
5235
5438
  options = mergedOptions;
5236
- const { font, maxCacheSizeMB, ...currentGeometryOptions } = options;
5237
- const newResult = await text.createGeometry(currentGeometryOptions);
5439
+ const newResult = await text.createGeometry(options);
5238
5440
  return {
5239
5441
  ...newResult,
5240
5442
  getLoadedFont: () => text.getLoadedFont(),
@@ -5659,7 +5861,7 @@ class Text {
5659
5861
  if (!this.textLayout) {
5660
5862
  this.textLayout = new TextLayout(this.loadedFont);
5661
5863
  }
5662
- const alignmentResult = this.textLayout.applyAlignment(vertices, {
5864
+ const alignmentResult = this.textLayout.computeAlignmentOffset({
5663
5865
  width,
5664
5866
  align,
5665
5867
  planeBounds
@@ -5668,9 +5870,19 @@ class Text {
5668
5870
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5669
5871
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5670
5872
  const finalScale = size / this.loadedFont.upem;
5873
+ const offsetScaled = offset * finalScale;
5671
5874
  // Scale vertices only (normals are unit vectors, don't scale)
5672
- for (let i = 0; i < vertices.length; i++) {
5673
- vertices[i] *= finalScale;
5875
+ if (offsetScaled === 0) {
5876
+ for (let i = 0; i < vertices.length; i++) {
5877
+ vertices[i] *= finalScale;
5878
+ }
5879
+ }
5880
+ else {
5881
+ for (let i = 0; i < vertices.length; i += 3) {
5882
+ vertices[i] = vertices[i] * finalScale + offsetScaled;
5883
+ vertices[i + 1] *= finalScale;
5884
+ vertices[i + 2] *= finalScale;
5885
+ }
5674
5886
  }
5675
5887
  planeBounds.min.x *= finalScale;
5676
5888
  planeBounds.min.y *= finalScale;
@@ -5680,14 +5892,10 @@ class Text {
5680
5892
  planeBounds.max.z *= finalScale;
5681
5893
  for (let i = 0; i < glyphInfoArray.length; i++) {
5682
5894
  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;
5895
+ glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5688
5896
  glyphInfo.bounds.min.y *= finalScale;
5689
5897
  glyphInfo.bounds.min.z *= finalScale;
5690
- glyphInfo.bounds.max.x *= finalScale;
5898
+ glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5691
5899
  glyphInfo.bounds.max.y *= finalScale;
5692
5900
  glyphInfo.bounds.max.z *= finalScale;
5693
5901
  }