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