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