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.cjs
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
|
|
@@ -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
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1602
|
+
else {
|
|
1524
1603
|
offset = width - planeBounds.max.x;
|
|
1525
1604
|
}
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
//
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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:
|
|
2737
|
+
contourCount: tessContours.length
|
|
2650
2738
|
});
|
|
2651
|
-
const boundaryResult = this.performTessellation(
|
|
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
|
-
//
|
|
2658
|
-
|
|
2659
|
-
|
|
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:
|
|
2756
|
+
contourCount: tessContours.length
|
|
2667
2757
|
});
|
|
2668
|
-
const triangleResult = this.performTessellation(
|
|
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
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
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
|
|
2790
|
-
let
|
|
2944
|
+
// Count boundary edges for side walls (4 vertices + 6 indices per edge)
|
|
2945
|
+
let boundaryEdges = [];
|
|
2791
2946
|
if (depth !== 0) {
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
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
|
|
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 +
|
|
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
|
-
//
|
|
3009
|
+
// Generate both caps in one pass
|
|
2830
3010
|
for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
|
|
2831
|
-
const
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
vertices[
|
|
2844
|
-
vertices[
|
|
2845
|
-
|
|
2846
|
-
normals[
|
|
2847
|
-
normals[
|
|
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 (
|
|
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
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3146
|
-
if (
|
|
3330
|
+
let optimized = this.simplifyPathVW(points, this.config.areaThreshold);
|
|
3331
|
+
if (optimized.length < 3) {
|
|
3147
3332
|
return path;
|
|
3148
3333
|
}
|
|
3149
|
-
|
|
3150
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
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)
|
|
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
|
|
4071
|
-
const
|
|
4072
|
-
|
|
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
|
|
4079
|
-
const
|
|
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
|
|
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,
|
|
4255
|
+
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosX, glyphPosY, glyphPosZ, glyphContours, depth);
|
|
4093
4256
|
glyphInfos.push(glyphInfo);
|
|
4094
4257
|
continue;
|
|
4095
4258
|
}
|
|
4096
|
-
|
|
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(
|
|
4263
|
+
this.cache.set(glyphCacheKey, cachedGlyph);
|
|
4100
4264
|
}
|
|
4101
4265
|
else {
|
|
4102
4266
|
cachedGlyph.useCount++;
|
|
4103
4267
|
}
|
|
4104
|
-
const
|
|
4105
|
-
|
|
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
|
-
//
|
|
4115
|
-
const vertexArray =
|
|
4116
|
-
const normalArray =
|
|
4117
|
-
const indexArray =
|
|
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,
|
|
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 +
|
|
4151
|
-
y: contours.bounds.min.y +
|
|
4152
|
-
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 +
|
|
4156
|
-
y: contours.bounds.max.y +
|
|
4157
|
-
z:
|
|
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
|
|
4306
|
-
let
|
|
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:
|
|
4535
|
+
position: new Vec3(clusterStartX, clusterStartY, cursorZ)
|
|
4332
4536
|
});
|
|
4333
4537
|
currentClusterGlyphs = [];
|
|
4334
4538
|
currentClusterText = '';
|
|
4335
4539
|
}
|
|
4336
4540
|
}
|
|
4337
|
-
const
|
|
4338
|
-
|
|
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
|
-
|
|
4545
|
+
clusterStartX = absoluteGlyphX;
|
|
4546
|
+
clusterStartY = absoluteGlyphY;
|
|
4343
4547
|
}
|
|
4344
|
-
glyph.x =
|
|
4345
|
-
glyph.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
|
-
|
|
4350
|
-
|
|
4553
|
+
cursorX += glyph.ax;
|
|
4554
|
+
cursorY += glyph.ay;
|
|
4351
4555
|
if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
|
|
4352
|
-
|
|
4556
|
+
cursorX += letterSpacingFU;
|
|
4353
4557
|
}
|
|
4354
4558
|
if (isWhitespace) {
|
|
4355
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
5213
|
-
const
|
|
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
|
|
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.
|
|
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
|
-
|
|
5673
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|