three-text 0.2.13 → 0.2.15
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 +4 -4
- package/dist/index.cjs +792 -677
- package/dist/index.d.ts +88 -76
- package/dist/index.js +792 -678
- package/dist/index.min.cjs +861 -2
- package/dist/index.min.js +860 -2
- package/dist/index.umd.js +792 -677
- package/dist/index.umd.min.js +862 -2
- package/dist/three/react.cjs +44 -20
- package/dist/three/react.d.ts +36 -24
- package/dist/three/react.js +45 -21
- package/dist/types/core/Text.d.ts +14 -7
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +12 -5
- package/dist/types/core/cache/sharedCaches.d.ts +12 -0
- package/dist/types/core/font/TableDirectory.d.ts +7 -0
- package/dist/types/core/font/constants.d.ts +0 -1
- package/dist/types/core/geometry/Extruder.d.ts +3 -6
- package/dist/types/core/layout/LineBreak.d.ts +2 -0
- package/dist/types/core/shaping/DrawCallbacks.d.ts +2 -0
- package/dist/types/core/shaping/TextShaper.d.ts +4 -1
- package/dist/types/core/types.d.ts +26 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/three/ThreeText.d.ts +3 -3
- package/dist/types/utils/LRUCache.d.ts +2 -2
- package/dist/types/webgpu/index.d.ts +1 -2
- package/dist/webgpu/index.cjs +3 -4
- package/dist/webgpu/index.d.ts +1 -2
- package/dist/webgpu/index.js +3 -4
- package/package.json +1 -1
- package/dist/types/core/cache/GlyphCache.d.ts +0 -50
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.15
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -48,7 +48,9 @@ class PerformanceLogger {
|
|
|
48
48
|
if (!isLogEnabled)
|
|
49
49
|
return;
|
|
50
50
|
const startTime = performance.now();
|
|
51
|
-
|
|
51
|
+
// Generate unique key for nested timing support
|
|
52
|
+
const timerKey = `${name}_${startTime}`;
|
|
53
|
+
this.activeTimers.set(timerKey, startTime);
|
|
52
54
|
this.metrics.push({
|
|
53
55
|
name,
|
|
54
56
|
startTime,
|
|
@@ -60,17 +62,26 @@ class PerformanceLogger {
|
|
|
60
62
|
if (!isLogEnabled)
|
|
61
63
|
return null;
|
|
62
64
|
const endTime = performance.now();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
// Find the most recent matching timer by scanning backwards
|
|
66
|
+
let timerKey;
|
|
67
|
+
let startTime;
|
|
68
|
+
for (const [key, time] of Array.from(this.activeTimers.entries()).reverse()) {
|
|
69
|
+
if (key.startsWith(`${name}_`)) {
|
|
70
|
+
timerKey = key;
|
|
71
|
+
startTime = time;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (startTime === undefined || !timerKey) {
|
|
65
76
|
logger.warn(`Performance timer "${name}" was not started`);
|
|
66
77
|
return null;
|
|
67
78
|
}
|
|
68
79
|
const duration = endTime - startTime;
|
|
69
|
-
this.activeTimers.delete(
|
|
80
|
+
this.activeTimers.delete(timerKey);
|
|
70
81
|
// Find the metric in reverse order (most recent first)
|
|
71
82
|
for (let i = this.metrics.length - 1; i >= 0; i--) {
|
|
72
83
|
const metric = this.metrics[i];
|
|
73
|
-
if (metric.name === name && !metric.endTime) {
|
|
84
|
+
if (metric.name === name && metric.startTime === startTime && !metric.endTime) {
|
|
74
85
|
metric.endTime = endTime;
|
|
75
86
|
metric.duration = duration;
|
|
76
87
|
break;
|
|
@@ -713,7 +724,7 @@ class LineBreak {
|
|
|
713
724
|
align: options.align || 'left',
|
|
714
725
|
hyphenate: options.hyphenate || false
|
|
715
726
|
});
|
|
716
|
-
const { text, width, align = 'left', direction = 'ltr', hyphenate = false, language = 'en-us', respectExistingBreaks = true, measureText, hyphenationPatterns, unitsPerEm, 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;
|
|
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;
|
|
717
728
|
// Handle multiple paragraphs by processing each independently
|
|
718
729
|
if (respectExistingBreaks && text.includes('\n')) {
|
|
719
730
|
const paragraphs = text.split('\n');
|
|
@@ -775,7 +786,11 @@ class LineBreak {
|
|
|
775
786
|
hyphenPenalty: hyphenpenalty,
|
|
776
787
|
exHyphenPenalty: exhyphenpenalty,
|
|
777
788
|
currentAlign: align,
|
|
778
|
-
unitsPerEm
|
|
789
|
+
unitsPerEm,
|
|
790
|
+
// measureText() includes trailing letter spacing after the final glyph of a token.
|
|
791
|
+
// Shaping applies letter spacing only between glyphs, so we subtract one
|
|
792
|
+
// trailing letterSpacingFU per line segment (see computeAdjustmentRatio/createLines).
|
|
793
|
+
letterSpacingFU: unitsPerEm ? letterSpacing * unitsPerEm : 0
|
|
779
794
|
};
|
|
780
795
|
if (!width || width === Infinity) {
|
|
781
796
|
const measuredWidth = measureText(text);
|
|
@@ -1083,7 +1098,7 @@ class LineBreak {
|
|
|
1083
1098
|
}
|
|
1084
1099
|
}
|
|
1085
1100
|
}
|
|
1086
|
-
static computeAdjustmentRatio(items, lineStart, lineEnd, _lineNumber, lineWidth, cumulativeWidths,
|
|
1101
|
+
static computeAdjustmentRatio(items, lineStart, lineEnd, _lineNumber, lineWidth, cumulativeWidths, context) {
|
|
1087
1102
|
let totalWidth = 0;
|
|
1088
1103
|
let totalStretch = 0;
|
|
1089
1104
|
let totalShrink = 0;
|
|
@@ -1126,6 +1141,12 @@ class LineBreak {
|
|
|
1126
1141
|
? items[lineEnd].width
|
|
1127
1142
|
: items[lineEnd].preBreakWidth;
|
|
1128
1143
|
}
|
|
1144
|
+
// Correct for trailing letter spacing at the end of the line segment.
|
|
1145
|
+
// Our token measurement includes letter spacing after the final glyph;
|
|
1146
|
+
// shaping does not add letter spacing after the final glyph in a line.
|
|
1147
|
+
if (context?.letterSpacingFU && totalWidth !== 0) {
|
|
1148
|
+
totalWidth -= context.letterSpacingFU;
|
|
1149
|
+
}
|
|
1129
1150
|
const adjustment = lineWidth - totalWidth;
|
|
1130
1151
|
let ratio;
|
|
1131
1152
|
if (adjustment > 0 && totalStretch > 0) {
|
|
@@ -1288,6 +1309,10 @@ class LineBreak {
|
|
|
1288
1309
|
}
|
|
1289
1310
|
}
|
|
1290
1311
|
const lineText = lineTextParts.join('');
|
|
1312
|
+
// Correct for trailing letter spacing at the end of the line.
|
|
1313
|
+
if (context?.letterSpacingFU && naturalWidth !== 0) {
|
|
1314
|
+
naturalWidth -= context.letterSpacingFU;
|
|
1315
|
+
}
|
|
1291
1316
|
let xOffset = 0;
|
|
1292
1317
|
let adjustmentRatio = 0;
|
|
1293
1318
|
let effectiveAlign = align;
|
|
@@ -1341,6 +1366,10 @@ class LineBreak {
|
|
|
1341
1366
|
finalNaturalWidth += item.width;
|
|
1342
1367
|
}
|
|
1343
1368
|
const finalLineText = finalLineTextParts.join('');
|
|
1369
|
+
// Correct for trailing letter spacing at the end of the final line.
|
|
1370
|
+
if (context?.letterSpacingFU && finalNaturalWidth !== 0) {
|
|
1371
|
+
finalNaturalWidth -= context.letterSpacingFU;
|
|
1372
|
+
}
|
|
1344
1373
|
let finalXOffset = 0;
|
|
1345
1374
|
let finalEffectiveAlign = align;
|
|
1346
1375
|
if (align === 'justify') {
|
|
@@ -1402,9 +1431,6 @@ function convertFontFeaturesToString(features) {
|
|
|
1402
1431
|
}
|
|
1403
1432
|
|
|
1404
1433
|
class TextMeasurer {
|
|
1405
|
-
// Measures text width including letter spacing
|
|
1406
|
-
// (letter spacing is added uniformly after each glyph during measurement,
|
|
1407
|
-
// so the widths given to the line-breaking algorithm already account for tracking)
|
|
1408
1434
|
static measureTextWidth(loadedFont, text, letterSpacing = 0) {
|
|
1409
1435
|
const buffer = loadedFont.hb.createBuffer();
|
|
1410
1436
|
buffer.addText(text);
|
|
@@ -1413,7 +1439,6 @@ class TextMeasurer {
|
|
|
1413
1439
|
loadedFont.hb.shape(loadedFont.font, buffer, featuresString);
|
|
1414
1440
|
const glyphInfos = buffer.json(loadedFont.font);
|
|
1415
1441
|
const letterSpacingInFontUnits = letterSpacing * loadedFont.upem;
|
|
1416
|
-
// Calculate total advance width with letter spacing
|
|
1417
1442
|
let totalWidth = 0;
|
|
1418
1443
|
glyphInfos.forEach((glyph) => {
|
|
1419
1444
|
totalWidth += glyph.ax;
|
|
@@ -1460,6 +1485,7 @@ class TextLayout {
|
|
|
1460
1485
|
disableShortLineDetection,
|
|
1461
1486
|
shortLineThreshold,
|
|
1462
1487
|
unitsPerEm: this.loadedFont.upem,
|
|
1488
|
+
letterSpacing,
|
|
1463
1489
|
measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
|
|
1464
1490
|
)
|
|
1465
1491
|
});
|
|
@@ -1470,10 +1496,11 @@ class TextLayout {
|
|
|
1470
1496
|
lines = [];
|
|
1471
1497
|
let currentIndex = 0;
|
|
1472
1498
|
for (const line of linesArray) {
|
|
1499
|
+
const originalEnd = line.length === 0 ? currentIndex : currentIndex + line.length - 1;
|
|
1473
1500
|
lines.push({
|
|
1474
1501
|
text: line,
|
|
1475
1502
|
originalStart: currentIndex,
|
|
1476
|
-
originalEnd
|
|
1503
|
+
originalEnd,
|
|
1477
1504
|
xOffset: 0
|
|
1478
1505
|
});
|
|
1479
1506
|
currentIndex += line.length + 1;
|
|
@@ -1512,7 +1539,6 @@ class TextLayout {
|
|
|
1512
1539
|
// Font file signature constants
|
|
1513
1540
|
const FONT_SIGNATURE_TRUE_TYPE = 0x00010000;
|
|
1514
1541
|
const FONT_SIGNATURE_OPEN_TYPE_CFF = 0x4f54544f; // 'OTTO'
|
|
1515
|
-
const FONT_SIGNATURE_TRUE_TYPE_COLLECTION = 0x74746366; // 'ttcf'
|
|
1516
1542
|
const FONT_SIGNATURE_WOFF = 0x774f4646; // 'wOFF'
|
|
1517
1543
|
const FONT_SIGNATURE_WOFF2 = 0x774f4632; // 'wOF2'
|
|
1518
1544
|
// Table Tags
|
|
@@ -1527,6 +1553,28 @@ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
|
|
|
1527
1553
|
const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
|
|
1528
1554
|
const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
|
|
1529
1555
|
|
|
1556
|
+
// Parses the SFNT table directory for TTF/OTF fonts
|
|
1557
|
+
// Assumes the DataView is positioned at the start of an sfnt font (offset 0)
|
|
1558
|
+
// Table records are 16 bytes each starting at byte offset 12
|
|
1559
|
+
function parseTableDirectory(view) {
|
|
1560
|
+
const numTables = view.getUint16(4);
|
|
1561
|
+
const tableRecordsStart = 12;
|
|
1562
|
+
const tables = new Map();
|
|
1563
|
+
for (let i = 0; i < numTables; i++) {
|
|
1564
|
+
const recordOffset = tableRecordsStart + i * 16;
|
|
1565
|
+
// Guard against corrupt buffers that report more tables than exist
|
|
1566
|
+
if (recordOffset + 16 > view.byteLength) {
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
const tag = view.getUint32(recordOffset);
|
|
1570
|
+
const checksum = view.getUint32(recordOffset + 4);
|
|
1571
|
+
const offset = view.getUint32(recordOffset + 8);
|
|
1572
|
+
const length = view.getUint32(recordOffset + 12);
|
|
1573
|
+
tables.set(tag, { tag, checksum, offset, length });
|
|
1574
|
+
}
|
|
1575
|
+
return tables;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1530
1578
|
class FontMetadataExtractor {
|
|
1531
1579
|
static extractMetadata(fontBuffer) {
|
|
1532
1580
|
if (!fontBuffer || fontBuffer.byteLength < 12) {
|
|
@@ -1536,45 +1584,19 @@ class FontMetadataExtractor {
|
|
|
1536
1584
|
const sfntVersion = view.getUint32(0);
|
|
1537
1585
|
const validSignatures = [
|
|
1538
1586
|
FONT_SIGNATURE_TRUE_TYPE,
|
|
1539
|
-
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
1540
|
-
FONT_SIGNATURE_TRUE_TYPE_COLLECTION
|
|
1587
|
+
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
1541
1588
|
];
|
|
1542
1589
|
if (!validSignatures.includes(sfntVersion)) {
|
|
1543
|
-
throw new Error(`Invalid font format. Expected
|
|
1544
|
-
}
|
|
1545
|
-
const
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
for (let i = 0; i < numTables; i++) {
|
|
1554
|
-
const offset = 12 + i * 16;
|
|
1555
|
-
const tag = view.getUint32(offset);
|
|
1556
|
-
if (tag === TABLE_TAG_CFF || tag === TABLE_TAG_CFF2) {
|
|
1557
|
-
isCFF = true;
|
|
1558
|
-
}
|
|
1559
|
-
else if (tag === TABLE_TAG_HEAD) {
|
|
1560
|
-
headTableOffset = view.getUint32(offset + 8);
|
|
1561
|
-
}
|
|
1562
|
-
else if (tag === TABLE_TAG_HHEA) {
|
|
1563
|
-
hheaTableOffset = view.getUint32(offset + 8);
|
|
1564
|
-
}
|
|
1565
|
-
else if (tag === TABLE_TAG_OS2) {
|
|
1566
|
-
os2TableOffset = view.getUint32(offset + 8);
|
|
1567
|
-
}
|
|
1568
|
-
else if (tag === TABLE_TAG_FVAR) {
|
|
1569
|
-
fvarTableOffset = view.getUint32(offset + 8);
|
|
1570
|
-
}
|
|
1571
|
-
else if (tag === TABLE_TAG_STAT) {
|
|
1572
|
-
statTableOffset = view.getUint32(offset + 8);
|
|
1573
|
-
}
|
|
1574
|
-
else if (tag === TABLE_TAG_NAME) {
|
|
1575
|
-
nameTableOffset = view.getUint32(offset + 8);
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1590
|
+
throw new Error(`Invalid font format. Expected TTF/OTF (or WOFF), got signature: 0x${sfntVersion.toString(16)}`);
|
|
1591
|
+
}
|
|
1592
|
+
const tableDirectory = parseTableDirectory(view);
|
|
1593
|
+
const isCFF = tableDirectory.has(TABLE_TAG_CFF) || tableDirectory.has(TABLE_TAG_CFF2);
|
|
1594
|
+
const headTableOffset = tableDirectory.get(TABLE_TAG_HEAD)?.offset ?? 0;
|
|
1595
|
+
const hheaTableOffset = tableDirectory.get(TABLE_TAG_HHEA)?.offset ?? 0;
|
|
1596
|
+
const os2TableOffset = tableDirectory.get(TABLE_TAG_OS2)?.offset ?? 0;
|
|
1597
|
+
const fvarTableOffset = tableDirectory.get(TABLE_TAG_FVAR)?.offset ?? 0;
|
|
1598
|
+
const statTableOffset = tableDirectory.get(TABLE_TAG_STAT)?.offset ?? 0;
|
|
1599
|
+
const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
|
|
1578
1600
|
const unitsPerEm = headTableOffset
|
|
1579
1601
|
? view.getUint16(headTableOffset + 18)
|
|
1580
1602
|
: 1000;
|
|
@@ -1617,23 +1639,10 @@ class FontMetadataExtractor {
|
|
|
1617
1639
|
}
|
|
1618
1640
|
static extractFeatureTags(fontBuffer) {
|
|
1619
1641
|
const view = new DataView(fontBuffer);
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
for (let i = 0; i < numTables; i++) {
|
|
1625
|
-
const offset = 12 + i * 16;
|
|
1626
|
-
const tag = view.getUint32(offset);
|
|
1627
|
-
if (tag === TABLE_TAG_GSUB) {
|
|
1628
|
-
gsubTableOffset = view.getUint32(offset + 8);
|
|
1629
|
-
}
|
|
1630
|
-
else if (tag === TABLE_TAG_GPOS) {
|
|
1631
|
-
gposTableOffset = view.getUint32(offset + 8);
|
|
1632
|
-
}
|
|
1633
|
-
else if (tag === TABLE_TAG_NAME) {
|
|
1634
|
-
nameTableOffset = view.getUint32(offset + 8);
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1642
|
+
const tableDirectory = parseTableDirectory(view);
|
|
1643
|
+
const gsubTableOffset = tableDirectory.get(TABLE_TAG_GSUB)?.offset ?? 0;
|
|
1644
|
+
const gposTableOffset = tableDirectory.get(TABLE_TAG_GPOS)?.offset ?? 0;
|
|
1645
|
+
const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
|
|
1637
1646
|
const features = new Set();
|
|
1638
1647
|
const featureNames = {};
|
|
1639
1648
|
try {
|
|
@@ -1864,6 +1873,7 @@ class WoffConverter {
|
|
|
1864
1873
|
sfntView.setUint16(6, searchRange);
|
|
1865
1874
|
sfntView.setUint16(8, Math.floor(Math.log2(numTables)));
|
|
1866
1875
|
sfntView.setUint16(10, numTables * 16 - searchRange);
|
|
1876
|
+
// Read and decompress table directory
|
|
1867
1877
|
let sfntOffset = 12 + numTables * 16; // Start of table data
|
|
1868
1878
|
const tableDirectory = [];
|
|
1869
1879
|
// Read WOFF table directory
|
|
@@ -1946,11 +1956,10 @@ class FontLoader {
|
|
|
1946
1956
|
const sfntVersion = view.getUint32(0);
|
|
1947
1957
|
const validSignatures = [
|
|
1948
1958
|
FONT_SIGNATURE_TRUE_TYPE,
|
|
1949
|
-
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
1950
|
-
FONT_SIGNATURE_TRUE_TYPE_COLLECTION
|
|
1959
|
+
FONT_SIGNATURE_OPEN_TYPE_CFF
|
|
1951
1960
|
];
|
|
1952
1961
|
if (!validSignatures.includes(sfntVersion)) {
|
|
1953
|
-
throw new Error(`Invalid font format. Expected
|
|
1962
|
+
throw new Error(`Invalid font format. Expected TTF/OTF (or WOFF), got signature: 0x${sfntVersion.toString(16)}`);
|
|
1954
1963
|
}
|
|
1955
1964
|
const { hb, module } = await this.getHarfBuzzInstance();
|
|
1956
1965
|
try {
|
|
@@ -1987,7 +1996,8 @@ class FontLoader {
|
|
|
1987
1996
|
isVariable,
|
|
1988
1997
|
variationAxes,
|
|
1989
1998
|
availableFeatures: featureData?.tags,
|
|
1990
|
-
featureNames: featureData?.names
|
|
1999
|
+
featureNames: featureData?.names,
|
|
2000
|
+
_buffer: fontBuffer // For stable font ID generation
|
|
1991
2001
|
};
|
|
1992
2002
|
}
|
|
1993
2003
|
catch (error) {
|
|
@@ -2017,7 +2027,91 @@ class FontLoader {
|
|
|
2017
2027
|
}
|
|
2018
2028
|
}
|
|
2019
2029
|
|
|
2030
|
+
const SAFE_LANGUAGE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,16})*$/i;
|
|
2031
|
+
// Built-in patterns shipped with three-text (matches files in src/hyphenation/*)
|
|
2032
|
+
const BUILTIN_PATTERN_LANGUAGES = new Set([
|
|
2033
|
+
'af',
|
|
2034
|
+
'as',
|
|
2035
|
+
'be',
|
|
2036
|
+
'bg',
|
|
2037
|
+
'bn',
|
|
2038
|
+
'ca',
|
|
2039
|
+
'cs',
|
|
2040
|
+
'cy',
|
|
2041
|
+
'da',
|
|
2042
|
+
'de-1996',
|
|
2043
|
+
'el-monoton',
|
|
2044
|
+
'el-polyton',
|
|
2045
|
+
'en-gb',
|
|
2046
|
+
'en-us',
|
|
2047
|
+
'eo',
|
|
2048
|
+
'es',
|
|
2049
|
+
'et',
|
|
2050
|
+
'eu',
|
|
2051
|
+
'fi',
|
|
2052
|
+
'fr',
|
|
2053
|
+
'fur',
|
|
2054
|
+
'ga',
|
|
2055
|
+
'gl',
|
|
2056
|
+
'gu',
|
|
2057
|
+
'hi',
|
|
2058
|
+
'hr',
|
|
2059
|
+
'hsb',
|
|
2060
|
+
'hu',
|
|
2061
|
+
'hy',
|
|
2062
|
+
'ia',
|
|
2063
|
+
'id',
|
|
2064
|
+
'is',
|
|
2065
|
+
'it',
|
|
2066
|
+
'ka',
|
|
2067
|
+
'kmr',
|
|
2068
|
+
'kn',
|
|
2069
|
+
'la',
|
|
2070
|
+
'lt',
|
|
2071
|
+
'lv',
|
|
2072
|
+
'mk',
|
|
2073
|
+
'ml',
|
|
2074
|
+
'mn-cyrl',
|
|
2075
|
+
'mr',
|
|
2076
|
+
'mul-ethi',
|
|
2077
|
+
'nb',
|
|
2078
|
+
'nl',
|
|
2079
|
+
'nn',
|
|
2080
|
+
'oc',
|
|
2081
|
+
'or',
|
|
2082
|
+
'pa',
|
|
2083
|
+
'pl',
|
|
2084
|
+
'pms',
|
|
2085
|
+
'pt',
|
|
2086
|
+
'rm',
|
|
2087
|
+
'ro',
|
|
2088
|
+
'ru',
|
|
2089
|
+
'sa',
|
|
2090
|
+
'sh-cyrl',
|
|
2091
|
+
'sh-latn',
|
|
2092
|
+
'sk',
|
|
2093
|
+
'sl',
|
|
2094
|
+
'sq',
|
|
2095
|
+
'sr-cyrl',
|
|
2096
|
+
'sv',
|
|
2097
|
+
'ta',
|
|
2098
|
+
'te',
|
|
2099
|
+
'th',
|
|
2100
|
+
'tk',
|
|
2101
|
+
'tr',
|
|
2102
|
+
'uk',
|
|
2103
|
+
'zh-latn-pinyin'
|
|
2104
|
+
]);
|
|
2020
2105
|
async function loadPattern(language, patternsPath) {
|
|
2106
|
+
if (!SAFE_LANGUAGE_RE.test(language)) {
|
|
2107
|
+
throw new Error(`Invalid hyphenation language code "${language}". Expected e.g. "en-us".`);
|
|
2108
|
+
}
|
|
2109
|
+
// When no patternsPath is provided, we only allow the built-in set shipped with
|
|
2110
|
+
// three-text to avoid accidental arbitrary imports / path traversal
|
|
2111
|
+
if (!patternsPath && !BUILTIN_PATTERN_LANGUAGES.has(language)) {
|
|
2112
|
+
throw new Error(`Unsupported hyphenation language "${language}". ` +
|
|
2113
|
+
`Use a built-in language (e.g. "en-us") or register patterns via Text.registerPattern("${language}", pattern).`);
|
|
2114
|
+
}
|
|
2021
2115
|
{
|
|
2022
2116
|
// In ESM build, use dynamic imports
|
|
2023
2117
|
try {
|
|
@@ -2206,108 +2300,233 @@ class Vec3 {
|
|
|
2206
2300
|
return this.x === v.x && this.y === v.y && this.z === v.z;
|
|
2207
2301
|
}
|
|
2208
2302
|
}
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
this.
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
this.
|
|
2217
|
-
|
|
2218
|
-
|
|
2303
|
+
|
|
2304
|
+
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
2305
|
+
class LRUCache {
|
|
2306
|
+
constructor(options = {}) {
|
|
2307
|
+
this.cache = new Map();
|
|
2308
|
+
this.head = null;
|
|
2309
|
+
this.tail = null;
|
|
2310
|
+
this.stats = {
|
|
2311
|
+
hits: 0,
|
|
2312
|
+
misses: 0,
|
|
2313
|
+
evictions: 0,
|
|
2314
|
+
size: 0,
|
|
2315
|
+
memoryUsage: 0
|
|
2316
|
+
};
|
|
2317
|
+
this.options = {
|
|
2318
|
+
maxEntries: options.maxEntries ?? Infinity,
|
|
2319
|
+
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
2320
|
+
calculateSize: options.calculateSize ?? (() => 0),
|
|
2321
|
+
onEvict: options.onEvict
|
|
2322
|
+
};
|
|
2219
2323
|
}
|
|
2220
|
-
|
|
2221
|
-
this.
|
|
2222
|
-
|
|
2223
|
-
this.
|
|
2324
|
+
get(key) {
|
|
2325
|
+
const node = this.cache.get(key);
|
|
2326
|
+
if (node) {
|
|
2327
|
+
this.stats.hits++;
|
|
2328
|
+
this.moveToHead(node);
|
|
2329
|
+
return node.value;
|
|
2330
|
+
}
|
|
2331
|
+
else {
|
|
2332
|
+
this.stats.misses++;
|
|
2333
|
+
return undefined;
|
|
2224
2334
|
}
|
|
2225
|
-
return this;
|
|
2226
2335
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
this.max.x = this.max.y = this.max.z = -Infinity;
|
|
2230
|
-
return this;
|
|
2336
|
+
has(key) {
|
|
2337
|
+
return this.cache.has(key);
|
|
2231
2338
|
}
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2339
|
+
set(key, value) {
|
|
2340
|
+
// If key already exists, update it
|
|
2341
|
+
const existingNode = this.cache.get(key);
|
|
2342
|
+
if (existingNode) {
|
|
2343
|
+
const oldSize = this.options.calculateSize(existingNode.value);
|
|
2344
|
+
const newSize = this.options.calculateSize(value);
|
|
2345
|
+
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
2346
|
+
existingNode.value = value;
|
|
2347
|
+
this.moveToHead(existingNode);
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
const size = this.options.calculateSize(value);
|
|
2351
|
+
// Evict entries if we exceed limits
|
|
2352
|
+
this.evictIfNeeded(size);
|
|
2353
|
+
// Create new node
|
|
2354
|
+
const node = {
|
|
2355
|
+
key,
|
|
2356
|
+
value,
|
|
2357
|
+
prev: null,
|
|
2358
|
+
next: null
|
|
2359
|
+
};
|
|
2360
|
+
this.cache.set(key, node);
|
|
2361
|
+
this.addToHead(node);
|
|
2362
|
+
this.stats.size = this.cache.size;
|
|
2363
|
+
this.stats.memoryUsage += size;
|
|
2245
2364
|
}
|
|
2246
|
-
|
|
2247
|
-
this.
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
this.
|
|
2251
|
-
this.
|
|
2252
|
-
this.
|
|
2253
|
-
|
|
2365
|
+
delete(key) {
|
|
2366
|
+
const node = this.cache.get(key);
|
|
2367
|
+
if (!node)
|
|
2368
|
+
return false;
|
|
2369
|
+
const size = this.options.calculateSize(node.value);
|
|
2370
|
+
this.removeNode(node);
|
|
2371
|
+
this.cache.delete(key);
|
|
2372
|
+
this.stats.size = this.cache.size;
|
|
2373
|
+
this.stats.memoryUsage -= size;
|
|
2374
|
+
if (this.options.onEvict) {
|
|
2375
|
+
this.options.onEvict(key, node.value);
|
|
2376
|
+
}
|
|
2377
|
+
return true;
|
|
2254
2378
|
}
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
intersectsBox(box) {
|
|
2272
|
-
return (box.max.x >= this.min.x &&
|
|
2273
|
-
box.min.x <= this.max.x &&
|
|
2274
|
-
box.max.y >= this.min.y &&
|
|
2275
|
-
box.min.y <= this.max.y &&
|
|
2276
|
-
box.max.z >= this.min.z &&
|
|
2277
|
-
box.min.z <= this.max.z);
|
|
2278
|
-
}
|
|
2279
|
-
getCenter(target = new Vec3()) {
|
|
2280
|
-
return this.isEmpty()
|
|
2281
|
-
? target.set(0, 0, 0)
|
|
2282
|
-
: target.set((this.min.x + this.max.x) * 0.5, (this.min.y + this.max.y) * 0.5, (this.min.z + this.max.z) * 0.5);
|
|
2283
|
-
}
|
|
2284
|
-
getSize(target = new Vec3()) {
|
|
2285
|
-
return this.isEmpty()
|
|
2286
|
-
? target.set(0, 0, 0)
|
|
2287
|
-
: target.set(this.max.x - this.min.x, this.max.y - this.min.y, this.max.z - this.min.z);
|
|
2379
|
+
clear() {
|
|
2380
|
+
if (this.options.onEvict) {
|
|
2381
|
+
for (const [key, node] of this.cache) {
|
|
2382
|
+
this.options.onEvict(key, node.value);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
this.cache.clear();
|
|
2386
|
+
this.head = null;
|
|
2387
|
+
this.tail = null;
|
|
2388
|
+
this.stats = {
|
|
2389
|
+
hits: 0,
|
|
2390
|
+
misses: 0,
|
|
2391
|
+
evictions: 0,
|
|
2392
|
+
size: 0,
|
|
2393
|
+
memoryUsage: 0
|
|
2394
|
+
};
|
|
2288
2395
|
}
|
|
2289
|
-
|
|
2290
|
-
|
|
2396
|
+
getStats() {
|
|
2397
|
+
const total = this.stats.hits + this.stats.misses;
|
|
2398
|
+
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
2399
|
+
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
2400
|
+
return {
|
|
2401
|
+
...this.stats,
|
|
2402
|
+
hitRate,
|
|
2403
|
+
memoryUsageMB
|
|
2404
|
+
};
|
|
2291
2405
|
}
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
this.
|
|
2295
|
-
|
|
2406
|
+
keys() {
|
|
2407
|
+
const keys = [];
|
|
2408
|
+
let current = this.head;
|
|
2409
|
+
while (current) {
|
|
2410
|
+
keys.push(current.key);
|
|
2411
|
+
current = current.next;
|
|
2412
|
+
}
|
|
2413
|
+
return keys;
|
|
2296
2414
|
}
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
this.
|
|
2303
|
-
|
|
2304
|
-
|
|
2415
|
+
get size() {
|
|
2416
|
+
return this.cache.size;
|
|
2417
|
+
}
|
|
2418
|
+
evictIfNeeded(requiredSize) {
|
|
2419
|
+
// Evict by entry count
|
|
2420
|
+
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
2421
|
+
this.evictTail();
|
|
2422
|
+
}
|
|
2423
|
+
// Evict by memory usage
|
|
2424
|
+
if (this.options.maxMemoryBytes < Infinity) {
|
|
2425
|
+
while (this.tail &&
|
|
2426
|
+
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
2427
|
+
this.evictTail();
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
evictTail() {
|
|
2432
|
+
if (!this.tail)
|
|
2433
|
+
return;
|
|
2434
|
+
const nodeToRemove = this.tail;
|
|
2435
|
+
const size = this.options.calculateSize(nodeToRemove.value);
|
|
2436
|
+
this.removeTail();
|
|
2437
|
+
this.cache.delete(nodeToRemove.key);
|
|
2438
|
+
this.stats.size = this.cache.size;
|
|
2439
|
+
this.stats.memoryUsage -= size;
|
|
2440
|
+
this.stats.evictions++;
|
|
2441
|
+
if (this.options.onEvict) {
|
|
2442
|
+
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
addToHead(node) {
|
|
2446
|
+
node.prev = null;
|
|
2447
|
+
node.next = null;
|
|
2448
|
+
if (!this.head) {
|
|
2449
|
+
this.head = this.tail = node;
|
|
2450
|
+
}
|
|
2451
|
+
else {
|
|
2452
|
+
node.next = this.head;
|
|
2453
|
+
this.head.prev = node;
|
|
2454
|
+
this.head = node;
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
removeNode(node) {
|
|
2458
|
+
if (node.prev) {
|
|
2459
|
+
node.prev.next = node.next;
|
|
2460
|
+
}
|
|
2461
|
+
else {
|
|
2462
|
+
this.head = node.next;
|
|
2463
|
+
}
|
|
2464
|
+
if (node.next) {
|
|
2465
|
+
node.next.prev = node.prev;
|
|
2466
|
+
}
|
|
2467
|
+
else {
|
|
2468
|
+
this.tail = node.prev;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
removeTail() {
|
|
2472
|
+
if (this.tail) {
|
|
2473
|
+
this.removeNode(this.tail);
|
|
2474
|
+
}
|
|
2305
2475
|
}
|
|
2306
|
-
|
|
2307
|
-
|
|
2476
|
+
moveToHead(node) {
|
|
2477
|
+
if (node === this.head)
|
|
2478
|
+
return;
|
|
2479
|
+
this.removeNode(node);
|
|
2480
|
+
this.addToHead(node);
|
|
2308
2481
|
}
|
|
2309
2482
|
}
|
|
2310
2483
|
|
|
2484
|
+
const DEFAULT_CACHE_SIZE_MB = 250;
|
|
2485
|
+
function getGlyphCacheKey(fontId, glyphId, depth, removeOverlaps) {
|
|
2486
|
+
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
2487
|
+
return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
|
|
2488
|
+
}
|
|
2489
|
+
function calculateGlyphMemoryUsage(glyph) {
|
|
2490
|
+
let size = 0;
|
|
2491
|
+
size += glyph.vertices.length * 4;
|
|
2492
|
+
size += glyph.normals.length * 4;
|
|
2493
|
+
size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
|
|
2494
|
+
size += 24; // 2 Vec3s
|
|
2495
|
+
size += 256; // Object overhead
|
|
2496
|
+
return size;
|
|
2497
|
+
}
|
|
2498
|
+
const globalGlyphCache = new LRUCache({
|
|
2499
|
+
maxEntries: Infinity,
|
|
2500
|
+
maxMemoryBytes: DEFAULT_CACHE_SIZE_MB * 1024 * 1024,
|
|
2501
|
+
calculateSize: calculateGlyphMemoryUsage
|
|
2502
|
+
});
|
|
2503
|
+
function createGlyphCache(maxCacheSizeMB = DEFAULT_CACHE_SIZE_MB) {
|
|
2504
|
+
return new LRUCache({
|
|
2505
|
+
maxEntries: Infinity,
|
|
2506
|
+
maxMemoryBytes: maxCacheSizeMB * 1024 * 1024,
|
|
2507
|
+
calculateSize: calculateGlyphMemoryUsage
|
|
2508
|
+
});
|
|
2509
|
+
}
|
|
2510
|
+
// Shared across builder instances: contour extraction, word clustering, boundary grouping
|
|
2511
|
+
const globalContourCache = new LRUCache({
|
|
2512
|
+
maxEntries: 1000,
|
|
2513
|
+
calculateSize: (contours) => {
|
|
2514
|
+
let size = 0;
|
|
2515
|
+
for (const path of contours.paths) {
|
|
2516
|
+
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
2517
|
+
}
|
|
2518
|
+
return size + 64; // bounds overhead
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
const globalWordCache = new LRUCache({
|
|
2522
|
+
maxEntries: 1000,
|
|
2523
|
+
calculateSize: calculateGlyphMemoryUsage
|
|
2524
|
+
});
|
|
2525
|
+
const globalClusteringCache = new LRUCache({
|
|
2526
|
+
maxEntries: 2000,
|
|
2527
|
+
calculateSize: () => 1
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2311
2530
|
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
|
|
2312
2531
|
|
|
2313
2532
|
function getDefaultExportFromCjs (x) {
|
|
@@ -2564,68 +2783,137 @@ class Tessellator {
|
|
|
2564
2783
|
class Extruder {
|
|
2565
2784
|
constructor() { }
|
|
2566
2785
|
extrude(geometry, depth = 0, unitsPerEm) {
|
|
2567
|
-
const
|
|
2568
|
-
const
|
|
2569
|
-
const
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
else {
|
|
2574
|
-
this.addFrontAndBackFaces(geometry.triangles, vertices, normals, indices, depth, unitsPerEm);
|
|
2786
|
+
const points = geometry.triangles.vertices;
|
|
2787
|
+
const triangleIndices = geometry.triangles.indices;
|
|
2788
|
+
const numPoints = points.length / 2;
|
|
2789
|
+
// Count side-wall segments (each segment emits 4 vertices + 6 indices)
|
|
2790
|
+
let sideSegments = 0;
|
|
2791
|
+
if (depth !== 0) {
|
|
2575
2792
|
for (const contour of geometry.contours) {
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
const
|
|
2584
|
-
const
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2793
|
+
// Each contour is a flat [x0,y0,x1,y1,...] array; side walls connect consecutive points
|
|
2794
|
+
// Contours are expected to be closed (last point repeats first), so segments = (nPoints - 1)
|
|
2795
|
+
const contourPoints = contour.length / 2;
|
|
2796
|
+
if (contourPoints >= 2)
|
|
2797
|
+
sideSegments += contourPoints - 1;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
const sideVertexCount = depth === 0 ? 0 : sideSegments * 4;
|
|
2801
|
+
const baseVertexCount = depth === 0 ? numPoints : numPoints * 2;
|
|
2802
|
+
const vertexCount = baseVertexCount + sideVertexCount;
|
|
2803
|
+
const vertices = new Float32Array(vertexCount * 3);
|
|
2804
|
+
const normals = new Float32Array(vertexCount * 3);
|
|
2805
|
+
const indexCount = depth === 0
|
|
2806
|
+
? triangleIndices.length
|
|
2807
|
+
: triangleIndices.length * 2 + sideSegments * 6;
|
|
2808
|
+
const indices = new Uint32Array(indexCount);
|
|
2809
|
+
if (depth === 0) {
|
|
2810
|
+
// Flat faces only
|
|
2811
|
+
let vPos = 0;
|
|
2812
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
2813
|
+
vertices[vPos] = points[i];
|
|
2814
|
+
vertices[vPos + 1] = points[i + 1];
|
|
2815
|
+
vertices[vPos + 2] = 0;
|
|
2816
|
+
normals[vPos] = 0;
|
|
2817
|
+
normals[vPos + 1] = 0;
|
|
2818
|
+
normals[vPos + 2] = 1;
|
|
2819
|
+
vPos += 3;
|
|
2820
|
+
}
|
|
2821
|
+
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2822
|
+
indices[i] = triangleIndices[i];
|
|
2823
|
+
}
|
|
2824
|
+
return { vertices, normals, indices };
|
|
2825
|
+
}
|
|
2826
|
+
// Front/back faces
|
|
2602
2827
|
const minBackOffset = unitsPerEm * 0.000025;
|
|
2603
2828
|
const backZ = depth <= minBackOffset ? minBackOffset : depth;
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2829
|
+
// Fill front vertices/normals (0..numPoints-1)
|
|
2830
|
+
for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
|
|
2831
|
+
const base = vi * 3;
|
|
2832
|
+
vertices[base] = points[p];
|
|
2833
|
+
vertices[base + 1] = points[p + 1];
|
|
2834
|
+
vertices[base + 2] = 0;
|
|
2835
|
+
normals[base] = 0;
|
|
2836
|
+
normals[base + 1] = 0;
|
|
2837
|
+
normals[base + 2] = 1;
|
|
2838
|
+
}
|
|
2839
|
+
// Fill back vertices/normals (numPoints..2*numPoints-1)
|
|
2840
|
+
for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
|
|
2841
|
+
const base = (numPoints + vi) * 3;
|
|
2842
|
+
vertices[base] = points[p];
|
|
2843
|
+
vertices[base + 1] = points[p + 1];
|
|
2844
|
+
vertices[base + 2] = backZ;
|
|
2845
|
+
normals[base] = 0;
|
|
2846
|
+
normals[base + 1] = 0;
|
|
2847
|
+
normals[base + 2] = -1;
|
|
2848
|
+
}
|
|
2849
|
+
// Front indices
|
|
2609
2850
|
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2610
|
-
indices
|
|
2611
|
-
}
|
|
2612
|
-
for (let i = triangleIndices.length - 1; i >= 0; i--) {
|
|
2613
|
-
indices.push(baseIndex + triangleIndices[i] + numPoints);
|
|
2851
|
+
indices[i] = triangleIndices[i];
|
|
2614
2852
|
}
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2853
|
+
// Back indices (reverse winding + offset)
|
|
2854
|
+
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2855
|
+
indices[triangleIndices.length + i] =
|
|
2856
|
+
triangleIndices[triangleIndices.length - 1 - i] + numPoints;
|
|
2857
|
+
}
|
|
2858
|
+
// Side walls
|
|
2859
|
+
let nextVertex = numPoints * 2;
|
|
2860
|
+
let idxPos = triangleIndices.length * 2;
|
|
2861
|
+
for (const contour of geometry.contours) {
|
|
2862
|
+
for (let i = 0; i < contour.length - 2; i += 2) {
|
|
2863
|
+
const p0x = contour[i];
|
|
2864
|
+
const p0y = contour[i + 1];
|
|
2865
|
+
const p1x = contour[i + 2];
|
|
2866
|
+
const p1y = contour[i + 3];
|
|
2867
|
+
// Unit normal for the wall quad (per-edge)
|
|
2868
|
+
const ex = p1x - p0x;
|
|
2869
|
+
const ey = p1y - p0y;
|
|
2870
|
+
const lenSq = ex * ex + ey * ey;
|
|
2871
|
+
let nx = 0;
|
|
2872
|
+
let ny = 0;
|
|
2873
|
+
if (lenSq > 0) {
|
|
2874
|
+
const invLen = 1 / Math.sqrt(lenSq);
|
|
2875
|
+
nx = ey * invLen;
|
|
2876
|
+
ny = -ex * invLen;
|
|
2877
|
+
}
|
|
2878
|
+
const baseVertex = nextVertex;
|
|
2879
|
+
const base = baseVertex * 3;
|
|
2880
|
+
// 4 vertices (two at z=0, two at z=depth)
|
|
2881
|
+
vertices[base] = p0x;
|
|
2882
|
+
vertices[base + 1] = p0y;
|
|
2883
|
+
vertices[base + 2] = 0;
|
|
2884
|
+
vertices[base + 3] = p1x;
|
|
2885
|
+
vertices[base + 4] = p1y;
|
|
2886
|
+
vertices[base + 5] = 0;
|
|
2887
|
+
vertices[base + 6] = p0x;
|
|
2888
|
+
vertices[base + 7] = p0y;
|
|
2889
|
+
vertices[base + 8] = backZ;
|
|
2890
|
+
vertices[base + 9] = p1x;
|
|
2891
|
+
vertices[base + 10] = p1y;
|
|
2892
|
+
vertices[base + 11] = backZ;
|
|
2893
|
+
// Normals (same for all 4 wall vertices)
|
|
2894
|
+
normals[base] = nx;
|
|
2895
|
+
normals[base + 1] = ny;
|
|
2896
|
+
normals[base + 2] = 0;
|
|
2897
|
+
normals[base + 3] = nx;
|
|
2898
|
+
normals[base + 4] = ny;
|
|
2899
|
+
normals[base + 5] = 0;
|
|
2900
|
+
normals[base + 6] = nx;
|
|
2901
|
+
normals[base + 7] = ny;
|
|
2902
|
+
normals[base + 8] = 0;
|
|
2903
|
+
normals[base + 9] = nx;
|
|
2904
|
+
normals[base + 10] = ny;
|
|
2905
|
+
normals[base + 11] = 0;
|
|
2906
|
+
// Indices (two triangles)
|
|
2907
|
+
indices[idxPos++] = baseVertex;
|
|
2908
|
+
indices[idxPos++] = baseVertex + 1;
|
|
2909
|
+
indices[idxPos++] = baseVertex + 2;
|
|
2910
|
+
indices[idxPos++] = baseVertex + 1;
|
|
2911
|
+
indices[idxPos++] = baseVertex + 3;
|
|
2912
|
+
indices[idxPos++] = baseVertex + 2;
|
|
2913
|
+
nextVertex += 4;
|
|
2914
|
+
}
|
|
2628
2915
|
}
|
|
2916
|
+
return { vertices, normals, indices };
|
|
2629
2917
|
}
|
|
2630
2918
|
}
|
|
2631
2919
|
|
|
@@ -2938,13 +3226,17 @@ class PathOptimizer {
|
|
|
2938
3226
|
const prev = points[i - 1];
|
|
2939
3227
|
const current = points[i];
|
|
2940
3228
|
const next = points[i + 1];
|
|
2941
|
-
const
|
|
2942
|
-
const
|
|
2943
|
-
const
|
|
2944
|
-
const
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
3229
|
+
const v1x = current.x - prev.x;
|
|
3230
|
+
const v1y = current.y - prev.y;
|
|
3231
|
+
const v2x = next.x - current.x;
|
|
3232
|
+
const v2y = next.y - current.y;
|
|
3233
|
+
const angle = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
|
|
3234
|
+
const v1LenSq = v1x * v1x + v1y * v1y;
|
|
3235
|
+
const v2LenSq = v2x * v2x + v2y * v2y;
|
|
3236
|
+
const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
|
|
3237
|
+
if (angle > threshold ||
|
|
3238
|
+
v1LenSq < minLenSq ||
|
|
3239
|
+
v2LenSq < minLenSq) {
|
|
2948
3240
|
result.push(current);
|
|
2949
3241
|
}
|
|
2950
3242
|
else {
|
|
@@ -3064,9 +3356,13 @@ class Polygonizer {
|
|
|
3064
3356
|
const angleTolerance = this.curveFidelityConfig.angleTolerance ??
|
|
3065
3357
|
DEFAULT_CURVE_FIDELITY.angleTolerance;
|
|
3066
3358
|
if (angleTolerance > 0) {
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3359
|
+
// Angle between segments (p1->p2) and (p2->p3).
|
|
3360
|
+
// Using atan2(cross, dot) avoids computing 2 separate atan2() values + wrap logic.
|
|
3361
|
+
const v1x = x2 - x1;
|
|
3362
|
+
const v1y = y2 - y1;
|
|
3363
|
+
const v2x = x3 - x2;
|
|
3364
|
+
const v2y = y3 - y2;
|
|
3365
|
+
const da = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
|
|
3070
3366
|
if (da < angleTolerance) {
|
|
3071
3367
|
this.addPoint(x2, y2, points);
|
|
3072
3368
|
return;
|
|
@@ -3161,9 +3457,12 @@ class Polygonizer {
|
|
|
3161
3457
|
const angleTolerance = this.curveFidelityConfig.angleTolerance ??
|
|
3162
3458
|
DEFAULT_CURVE_FIDELITY.angleTolerance;
|
|
3163
3459
|
if (angleTolerance > 0) {
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3460
|
+
// Angle between segments (p2->p3) and (p3->p4)
|
|
3461
|
+
const v1x = x3 - x2;
|
|
3462
|
+
const v1y = y3 - y2;
|
|
3463
|
+
const v2x = x4 - x3;
|
|
3464
|
+
const v2y = y4 - y3;
|
|
3465
|
+
const da1 = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
|
|
3167
3466
|
if (da1 < angleTolerance) {
|
|
3168
3467
|
this.addPoint(x2, y2, points);
|
|
3169
3468
|
this.addPoint(x3, y3, points);
|
|
@@ -3183,9 +3482,12 @@ class Polygonizer {
|
|
|
3183
3482
|
const angleTolerance = this.curveFidelityConfig.angleTolerance ??
|
|
3184
3483
|
DEFAULT_CURVE_FIDELITY.angleTolerance;
|
|
3185
3484
|
if (angleTolerance > 0) {
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3485
|
+
// Angle between segments (p1->p2) and (p2->p3)
|
|
3486
|
+
const v1x = x2 - x1;
|
|
3487
|
+
const v1y = y2 - y1;
|
|
3488
|
+
const v2x = x3 - x2;
|
|
3489
|
+
const v2y = y3 - y2;
|
|
3490
|
+
const da1 = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
|
|
3189
3491
|
if (da1 < angleTolerance) {
|
|
3190
3492
|
this.addPoint(x2, y2, points);
|
|
3191
3493
|
this.addPoint(x3, y3, points);
|
|
@@ -3205,12 +3507,18 @@ class Polygonizer {
|
|
|
3205
3507
|
const angleTolerance = this.curveFidelityConfig.angleTolerance ??
|
|
3206
3508
|
DEFAULT_CURVE_FIDELITY.angleTolerance;
|
|
3207
3509
|
if (angleTolerance > 0) {
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3510
|
+
// da1: angle between (p1->p2) and (p2->p3)
|
|
3511
|
+
const a1x = x2 - x1;
|
|
3512
|
+
const a1y = y2 - y1;
|
|
3513
|
+
const a2x = x3 - x2;
|
|
3514
|
+
const a2y = y3 - y2;
|
|
3515
|
+
const da1 = Math.abs(Math.atan2(a1x * a2y - a1y * a2x, a1x * a2x + a1y * a2y));
|
|
3516
|
+
// da2: angle between (p2->p3) and (p3->p4)
|
|
3517
|
+
const b1x = a2x;
|
|
3518
|
+
const b1y = a2y;
|
|
3519
|
+
const b2x = x4 - x3;
|
|
3520
|
+
const b2y = y4 - y3;
|
|
3521
|
+
const da2 = Math.abs(Math.atan2(b1x * b2y - b1y * b2x, b1x * b2x + b1y * b2y));
|
|
3214
3522
|
if (da1 + da2 < angleTolerance) {
|
|
3215
3523
|
this.addPoint(x2, y2, points);
|
|
3216
3524
|
this.addPoint(x3, y3, points);
|
|
@@ -3472,6 +3780,9 @@ class DrawCallbackHandler {
|
|
|
3472
3780
|
this.collector.updatePosition(dx, dy);
|
|
3473
3781
|
}
|
|
3474
3782
|
}
|
|
3783
|
+
setCollector(collector) {
|
|
3784
|
+
this.collector = collector;
|
|
3785
|
+
}
|
|
3475
3786
|
createDrawFuncs(font, collector) {
|
|
3476
3787
|
if (!font || !font.module || !font.hb) {
|
|
3477
3788
|
throw new Error('Invalid font object');
|
|
@@ -3548,233 +3859,73 @@ class DrawCallbackHandler {
|
|
|
3548
3859
|
this.collector = undefined;
|
|
3549
3860
|
}
|
|
3550
3861
|
}
|
|
3862
|
+
// Share a single DrawCallbackHandler per HarfBuzz module to avoid leaking
|
|
3863
|
+
// wasm function pointers when users create many Text instances
|
|
3864
|
+
const sharedDrawCallbackHandlers = new WeakMap();
|
|
3865
|
+
function getSharedDrawCallbackHandler(font) {
|
|
3866
|
+
const key = font.module;
|
|
3867
|
+
const existing = sharedDrawCallbackHandlers.get(key);
|
|
3868
|
+
if (existing)
|
|
3869
|
+
return existing;
|
|
3870
|
+
const handler = new DrawCallbackHandler();
|
|
3871
|
+
sharedDrawCallbackHandlers.set(key, handler);
|
|
3872
|
+
return handler;
|
|
3873
|
+
}
|
|
3551
3874
|
|
|
3552
|
-
// Generic LRU (Least Recently Used) cache with optional memory-based eviction
|
|
3553
|
-
class LRUCache {
|
|
3554
|
-
constructor(options = {}) {
|
|
3555
|
-
this.cache = new Map();
|
|
3556
|
-
this.head = null;
|
|
3557
|
-
this.tail = null;
|
|
3558
|
-
this.stats = {
|
|
3559
|
-
hits: 0,
|
|
3560
|
-
misses: 0,
|
|
3561
|
-
evictions: 0,
|
|
3562
|
-
size: 0,
|
|
3563
|
-
memoryUsage: 0
|
|
3564
|
-
};
|
|
3565
|
-
this.options = {
|
|
3566
|
-
maxEntries: options.maxEntries ?? Infinity,
|
|
3567
|
-
maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
|
|
3568
|
-
calculateSize: options.calculateSize ?? (() => 0),
|
|
3569
|
-
onEvict: options.onEvict
|
|
3570
|
-
};
|
|
3571
|
-
}
|
|
3572
|
-
get(key) {
|
|
3573
|
-
const node = this.cache.get(key);
|
|
3574
|
-
if (node) {
|
|
3575
|
-
this.stats.hits++;
|
|
3576
|
-
this.moveToHead(node);
|
|
3577
|
-
return node.value;
|
|
3578
|
-
}
|
|
3579
|
-
else {
|
|
3580
|
-
this.stats.misses++;
|
|
3581
|
-
return undefined;
|
|
3582
|
-
}
|
|
3583
|
-
}
|
|
3584
|
-
has(key) {
|
|
3585
|
-
return this.cache.has(key);
|
|
3586
|
-
}
|
|
3587
|
-
set(key, value) {
|
|
3588
|
-
// If key already exists, update it
|
|
3589
|
-
const existingNode = this.cache.get(key);
|
|
3590
|
-
if (existingNode) {
|
|
3591
|
-
const oldSize = this.options.calculateSize(existingNode.value);
|
|
3592
|
-
const newSize = this.options.calculateSize(value);
|
|
3593
|
-
this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
|
|
3594
|
-
existingNode.value = value;
|
|
3595
|
-
this.moveToHead(existingNode);
|
|
3596
|
-
return;
|
|
3597
|
-
}
|
|
3598
|
-
const size = this.options.calculateSize(value);
|
|
3599
|
-
// Evict entries if we exceed limits
|
|
3600
|
-
this.evictIfNeeded(size);
|
|
3601
|
-
// Create new node
|
|
3602
|
-
const node = {
|
|
3603
|
-
key,
|
|
3604
|
-
value,
|
|
3605
|
-
prev: null,
|
|
3606
|
-
next: null
|
|
3607
|
-
};
|
|
3608
|
-
this.cache.set(key, node);
|
|
3609
|
-
this.addToHead(node);
|
|
3610
|
-
this.stats.size = this.cache.size;
|
|
3611
|
-
this.stats.memoryUsage += size;
|
|
3612
|
-
}
|
|
3613
|
-
delete(key) {
|
|
3614
|
-
const node = this.cache.get(key);
|
|
3615
|
-
if (!node)
|
|
3616
|
-
return false;
|
|
3617
|
-
const size = this.options.calculateSize(node.value);
|
|
3618
|
-
this.removeNode(node);
|
|
3619
|
-
this.cache.delete(key);
|
|
3620
|
-
this.stats.size = this.cache.size;
|
|
3621
|
-
this.stats.memoryUsage -= size;
|
|
3622
|
-
if (this.options.onEvict) {
|
|
3623
|
-
this.options.onEvict(key, node.value);
|
|
3624
|
-
}
|
|
3625
|
-
return true;
|
|
3626
|
-
}
|
|
3627
|
-
clear() {
|
|
3628
|
-
if (this.options.onEvict) {
|
|
3629
|
-
for (const [key, node] of this.cache) {
|
|
3630
|
-
this.options.onEvict(key, node.value);
|
|
3631
|
-
}
|
|
3632
|
-
}
|
|
3633
|
-
this.cache.clear();
|
|
3634
|
-
this.head = null;
|
|
3635
|
-
this.tail = null;
|
|
3636
|
-
this.stats = {
|
|
3637
|
-
hits: 0,
|
|
3638
|
-
misses: 0,
|
|
3639
|
-
evictions: 0,
|
|
3640
|
-
size: 0,
|
|
3641
|
-
memoryUsage: 0
|
|
3642
|
-
};
|
|
3643
|
-
}
|
|
3644
|
-
getStats() {
|
|
3645
|
-
const total = this.stats.hits + this.stats.misses;
|
|
3646
|
-
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
|
|
3647
|
-
const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
|
|
3648
|
-
return {
|
|
3649
|
-
...this.stats,
|
|
3650
|
-
hitRate,
|
|
3651
|
-
memoryUsageMB
|
|
3652
|
-
};
|
|
3653
|
-
}
|
|
3654
|
-
keys() {
|
|
3655
|
-
const keys = [];
|
|
3656
|
-
let current = this.head;
|
|
3657
|
-
while (current) {
|
|
3658
|
-
keys.push(current.key);
|
|
3659
|
-
current = current.next;
|
|
3660
|
-
}
|
|
3661
|
-
return keys;
|
|
3662
|
-
}
|
|
3663
|
-
get size() {
|
|
3664
|
-
return this.cache.size;
|
|
3665
|
-
}
|
|
3666
|
-
evictIfNeeded(requiredSize) {
|
|
3667
|
-
// Evict by entry count
|
|
3668
|
-
while (this.cache.size >= this.options.maxEntries && this.tail) {
|
|
3669
|
-
this.evictTail();
|
|
3670
|
-
}
|
|
3671
|
-
// Evict by memory usage
|
|
3672
|
-
if (this.options.maxMemoryBytes < Infinity) {
|
|
3673
|
-
while (this.tail &&
|
|
3674
|
-
this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
|
|
3675
|
-
this.evictTail();
|
|
3676
|
-
}
|
|
3677
|
-
}
|
|
3678
|
-
}
|
|
3679
|
-
evictTail() {
|
|
3680
|
-
if (!this.tail)
|
|
3681
|
-
return;
|
|
3682
|
-
const nodeToRemove = this.tail;
|
|
3683
|
-
const size = this.options.calculateSize(nodeToRemove.value);
|
|
3684
|
-
this.removeTail();
|
|
3685
|
-
this.cache.delete(nodeToRemove.key);
|
|
3686
|
-
this.stats.size = this.cache.size;
|
|
3687
|
-
this.stats.memoryUsage -= size;
|
|
3688
|
-
this.stats.evictions++;
|
|
3689
|
-
if (this.options.onEvict) {
|
|
3690
|
-
this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
|
|
3691
|
-
}
|
|
3692
|
-
}
|
|
3693
|
-
addToHead(node) {
|
|
3694
|
-
if (!this.head) {
|
|
3695
|
-
this.head = this.tail = node;
|
|
3696
|
-
}
|
|
3697
|
-
else {
|
|
3698
|
-
node.next = this.head;
|
|
3699
|
-
this.head.prev = node;
|
|
3700
|
-
this.head = node;
|
|
3701
|
-
}
|
|
3702
|
-
}
|
|
3703
|
-
removeNode(node) {
|
|
3704
|
-
if (node.prev) {
|
|
3705
|
-
node.prev.next = node.next;
|
|
3706
|
-
}
|
|
3707
|
-
else {
|
|
3708
|
-
this.head = node.next;
|
|
3709
|
-
}
|
|
3710
|
-
if (node.next) {
|
|
3711
|
-
node.next.prev = node.prev;
|
|
3712
|
-
}
|
|
3713
|
-
else {
|
|
3714
|
-
this.tail = node.prev;
|
|
3715
|
-
}
|
|
3716
|
-
}
|
|
3717
|
-
removeTail() {
|
|
3718
|
-
if (this.tail) {
|
|
3719
|
-
this.removeNode(this.tail);
|
|
3720
|
-
}
|
|
3721
|
-
}
|
|
3722
|
-
moveToHead(node) {
|
|
3723
|
-
if (node === this.head)
|
|
3724
|
-
return;
|
|
3725
|
-
this.removeNode(node);
|
|
3726
|
-
this.addToHead(node);
|
|
3727
|
-
}
|
|
3728
|
-
}
|
|
3729
|
-
|
|
3730
|
-
const CONTOUR_CACHE_MAX_ENTRIES = 1000;
|
|
3731
|
-
const WORD_CACHE_MAX_ENTRIES = 1000;
|
|
3732
3875
|
class GlyphGeometryBuilder {
|
|
3733
3876
|
constructor(cache, loadedFont) {
|
|
3734
3877
|
this.fontId = 'default';
|
|
3878
|
+
this.cacheKeyPrefix = 'default';
|
|
3735
3879
|
this.cache = cache;
|
|
3736
3880
|
this.loadedFont = loadedFont;
|
|
3737
3881
|
this.tessellator = new Tessellator();
|
|
3738
3882
|
this.extruder = new Extruder();
|
|
3739
3883
|
this.clusterer = new BoundaryClusterer();
|
|
3740
3884
|
this.collector = new GlyphContourCollector();
|
|
3741
|
-
this.drawCallbacks =
|
|
3885
|
+
this.drawCallbacks = getSharedDrawCallbackHandler(this.loadedFont);
|
|
3742
3886
|
this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
|
|
3743
|
-
this.contourCache =
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
let size = 0;
|
|
3747
|
-
for (const path of contours.paths) {
|
|
3748
|
-
size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
|
|
3749
|
-
}
|
|
3750
|
-
return size + 64; // bounds overhead
|
|
3751
|
-
}
|
|
3752
|
-
});
|
|
3753
|
-
this.wordCache = new LRUCache({
|
|
3754
|
-
maxEntries: WORD_CACHE_MAX_ENTRIES,
|
|
3755
|
-
calculateSize: (data) => {
|
|
3756
|
-
let size = data.vertices.length * 4;
|
|
3757
|
-
size += data.normals.length * 4;
|
|
3758
|
-
size += data.indices.length * data.indices.BYTES_PER_ELEMENT;
|
|
3759
|
-
return size;
|
|
3760
|
-
}
|
|
3761
|
-
});
|
|
3762
|
-
this.clusteringCache = new LRUCache({
|
|
3763
|
-
maxEntries: 2000,
|
|
3764
|
-
calculateSize: () => 1
|
|
3765
|
-
});
|
|
3887
|
+
this.contourCache = globalContourCache;
|
|
3888
|
+
this.wordCache = globalWordCache;
|
|
3889
|
+
this.clusteringCache = globalClusteringCache;
|
|
3766
3890
|
}
|
|
3767
3891
|
getOptimizationStats() {
|
|
3768
3892
|
return this.collector.getOptimizationStats();
|
|
3769
3893
|
}
|
|
3770
3894
|
setCurveFidelityConfig(config) {
|
|
3895
|
+
this.curveFidelityConfig = config;
|
|
3771
3896
|
this.collector.setCurveFidelityConfig(config);
|
|
3897
|
+
this.updateCacheKeyPrefix();
|
|
3772
3898
|
}
|
|
3773
3899
|
setGeometryOptimization(options) {
|
|
3900
|
+
this.geometryOptimizationOptions = options;
|
|
3774
3901
|
this.collector.setGeometryOptimization(options);
|
|
3902
|
+
this.updateCacheKeyPrefix();
|
|
3775
3903
|
}
|
|
3776
3904
|
setFontId(fontId) {
|
|
3777
3905
|
this.fontId = fontId;
|
|
3906
|
+
this.updateCacheKeyPrefix();
|
|
3907
|
+
}
|
|
3908
|
+
updateCacheKeyPrefix() {
|
|
3909
|
+
this.cacheKeyPrefix = `${this.fontId}__${this.getGeometryConfigSignature()}`;
|
|
3910
|
+
}
|
|
3911
|
+
getGeometryConfigSignature() {
|
|
3912
|
+
const distanceTolerance = this.curveFidelityConfig?.distanceTolerance ??
|
|
3913
|
+
DEFAULT_CURVE_FIDELITY.distanceTolerance;
|
|
3914
|
+
const angleTolerance = this.curveFidelityConfig?.angleTolerance ??
|
|
3915
|
+
DEFAULT_CURVE_FIDELITY.angleTolerance;
|
|
3916
|
+
const enabled = this.geometryOptimizationOptions?.enabled ??
|
|
3917
|
+
DEFAULT_OPTIMIZATION_CONFIG.enabled;
|
|
3918
|
+
const areaThreshold = this.geometryOptimizationOptions?.areaThreshold ??
|
|
3919
|
+
DEFAULT_OPTIMIZATION_CONFIG.areaThreshold;
|
|
3920
|
+
const colinearThreshold = this.geometryOptimizationOptions?.colinearThreshold ??
|
|
3921
|
+
DEFAULT_OPTIMIZATION_CONFIG.colinearThreshold;
|
|
3922
|
+
const minSegmentLength = this.geometryOptimizationOptions?.minSegmentLength ??
|
|
3923
|
+
DEFAULT_OPTIMIZATION_CONFIG.minSegmentLength;
|
|
3924
|
+
// Use fixed precision to keep cache keys stable and avoid float noise
|
|
3925
|
+
return [
|
|
3926
|
+
`cf:${distanceTolerance.toFixed(4)},${angleTolerance.toFixed(4)}`,
|
|
3927
|
+
`opt:${enabled ? 1 : 0},${areaThreshold.toFixed(4)},${colinearThreshold.toFixed(6)},${minSegmentLength.toFixed(4)}`
|
|
3928
|
+
].join('|');
|
|
3778
3929
|
}
|
|
3779
3930
|
// Build instanced geometry from glyph contours
|
|
3780
3931
|
buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
|
|
@@ -3784,9 +3935,58 @@ class GlyphGeometryBuilder {
|
|
|
3784
3935
|
depth,
|
|
3785
3936
|
removeOverlaps
|
|
3786
3937
|
});
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3938
|
+
// Growable typed arrays; slice to final size at end
|
|
3939
|
+
let vertexBuffer = new Float32Array(1024);
|
|
3940
|
+
let normalBuffer = new Float32Array(1024);
|
|
3941
|
+
let indexBuffer = new Uint32Array(1024);
|
|
3942
|
+
let vertexPos = 0; // float index (multiple of 3)
|
|
3943
|
+
let normalPos = 0; // float index (multiple of 3)
|
|
3944
|
+
let indexPos = 0; // index count
|
|
3945
|
+
const ensureFloatCapacity = (buffer, needed) => {
|
|
3946
|
+
if (needed <= buffer.length)
|
|
3947
|
+
return buffer;
|
|
3948
|
+
let nextSize = buffer.length;
|
|
3949
|
+
while (nextSize < needed)
|
|
3950
|
+
nextSize *= 2;
|
|
3951
|
+
const next = new Float32Array(nextSize);
|
|
3952
|
+
next.set(buffer);
|
|
3953
|
+
return next;
|
|
3954
|
+
};
|
|
3955
|
+
const ensureIndexCapacity = (buffer, needed) => {
|
|
3956
|
+
if (needed <= buffer.length)
|
|
3957
|
+
return buffer;
|
|
3958
|
+
let nextSize = buffer.length;
|
|
3959
|
+
while (nextSize < needed)
|
|
3960
|
+
nextSize *= 2;
|
|
3961
|
+
const next = new Uint32Array(nextSize);
|
|
3962
|
+
next.set(buffer);
|
|
3963
|
+
return next;
|
|
3964
|
+
};
|
|
3965
|
+
const appendGeometryToBuffers = (data, position, vertexOffset) => {
|
|
3966
|
+
const v = data.vertices;
|
|
3967
|
+
const n = data.normals;
|
|
3968
|
+
const idx = data.indices;
|
|
3969
|
+
// Grow buffers as needed
|
|
3970
|
+
vertexBuffer = ensureFloatCapacity(vertexBuffer, vertexPos + v.length);
|
|
3971
|
+
normalBuffer = ensureFloatCapacity(normalBuffer, normalPos + n.length);
|
|
3972
|
+
indexBuffer = ensureIndexCapacity(indexBuffer, indexPos + idx.length);
|
|
3973
|
+
// Vertices: translate by position
|
|
3974
|
+
const px = position.x;
|
|
3975
|
+
const py = position.y;
|
|
3976
|
+
const pz = position.z;
|
|
3977
|
+
for (let j = 0; j < v.length; j += 3) {
|
|
3978
|
+
vertexBuffer[vertexPos++] = v[j] + px;
|
|
3979
|
+
vertexBuffer[vertexPos++] = v[j + 1] + py;
|
|
3980
|
+
vertexBuffer[vertexPos++] = v[j + 2] + pz;
|
|
3981
|
+
}
|
|
3982
|
+
// Normals: straight copy
|
|
3983
|
+
normalBuffer.set(n, normalPos);
|
|
3984
|
+
normalPos += n.length;
|
|
3985
|
+
// Indices: copy with vertex offset
|
|
3986
|
+
for (let j = 0; j < idx.length; j++) {
|
|
3987
|
+
indexBuffer[indexPos++] = idx[j] + vertexOffset;
|
|
3988
|
+
}
|
|
3989
|
+
};
|
|
3790
3990
|
const glyphInfos = [];
|
|
3791
3991
|
const planeBounds = {
|
|
3792
3992
|
min: { x: Infinity, y: Infinity, z: 0 },
|
|
@@ -3795,19 +3995,18 @@ class GlyphGeometryBuilder {
|
|
|
3795
3995
|
for (let lineIndex = 0; lineIndex < clustersByLine.length; lineIndex++) {
|
|
3796
3996
|
const line = clustersByLine[lineIndex];
|
|
3797
3997
|
for (const cluster of line) {
|
|
3798
|
-
// Step 1: Get contours for all glyphs in the cluster
|
|
3799
3998
|
const clusterGlyphContours = [];
|
|
3800
3999
|
for (const glyph of cluster.glyphs) {
|
|
3801
4000
|
clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
|
|
3802
4001
|
}
|
|
3803
|
-
// Step 2: Check for overlaps within the cluster
|
|
3804
4002
|
let boundaryGroups;
|
|
3805
4003
|
if (cluster.glyphs.length <= 1) {
|
|
3806
4004
|
boundaryGroups = [[0]];
|
|
3807
4005
|
}
|
|
3808
4006
|
else {
|
|
3809
4007
|
// Check clustering cache (same text + glyph IDs = same overlap groups)
|
|
3810
|
-
|
|
4008
|
+
// Key must be font-specific; glyph ids/bounds differ between fonts
|
|
4009
|
+
const cacheKey = `${this.cacheKeyPrefix}_${cluster.text}`;
|
|
3811
4010
|
const cached = this.clusteringCache.get(cacheKey);
|
|
3812
4011
|
let isValid = false;
|
|
3813
4012
|
if (cached && cached.glyphIds.length === cluster.glyphs.length) {
|
|
@@ -3823,7 +4022,7 @@ class GlyphGeometryBuilder {
|
|
|
3823
4022
|
boundaryGroups = cached.groups;
|
|
3824
4023
|
}
|
|
3825
4024
|
else {
|
|
3826
|
-
const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
|
|
4025
|
+
const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
|
|
3827
4026
|
boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
|
|
3828
4027
|
this.clusteringCache.set(cacheKey, {
|
|
3829
4028
|
glyphIds: cluster.glyphs.map(g => g.g),
|
|
@@ -3833,9 +4032,7 @@ class GlyphGeometryBuilder {
|
|
|
3833
4032
|
}
|
|
3834
4033
|
const clusterHasColoredGlyphs = coloredTextIndices &&
|
|
3835
4034
|
cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
|
|
3836
|
-
//
|
|
3837
|
-
// - separateGlyphs flag is set (for shader attributes), OR
|
|
3838
|
-
// - cluster contains selectively colored text (needs separate vertex ranges per glyph)
|
|
4035
|
+
// Use glyph-level caching when separateGlyphs is set or when cluster contains colored text
|
|
3839
4036
|
const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
|
|
3840
4037
|
// Iterate over the geometric groups identified by BoundaryClusterer
|
|
3841
4038
|
// logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
|
|
@@ -3855,7 +4052,6 @@ class GlyphGeometryBuilder {
|
|
|
3855
4052
|
const originalIndex = groupIndices[i];
|
|
3856
4053
|
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3857
4054
|
const glyph = cluster.glyphs[originalIndex];
|
|
3858
|
-
// Position relative to the sub-cluster start
|
|
3859
4055
|
const relX = (glyph.x ?? 0) - refX;
|
|
3860
4056
|
const relY = (glyph.y ?? 0) - refY;
|
|
3861
4057
|
for (const path of glyphContours.paths) {
|
|
@@ -3872,11 +4068,9 @@ class GlyphGeometryBuilder {
|
|
|
3872
4068
|
// (since the cached geometry is relative to that first glyph)
|
|
3873
4069
|
const firstGlyphInGroup = subClusterGlyphs[0];
|
|
3874
4070
|
const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
|
|
3875
|
-
const vertexOffset =
|
|
3876
|
-
|
|
4071
|
+
const vertexOffset = vertexPos / 3;
|
|
4072
|
+
appendGeometryToBuffers(cachedCluster, groupPosition, vertexOffset);
|
|
3877
4073
|
const clusterVertexCount = cachedCluster.vertices.length / 3;
|
|
3878
|
-
// Register glyph infos for all glyphs in this sub-cluster
|
|
3879
|
-
// They all point to the same merged geometry
|
|
3880
4074
|
for (let i = 0; i < groupIndices.length; i++) {
|
|
3881
4075
|
const originalIndex = groupIndices[i];
|
|
3882
4076
|
const glyph = cluster.glyphs[originalIndex];
|
|
@@ -3899,13 +4093,16 @@ class GlyphGeometryBuilder {
|
|
|
3899
4093
|
glyphInfos.push(glyphInfo);
|
|
3900
4094
|
continue;
|
|
3901
4095
|
}
|
|
3902
|
-
let cachedGlyph = this.cache.get(this.
|
|
4096
|
+
let cachedGlyph = this.cache.get(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps));
|
|
3903
4097
|
if (!cachedGlyph) {
|
|
3904
4098
|
cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
|
|
3905
|
-
this.cache.set(this.
|
|
4099
|
+
this.cache.set(getGlyphCacheKey(this.cacheKeyPrefix, glyph.g, depth, removeOverlaps), cachedGlyph);
|
|
4100
|
+
}
|
|
4101
|
+
else {
|
|
4102
|
+
cachedGlyph.useCount++;
|
|
3906
4103
|
}
|
|
3907
|
-
const vertexOffset =
|
|
3908
|
-
|
|
4104
|
+
const vertexOffset = vertexPos / 3;
|
|
4105
|
+
appendGeometryToBuffers(cachedGlyph, glyphPosition, vertexOffset);
|
|
3909
4106
|
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3910
4107
|
glyphInfos.push(glyphInfo);
|
|
3911
4108
|
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
@@ -3914,9 +4111,10 @@ class GlyphGeometryBuilder {
|
|
|
3914
4111
|
}
|
|
3915
4112
|
}
|
|
3916
4113
|
}
|
|
3917
|
-
|
|
3918
|
-
const
|
|
3919
|
-
const
|
|
4114
|
+
// Slice to used lengths (avoid returning oversized buffers)
|
|
4115
|
+
const vertexArray = vertexBuffer.slice(0, vertexPos);
|
|
4116
|
+
const normalArray = normalBuffer.slice(0, normalPos);
|
|
4117
|
+
const indexArray = indexBuffer.slice(0, indexPos);
|
|
3920
4118
|
perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
|
|
3921
4119
|
return {
|
|
3922
4120
|
vertices: vertexArray,
|
|
@@ -3939,18 +4137,7 @@ class GlyphGeometryBuilder {
|
|
|
3939
4137
|
});
|
|
3940
4138
|
const ids = parts.join('|');
|
|
3941
4139
|
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
3942
|
-
return `${this.
|
|
3943
|
-
}
|
|
3944
|
-
appendGeometry(vertices, normals, indices, data, position, offset) {
|
|
3945
|
-
for (let j = 0; j < data.vertices.length; j += 3) {
|
|
3946
|
-
vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
|
|
3947
|
-
}
|
|
3948
|
-
for (let j = 0; j < data.normals.length; j++) {
|
|
3949
|
-
normals.push(data.normals[j]);
|
|
3950
|
-
}
|
|
3951
|
-
for (let j = 0; j < data.indices.length; j++) {
|
|
3952
|
-
indices.push(data.indices[j] + offset);
|
|
3953
|
-
}
|
|
4140
|
+
return `${this.cacheKeyPrefix}_${ids}_${roundedDepth}_${removeOverlaps}`;
|
|
3954
4141
|
}
|
|
3955
4142
|
createGlyphInfo(glyph, vertexStart, vertexCount, position, contours, depth) {
|
|
3956
4143
|
return {
|
|
@@ -3973,10 +4160,13 @@ class GlyphGeometryBuilder {
|
|
|
3973
4160
|
};
|
|
3974
4161
|
}
|
|
3975
4162
|
getContoursForGlyph(glyphId) {
|
|
3976
|
-
const
|
|
4163
|
+
const key = `${this.cacheKeyPrefix}_${glyphId}`;
|
|
4164
|
+
const cached = this.contourCache.get(key);
|
|
3977
4165
|
if (cached) {
|
|
3978
4166
|
return cached;
|
|
3979
4167
|
}
|
|
4168
|
+
// Rebind collector before draw operation
|
|
4169
|
+
this.drawCallbacks.setCollector(this.collector);
|
|
3980
4170
|
this.collector.reset();
|
|
3981
4171
|
this.collector.beginGlyph(glyphId, 0);
|
|
3982
4172
|
this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
|
|
@@ -3990,7 +4180,7 @@ class GlyphGeometryBuilder {
|
|
|
3990
4180
|
max: { x: 0, y: 0 }
|
|
3991
4181
|
}
|
|
3992
4182
|
};
|
|
3993
|
-
this.contourCache.set(
|
|
4183
|
+
this.contourCache.set(key, contours);
|
|
3994
4184
|
return contours;
|
|
3995
4185
|
}
|
|
3996
4186
|
tessellateGlyphCluster(paths, depth, isCFF) {
|
|
@@ -4027,13 +4217,11 @@ class GlyphGeometryBuilder {
|
|
|
4027
4217
|
}
|
|
4028
4218
|
const boundsMin = new Vec3(minX, minY, minZ);
|
|
4029
4219
|
const boundsMax = new Vec3(maxX, maxY, maxZ);
|
|
4030
|
-
const vertexCount = extrudedResult.vertices.length / 3;
|
|
4031
|
-
const IndexArray = vertexCount < 65536 ? Uint16Array : Uint32Array;
|
|
4032
4220
|
return {
|
|
4033
4221
|
geometry: processedGeometry,
|
|
4034
|
-
vertices:
|
|
4035
|
-
normals:
|
|
4036
|
-
indices:
|
|
4222
|
+
vertices: extrudedResult.vertices,
|
|
4223
|
+
normals: extrudedResult.normals,
|
|
4224
|
+
indices: extrudedResult.indices,
|
|
4037
4225
|
bounds: { min: boundsMin, max: boundsMax },
|
|
4038
4226
|
useCount: 1
|
|
4039
4227
|
};
|
|
@@ -4049,15 +4237,22 @@ class GlyphGeometryBuilder {
|
|
|
4049
4237
|
return this.extrudeAndPackage(processedGeometry, depth);
|
|
4050
4238
|
}
|
|
4051
4239
|
updatePlaneBounds(glyphBounds, planeBounds) {
|
|
4052
|
-
const
|
|
4053
|
-
const
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4240
|
+
const pMin = planeBounds.min;
|
|
4241
|
+
const pMax = planeBounds.max;
|
|
4242
|
+
const gMin = glyphBounds.min;
|
|
4243
|
+
const gMax = glyphBounds.max;
|
|
4244
|
+
if (gMin.x < pMin.x)
|
|
4245
|
+
pMin.x = gMin.x;
|
|
4246
|
+
if (gMin.y < pMin.y)
|
|
4247
|
+
pMin.y = gMin.y;
|
|
4248
|
+
if (gMin.z < pMin.z)
|
|
4249
|
+
pMin.z = gMin.z;
|
|
4250
|
+
if (gMax.x > pMax.x)
|
|
4251
|
+
pMax.x = gMax.x;
|
|
4252
|
+
if (gMax.y > pMax.y)
|
|
4253
|
+
pMax.y = gMax.y;
|
|
4254
|
+
if (gMax.z > pMax.z)
|
|
4255
|
+
pMax.z = gMax.z;
|
|
4061
4256
|
}
|
|
4062
4257
|
getCacheStats() {
|
|
4063
4258
|
return this.cache.getStats();
|
|
@@ -4066,6 +4261,7 @@ class GlyphGeometryBuilder {
|
|
|
4066
4261
|
this.cache.clear();
|
|
4067
4262
|
this.wordCache.clear();
|
|
4068
4263
|
this.clusteringCache.clear();
|
|
4264
|
+
this.contourCache.clear();
|
|
4069
4265
|
}
|
|
4070
4266
|
}
|
|
4071
4267
|
|
|
@@ -4080,12 +4276,17 @@ class TextShaper {
|
|
|
4080
4276
|
perfLogger.start('TextShaper.shapeLines', {
|
|
4081
4277
|
lineCount: lineInfos.length
|
|
4082
4278
|
});
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4279
|
+
try {
|
|
4280
|
+
const clustersByLine = [];
|
|
4281
|
+
lineInfos.forEach((lineInfo, lineIndex) => {
|
|
4282
|
+
const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
|
|
4283
|
+
clustersByLine.push(clusters);
|
|
4284
|
+
});
|
|
4285
|
+
return clustersByLine;
|
|
4286
|
+
}
|
|
4287
|
+
finally {
|
|
4288
|
+
perfLogger.end('TextShaper.shapeLines');
|
|
4289
|
+
}
|
|
4089
4290
|
}
|
|
4090
4291
|
shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
|
|
4091
4292
|
const buffer = this.loadedFont.hb.createBuffer();
|
|
@@ -4233,162 +4434,6 @@ class TextShaper {
|
|
|
4233
4434
|
}
|
|
4234
4435
|
}
|
|
4235
4436
|
|
|
4236
|
-
const DEFAULT_CACHE_SIZE_MB = 250;
|
|
4237
|
-
class GlyphCache {
|
|
4238
|
-
constructor(maxCacheSizeMB) {
|
|
4239
|
-
this.cache = new Map();
|
|
4240
|
-
this.head = null;
|
|
4241
|
-
this.tail = null;
|
|
4242
|
-
this.stats = {
|
|
4243
|
-
hits: 0,
|
|
4244
|
-
misses: 0,
|
|
4245
|
-
totalGlyphs: 0,
|
|
4246
|
-
uniqueGlyphs: 0,
|
|
4247
|
-
cacheSize: 0,
|
|
4248
|
-
saved: 0,
|
|
4249
|
-
memoryUsage: 0
|
|
4250
|
-
};
|
|
4251
|
-
if (maxCacheSizeMB) {
|
|
4252
|
-
this.maxCacheSize = maxCacheSizeMB * 1024 * 1024;
|
|
4253
|
-
}
|
|
4254
|
-
}
|
|
4255
|
-
getCacheKey(fontId, glyphId, depth, removeOverlaps) {
|
|
4256
|
-
// Round depth to avoid floating point precision issues
|
|
4257
|
-
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
4258
|
-
return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
|
|
4259
|
-
}
|
|
4260
|
-
has(fontId, glyphId, depth, removeOverlaps) {
|
|
4261
|
-
const key = this.getCacheKey(fontId, glyphId, depth, removeOverlaps);
|
|
4262
|
-
return this.cache.has(key);
|
|
4263
|
-
}
|
|
4264
|
-
get(fontId, glyphId, depth, removeOverlaps) {
|
|
4265
|
-
const key = this.getCacheKey(fontId, glyphId, depth, removeOverlaps);
|
|
4266
|
-
const node = this.cache.get(key);
|
|
4267
|
-
if (node) {
|
|
4268
|
-
this.stats.hits++;
|
|
4269
|
-
this.stats.saved++;
|
|
4270
|
-
node.data.useCount++;
|
|
4271
|
-
// Move to head (most recently used)
|
|
4272
|
-
this.moveToHead(node);
|
|
4273
|
-
this.stats.totalGlyphs++;
|
|
4274
|
-
return node.data;
|
|
4275
|
-
}
|
|
4276
|
-
else {
|
|
4277
|
-
this.stats.misses++;
|
|
4278
|
-
this.stats.totalGlyphs++;
|
|
4279
|
-
return undefined;
|
|
4280
|
-
}
|
|
4281
|
-
}
|
|
4282
|
-
set(fontId, glyphId, depth, removeOverlaps, glyph) {
|
|
4283
|
-
const key = this.getCacheKey(fontId, glyphId, depth, removeOverlaps);
|
|
4284
|
-
const memoryUsage = this.calculateMemoryUsage(glyph);
|
|
4285
|
-
// LRU eviction when memory limit exceeded
|
|
4286
|
-
if (this.maxCacheSize &&
|
|
4287
|
-
this.stats.memoryUsage + memoryUsage > this.maxCacheSize) {
|
|
4288
|
-
this.evictLRU(memoryUsage);
|
|
4289
|
-
}
|
|
4290
|
-
const node = {
|
|
4291
|
-
key,
|
|
4292
|
-
data: glyph,
|
|
4293
|
-
prev: null,
|
|
4294
|
-
next: null
|
|
4295
|
-
};
|
|
4296
|
-
this.cache.set(key, node);
|
|
4297
|
-
this.addToHead(node);
|
|
4298
|
-
this.stats.uniqueGlyphs = this.cache.size;
|
|
4299
|
-
this.stats.cacheSize++;
|
|
4300
|
-
this.stats.memoryUsage += memoryUsage;
|
|
4301
|
-
}
|
|
4302
|
-
calculateMemoryUsage(glyph) {
|
|
4303
|
-
let size = 0;
|
|
4304
|
-
// 3 floats per vertex * 4 bytes per float
|
|
4305
|
-
size += glyph.vertices.length * 4;
|
|
4306
|
-
// 3 floats per normal * 4 bytes per float
|
|
4307
|
-
size += glyph.normals.length * 4;
|
|
4308
|
-
// Indices (Uint16Array or Uint32Array)
|
|
4309
|
-
size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
|
|
4310
|
-
// Bounds (2 Vec3s = 6 floats * 4 bytes)
|
|
4311
|
-
size += 24;
|
|
4312
|
-
// Object overhead
|
|
4313
|
-
size += 256;
|
|
4314
|
-
return size;
|
|
4315
|
-
}
|
|
4316
|
-
// LRU eviction
|
|
4317
|
-
evictLRU(requiredSpace) {
|
|
4318
|
-
let freedSpace = 0;
|
|
4319
|
-
while (this.tail && freedSpace < requiredSpace) {
|
|
4320
|
-
const memoryUsage = this.calculateMemoryUsage(this.tail.data);
|
|
4321
|
-
const nodeToRemove = this.tail;
|
|
4322
|
-
this.removeTail();
|
|
4323
|
-
this.cache.delete(nodeToRemove.key);
|
|
4324
|
-
this.stats.memoryUsage -= memoryUsage;
|
|
4325
|
-
this.stats.cacheSize--;
|
|
4326
|
-
freedSpace += memoryUsage;
|
|
4327
|
-
}
|
|
4328
|
-
}
|
|
4329
|
-
addToHead(node) {
|
|
4330
|
-
if (!this.head) {
|
|
4331
|
-
this.head = this.tail = node;
|
|
4332
|
-
}
|
|
4333
|
-
else {
|
|
4334
|
-
node.next = this.head;
|
|
4335
|
-
this.head.prev = node;
|
|
4336
|
-
this.head = node;
|
|
4337
|
-
}
|
|
4338
|
-
}
|
|
4339
|
-
removeNode(node) {
|
|
4340
|
-
if (node.prev) {
|
|
4341
|
-
node.prev.next = node.next;
|
|
4342
|
-
}
|
|
4343
|
-
else {
|
|
4344
|
-
this.head = node.next;
|
|
4345
|
-
}
|
|
4346
|
-
if (node.next) {
|
|
4347
|
-
node.next.prev = node.prev;
|
|
4348
|
-
}
|
|
4349
|
-
else {
|
|
4350
|
-
this.tail = node.prev;
|
|
4351
|
-
}
|
|
4352
|
-
}
|
|
4353
|
-
removeTail() {
|
|
4354
|
-
if (this.tail) {
|
|
4355
|
-
this.removeNode(this.tail);
|
|
4356
|
-
}
|
|
4357
|
-
}
|
|
4358
|
-
moveToHead(node) {
|
|
4359
|
-
if (node === this.head)
|
|
4360
|
-
return;
|
|
4361
|
-
this.removeNode(node);
|
|
4362
|
-
this.addToHead(node);
|
|
4363
|
-
}
|
|
4364
|
-
clear() {
|
|
4365
|
-
this.cache.clear();
|
|
4366
|
-
this.head = null;
|
|
4367
|
-
this.tail = null;
|
|
4368
|
-
this.stats = {
|
|
4369
|
-
hits: 0,
|
|
4370
|
-
misses: 0,
|
|
4371
|
-
totalGlyphs: 0,
|
|
4372
|
-
uniqueGlyphs: 0,
|
|
4373
|
-
cacheSize: 0,
|
|
4374
|
-
saved: 0,
|
|
4375
|
-
memoryUsage: 0
|
|
4376
|
-
};
|
|
4377
|
-
}
|
|
4378
|
-
getStats() {
|
|
4379
|
-
const hitRate = this.stats.totalGlyphs > 0
|
|
4380
|
-
? (this.stats.hits / this.stats.totalGlyphs) * 100
|
|
4381
|
-
: 0;
|
|
4382
|
-
this.stats.uniqueGlyphs = this.cache.size;
|
|
4383
|
-
return {
|
|
4384
|
-
...this.stats,
|
|
4385
|
-
hitRate,
|
|
4386
|
-
memoryUsageMB: this.stats.memoryUsage / (1024 * 1024)
|
|
4387
|
-
};
|
|
4388
|
-
}
|
|
4389
|
-
}
|
|
4390
|
-
const globalGlyphCache = new GlyphCache(DEFAULT_CACHE_SIZE_MB);
|
|
4391
|
-
|
|
4392
4437
|
var hb = {exports: {}};
|
|
4393
4438
|
|
|
4394
4439
|
var fs = {}; const readFileSync = () => { throw new Error('fs not available in browser'); };
|
|
@@ -5094,14 +5139,25 @@ class TextRangeQuery {
|
|
|
5094
5139
|
max: { x: 0, y: 0, z: 0 }
|
|
5095
5140
|
};
|
|
5096
5141
|
}
|
|
5097
|
-
|
|
5142
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
5143
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
5098
5144
|
for (const glyph of glyphs) {
|
|
5099
|
-
|
|
5100
|
-
|
|
5145
|
+
if (glyph.bounds.min.x < minX)
|
|
5146
|
+
minX = glyph.bounds.min.x;
|
|
5147
|
+
if (glyph.bounds.min.y < minY)
|
|
5148
|
+
minY = glyph.bounds.min.y;
|
|
5149
|
+
if (glyph.bounds.min.z < minZ)
|
|
5150
|
+
minZ = glyph.bounds.min.z;
|
|
5151
|
+
if (glyph.bounds.max.x > maxX)
|
|
5152
|
+
maxX = glyph.bounds.max.x;
|
|
5153
|
+
if (glyph.bounds.max.y > maxY)
|
|
5154
|
+
maxY = glyph.bounds.max.y;
|
|
5155
|
+
if (glyph.bounds.max.z > maxZ)
|
|
5156
|
+
maxZ = glyph.bounds.max.z;
|
|
5101
5157
|
}
|
|
5102
5158
|
return {
|
|
5103
|
-
min: { x:
|
|
5104
|
-
max: { x:
|
|
5159
|
+
min: { x: minX, y: minY, z: minZ },
|
|
5160
|
+
max: { x: maxX, y: maxY, z: maxZ }
|
|
5105
5161
|
};
|
|
5106
5162
|
}
|
|
5107
5163
|
}
|
|
@@ -5111,8 +5167,16 @@ class Text {
|
|
|
5111
5167
|
static { this.patternCache = new Map(); }
|
|
5112
5168
|
static { this.hbInitPromise = null; }
|
|
5113
5169
|
static { this.fontCache = new Map(); }
|
|
5170
|
+
static { this.fontCacheMemoryBytes = 0; }
|
|
5171
|
+
static { this.maxFontCacheMemoryBytes = Infinity; }
|
|
5114
5172
|
static { this.fontIdCounter = 0; }
|
|
5115
|
-
|
|
5173
|
+
// Stringify with sorted keys for cache stability
|
|
5174
|
+
static stableStringify(obj) {
|
|
5175
|
+
const keys = Object.keys(obj).sort();
|
|
5176
|
+
const pairs = keys.map(k => `${k}:${obj[k]}`);
|
|
5177
|
+
return pairs.join(',');
|
|
5178
|
+
}
|
|
5179
|
+
constructor() {
|
|
5116
5180
|
this.currentFontId = '';
|
|
5117
5181
|
if (!Text.hbInitPromise) {
|
|
5118
5182
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
@@ -5143,7 +5207,7 @@ class Text {
|
|
|
5143
5207
|
Text.hbInitPromise = HarfBuzzLoader.getHarfBuzz();
|
|
5144
5208
|
}
|
|
5145
5209
|
const loadedFont = await Text.resolveFont(options);
|
|
5146
|
-
const text = new Text(
|
|
5210
|
+
const text = new Text();
|
|
5147
5211
|
text.setLoadedFont(loadedFont);
|
|
5148
5212
|
// Initial creation
|
|
5149
5213
|
const { font, maxCacheSizeMB, ...geometryOptions } = options;
|
|
@@ -5195,10 +5259,10 @@ class Text {
|
|
|
5195
5259
|
: `buffer-${Text.generateFontContentHash(options.font)}`;
|
|
5196
5260
|
let fontKey = baseFontKey;
|
|
5197
5261
|
if (options.fontVariations) {
|
|
5198
|
-
fontKey += `_var_${
|
|
5262
|
+
fontKey += `_var_${Text.stableStringify(options.fontVariations)}`;
|
|
5199
5263
|
}
|
|
5200
5264
|
if (options.fontFeatures) {
|
|
5201
|
-
fontKey += `_feat_${
|
|
5265
|
+
fontKey += `_feat_${Text.stableStringify(options.fontFeatures)}`;
|
|
5202
5266
|
}
|
|
5203
5267
|
let loadedFont = Text.fontCache.get(fontKey);
|
|
5204
5268
|
if (!loadedFont) {
|
|
@@ -5211,17 +5275,53 @@ class Text {
|
|
|
5211
5275
|
await tempText.loadFont(font, fontVariations, fontFeatures);
|
|
5212
5276
|
const loadedFont = tempText.getLoadedFont();
|
|
5213
5277
|
Text.fontCache.set(fontKey, loadedFont);
|
|
5278
|
+
Text.trackFontCacheAdd(loadedFont);
|
|
5279
|
+
Text.enforceFontCacheMemoryLimit();
|
|
5214
5280
|
return loadedFont;
|
|
5215
5281
|
}
|
|
5282
|
+
static trackFontCacheAdd(loadedFont) {
|
|
5283
|
+
const size = loadedFont._buffer?.byteLength ?? 0;
|
|
5284
|
+
Text.fontCacheMemoryBytes += size;
|
|
5285
|
+
}
|
|
5286
|
+
static trackFontCacheRemove(fontKey) {
|
|
5287
|
+
const font = Text.fontCache.get(fontKey);
|
|
5288
|
+
if (!font)
|
|
5289
|
+
return;
|
|
5290
|
+
const size = font._buffer?.byteLength ?? 0;
|
|
5291
|
+
Text.fontCacheMemoryBytes -= size;
|
|
5292
|
+
if (Text.fontCacheMemoryBytes < 0)
|
|
5293
|
+
Text.fontCacheMemoryBytes = 0;
|
|
5294
|
+
}
|
|
5295
|
+
static enforceFontCacheMemoryLimit() {
|
|
5296
|
+
if (Text.maxFontCacheMemoryBytes === Infinity)
|
|
5297
|
+
return;
|
|
5298
|
+
while (Text.fontCacheMemoryBytes > Text.maxFontCacheMemoryBytes &&
|
|
5299
|
+
Text.fontCache.size > 0) {
|
|
5300
|
+
const firstKey = Text.fontCache.keys().next().value;
|
|
5301
|
+
if (firstKey === undefined)
|
|
5302
|
+
break;
|
|
5303
|
+
Text.trackFontCacheRemove(firstKey);
|
|
5304
|
+
Text.fontCache.delete(firstKey);
|
|
5305
|
+
}
|
|
5306
|
+
}
|
|
5216
5307
|
static generateFontContentHash(buffer) {
|
|
5217
5308
|
if (buffer) {
|
|
5218
|
-
//
|
|
5309
|
+
// FNV-1a hash sampling 32 points
|
|
5219
5310
|
const view = new Uint8Array(buffer);
|
|
5220
|
-
|
|
5311
|
+
let hash = 2166136261;
|
|
5312
|
+
const samplePoints = Math.min(32, view.length);
|
|
5313
|
+
const step = Math.floor(view.length / samplePoints);
|
|
5314
|
+
for (let i = 0; i < samplePoints; i++) {
|
|
5315
|
+
const index = i * step;
|
|
5316
|
+
hash ^= view[index];
|
|
5317
|
+
hash = Math.imul(hash, 16777619);
|
|
5318
|
+
}
|
|
5319
|
+
hash ^= view.length;
|
|
5320
|
+
hash = Math.imul(hash, 16777619);
|
|
5321
|
+
return (hash >>> 0).toString(36);
|
|
5221
5322
|
}
|
|
5222
5323
|
else {
|
|
5223
|
-
|
|
5224
|
-
return `${++Text.fontIdCounter}`;
|
|
5324
|
+
return `c${++Text.fontIdCounter}`;
|
|
5225
5325
|
}
|
|
5226
5326
|
}
|
|
5227
5327
|
setLoadedFont(loadedFont) {
|
|
@@ -5229,10 +5329,10 @@ class Text {
|
|
|
5229
5329
|
const contentHash = Text.generateFontContentHash(loadedFont._buffer);
|
|
5230
5330
|
this.currentFontId = `font_${contentHash}`;
|
|
5231
5331
|
if (loadedFont.fontVariations) {
|
|
5232
|
-
this.currentFontId += `_var_${
|
|
5332
|
+
this.currentFontId += `_var_${Text.stableStringify(loadedFont.fontVariations)}`;
|
|
5233
5333
|
}
|
|
5234
5334
|
if (loadedFont.fontFeatures) {
|
|
5235
|
-
this.currentFontId += `_feat_${
|
|
5335
|
+
this.currentFontId += `_feat_${Text.stableStringify(loadedFont.fontFeatures)}`;
|
|
5236
5336
|
}
|
|
5237
5337
|
}
|
|
5238
5338
|
async loadFont(fontSrc, fontVariations, fontFeatures) {
|
|
@@ -5262,10 +5362,10 @@ class Text {
|
|
|
5262
5362
|
const contentHash = Text.generateFontContentHash(fontBuffer);
|
|
5263
5363
|
this.currentFontId = `font_${contentHash}`;
|
|
5264
5364
|
if (fontVariations) {
|
|
5265
|
-
this.currentFontId += `_var_${
|
|
5365
|
+
this.currentFontId += `_var_${Text.stableStringify(fontVariations)}`;
|
|
5266
5366
|
}
|
|
5267
5367
|
if (fontFeatures) {
|
|
5268
|
-
this.currentFontId += `_feat_${
|
|
5368
|
+
this.currentFontId += `_feat_${Text.stableStringify(fontFeatures)}`;
|
|
5269
5369
|
}
|
|
5270
5370
|
}
|
|
5271
5371
|
catch (error) {
|
|
@@ -5293,7 +5393,7 @@ class Text {
|
|
|
5293
5393
|
this.updateFontVariations(options);
|
|
5294
5394
|
if (!this.geometryBuilder) {
|
|
5295
5395
|
const cache = options.maxCacheSizeMB
|
|
5296
|
-
?
|
|
5396
|
+
? createGlyphCache(options.maxCacheSizeMB)
|
|
5297
5397
|
: globalGlyphCache;
|
|
5298
5398
|
this.geometryBuilder = new GlyphGeometryBuilder(cache, this.loadedFont);
|
|
5299
5399
|
this.geometryBuilder.setFontId(this.currentFontId);
|
|
@@ -5396,8 +5496,8 @@ class Text {
|
|
|
5396
5496
|
}
|
|
5397
5497
|
updateFontVariations(options) {
|
|
5398
5498
|
if (options.fontVariations && this.loadedFont) {
|
|
5399
|
-
if (
|
|
5400
|
-
|
|
5499
|
+
if (Text.stableStringify(options.fontVariations) !==
|
|
5500
|
+
Text.stableStringify(this.loadedFont.fontVariations || {})) {
|
|
5401
5501
|
this.loadedFont.font.setVariations(options.fontVariations);
|
|
5402
5502
|
this.loadedFont.fontVariations = options.fontVariations;
|
|
5403
5503
|
}
|
|
@@ -5413,7 +5513,12 @@ class Text {
|
|
|
5413
5513
|
if (width !== undefined) {
|
|
5414
5514
|
widthInFontUnits = width * (this.loadedFont.upem / size);
|
|
5415
5515
|
}
|
|
5416
|
-
|
|
5516
|
+
// Keep depth behavior consistent with Extruder: extremely small non-zero depths
|
|
5517
|
+
// are clamped to a minimum back offset so the back face is not coplanar.
|
|
5518
|
+
const depthScale = this.loadedFont.upem / size;
|
|
5519
|
+
const rawDepthInFontUnits = depth * depthScale;
|
|
5520
|
+
const minExtrudeDepth = this.loadedFont.upem * 0.000025;
|
|
5521
|
+
const depthInFontUnits = rawDepthInFontUnits <= 0 ? 0 : Math.max(rawDepthInFontUnits, minExtrudeDepth);
|
|
5417
5522
|
if (!this.textLayout) {
|
|
5418
5523
|
this.textLayout = new TextLayout(this.loadedFont);
|
|
5419
5524
|
}
|
|
@@ -5645,6 +5750,15 @@ class Text {
|
|
|
5645
5750
|
static registerPattern(language, pattern) {
|
|
5646
5751
|
Text.patternCache.set(language, pattern);
|
|
5647
5752
|
}
|
|
5753
|
+
static clearFontCache() {
|
|
5754
|
+
Text.fontCache.clear();
|
|
5755
|
+
Text.fontCacheMemoryBytes = 0;
|
|
5756
|
+
}
|
|
5757
|
+
static setMaxFontCacheMemoryMB(limitMB) {
|
|
5758
|
+
Text.maxFontCacheMemoryBytes =
|
|
5759
|
+
limitMB === Infinity ? Infinity : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
|
|
5760
|
+
Text.enforceFontCacheMemoryLimit();
|
|
5761
|
+
}
|
|
5648
5762
|
getLoadedFont() {
|
|
5649
5763
|
return this.loadedFont;
|
|
5650
5764
|
}
|
|
@@ -5717,4 +5831,5 @@ class Text {
|
|
|
5717
5831
|
exports.DEFAULT_CURVE_FIDELITY = DEFAULT_CURVE_FIDELITY;
|
|
5718
5832
|
exports.FontMetadataExtractor = FontMetadataExtractor;
|
|
5719
5833
|
exports.Text = Text;
|
|
5834
|
+
exports.createGlyphCache = createGlyphCache;
|
|
5720
5835
|
exports.globalGlyphCache = globalGlyphCache;
|