three-text 0.2.19 → 0.3.0

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.19
2
+ * three-text v0.3.0
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -2379,231 +2379,50 @@ class Vec3 {
2379
2379
  }
2380
2380
  }
2381
2381
 
2382
- // Generic LRU (Least Recently Used) cache with optional memory-based eviction
2383
- class LRUCache {
2384
- constructor(options = {}) {
2382
+ // Map-based cache with no eviction policy
2383
+ class Cache {
2384
+ constructor() {
2385
2385
  this.cache = new Map();
2386
- this.head = null;
2387
- this.tail = null;
2388
- this.stats = {
2389
- hits: 0,
2390
- misses: 0,
2391
- evictions: 0,
2392
- size: 0,
2393
- memoryUsage: 0
2394
- };
2395
- this.options = {
2396
- maxEntries: options.maxEntries ?? Infinity,
2397
- maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
2398
- calculateSize: options.calculateSize ?? (() => 0),
2399
- onEvict: options.onEvict
2400
- };
2401
2386
  }
2402
2387
  get(key) {
2403
- const node = this.cache.get(key);
2404
- if (node) {
2405
- this.stats.hits++;
2406
- this.moveToHead(node);
2407
- return node.value;
2408
- }
2409
- else {
2410
- this.stats.misses++;
2411
- return undefined;
2412
- }
2388
+ return this.cache.get(key);
2413
2389
  }
2414
2390
  has(key) {
2415
2391
  return this.cache.has(key);
2416
2392
  }
2417
2393
  set(key, value) {
2418
- // If key already exists, update it
2419
- const existingNode = this.cache.get(key);
2420
- if (existingNode) {
2421
- const oldSize = this.options.calculateSize(existingNode.value);
2422
- const newSize = this.options.calculateSize(value);
2423
- this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
2424
- existingNode.value = value;
2425
- this.moveToHead(existingNode);
2426
- return;
2427
- }
2428
- const size = this.options.calculateSize(value);
2429
- // Evict entries if we exceed limits
2430
- this.evictIfNeeded(size);
2431
- // Create new node
2432
- const node = {
2433
- key,
2434
- value,
2435
- prev: null,
2436
- next: null
2437
- };
2438
- this.cache.set(key, node);
2439
- this.addToHead(node);
2440
- this.stats.size = this.cache.size;
2441
- this.stats.memoryUsage += size;
2394
+ this.cache.set(key, value);
2442
2395
  }
2443
2396
  delete(key) {
2444
- const node = this.cache.get(key);
2445
- if (!node)
2446
- return false;
2447
- const size = this.options.calculateSize(node.value);
2448
- this.removeNode(node);
2449
- this.cache.delete(key);
2450
- this.stats.size = this.cache.size;
2451
- this.stats.memoryUsage -= size;
2452
- if (this.options.onEvict) {
2453
- this.options.onEvict(key, node.value);
2454
- }
2455
- return true;
2397
+ return this.cache.delete(key);
2456
2398
  }
2457
2399
  clear() {
2458
- if (this.options.onEvict) {
2459
- for (const [key, node] of this.cache) {
2460
- this.options.onEvict(key, node.value);
2461
- }
2462
- }
2463
2400
  this.cache.clear();
2464
- this.head = null;
2465
- this.tail = null;
2466
- this.stats = {
2467
- hits: 0,
2468
- misses: 0,
2469
- evictions: 0,
2470
- size: 0,
2471
- memoryUsage: 0
2472
- };
2473
- }
2474
- getStats() {
2475
- const total = this.stats.hits + this.stats.misses;
2476
- const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
2477
- const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
2478
- return {
2479
- ...this.stats,
2480
- hitRate,
2481
- memoryUsageMB
2482
- };
2483
- }
2484
- keys() {
2485
- const keys = [];
2486
- let current = this.head;
2487
- while (current) {
2488
- keys.push(current.key);
2489
- current = current.next;
2490
- }
2491
- return keys;
2492
2401
  }
2493
2402
  get size() {
2494
2403
  return this.cache.size;
2495
2404
  }
2496
- evictIfNeeded(requiredSize) {
2497
- // Evict by entry count
2498
- while (this.cache.size >= this.options.maxEntries && this.tail) {
2499
- this.evictTail();
2500
- }
2501
- // Evict by memory usage
2502
- if (this.options.maxMemoryBytes < Infinity) {
2503
- while (this.tail &&
2504
- this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
2505
- this.evictTail();
2506
- }
2507
- }
2508
- }
2509
- evictTail() {
2510
- if (!this.tail)
2511
- return;
2512
- const nodeToRemove = this.tail;
2513
- const size = this.options.calculateSize(nodeToRemove.value);
2514
- this.removeTail();
2515
- this.cache.delete(nodeToRemove.key);
2516
- this.stats.size = this.cache.size;
2517
- this.stats.memoryUsage -= size;
2518
- this.stats.evictions++;
2519
- if (this.options.onEvict) {
2520
- this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
2521
- }
2522
- }
2523
- addToHead(node) {
2524
- node.prev = null;
2525
- node.next = null;
2526
- if (!this.head) {
2527
- this.head = this.tail = node;
2528
- }
2529
- else {
2530
- node.next = this.head;
2531
- this.head.prev = node;
2532
- this.head = node;
2533
- }
2534
- }
2535
- removeNode(node) {
2536
- if (node.prev) {
2537
- node.prev.next = node.next;
2538
- }
2539
- else {
2540
- this.head = node.next;
2541
- }
2542
- if (node.next) {
2543
- node.next.prev = node.prev;
2544
- }
2545
- else {
2546
- this.tail = node.prev;
2547
- }
2548
- }
2549
- removeTail() {
2550
- if (this.tail) {
2551
- this.removeNode(this.tail);
2552
- }
2405
+ keys() {
2406
+ return Array.from(this.cache.keys());
2553
2407
  }
2554
- moveToHead(node) {
2555
- if (node === this.head)
2556
- return;
2557
- this.removeNode(node);
2558
- this.addToHead(node);
2408
+ getStats() {
2409
+ return {
2410
+ size: this.cache.size
2411
+ };
2559
2412
  }
2560
2413
  }
2561
2414
 
2562
- const DEFAULT_CACHE_SIZE_MB = 250;
2563
2415
  function getGlyphCacheKey(fontId, glyphId, depth, removeOverlaps) {
2564
2416
  const roundedDepth = Math.round(depth * 1000) / 1000;
2565
2417
  return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
2566
2418
  }
2567
- function calculateGlyphMemoryUsage(glyph) {
2568
- let size = 0;
2569
- size += glyph.vertices.length * 4;
2570
- size += glyph.normals.length * 4;
2571
- size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
2572
- size += 24; // 2 Vec3s
2573
- size += 256; // Object overhead
2574
- return size;
2419
+ const globalGlyphCache = new Cache();
2420
+ function createGlyphCache() {
2421
+ return new Cache();
2575
2422
  }
2576
- const globalGlyphCache = new LRUCache({
2577
- maxEntries: Infinity,
2578
- maxMemoryBytes: DEFAULT_CACHE_SIZE_MB * 1024 * 1024,
2579
- calculateSize: calculateGlyphMemoryUsage
2580
- });
2581
- function createGlyphCache(maxCacheSizeMB = DEFAULT_CACHE_SIZE_MB) {
2582
- return new LRUCache({
2583
- maxEntries: Infinity,
2584
- maxMemoryBytes: maxCacheSizeMB * 1024 * 1024,
2585
- calculateSize: calculateGlyphMemoryUsage
2586
- });
2587
- }
2588
- // Shared across builder instances: contour extraction, word clustering, boundary grouping
2589
- const globalContourCache = new LRUCache({
2590
- maxEntries: 1000,
2591
- calculateSize: (contours) => {
2592
- let size = 0;
2593
- for (const path of contours.paths) {
2594
- size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
2595
- }
2596
- return size + 64; // bounds overhead
2597
- }
2598
- });
2599
- const globalWordCache = new LRUCache({
2600
- maxEntries: 1000,
2601
- calculateSize: calculateGlyphMemoryUsage
2602
- });
2603
- const globalClusteringCache = new LRUCache({
2604
- maxEntries: 2000,
2605
- calculateSize: () => 1
2606
- });
2423
+ const globalContourCache = new Cache();
2424
+ const globalWordCache = new Cache();
2425
+ const globalClusteringCache = new Cache();
2607
2426
 
2608
2427
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
2609
2428
 
@@ -2938,47 +2757,79 @@ class Tessellator {
2938
2757
 
2939
2758
  class Extruder {
2940
2759
  constructor() { }
2941
- packEdge(a, b) {
2942
- const lo = a < b ? a : b;
2943
- const hi = a < b ? b : a;
2944
- return lo * 0x100000000 + hi;
2945
- }
2946
2760
  extrude(geometry, depth = 0, unitsPerEm) {
2947
2761
  const points = geometry.triangles.vertices;
2948
2762
  const triangleIndices = geometry.triangles.indices;
2949
2763
  const numPoints = points.length / 2;
2950
- // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2764
+ // Boundary edges are those that appear in exactly one triangle
2951
2765
  let boundaryEdges = [];
2952
2766
  if (depth !== 0) {
2953
- const counts = new Map();
2954
- const oriented = new Map();
2955
- for (let i = 0; i < triangleIndices.length; i += 3) {
2767
+ // Pack edge pair into integer key: (min << 16) | max
2768
+ // Fits glyph vertex indices comfortably, good hash distribution
2769
+ const edgeMap = new Map();
2770
+ const triLen = triangleIndices.length;
2771
+ for (let i = 0; i < triLen; i += 3) {
2956
2772
  const a = triangleIndices[i];
2957
2773
  const b = triangleIndices[i + 1];
2958
2774
  const c = triangleIndices[i + 2];
2959
- const k0 = this.packEdge(a, b);
2960
- const n0 = (counts.get(k0) ?? 0) + 1;
2961
- counts.set(k0, n0);
2962
- if (n0 === 1)
2963
- oriented.set(k0, [a, b]);
2964
- const k1 = this.packEdge(b, c);
2965
- const n1 = (counts.get(k1) ?? 0) + 1;
2966
- counts.set(k1, n1);
2967
- if (n1 === 1)
2968
- oriented.set(k1, [b, c]);
2969
- const k2 = this.packEdge(c, a);
2970
- const n2 = (counts.get(k2) ?? 0) + 1;
2971
- counts.set(k2, n2);
2972
- if (n2 === 1)
2973
- oriented.set(k2, [c, a]);
2775
+ let key, v0, v1;
2776
+ if (a < b) {
2777
+ key = (a << 16) | b;
2778
+ v0 = a;
2779
+ v1 = b;
2780
+ }
2781
+ else {
2782
+ key = (b << 16) | a;
2783
+ v0 = a;
2784
+ v1 = b;
2785
+ }
2786
+ let data = edgeMap.get(key);
2787
+ if (data) {
2788
+ data[2]++;
2789
+ }
2790
+ else {
2791
+ edgeMap.set(key, [v0, v1, 1]);
2792
+ }
2793
+ if (b < c) {
2794
+ key = (b << 16) | c;
2795
+ v0 = b;
2796
+ v1 = c;
2797
+ }
2798
+ else {
2799
+ key = (c << 16) | b;
2800
+ v0 = b;
2801
+ v1 = c;
2802
+ }
2803
+ data = edgeMap.get(key);
2804
+ if (data) {
2805
+ data[2]++;
2806
+ }
2807
+ else {
2808
+ edgeMap.set(key, [v0, v1, 1]);
2809
+ }
2810
+ if (c < a) {
2811
+ key = (c << 16) | a;
2812
+ v0 = c;
2813
+ v1 = a;
2814
+ }
2815
+ else {
2816
+ key = (a << 16) | c;
2817
+ v0 = c;
2818
+ v1 = a;
2819
+ }
2820
+ data = edgeMap.get(key);
2821
+ if (data) {
2822
+ data[2]++;
2823
+ }
2824
+ else {
2825
+ edgeMap.set(key, [v0, v1, 1]);
2826
+ }
2974
2827
  }
2975
2828
  boundaryEdges = [];
2976
- for (const [key, count] of counts) {
2977
- if (count !== 1)
2978
- continue;
2979
- const edge = oriented.get(key);
2980
- if (edge)
2981
- boundaryEdges.push(edge);
2829
+ for (const [v0, v1, count] of edgeMap.values()) {
2830
+ if (count === 1) {
2831
+ boundaryEdges.push([v0, v1]);
2832
+ }
2982
2833
  }
2983
2834
  }
2984
2835
  const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
@@ -2992,7 +2843,6 @@ class Extruder {
2992
2843
  : triangleIndices.length * 2 + sideEdgeCount * 6;
2993
2844
  const indices = new Uint32Array(indexCount);
2994
2845
  if (depth === 0) {
2995
- // Single-sided flat geometry at z=0
2996
2846
  let vPos = 0;
2997
2847
  for (let i = 0; i < points.length; i += 2) {
2998
2848
  vertices[vPos] = points[i];
@@ -3003,16 +2853,11 @@ class Extruder {
3003
2853
  normals[vPos + 2] = 1;
3004
2854
  vPos += 3;
3005
2855
  }
3006
- // libtess outputs CCW, use as-is for +Z facing geometry
3007
- for (let i = 0; i < triangleIndices.length; i++) {
3008
- indices[i] = triangleIndices[i];
3009
- }
2856
+ indices.set(triangleIndices);
3010
2857
  return { vertices, normals, indices };
3011
2858
  }
3012
- // Extruded geometry: front at z=0, back at z=depth
3013
2859
  const minBackOffset = unitsPerEm * 0.000025;
3014
2860
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
3015
- // Generate both caps in one pass
3016
2861
  for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
3017
2862
  const x = points[p];
3018
2863
  const y = points[p + 1];
@@ -3033,41 +2878,39 @@ class Extruder {
3033
2878
  normals[baseD + 1] = 0;
3034
2879
  normals[baseD + 2] = 1;
3035
2880
  }
3036
- // libtess outputs CCW triangles (viewed from +Z)
3037
- // Z=0 cap faces -Z, reverse winding
3038
- for (let i = 0; i < triangleIndices.length; i++) {
3039
- indices[i] = triangleIndices[triangleIndices.length - 1 - i];
2881
+ // Front cap faces -Z, reverse winding from libtess CCW output
2882
+ const triLen = triangleIndices.length;
2883
+ for (let i = 0; i < triLen; i++) {
2884
+ indices[i] = triangleIndices[triLen - 1 - i];
3040
2885
  }
3041
- // Z=depth cap faces +Z, use original winding
3042
- for (let i = 0; i < triangleIndices.length; i++) {
3043
- indices[triangleIndices.length + i] = triangleIndices[i] + numPoints;
2886
+ // Back cap faces +Z, use original winding
2887
+ for (let i = 0; i < triLen; i++) {
2888
+ indices[triLen + i] = triangleIndices[i] + numPoints;
3044
2889
  }
3045
- // Side walls
3046
2890
  let nextVertex = numPoints * 2;
3047
- let idxPos = triangleIndices.length * 2;
3048
- for (let e = 0; e < boundaryEdges.length; e++) {
3049
- const [u, v] = boundaryEdges[e];
3050
- const u2 = u * 2;
3051
- const v2 = v * 2;
2891
+ let idxPos = triLen * 2;
2892
+ const numEdges = boundaryEdges.length;
2893
+ for (let e = 0; e < numEdges; e++) {
2894
+ const edge = boundaryEdges[e];
2895
+ const u = edge[0];
2896
+ const v = edge[1];
2897
+ const u2 = u << 1;
2898
+ const v2 = v << 1;
3052
2899
  const p0x = points[u2];
3053
2900
  const p0y = points[u2 + 1];
3054
2901
  const p1x = points[v2];
3055
2902
  const p1y = points[v2 + 1];
3056
- // Perpendicular normal for this wall segment
3057
- // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3058
2903
  const ex = p1x - p0x;
3059
2904
  const ey = p1y - p0y;
3060
2905
  const lenSq = ex * ex + ey * ey;
3061
2906
  let nx = 0;
3062
2907
  let ny = 0;
3063
- if (lenSq > 0) {
2908
+ if (lenSq > 1e-10) {
3064
2909
  const invLen = 1 / Math.sqrt(lenSq);
3065
2910
  nx = ey * invLen;
3066
2911
  ny = -ex * invLen;
3067
2912
  }
3068
- const baseVertex = nextVertex;
3069
- const base = baseVertex * 3;
3070
- // Wall quad: front edge at z=0, back edge at z=depth
2913
+ const base = nextVertex * 3;
3071
2914
  vertices[base] = p0x;
3072
2915
  vertices[base + 1] = p0y;
3073
2916
  vertices[base + 2] = 0;
@@ -3080,7 +2923,6 @@ class Extruder {
3080
2923
  vertices[base + 9] = p1x;
3081
2924
  vertices[base + 10] = p1y;
3082
2925
  vertices[base + 11] = backZ;
3083
- // Wall normals point perpendicular to edge
3084
2926
  normals[base] = nx;
3085
2927
  normals[base + 1] = ny;
3086
2928
  normals[base + 2] = 0;
@@ -3093,13 +2935,14 @@ class Extruder {
3093
2935
  normals[base + 9] = nx;
3094
2936
  normals[base + 10] = ny;
3095
2937
  normals[base + 11] = 0;
3096
- // Two triangles per wall segment
3097
- indices[idxPos++] = baseVertex;
3098
- indices[idxPos++] = baseVertex + 1;
3099
- indices[idxPos++] = baseVertex + 2;
3100
- indices[idxPos++] = baseVertex + 1;
3101
- indices[idxPos++] = baseVertex + 3;
3102
- indices[idxPos++] = baseVertex + 2;
2938
+ const baseVertex = nextVertex;
2939
+ indices[idxPos] = baseVertex;
2940
+ indices[idxPos + 1] = baseVertex + 1;
2941
+ indices[idxPos + 2] = baseVertex + 2;
2942
+ indices[idxPos + 3] = baseVertex + 1;
2943
+ indices[idxPos + 4] = baseVertex + 3;
2944
+ indices[idxPos + 5] = baseVertex + 2;
2945
+ idxPos += 6;
3103
2946
  nextVertex += 4;
3104
2947
  }
3105
2948
  return { vertices, normals, indices };
@@ -4116,7 +3959,7 @@ class GlyphGeometryBuilder {
4116
3959
  ].join('|');
4117
3960
  }
4118
3961
  // Build instanced geometry from glyph contours
4119
- buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3962
+ buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, scale, separateGlyphs = false, coloredTextIndices) {
4120
3963
  if (isLogEnabled) {
4121
3964
  let wordCount = 0;
4122
3965
  for (let i = 0; i < clustersByLine.length; i++) {
@@ -4294,9 +4137,9 @@ class GlyphGeometryBuilder {
4294
4137
  const py = task.py;
4295
4138
  const pz = task.pz;
4296
4139
  for (let j = 0; j < v.length; j += 3) {
4297
- vertexArray[vertexPos++] = v[j] + px;
4298
- vertexArray[vertexPos++] = v[j + 1] + py;
4299
- vertexArray[vertexPos++] = v[j + 2] + pz;
4140
+ vertexArray[vertexPos++] = (v[j] + px) * scale;
4141
+ vertexArray[vertexPos++] = (v[j + 1] + py) * scale;
4142
+ vertexArray[vertexPos++] = (v[j + 2] + pz) * scale;
4300
4143
  }
4301
4144
  normalArray.set(n, normalPos);
4302
4145
  normalPos += n.length;
@@ -4306,6 +4149,20 @@ class GlyphGeometryBuilder {
4306
4149
  }
4307
4150
  }
4308
4151
  perfLogger.end('GlyphGeometryBuilder.buildInstancedGeometry');
4152
+ planeBounds.min.x *= scale;
4153
+ planeBounds.min.y *= scale;
4154
+ planeBounds.min.z *= scale;
4155
+ planeBounds.max.x *= scale;
4156
+ planeBounds.max.y *= scale;
4157
+ planeBounds.max.z *= scale;
4158
+ for (let i = 0; i < glyphInfos.length; i++) {
4159
+ glyphInfos[i].bounds.min.x *= scale;
4160
+ glyphInfos[i].bounds.min.y *= scale;
4161
+ glyphInfos[i].bounds.min.z *= scale;
4162
+ glyphInfos[i].bounds.max.x *= scale;
4163
+ glyphInfos[i].bounds.max.y *= scale;
4164
+ glyphInfos[i].bounds.max.z *= scale;
4165
+ }
4309
4166
  return {
4310
4167
  vertices: vertexArray,
4311
4168
  normals: normalArray,
@@ -4317,7 +4174,6 @@ class GlyphGeometryBuilder {
4317
4174
  getClusterKey(glyphs, depth, removeOverlaps) {
4318
4175
  if (glyphs.length === 0)
4319
4176
  return '';
4320
- // Normalize positions relative to the first glyph in the cluster
4321
4177
  const refX = glyphs[0].x ?? 0;
4322
4178
  const refY = glyphs[0].y ?? 0;
4323
4179
  const parts = glyphs.map((g) => {
@@ -5372,6 +5228,7 @@ class TextRangeQuery {
5372
5228
  }
5373
5229
 
5374
5230
  const DEFAULT_MAX_TEXT_LENGTH = 100000;
5231
+ const DEFAULT_FONT_SIZE = 72;
5375
5232
  class Text {
5376
5233
  static { this.patternCache = new Map(); }
5377
5234
  static { this.hbInitPromise = null; }
@@ -5445,7 +5302,7 @@ class Text {
5445
5302
  return {
5446
5303
  ...newResult,
5447
5304
  getLoadedFont: () => text.getLoadedFont(),
5448
- getCacheStatistics: () => text.getCacheStatistics(),
5305
+ getCacheSize: () => text.getCacheSize(),
5449
5306
  clearCache: () => text.clearCache(),
5450
5307
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5451
5308
  update
@@ -5454,7 +5311,7 @@ class Text {
5454
5311
  return {
5455
5312
  ...result,
5456
5313
  getLoadedFont: () => text.getLoadedFont(),
5457
- getCacheStatistics: () => text.getCacheStatistics(),
5314
+ getCacheSize: () => text.getCacheSize(),
5458
5315
  clearCache: () => text.clearCache(),
5459
5316
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5460
5317
  update
@@ -5586,7 +5443,7 @@ class Text {
5586
5443
  async createGeometry(options) {
5587
5444
  perfLogger.start('Text.createGeometry', {
5588
5445
  textLength: options.text.length,
5589
- size: options.size || 72,
5446
+ size: options.size || DEFAULT_FONT_SIZE,
5590
5447
  hasLayout: !!options.layout,
5591
5448
  mode: 'cached'
5592
5449
  });
@@ -5600,7 +5457,7 @@ class Text {
5600
5457
  this.updateFontVariations(options);
5601
5458
  if (!this.geometryBuilder) {
5602
5459
  const cache = options.maxCacheSizeMB
5603
- ? createGlyphCache(options.maxCacheSizeMB)
5460
+ ? createGlyphCache()
5604
5461
  : globalGlyphCache;
5605
5462
  this.geometryBuilder = new GlyphGeometryBuilder(cache, this.loadedFont);
5606
5463
  this.geometryBuilder.setFontId(this.currentFontId);
@@ -5646,10 +5503,9 @@ class Text {
5646
5503
  }
5647
5504
  }
5648
5505
  }
5649
- const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
5650
- const cacheStats = this.geometryBuilder.getCacheStats();
5651
- const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
5652
- if (options.separateGlyphsWithAttributes) {
5506
+ const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, layoutData.pixelsPerFontUnit, options.perGlyphAttributes ?? false, coloredTextIndices);
5507
+ const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, options.text);
5508
+ if (options.perGlyphAttributes) {
5653
5509
  const glyphAttrs = this.createGlyphAttributes(result.vertices.length / 3, result.glyphs);
5654
5510
  result.glyphAttributes = glyphAttrs;
5655
5511
  }
@@ -5716,16 +5572,16 @@ class Text {
5716
5572
  if (!this.loadedFont) {
5717
5573
  throw new Error('Font not loaded. Use Text.create() with a font option');
5718
5574
  }
5719
- const { text, size = 72, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5575
+ const { text, size = DEFAULT_FONT_SIZE, depth = 0, lineHeight = 1.0, letterSpacing = 0, layout = {} } = options;
5720
5576
  const { width, direction = 'ltr', align = direction === 'rtl' ? 'right' : 'left', respectExistingBreaks = true, hyphenate = true, language = 'en-us', tolerance = DEFAULT_TOLERANCE, pretolerance = DEFAULT_PRETOLERANCE, emergencyStretch = DEFAULT_EMERGENCY_STRETCH, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableShortLineDetection, shortLineThreshold } = layout;
5577
+ const fontUnitsPerPixel = this.loadedFont.upem / size;
5721
5578
  let widthInFontUnits;
5722
5579
  if (width !== undefined) {
5723
- widthInFontUnits = width * (this.loadedFont.upem / size);
5580
+ widthInFontUnits = width * fontUnitsPerPixel;
5724
5581
  }
5725
5582
  // Keep depth behavior consistent with Extruder: extremely small non-zero depths
5726
- // are clamped to a minimum back offset so the back face is not coplanar.
5727
- const depthScale = this.loadedFont.upem / size;
5728
- const rawDepthInFontUnits = depth * depthScale;
5583
+ // are clamped to a minimum back offset to prevent Z fighting
5584
+ const rawDepthInFontUnits = depth * fontUnitsPerPixel;
5729
5585
  const minExtrudeDepth = this.loadedFont.upem * 0.000025;
5730
5586
  const depthInFontUnits = rawDepthInFontUnits <= 0
5731
5587
  ? 0
@@ -5768,7 +5624,8 @@ class Text {
5768
5624
  align,
5769
5625
  direction,
5770
5626
  depth: depthInFontUnits,
5771
- size
5627
+ size,
5628
+ pixelsPerFontUnit: 1 / fontUnitsPerPixel
5772
5629
  };
5773
5630
  }
5774
5631
  applyColorSystem(vertices, glyphInfoArray, color, originalText) {
@@ -5864,8 +5721,8 @@ class Text {
5864
5721
  }
5865
5722
  return { colors, coloredRanges };
5866
5723
  }
5867
- finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, cacheStats, originalText) {
5868
- const { layout = {}, size = 72 } = options;
5724
+ finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText) {
5725
+ const { layout = {} } = options;
5869
5726
  const { width, align = layout.direction === 'rtl' ? 'right' : 'left' } = layout;
5870
5727
  if (!this.textLayout) {
5871
5728
  this.textLayout = new TextLayout(this.loadedFont);
@@ -5878,37 +5735,14 @@ class Text {
5878
5735
  const offset = alignmentResult.offset;
5879
5736
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5880
5737
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5881
- const finalScale = size / this.loadedFont.upem;
5882
- const offsetScaled = offset * finalScale;
5883
- // Scale vertices only (normals are unit vectors, don't scale)
5884
- if (offsetScaled === 0) {
5885
- for (let i = 0; i < vertices.length; i++) {
5886
- vertices[i] *= finalScale;
5887
- }
5888
- }
5889
- else {
5738
+ if (offset !== 0) {
5890
5739
  for (let i = 0; i < vertices.length; i += 3) {
5891
- vertices[i] = vertices[i] * finalScale + offsetScaled;
5892
- vertices[i + 1] *= finalScale;
5893
- vertices[i + 2] *= finalScale;
5894
- }
5895
- }
5896
- planeBounds.min.x *= finalScale;
5897
- planeBounds.min.y *= finalScale;
5898
- planeBounds.min.z *= finalScale;
5899
- planeBounds.max.x *= finalScale;
5900
- planeBounds.max.y *= finalScale;
5901
- planeBounds.max.z *= finalScale;
5902
- for (let i = 0; i < glyphInfoArray.length; i++) {
5903
- const glyphInfo = glyphInfoArray[i];
5904
- glyphInfo.bounds.min.x =
5905
- glyphInfo.bounds.min.x * finalScale + offsetScaled;
5906
- glyphInfo.bounds.min.y *= finalScale;
5907
- glyphInfo.bounds.min.z *= finalScale;
5908
- glyphInfo.bounds.max.x =
5909
- glyphInfo.bounds.max.x * finalScale + offsetScaled;
5910
- glyphInfo.bounds.max.y *= finalScale;
5911
- glyphInfo.bounds.max.z *= finalScale;
5740
+ vertices[i] += offset;
5741
+ }
5742
+ for (let i = 0; i < glyphInfoArray.length; i++) {
5743
+ glyphInfoArray[i].bounds.min.x += offset;
5744
+ glyphInfoArray[i].bounds.max.x += offset;
5745
+ }
5912
5746
  }
5913
5747
  let colors;
5914
5748
  let coloredRanges;
@@ -5933,8 +5767,7 @@ class Text {
5933
5767
  verticesGenerated,
5934
5768
  pointsRemovedByVisvalingam: optimizationStats.pointsRemovedByVisvalingam,
5935
5769
  pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5936
- originalPointCount: optimizationStats.originalPointCount,
5937
- ...(cacheStats || {})
5770
+ originalPointCount: optimizationStats.originalPointCount
5938
5771
  },
5939
5772
  query: (options) => {
5940
5773
  if (!originalText) {
@@ -5985,11 +5818,11 @@ class Text {
5985
5818
  }
5986
5819
  return TextMeasurer.measureTextWidth(this.loadedFont, text, letterSpacing);
5987
5820
  }
5988
- getCacheStatistics() {
5821
+ getCacheSize() {
5989
5822
  if (this.geometryBuilder) {
5990
- return this.geometryBuilder.getCacheStats();
5823
+ return this.geometryBuilder.getCacheStats().size;
5991
5824
  }
5992
- return null;
5825
+ return 0;
5993
5826
  }
5994
5827
  clearCache() {
5995
5828
  if (this.geometryBuilder) {
@@ -6000,25 +5833,45 @@ class Text {
6000
5833
  const glyphCenters = new Float32Array(vertexCount * 3);
6001
5834
  const glyphIndices = new Float32Array(vertexCount);
6002
5835
  const glyphLineIndices = new Float32Array(vertexCount);
6003
- glyphs.forEach((glyph, index) => {
5836
+ const glyphProgress = new Float32Array(vertexCount);
5837
+ const glyphBaselineY = new Float32Array(vertexCount);
5838
+ let minX = Infinity;
5839
+ let maxX = -Infinity;
5840
+ for (let i = 0; i < glyphs.length; i++) {
5841
+ const cx = (glyphs[i].bounds.min.x + glyphs[i].bounds.max.x) / 2;
5842
+ if (cx < minX)
5843
+ minX = cx;
5844
+ if (cx > maxX)
5845
+ maxX = cx;
5846
+ }
5847
+ const range = maxX - minX;
5848
+ for (let index = 0; index < glyphs.length; index++) {
5849
+ const glyph = glyphs[index];
6004
5850
  const centerX = (glyph.bounds.min.x + glyph.bounds.max.x) / 2;
6005
5851
  const centerY = (glyph.bounds.min.y + glyph.bounds.max.y) / 2;
6006
5852
  const centerZ = (glyph.bounds.min.z + glyph.bounds.max.z) / 2;
6007
- for (let i = 0; i < glyph.vertexCount; i++) {
6008
- const vertexIndex = glyph.vertexStart + i;
6009
- if (vertexIndex < vertexCount) {
6010
- glyphCenters[vertexIndex * 3] = centerX;
6011
- glyphCenters[vertexIndex * 3 + 1] = centerY;
6012
- glyphCenters[vertexIndex * 3 + 2] = centerZ;
6013
- glyphIndices[vertexIndex] = index;
6014
- glyphLineIndices[vertexIndex] = glyph.lineIndex;
6015
- }
5853
+ const baselineY = glyph.bounds.min.y;
5854
+ const progress = range > 0 ? (centerX - minX) / range : 0;
5855
+ const start = glyph.vertexStart;
5856
+ const end = Math.min(start + glyph.vertexCount, vertexCount);
5857
+ if (end <= start)
5858
+ continue;
5859
+ glyphIndices.fill(index, start, end);
5860
+ glyphLineIndices.fill(glyph.lineIndex, start, end);
5861
+ glyphProgress.fill(progress, start, end);
5862
+ glyphBaselineY.fill(baselineY, start, end);
5863
+ for (let v = start * 3; v < end * 3; v += 3) {
5864
+ glyphCenters[v] = centerX;
5865
+ glyphCenters[v + 1] = centerY;
5866
+ glyphCenters[v + 2] = centerZ;
6016
5867
  }
6017
- });
5868
+ }
6018
5869
  return {
6019
5870
  glyphCenter: glyphCenters,
6020
5871
  glyphIndex: glyphIndices,
6021
- glyphLineIndex: glyphLineIndices
5872
+ glyphLineIndex: glyphLineIndices,
5873
+ glyphProgress,
5874
+ glyphBaselineY
6022
5875
  };
6023
5876
  }
6024
5877
  resetHelpers() {