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