three-text 0.2.18 → 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.18
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
@@ -83,7 +83,9 @@
83
83
  // Find the metric in reverse order (most recent first)
84
84
  for (let i = this.metrics.length - 1; i >= 0; i--) {
85
85
  const metric = this.metrics[i];
86
- if (metric.name === name && metric.startTime === startTime && !metric.endTime) {
86
+ if (metric.name === name &&
87
+ metric.startTime === startTime &&
88
+ !metric.endTime) {
87
89
  metric.endTime = endTime;
88
90
  metric.duration = duration;
89
91
  break;
@@ -471,7 +473,9 @@
471
473
  const char = chars[i];
472
474
  const nextChar = i < chars.length - 1 ? chars[i + 1] : null;
473
475
  if (/\s/.test(char)) {
474
- const width = widths ? (widths[i] ?? measureText(char)) : measureText(char);
476
+ const width = widths
477
+ ? (widths[i] ?? measureText(char))
478
+ : measureText(char);
475
479
  items.push({
476
480
  type: ItemType.GLUE,
477
481
  width,
@@ -844,7 +848,9 @@
844
848
  if (breaks.length === 0) {
845
849
  // For first emergency attempt, use initialEmergencyStretch
846
850
  // For subsequent iterations (short line detection), progressively increase
847
- currentEmergencyStretch = initialEmergencyStretch + (iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT);
851
+ currentEmergencyStretch =
852
+ initialEmergencyStretch +
853
+ iteration * width * SHORT_LINE_EMERGENCY_STRETCH_INCREMENT;
848
854
  breaks = LineBreak.findBreakpoints(currentItems, width, tolerance, looseness, true, currentEmergencyStretch, context);
849
855
  }
850
856
  // Last resort: allow higher badness (but not infinite)
@@ -1725,12 +1731,12 @@
1725
1731
  try {
1726
1732
  if (gsubTableOffset) {
1727
1733
  const gsubData = this.extractFeatureDataFromTable(view, gsubTableOffset, nameTableOffset);
1728
- gsubData.features.forEach(f => features.add(f));
1734
+ gsubData.features.forEach((f) => features.add(f));
1729
1735
  Object.assign(featureNames, gsubData.names);
1730
1736
  }
1731
1737
  if (gposTableOffset) {
1732
1738
  const gposData = this.extractFeatureDataFromTable(view, gposTableOffset, nameTableOffset);
1733
- gposData.features.forEach(f => features.add(f));
1739
+ gposData.features.forEach((f) => features.add(f));
1734
1740
  Object.assign(featureNames, gposData.names);
1735
1741
  }
1736
1742
  }
@@ -2380,231 +2386,50 @@
2380
2386
  }
2381
2387
  }
2382
2388
 
2383
- // Generic LRU (Least Recently Used) cache with optional memory-based eviction
2384
- class LRUCache {
2385
- constructor(options = {}) {
2389
+ // Map-based cache with no eviction policy
2390
+ class Cache {
2391
+ constructor() {
2386
2392
  this.cache = new Map();
2387
- this.head = null;
2388
- this.tail = null;
2389
- this.stats = {
2390
- hits: 0,
2391
- misses: 0,
2392
- evictions: 0,
2393
- size: 0,
2394
- memoryUsage: 0
2395
- };
2396
- this.options = {
2397
- maxEntries: options.maxEntries ?? Infinity,
2398
- maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
2399
- calculateSize: options.calculateSize ?? (() => 0),
2400
- onEvict: options.onEvict
2401
- };
2402
2393
  }
2403
2394
  get(key) {
2404
- const node = this.cache.get(key);
2405
- if (node) {
2406
- this.stats.hits++;
2407
- this.moveToHead(node);
2408
- return node.value;
2409
- }
2410
- else {
2411
- this.stats.misses++;
2412
- return undefined;
2413
- }
2395
+ return this.cache.get(key);
2414
2396
  }
2415
2397
  has(key) {
2416
2398
  return this.cache.has(key);
2417
2399
  }
2418
2400
  set(key, value) {
2419
- // If key already exists, update it
2420
- const existingNode = this.cache.get(key);
2421
- if (existingNode) {
2422
- const oldSize = this.options.calculateSize(existingNode.value);
2423
- const newSize = this.options.calculateSize(value);
2424
- this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
2425
- existingNode.value = value;
2426
- this.moveToHead(existingNode);
2427
- return;
2428
- }
2429
- const size = this.options.calculateSize(value);
2430
- // Evict entries if we exceed limits
2431
- this.evictIfNeeded(size);
2432
- // Create new node
2433
- const node = {
2434
- key,
2435
- value,
2436
- prev: null,
2437
- next: null
2438
- };
2439
- this.cache.set(key, node);
2440
- this.addToHead(node);
2441
- this.stats.size = this.cache.size;
2442
- this.stats.memoryUsage += size;
2401
+ this.cache.set(key, value);
2443
2402
  }
2444
2403
  delete(key) {
2445
- const node = this.cache.get(key);
2446
- if (!node)
2447
- return false;
2448
- const size = this.options.calculateSize(node.value);
2449
- this.removeNode(node);
2450
- this.cache.delete(key);
2451
- this.stats.size = this.cache.size;
2452
- this.stats.memoryUsage -= size;
2453
- if (this.options.onEvict) {
2454
- this.options.onEvict(key, node.value);
2455
- }
2456
- return true;
2404
+ return this.cache.delete(key);
2457
2405
  }
2458
2406
  clear() {
2459
- if (this.options.onEvict) {
2460
- for (const [key, node] of this.cache) {
2461
- this.options.onEvict(key, node.value);
2462
- }
2463
- }
2464
2407
  this.cache.clear();
2465
- this.head = null;
2466
- this.tail = null;
2467
- this.stats = {
2468
- hits: 0,
2469
- misses: 0,
2470
- evictions: 0,
2471
- size: 0,
2472
- memoryUsage: 0
2473
- };
2474
- }
2475
- getStats() {
2476
- const total = this.stats.hits + this.stats.misses;
2477
- const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
2478
- const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
2479
- return {
2480
- ...this.stats,
2481
- hitRate,
2482
- memoryUsageMB
2483
- };
2484
- }
2485
- keys() {
2486
- const keys = [];
2487
- let current = this.head;
2488
- while (current) {
2489
- keys.push(current.key);
2490
- current = current.next;
2491
- }
2492
- return keys;
2493
2408
  }
2494
2409
  get size() {
2495
2410
  return this.cache.size;
2496
2411
  }
2497
- evictIfNeeded(requiredSize) {
2498
- // Evict by entry count
2499
- while (this.cache.size >= this.options.maxEntries && this.tail) {
2500
- this.evictTail();
2501
- }
2502
- // Evict by memory usage
2503
- if (this.options.maxMemoryBytes < Infinity) {
2504
- while (this.tail &&
2505
- this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
2506
- this.evictTail();
2507
- }
2508
- }
2509
- }
2510
- evictTail() {
2511
- if (!this.tail)
2512
- return;
2513
- const nodeToRemove = this.tail;
2514
- const size = this.options.calculateSize(nodeToRemove.value);
2515
- this.removeTail();
2516
- this.cache.delete(nodeToRemove.key);
2517
- this.stats.size = this.cache.size;
2518
- this.stats.memoryUsage -= size;
2519
- this.stats.evictions++;
2520
- if (this.options.onEvict) {
2521
- this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
2522
- }
2523
- }
2524
- addToHead(node) {
2525
- node.prev = null;
2526
- node.next = null;
2527
- if (!this.head) {
2528
- this.head = this.tail = node;
2529
- }
2530
- else {
2531
- node.next = this.head;
2532
- this.head.prev = node;
2533
- this.head = node;
2534
- }
2535
- }
2536
- removeNode(node) {
2537
- if (node.prev) {
2538
- node.prev.next = node.next;
2539
- }
2540
- else {
2541
- this.head = node.next;
2542
- }
2543
- if (node.next) {
2544
- node.next.prev = node.prev;
2545
- }
2546
- else {
2547
- this.tail = node.prev;
2548
- }
2549
- }
2550
- removeTail() {
2551
- if (this.tail) {
2552
- this.removeNode(this.tail);
2553
- }
2412
+ keys() {
2413
+ return Array.from(this.cache.keys());
2554
2414
  }
2555
- moveToHead(node) {
2556
- if (node === this.head)
2557
- return;
2558
- this.removeNode(node);
2559
- this.addToHead(node);
2415
+ getStats() {
2416
+ return {
2417
+ size: this.cache.size
2418
+ };
2560
2419
  }
2561
2420
  }
2562
2421
 
2563
- const DEFAULT_CACHE_SIZE_MB = 250;
2564
2422
  function getGlyphCacheKey(fontId, glyphId, depth, removeOverlaps) {
2565
2423
  const roundedDepth = Math.round(depth * 1000) / 1000;
2566
2424
  return `${fontId}_${glyphId}_${roundedDepth}_${removeOverlaps}`;
2567
2425
  }
2568
- function calculateGlyphMemoryUsage(glyph) {
2569
- let size = 0;
2570
- size += glyph.vertices.length * 4;
2571
- size += glyph.normals.length * 4;
2572
- size += glyph.indices.length * glyph.indices.BYTES_PER_ELEMENT;
2573
- size += 24; // 2 Vec3s
2574
- size += 256; // Object overhead
2575
- return size;
2576
- }
2577
- const globalGlyphCache = new LRUCache({
2578
- maxEntries: Infinity,
2579
- maxMemoryBytes: DEFAULT_CACHE_SIZE_MB * 1024 * 1024,
2580
- calculateSize: calculateGlyphMemoryUsage
2581
- });
2582
- function createGlyphCache(maxCacheSizeMB = DEFAULT_CACHE_SIZE_MB) {
2583
- return new LRUCache({
2584
- maxEntries: Infinity,
2585
- maxMemoryBytes: maxCacheSizeMB * 1024 * 1024,
2586
- calculateSize: calculateGlyphMemoryUsage
2587
- });
2426
+ const globalGlyphCache = new Cache();
2427
+ function createGlyphCache() {
2428
+ return new Cache();
2588
2429
  }
2589
- // Shared across builder instances: contour extraction, word clustering, boundary grouping
2590
- const globalContourCache = new LRUCache({
2591
- maxEntries: 1000,
2592
- calculateSize: (contours) => {
2593
- let size = 0;
2594
- for (const path of contours.paths) {
2595
- size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
2596
- }
2597
- return size + 64; // bounds overhead
2598
- }
2599
- });
2600
- const globalWordCache = new LRUCache({
2601
- maxEntries: 1000,
2602
- calculateSize: calculateGlyphMemoryUsage
2603
- });
2604
- const globalClusteringCache = new LRUCache({
2605
- maxEntries: 2000,
2606
- calculateSize: () => 1
2607
- });
2430
+ const globalContourCache = new Cache();
2431
+ const globalWordCache = new Cache();
2432
+ const globalClusteringCache = new Cache();
2608
2433
 
2609
2434
  var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
2610
2435
 
@@ -2733,7 +2558,7 @@
2733
2558
  let extrusionContours = needsExtrusionContours
2734
2559
  ? needsWindingReversal
2735
2560
  ? tessContours
2736
- : originalContours ?? this.pathsToContours(paths)
2561
+ : (originalContours ?? this.pathsToContours(paths))
2737
2562
  : [];
2738
2563
  if (removeOverlaps) {
2739
2564
  logger.log('Two-pass: boundary extraction then triangulation');
@@ -2766,7 +2591,10 @@
2766
2591
  ? 'libtess returned empty result from triangulation pass'
2767
2592
  : 'libtess returned empty result from single-pass triangulation';
2768
2593
  logger.warn(warning);
2769
- return { triangles: { vertices: [], indices: [] }, contours: extrusionContours };
2594
+ return {
2595
+ triangles: { vertices: [], indices: [] },
2596
+ contours: extrusionContours
2597
+ };
2770
2598
  }
2771
2599
  return {
2772
2600
  triangles: {
@@ -2936,47 +2764,79 @@
2936
2764
 
2937
2765
  class Extruder {
2938
2766
  constructor() { }
2939
- packEdge(a, b) {
2940
- const lo = a < b ? a : b;
2941
- const hi = a < b ? b : a;
2942
- return lo * 0x100000000 + hi;
2943
- }
2944
2767
  extrude(geometry, depth = 0, unitsPerEm) {
2945
2768
  const points = geometry.triangles.vertices;
2946
2769
  const triangleIndices = geometry.triangles.indices;
2947
2770
  const numPoints = points.length / 2;
2948
- // Count boundary edges for side walls (4 vertices + 6 indices per edge)
2771
+ // Boundary edges are those that appear in exactly one triangle
2949
2772
  let boundaryEdges = [];
2950
2773
  if (depth !== 0) {
2951
- const counts = new Map();
2952
- const oriented = new Map();
2953
- 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) {
2954
2779
  const a = triangleIndices[i];
2955
2780
  const b = triangleIndices[i + 1];
2956
2781
  const c = triangleIndices[i + 2];
2957
- const k0 = this.packEdge(a, b);
2958
- const n0 = (counts.get(k0) ?? 0) + 1;
2959
- counts.set(k0, n0);
2960
- if (n0 === 1)
2961
- oriented.set(k0, [a, b]);
2962
- const k1 = this.packEdge(b, c);
2963
- const n1 = (counts.get(k1) ?? 0) + 1;
2964
- counts.set(k1, n1);
2965
- if (n1 === 1)
2966
- oriented.set(k1, [b, c]);
2967
- const k2 = this.packEdge(c, a);
2968
- const n2 = (counts.get(k2) ?? 0) + 1;
2969
- counts.set(k2, n2);
2970
- if (n2 === 1)
2971
- 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
+ }
2972
2834
  }
2973
2835
  boundaryEdges = [];
2974
- for (const [key, count] of counts) {
2975
- if (count !== 1)
2976
- continue;
2977
- const edge = oriented.get(key);
2978
- if (edge)
2979
- boundaryEdges.push(edge);
2836
+ for (const [v0, v1, count] of edgeMap.values()) {
2837
+ if (count === 1) {
2838
+ boundaryEdges.push([v0, v1]);
2839
+ }
2980
2840
  }
2981
2841
  }
2982
2842
  const sideEdgeCount = depth === 0 ? 0 : boundaryEdges.length;
@@ -2990,7 +2850,6 @@
2990
2850
  : triangleIndices.length * 2 + sideEdgeCount * 6;
2991
2851
  const indices = new Uint32Array(indexCount);
2992
2852
  if (depth === 0) {
2993
- // Single-sided flat geometry at z=0
2994
2853
  let vPos = 0;
2995
2854
  for (let i = 0; i < points.length; i += 2) {
2996
2855
  vertices[vPos] = points[i];
@@ -3001,16 +2860,11 @@
3001
2860
  normals[vPos + 2] = 1;
3002
2861
  vPos += 3;
3003
2862
  }
3004
- // libtess outputs CCW, use as-is for +Z facing geometry
3005
- for (let i = 0; i < triangleIndices.length; i++) {
3006
- indices[i] = triangleIndices[i];
3007
- }
2863
+ indices.set(triangleIndices);
3008
2864
  return { vertices, normals, indices };
3009
2865
  }
3010
- // Extruded geometry: front at z=0, back at z=depth
3011
2866
  const minBackOffset = unitsPerEm * 0.000025;
3012
2867
  const backZ = depth <= minBackOffset ? minBackOffset : depth;
3013
- // Generate both caps in one pass
3014
2868
  for (let p = 0, vi = 0; p < points.length; p += 2, vi++) {
3015
2869
  const x = points[p];
3016
2870
  const y = points[p + 1];
@@ -3031,41 +2885,39 @@
3031
2885
  normals[baseD + 1] = 0;
3032
2886
  normals[baseD + 2] = 1;
3033
2887
  }
3034
- // libtess outputs CCW triangles (viewed from +Z)
3035
- // Z=0 cap faces -Z, reverse winding
3036
- for (let i = 0; i < triangleIndices.length; i++) {
3037
- 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];
3038
2892
  }
3039
- // Z=depth cap faces +Z, use original winding
3040
- for (let i = 0; i < triangleIndices.length; i++) {
3041
- 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;
3042
2896
  }
3043
- // Side walls
3044
2897
  let nextVertex = numPoints * 2;
3045
- let idxPos = triangleIndices.length * 2;
3046
- for (let e = 0; e < boundaryEdges.length; e++) {
3047
- const [u, v] = boundaryEdges[e];
3048
- const u2 = u * 2;
3049
- 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;
3050
2906
  const p0x = points[u2];
3051
2907
  const p0y = points[u2 + 1];
3052
2908
  const p1x = points[v2];
3053
2909
  const p1y = points[v2 + 1];
3054
- // Perpendicular normal for this wall segment
3055
- // Uses the edge direction from the cap triangulation so winding does not depend on contour direction
3056
2910
  const ex = p1x - p0x;
3057
2911
  const ey = p1y - p0y;
3058
2912
  const lenSq = ex * ex + ey * ey;
3059
2913
  let nx = 0;
3060
2914
  let ny = 0;
3061
- if (lenSq > 0) {
2915
+ if (lenSq > 1e-10) {
3062
2916
  const invLen = 1 / Math.sqrt(lenSq);
3063
2917
  nx = ey * invLen;
3064
2918
  ny = -ex * invLen;
3065
2919
  }
3066
- const baseVertex = nextVertex;
3067
- const base = baseVertex * 3;
3068
- // Wall quad: front edge at z=0, back edge at z=depth
2920
+ const base = nextVertex * 3;
3069
2921
  vertices[base] = p0x;
3070
2922
  vertices[base + 1] = p0y;
3071
2923
  vertices[base + 2] = 0;
@@ -3078,7 +2930,6 @@
3078
2930
  vertices[base + 9] = p1x;
3079
2931
  vertices[base + 10] = p1y;
3080
2932
  vertices[base + 11] = backZ;
3081
- // Wall normals point perpendicular to edge
3082
2933
  normals[base] = nx;
3083
2934
  normals[base + 1] = ny;
3084
2935
  normals[base + 2] = 0;
@@ -3091,13 +2942,14 @@
3091
2942
  normals[base + 9] = nx;
3092
2943
  normals[base + 10] = ny;
3093
2944
  normals[base + 11] = 0;
3094
- // Two triangles per wall segment
3095
- indices[idxPos++] = baseVertex;
3096
- indices[idxPos++] = baseVertex + 1;
3097
- indices[idxPos++] = baseVertex + 2;
3098
- indices[idxPos++] = baseVertex + 1;
3099
- indices[idxPos++] = baseVertex + 3;
3100
- 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;
3101
2953
  nextVertex += 4;
3102
2954
  }
3103
2955
  return { vertices, normals, indices };
@@ -3423,9 +3275,7 @@
3423
3275
  const v1LenSq = v1x * v1x + v1y * v1y;
3424
3276
  const v2LenSq = v2x * v2x + v2y * v2y;
3425
3277
  const minLenSq = this.config.minSegmentLength * this.config.minSegmentLength;
3426
- if (angle > threshold ||
3427
- v1LenSq < minLenSq ||
3428
- v2LenSq < minLenSq) {
3278
+ if (angle > threshold || v1LenSq < minLenSq || v2LenSq < minLenSq) {
3429
3279
  result.push(current);
3430
3280
  }
3431
3281
  else {
@@ -4116,7 +3966,7 @@
4116
3966
  ].join('|');
4117
3967
  }
4118
3968
  // Build instanced geometry from glyph contours
4119
- buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
3969
+ buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, scale, separateGlyphs = false, coloredTextIndices) {
4120
3970
  if (isLogEnabled) {
4121
3971
  let wordCount = 0;
4122
3972
  for (let i = 0; i < clustersByLine.length; i++) {
@@ -4187,7 +4037,7 @@
4187
4037
  const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x ?? 0, g.y ?? 0, 0));
4188
4038
  boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
4189
4039
  this.clusteringCache.set(cacheKey, {
4190
- glyphIds: cluster.glyphs.map(g => g.g),
4040
+ glyphIds: cluster.glyphs.map((g) => g.g),
4191
4041
  groups: boundaryGroups
4192
4042
  });
4193
4043
  }
@@ -4294,9 +4144,9 @@
4294
4144
  const py = task.py;
4295
4145
  const pz = task.pz;
4296
4146
  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;
4147
+ vertexArray[vertexPos++] = (v[j] + px) * scale;
4148
+ vertexArray[vertexPos++] = (v[j + 1] + py) * scale;
4149
+ vertexArray[vertexPos++] = (v[j + 2] + pz) * scale;
4300
4150
  }
4301
4151
  normalArray.set(n, normalPos);
4302
4152
  normalPos += n.length;
@@ -4306,6 +4156,20 @@
4306
4156
  }
4307
4157
  }
4308
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
+ }
4309
4173
  return {
4310
4174
  vertices: vertexArray,
4311
4175
  normals: normalArray,
@@ -4317,7 +4181,6 @@
4317
4181
  getClusterKey(glyphs, depth, removeOverlaps) {
4318
4182
  if (glyphs.length === 0)
4319
4183
  return '';
4320
- // Normalize positions relative to the first glyph in the cluster
4321
4184
  const refX = glyphs[0].x ?? 0;
4322
4185
  const refY = glyphs[0].y ?? 0;
4323
4186
  const parts = glyphs.map((g) => {
@@ -4577,7 +4440,8 @@
4577
4440
  if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4578
4441
  shouldApply = false;
4579
4442
  }
4580
- if (LineBreak.isCJPunctuation(currentChar) && LineBreak.isCJPunctuation(nextChar)) {
4443
+ if (LineBreak.isCJPunctuation(currentChar) &&
4444
+ LineBreak.isCJPunctuation(nextChar)) {
4581
4445
  shouldApply = false;
4582
4446
  }
4583
4447
  if (shouldApply) {
@@ -5371,6 +5235,7 @@
5371
5235
  }
5372
5236
 
5373
5237
  const DEFAULT_MAX_TEXT_LENGTH = 100000;
5238
+ const DEFAULT_FONT_SIZE = 72;
5374
5239
  class Text {
5375
5240
  static { this.patternCache = new Map(); }
5376
5241
  static { this.hbInitPromise = null; }
@@ -5381,7 +5246,7 @@
5381
5246
  // Stringify with sorted keys for cache stability
5382
5247
  static stableStringify(obj) {
5383
5248
  const keys = Object.keys(obj).sort();
5384
- const pairs = keys.map(k => `${k}:${obj[k]}`);
5249
+ const pairs = keys.map((k) => `${k}:${obj[k]}`);
5385
5250
  return pairs.join(',');
5386
5251
  }
5387
5252
  constructor() {
@@ -5444,7 +5309,7 @@
5444
5309
  return {
5445
5310
  ...newResult,
5446
5311
  getLoadedFont: () => text.getLoadedFont(),
5447
- getCacheStatistics: () => text.getCacheStatistics(),
5312
+ getCacheSize: () => text.getCacheSize(),
5448
5313
  clearCache: () => text.clearCache(),
5449
5314
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5450
5315
  update
@@ -5453,7 +5318,7 @@
5453
5318
  return {
5454
5319
  ...result,
5455
5320
  getLoadedFont: () => text.getLoadedFont(),
5456
- getCacheStatistics: () => text.getCacheStatistics(),
5321
+ getCacheSize: () => text.getCacheSize(),
5457
5322
  clearCache: () => text.clearCache(),
5458
5323
  measureTextWidth: (textString, letterSpacing) => text.measureTextWidth(textString, letterSpacing),
5459
5324
  update
@@ -5585,7 +5450,7 @@
5585
5450
  async createGeometry(options) {
5586
5451
  perfLogger.start('Text.createGeometry', {
5587
5452
  textLength: options.text.length,
5588
- size: options.size || 72,
5453
+ size: options.size || DEFAULT_FONT_SIZE,
5589
5454
  hasLayout: !!options.layout,
5590
5455
  mode: 'cached'
5591
5456
  });
@@ -5599,7 +5464,7 @@
5599
5464
  this.updateFontVariations(options);
5600
5465
  if (!this.geometryBuilder) {
5601
5466
  const cache = options.maxCacheSizeMB
5602
- ? createGlyphCache(options.maxCacheSizeMB)
5467
+ ? createGlyphCache()
5603
5468
  : globalGlyphCache;
5604
5469
  this.geometryBuilder = new GlyphGeometryBuilder(cache, this.loadedFont);
5605
5470
  this.geometryBuilder.setFontId(this.currentFontId);
@@ -5619,7 +5484,9 @@
5619
5484
  // to selectively use glyph-level caching (separate vertices) only for clusters containing
5620
5485
  // colored text, while non-colored clusters can still use fast cluster-level merging
5621
5486
  let coloredTextIndices;
5622
- if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
5487
+ if (options.color &&
5488
+ typeof options.color === 'object' &&
5489
+ !Array.isArray(options.color)) {
5623
5490
  if (options.color.byText || options.color.byCharRange) {
5624
5491
  // Build the set manually since glyphs don't exist yet
5625
5492
  coloredTextIndices = new Set();
@@ -5643,10 +5510,9 @@
5643
5510
  }
5644
5511
  }
5645
5512
  }
5646
- const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
5647
- const cacheStats = this.geometryBuilder.getCacheStats();
5648
- const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
5649
- 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) {
5650
5516
  const glyphAttrs = this.createGlyphAttributes(result.vertices.length / 3, result.glyphs);
5651
5517
  result.glyphAttributes = glyphAttrs;
5652
5518
  }
@@ -5713,18 +5579,20 @@
5713
5579
  if (!this.loadedFont) {
5714
5580
  throw new Error('Font not loaded. Use Text.create() with a font option');
5715
5581
  }
5716
- 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;
5717
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;
5718
5585
  let widthInFontUnits;
5719
5586
  if (width !== undefined) {
5720
- widthInFontUnits = width * (this.loadedFont.upem / size);
5587
+ widthInFontUnits = width * fontUnitsPerPixel;
5721
5588
  }
5722
5589
  // Keep depth behavior consistent with Extruder: extremely small non-zero depths
5723
- // are clamped to a minimum back offset so the back face is not coplanar.
5724
- const depthScale = this.loadedFont.upem / size;
5725
- const rawDepthInFontUnits = depth * depthScale;
5590
+ // are clamped to a minimum back offset to prevent Z fighting
5591
+ const rawDepthInFontUnits = depth * fontUnitsPerPixel;
5726
5592
  const minExtrudeDepth = this.loadedFont.upem * 0.000025;
5727
- const depthInFontUnits = rawDepthInFontUnits <= 0 ? 0 : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5593
+ const depthInFontUnits = rawDepthInFontUnits <= 0
5594
+ ? 0
5595
+ : Math.max(rawDepthInFontUnits, minExtrudeDepth);
5728
5596
  if (!this.textLayout) {
5729
5597
  this.textLayout = new TextLayout(this.loadedFont);
5730
5598
  }
@@ -5763,7 +5631,8 @@
5763
5631
  align,
5764
5632
  direction,
5765
5633
  depth: depthInFontUnits,
5766
- size
5634
+ size,
5635
+ pixelsPerFontUnit: 1 / fontUnitsPerPixel
5767
5636
  };
5768
5637
  }
5769
5638
  applyColorSystem(vertices, glyphInfoArray, color, originalText) {
@@ -5859,8 +5728,8 @@
5859
5728
  }
5860
5729
  return { colors, coloredRanges };
5861
5730
  }
5862
- finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, cacheStats, originalText) {
5863
- const { layout = {}, size = 72 } = options;
5731
+ finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText) {
5732
+ const { layout = {} } = options;
5864
5733
  const { width, align = layout.direction === 'rtl' ? 'right' : 'left' } = layout;
5865
5734
  if (!this.textLayout) {
5866
5735
  this.textLayout = new TextLayout(this.loadedFont);
@@ -5873,35 +5742,14 @@
5873
5742
  const offset = alignmentResult.offset;
5874
5743
  planeBounds.min.x = alignmentResult.adjustedBounds.min.x;
5875
5744
  planeBounds.max.x = alignmentResult.adjustedBounds.max.x;
5876
- const finalScale = size / this.loadedFont.upem;
5877
- const offsetScaled = offset * finalScale;
5878
- // Scale vertices only (normals are unit vectors, don't scale)
5879
- if (offsetScaled === 0) {
5880
- for (let i = 0; i < vertices.length; i++) {
5881
- vertices[i] *= finalScale;
5882
- }
5883
- }
5884
- else {
5745
+ if (offset !== 0) {
5885
5746
  for (let i = 0; i < vertices.length; i += 3) {
5886
- vertices[i] = vertices[i] * finalScale + offsetScaled;
5887
- vertices[i + 1] *= finalScale;
5888
- vertices[i + 2] *= finalScale;
5889
- }
5890
- }
5891
- planeBounds.min.x *= finalScale;
5892
- planeBounds.min.y *= finalScale;
5893
- planeBounds.min.z *= finalScale;
5894
- planeBounds.max.x *= finalScale;
5895
- planeBounds.max.y *= finalScale;
5896
- planeBounds.max.z *= finalScale;
5897
- for (let i = 0; i < glyphInfoArray.length; i++) {
5898
- const glyphInfo = glyphInfoArray[i];
5899
- glyphInfo.bounds.min.x = glyphInfo.bounds.min.x * finalScale + offsetScaled;
5900
- glyphInfo.bounds.min.y *= finalScale;
5901
- glyphInfo.bounds.min.z *= finalScale;
5902
- glyphInfo.bounds.max.x = glyphInfo.bounds.max.x * finalScale + offsetScaled;
5903
- glyphInfo.bounds.max.y *= finalScale;
5904
- 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
+ }
5905
5753
  }
5906
5754
  let colors;
5907
5755
  let coloredRanges;
@@ -5926,8 +5774,7 @@
5926
5774
  verticesGenerated,
5927
5775
  pointsRemovedByVisvalingam: optimizationStats.pointsRemovedByVisvalingam,
5928
5776
  pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5929
- originalPointCount: optimizationStats.originalPointCount,
5930
- ...(cacheStats || {})
5777
+ originalPointCount: optimizationStats.originalPointCount
5931
5778
  },
5932
5779
  query: (options) => {
5933
5780
  if (!originalText) {
@@ -5962,13 +5809,11 @@
5962
5809
  static registerPattern(language, pattern) {
5963
5810
  Text.patternCache.set(language, pattern);
5964
5811
  }
5965
- static clearFontCache() {
5966
- Text.fontCache.clear();
5967
- Text.fontCacheMemoryBytes = 0;
5968
- }
5969
5812
  static setMaxFontCacheMemoryMB(limitMB) {
5970
5813
  Text.maxFontCacheMemoryBytes =
5971
- limitMB === Infinity ? Infinity : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5814
+ limitMB === Infinity
5815
+ ? Infinity
5816
+ : Math.max(1, Math.floor(limitMB)) * 1024 * 1024;
5972
5817
  Text.enforceFontCacheMemoryLimit();
5973
5818
  }
5974
5819
  getLoadedFont() {
@@ -5980,11 +5825,11 @@
5980
5825
  }
5981
5826
  return TextMeasurer.measureTextWidth(this.loadedFont, text, letterSpacing);
5982
5827
  }
5983
- getCacheStatistics() {
5828
+ getCacheSize() {
5984
5829
  if (this.geometryBuilder) {
5985
- return this.geometryBuilder.getCacheStats();
5830
+ return this.geometryBuilder.getCacheStats().size;
5986
5831
  }
5987
- return null;
5832
+ return 0;
5988
5833
  }
5989
5834
  clearCache() {
5990
5835
  if (this.geometryBuilder) {
@@ -5995,25 +5840,45 @@
5995
5840
  const glyphCenters = new Float32Array(vertexCount * 3);
5996
5841
  const glyphIndices = new Float32Array(vertexCount);
5997
5842
  const glyphLineIndices = new Float32Array(vertexCount);
5998
- 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];
5999
5857
  const centerX = (glyph.bounds.min.x + glyph.bounds.max.x) / 2;
6000
5858
  const centerY = (glyph.bounds.min.y + glyph.bounds.max.y) / 2;
6001
5859
  const centerZ = (glyph.bounds.min.z + glyph.bounds.max.z) / 2;
6002
- for (let i = 0; i < glyph.vertexCount; i++) {
6003
- const vertexIndex = glyph.vertexStart + i;
6004
- if (vertexIndex < vertexCount) {
6005
- glyphCenters[vertexIndex * 3] = centerX;
6006
- glyphCenters[vertexIndex * 3 + 1] = centerY;
6007
- glyphCenters[vertexIndex * 3 + 2] = centerZ;
6008
- glyphIndices[vertexIndex] = index;
6009
- glyphLineIndices[vertexIndex] = glyph.lineIndex;
6010
- }
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;
6011
5874
  }
6012
- });
5875
+ }
6013
5876
  return {
6014
5877
  glyphCenter: glyphCenters,
6015
5878
  glyphIndex: glyphIndices,
6016
- glyphLineIndex: glyphLineIndices
5879
+ glyphLineIndex: glyphLineIndices,
5880
+ glyphProgress,
5881
+ glyphBaselineY
6017
5882
  };
6018
5883
  }
6019
5884
  resetHelpers() {