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