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