@codexo/exojs 0.6.8 → 0.6.10

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/exo.esm.js CHANGED
@@ -706,7 +706,7 @@ const trimRotation = (degrees) => {
706
706
  const degreesToRadians = (degree) => degree * radiansPerDegree;
707
707
  const radiansToDegrees = (radian) => radian * degreesPerRadian;
708
708
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
709
- const sign$1 = (value) => (value && (value < 0 ? -1 : 1));
709
+ const sign = (value) => (value && (value < 0 ? -1 : 1));
710
710
  const lerp = (startValue, endValue, ratio) => (((1 - ratio) * startValue) + (ratio * endValue));
711
711
  const isPowerOfTwo = (value) => ((value !== 0) && ((value & (value - 1)) === 0));
712
712
  const inRange = (value, min, max) => (value >= Math.min(min, max) && value <= Math.max(min, max));
@@ -6211,19 +6211,6 @@ const createCanvas = (options = {}) => {
6211
6211
  context.fillRect(0, 0, newCanvas.width, newCanvas.height);
6212
6212
  return newCanvas;
6213
6213
  };
6214
- const heightCache = new Map();
6215
- const determineFontHeight = (font) => {
6216
- if (!heightCache.has(font)) {
6217
- const body = document.body;
6218
- const dummy = document.createElement('div');
6219
- dummy.appendChild(document.createTextNode('M'));
6220
- dummy.setAttribute('style', `font: ${font};position:absolute;top:0;left:0`);
6221
- body.appendChild(dummy);
6222
- heightCache.set(font, dummy.offsetHeight);
6223
- body.removeChild(dummy);
6224
- }
6225
- return heightCache.get(font);
6226
- };
6227
6214
 
6228
6215
  class Texture {
6229
6216
  static _black = null;
@@ -15465,636 +15452,166 @@ class Input {
15465
15452
  }
15466
15453
  }
15467
15454
 
15468
- function earcut(data, holeIndices, dim = 2) {
15469
-
15470
- const hasHoles = holeIndices && holeIndices.length;
15471
- const outerLen = hasHoles ? holeIndices[0] * dim : data.length;
15472
- let outerNode = linkedList(data, 0, outerLen, dim, true);
15473
- const triangles = [];
15474
-
15475
- if (!outerNode || outerNode.next === outerNode.prev) return triangles;
15476
-
15477
- let minX, minY, invSize;
15478
-
15479
- if (hasHoles) outerNode = eliminateHoles(data, holeIndices, outerNode, dim);
15480
-
15481
- // if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox
15482
- if (data.length > 80 * dim) {
15483
- minX = data[0];
15484
- minY = data[1];
15485
- let maxX = minX;
15486
- let maxY = minY;
15487
-
15488
- for (let i = dim; i < outerLen; i += dim) {
15489
- const x = data[i];
15490
- const y = data[i + 1];
15491
- if (x < minX) minX = x;
15492
- if (y < minY) minY = y;
15493
- if (x > maxX) maxX = x;
15494
- if (y > maxY) maxY = y;
15495
- }
15496
-
15497
- // minX, minY and invSize are later used to transform coords into integers for z-order calculation
15498
- invSize = Math.max(maxX - minX, maxY - minY);
15499
- invSize = invSize !== 0 ? 32767 / invSize : 0;
15500
- }
15501
-
15502
- earcutLinked(outerNode, triangles, dim, minX, minY, invSize, 0);
15503
-
15504
- return triangles;
15505
- }
15506
-
15507
- // create a circular doubly linked list from polygon points in the specified winding order
15508
- function linkedList(data, start, end, dim, clockwise) {
15509
- let last;
15510
-
15511
- if (clockwise === (signedArea(data, start, end, dim) > 0)) {
15512
- for (let i = start; i < end; i += dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
15513
- } else {
15514
- for (let i = end - dim; i >= start; i -= dim) last = insertNode(i / dim | 0, data[i], data[i + 1], last);
15515
- }
15516
-
15517
- if (last && equals(last, last.next)) {
15518
- removeNode(last);
15519
- last = last.next;
15520
- }
15521
-
15522
- return last;
15523
- }
15524
-
15525
- // eliminate colinear or duplicate points
15526
- function filterPoints(start, end) {
15527
- if (!start) return start;
15528
- if (!end) end = start;
15529
-
15530
- let p = start,
15531
- again;
15532
- do {
15533
- again = false;
15534
-
15535
- if (!p.steiner && (equals(p, p.next) || area(p.prev, p, p.next) === 0)) {
15536
- removeNode(p);
15537
- p = end = p.prev;
15538
- if (p === p.next) break;
15539
- again = true;
15540
-
15541
- } else {
15542
- p = p.next;
15543
- }
15544
- } while (again || p !== end);
15545
-
15546
- return end;
15547
- }
15548
-
15549
- // main ear slicing loop which triangulates a polygon (given as a linked list)
15550
- function earcutLinked(ear, triangles, dim, minX, minY, invSize, pass) {
15551
- if (!ear) return;
15552
-
15553
- // interlink polygon nodes in z-order
15554
- if (!pass && invSize) indexCurve(ear, minX, minY, invSize);
15555
-
15556
- let stop = ear;
15557
-
15558
- // iterate through ears, slicing them one by one
15559
- while (ear.prev !== ear.next) {
15560
- const prev = ear.prev;
15561
- const next = ear.next;
15562
-
15563
- if (invSize ? isEarHashed(ear, minX, minY, invSize) : isEar(ear)) {
15564
- triangles.push(prev.i, ear.i, next.i); // cut off the triangle
15565
-
15566
- removeNode(ear);
15567
-
15568
- // skipping the next vertex leads to less sliver triangles
15569
- ear = next.next;
15570
- stop = next.next;
15571
-
15572
- continue;
15573
- }
15574
-
15575
- ear = next;
15576
-
15577
- // if we looped through the whole remaining polygon and can't find any more ears
15578
- if (ear === stop) {
15579
- // try filtering points and slicing again
15580
- if (!pass) {
15581
- earcutLinked(filterPoints(ear), triangles, dim, minX, minY, invSize, 1);
15582
-
15583
- // if this didn't work, try curing all small self-intersections locally
15584
- } else if (pass === 1) {
15585
- ear = cureLocalIntersections(filterPoints(ear), triangles);
15586
- earcutLinked(ear, triangles, dim, minX, minY, invSize, 2);
15587
-
15588
- // as a last resort, try splitting the remaining polygon into two
15589
- } else if (pass === 2) {
15590
- splitEarcut(ear, triangles, dim, minX, minY, invSize);
15455
+ /**
15456
+ * Triangulate a simple 2D polygon by ear-clipping.
15457
+ *
15458
+ * Input: `vertices` is a flat sequence of (x, y) pairs (length must be even,
15459
+ * minimum 6 = 3 vertices). Polygon may be CW or CCW; the algorithm normalises
15460
+ * to CCW internally.
15461
+ *
15462
+ * Output: a `Uint32Array` of indices in groups of 3 (per triangle), referencing
15463
+ * vertex positions (the i-th vertex spans `vertices[2*i], vertices[2*i + 1]`).
15464
+ *
15465
+ * Polygons that are degenerate (all collinear), zero-area, or self-intersecting
15466
+ * may produce incomplete output but must not throw or hang.
15467
+ *
15468
+ * @param vertices flat (x, y) pairs
15469
+ * @returns triangle index list (length is multiple of 3)
15470
+ */
15471
+ function triangulate(vertices) {
15472
+ const n = vertices.length >> 1;
15473
+ if (n < 3) {
15474
+ return new Uint32Array(0);
15475
+ }
15476
+ if (n === 3) {
15477
+ // Return in CCW order; swap if input triangle is CW.
15478
+ const ax = vertices[0], ay = vertices[1];
15479
+ const bx = vertices[2], by = vertices[3];
15480
+ const cx = vertices[4], cy = vertices[5];
15481
+ return isCcwTriangle(ax, ay, bx, by, cx, cy)
15482
+ ? new Uint32Array([0, 1, 2])
15483
+ : new Uint32Array([2, 1, 0]);
15484
+ }
15485
+ // Build doubly-linked list of vertex indices.
15486
+ const prev = new Uint32Array(n);
15487
+ const next = new Uint32Array(n);
15488
+ for (let i = 0; i < n; i++) {
15489
+ prev[i] = (i + n - 1) % n;
15490
+ next[i] = (i + 1) % n;
15491
+ }
15492
+ // Normalise to CCW: compute signed area; if negative (CW), reverse the list.
15493
+ if (signedArea(vertices) < 0) {
15494
+ for (let i = 0; i < n; i++) {
15495
+ const tmp = prev[i];
15496
+ prev[i] = next[i];
15497
+ next[i] = tmp;
15498
+ }
15499
+ }
15500
+ const maxTriangles = n - 2;
15501
+ const out = new Uint32Array(maxTriangles * 3);
15502
+ let outIdx = 0;
15503
+ let remaining = n;
15504
+ let current = 0;
15505
+ // Ear-clipping: at most O(n^2) iterations; bail after one full pass finds no ear.
15506
+ while (remaining > 3) {
15507
+ // Try each remaining vertex as a potential ear, one full pass at a time.
15508
+ let earFound = false;
15509
+ const passStart = current;
15510
+ do {
15511
+ const p = prev[current];
15512
+ const nx = next[current];
15513
+ const v = current;
15514
+ const ax = vertices[p * 2], ay = vertices[p * 2 + 1];
15515
+ const bx = vertices[v * 2], by = vertices[v * 2 + 1];
15516
+ const cx = vertices[nx * 2], cy = vertices[nx * 2 + 1];
15517
+ if (isCcwTriangle(ax, ay, bx, by, cx, cy) && isEar(vertices, prev, next, p, v, nx)) {
15518
+ // Emit triangle (prev, v, next).
15519
+ out[outIdx++] = p;
15520
+ out[outIdx++] = v;
15521
+ out[outIdx++] = nx;
15522
+ // Remove v from the list.
15523
+ next[p] = nx;
15524
+ prev[nx] = p;
15525
+ remaining--;
15526
+ earFound = true;
15527
+ current = nx;
15528
+ break;
15591
15529
  }
15592
-
15530
+ current = next[current];
15531
+ } while (current !== passStart);
15532
+ if (!earFound) {
15533
+ // Degenerate polygon: no ear found in a full pass; bail out.
15593
15534
  break;
15594
15535
  }
15595
15536
  }
15596
- }
15597
-
15598
- // check whether a polygon node forms a valid ear with adjacent nodes
15599
- function isEar(ear) {
15600
- const a = ear.prev,
15601
- b = ear,
15602
- c = ear.next;
15603
-
15604
- if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
15605
-
15606
- // now make sure we don't have other points inside the potential ear
15607
- const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
15608
-
15609
- // triangle bbox
15610
- const x0 = Math.min(ax, bx, cx),
15611
- y0 = Math.min(ay, by, cy),
15612
- x1 = Math.max(ax, bx, cx),
15613
- y1 = Math.max(ay, by, cy);
15614
-
15615
- let p = c.next;
15616
- while (p !== a) {
15617
- if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 &&
15618
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) &&
15619
- area(p.prev, p, p.next) >= 0) return false;
15620
- p = p.next;
15621
- }
15622
-
15623
- return true;
15624
- }
15625
-
15626
- function isEarHashed(ear, minX, minY, invSize) {
15627
- const a = ear.prev,
15628
- b = ear,
15629
- c = ear.next;
15630
-
15631
- if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
15632
-
15633
- const ax = a.x, bx = b.x, cx = c.x, ay = a.y, by = b.y, cy = c.y;
15634
-
15635
- // triangle bbox
15636
- const x0 = Math.min(ax, bx, cx),
15637
- y0 = Math.min(ay, by, cy),
15638
- x1 = Math.max(ax, bx, cx),
15639
- y1 = Math.max(ay, by, cy);
15640
-
15641
- // z-order range for the current triangle bbox;
15642
- const minZ = zOrder(x0, y0, minX, minY, invSize),
15643
- maxZ = zOrder(x1, y1, minX, minY, invSize);
15644
-
15645
- let p = ear.prevZ,
15646
- n = ear.nextZ;
15647
-
15648
- // look for points inside the triangle in both directions
15649
- while (p && p.z >= minZ && n && n.z <= maxZ) {
15650
- if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
15651
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
15652
- p = p.prevZ;
15653
-
15654
- if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
15655
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
15656
- n = n.nextZ;
15657
- }
15658
-
15659
- // look for remaining points in decreasing z-order
15660
- while (p && p.z >= minZ) {
15661
- if (p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 && p !== a && p !== c &&
15662
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, p.x, p.y) && area(p.prev, p, p.next) >= 0) return false;
15663
- p = p.prevZ;
15664
- }
15665
-
15666
- // look for remaining points in increasing z-order
15667
- while (n && n.z <= maxZ) {
15668
- if (n.x >= x0 && n.x <= x1 && n.y >= y0 && n.y <= y1 && n !== a && n !== c &&
15669
- pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, n.x, n.y) && area(n.prev, n, n.next) >= 0) return false;
15670
- n = n.nextZ;
15671
- }
15672
-
15673
- return true;
15674
- }
15675
-
15676
- // go through all polygon nodes and cure small local self-intersections
15677
- function cureLocalIntersections(start, triangles) {
15678
- let p = start;
15679
- do {
15680
- const a = p.prev,
15681
- b = p.next.next;
15682
-
15683
- if (!equals(a, b) && intersects(a, p, p.next, b) && locallyInside(a, b) && locallyInside(b, a)) {
15684
-
15685
- triangles.push(a.i, p.i, b.i);
15686
-
15687
- // remove two nodes involved
15688
- removeNode(p);
15689
- removeNode(p.next);
15690
-
15691
- p = start = b;
15537
+ // Emit the final triangle if exactly 3 vertices remain.
15538
+ // Emit in CCW order (the linked-list direction may be CW after prior clips on concave polygons).
15539
+ if (remaining === 3) {
15540
+ const pa = prev[current];
15541
+ const nc = next[current];
15542
+ const ax = vertices[pa * 2], ay = vertices[pa * 2 + 1];
15543
+ const bx = vertices[current * 2], by = vertices[current * 2 + 1];
15544
+ const cx = vertices[nc * 2], cy = vertices[nc * 2 + 1];
15545
+ if (isCcwTriangle(ax, ay, bx, by, cx, cy)) {
15546
+ out[outIdx++] = pa;
15547
+ out[outIdx++] = current;
15548
+ out[outIdx++] = nc;
15692
15549
  }
15693
- p = p.next;
15694
- } while (p !== start);
15695
-
15696
- return filterPoints(p);
15697
- }
15698
-
15699
- // try splitting polygon into two and triangulate them independently
15700
- function splitEarcut(start, triangles, dim, minX, minY, invSize) {
15701
- // look for a valid diagonal that divides the polygon into two
15702
- let a = start;
15703
- do {
15704
- let b = a.next.next;
15705
- while (b !== a.prev) {
15706
- if (a.i !== b.i && isValidDiagonal(a, b)) {
15707
- // split the polygon in two by the diagonal
15708
- let c = splitPolygon(a, b);
15709
-
15710
- // filter colinear points around the cuts
15711
- a = filterPoints(a, a.next);
15712
- c = filterPoints(c, c.next);
15713
-
15714
- // run earcut on each half
15715
- earcutLinked(a, triangles, dim, minX, minY, invSize, 0);
15716
- earcutLinked(c, triangles, dim, minX, minY, invSize, 0);
15717
- return;
15718
- }
15719
- b = b.next;
15720
- }
15721
- a = a.next;
15722
- } while (a !== start);
15723
- }
15724
-
15725
- // link every hole into the outer loop, producing a single-ring polygon without holes
15726
- function eliminateHoles(data, holeIndices, outerNode, dim) {
15727
- const queue = [];
15728
-
15729
- for (let i = 0, len = holeIndices.length; i < len; i++) {
15730
- const start = holeIndices[i] * dim;
15731
- const end = i < len - 1 ? holeIndices[i + 1] * dim : data.length;
15732
- const list = linkedList(data, start, end, dim, false);
15733
- if (list === list.next) list.steiner = true;
15734
- queue.push(getLeftmost(list));
15735
- }
15736
-
15737
- queue.sort(compareXYSlope);
15738
-
15739
- // process holes from left to right
15740
- for (let i = 0; i < queue.length; i++) {
15741
- outerNode = eliminateHole(queue[i], outerNode);
15742
- }
15743
-
15744
- return outerNode;
15745
- }
15746
-
15747
- function compareXYSlope(a, b) {
15748
- let result = a.x - b.x;
15749
- // when the left-most point of 2 holes meet at a vertex, sort the holes counterclockwise so that when we find
15750
- // the bridge to the outer shell is always the point that they meet at.
15751
- if (result === 0) {
15752
- result = a.y - b.y;
15753
- if (result === 0) {
15754
- const aSlope = (a.next.y - a.y) / (a.next.x - a.x);
15755
- const bSlope = (b.next.y - b.y) / (b.next.x - b.x);
15756
- result = aSlope - bSlope;
15550
+ else {
15551
+ out[outIdx++] = nc;
15552
+ out[outIdx++] = current;
15553
+ out[outIdx++] = pa;
15757
15554
  }
15758
15555
  }
15759
- return result;
15556
+ return out.subarray(0, outIdx);
15760
15557
  }
15761
-
15762
- // find a bridge between vertices that connects hole with an outer ring and link it
15763
- function eliminateHole(hole, outerNode) {
15764
- const bridge = findHoleBridge(hole, outerNode);
15765
- if (!bridge) {
15766
- return outerNode;
15767
- }
15768
-
15769
- const bridgeReverse = splitPolygon(bridge, hole);
15770
-
15771
- // filter collinear points around the cuts
15772
- filterPoints(bridgeReverse, bridgeReverse.next);
15773
- return filterPoints(bridge, bridge.next);
15558
+ /** Shoelace signed area. Positive = CCW (mathematical orientation), negative = CW. */
15559
+ function signedArea(vertices) {
15560
+ const n = vertices.length >> 1;
15561
+ let area = 0;
15562
+ for (let i = 0; i < n; i++) {
15563
+ const j = (i + 1) % n;
15564
+ const x0 = vertices[i * 2];
15565
+ const y0 = vertices[i * 2 + 1];
15566
+ const x1 = vertices[j * 2];
15567
+ const y1 = vertices[j * 2 + 1];
15568
+ area += (x0 * y1) - (x1 * y0);
15569
+ }
15570
+ return area; // Positive = CCW, negative = CW.
15774
15571
  }
15775
-
15776
- // David Eberly's algorithm for finding a bridge between hole and outer polygon
15777
- function findHoleBridge(hole, outerNode) {
15778
- let p = outerNode;
15779
- const hx = hole.x;
15780
- const hy = hole.y;
15781
- let qx = -Infinity;
15782
- let m;
15783
-
15784
- // find a segment intersected by a ray from the hole's leftmost point to the left;
15785
- // segment's endpoint with lesser x will be potential connection point
15786
- // unless they intersect at a vertex, then choose the vertex
15787
- if (equals(hole, p)) return p;
15788
- do {
15789
- if (equals(hole, p.next)) return p.next;
15790
- else if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
15791
- const x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);
15792
- if (x <= hx && x > qx) {
15793
- qx = x;
15794
- m = p.x < p.next.x ? p : p.next;
15795
- if (x === hx) return m; // hole touches outer segment; pick leftmost endpoint
15796
- }
15797
- }
15798
- p = p.next;
15799
- } while (p !== outerNode);
15800
-
15801
- if (!m) return null;
15802
-
15803
- // look for points inside the triangle of hole point, segment intersection and endpoint;
15804
- // if there are no points found, we have a valid connection;
15805
- // otherwise choose the point of the minimum angle with the ray as connection point
15806
-
15807
- const stop = m;
15808
- const mx = m.x;
15809
- const my = m.y;
15810
- let tanMin = Infinity;
15811
-
15812
- p = m;
15813
-
15814
- do {
15815
- if (hx >= p.x && p.x >= mx && hx !== p.x &&
15816
- pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y)) {
15817
-
15818
- const tan = Math.abs(hy - p.y) / (hx - p.x); // tangential
15819
-
15820
- if (locallyInside(p, hole) &&
15821
- (tan < tanMin || (tan === tanMin && (p.x > m.x || (p.x === m.x && sectorContainsSector(m, p)))))) {
15822
- m = p;
15823
- tanMin = tan;
15824
- }
15825
- }
15826
-
15827
- p = p.next;
15828
- } while (p !== stop);
15829
-
15830
- return m;
15831
- }
15832
-
15833
- // whether sector in vertex m contains sector in vertex p in the same coordinates
15834
- function sectorContainsSector(m, p) {
15835
- return area(m.prev, m, p.prev) < 0 && area(p.next, m, m.next) < 0;
15572
+ /**
15573
+ * Returns true if the triangle (a, b, c) has a counter-clockwise (CCW) winding.
15574
+ * Uses the cross product of (b-a) × (c-a); positive = CCW.
15575
+ */
15576
+ function isCcwTriangle(ax, ay, bx, by, cx, cy) {
15577
+ return ((bx - ax) * (cy - ay) - (by - ay) * (cx - ax)) > 0;
15836
15578
  }
15837
-
15838
- // interlink polygon nodes in z-order
15839
- function indexCurve(start, minX, minY, invSize) {
15840
- let p = start;
15841
- do {
15842
- if (p.z === 0) p.z = zOrder(p.x, p.y, minX, minY, invSize);
15843
- p.prevZ = p.prev;
15844
- p.nextZ = p.next;
15845
- p = p.next;
15846
- } while (p !== start);
15847
-
15848
- p.prevZ.nextZ = null;
15849
- p.prevZ = null;
15850
-
15851
- sortLinked(p);
15579
+ /**
15580
+ * Returns true if point (px, py) lies strictly inside triangle (a, b, c).
15581
+ * Uses sign-of-cross-products. Boundary points (including corners) return false.
15582
+ */
15583
+ function pointInTriangle(px, py, ax, ay, bx, by, cx, cy) {
15584
+ const d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by);
15585
+ const d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy);
15586
+ const d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay);
15587
+ const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
15588
+ const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
15589
+ // Strictly inside: all same sign and none are exactly zero (exclude boundary).
15590
+ return !(hasNeg && hasPos) && (d1 !== 0) && (d2 !== 0) && (d3 !== 0);
15852
15591
  }
15853
-
15854
- // Simon Tatham's linked list merge sort algorithm
15855
- // http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html
15856
- function sortLinked(list) {
15857
- let numMerges;
15858
- let inSize = 1;
15859
-
15860
- do {
15861
- let p = list;
15862
- let e;
15863
- list = null;
15864
- let tail = null;
15865
- numMerges = 0;
15866
-
15867
- while (p) {
15868
- numMerges++;
15869
- let q = p;
15870
- let pSize = 0;
15871
- for (let i = 0; i < inSize; i++) {
15872
- pSize++;
15873
- q = q.nextZ;
15874
- if (!q) break;
15875
- }
15876
- let qSize = inSize;
15877
-
15878
- while (pSize > 0 || (qSize > 0 && q)) {
15879
-
15880
- if (pSize !== 0 && (qSize === 0 || !q || p.z <= q.z)) {
15881
- e = p;
15882
- p = p.nextZ;
15883
- pSize--;
15884
- } else {
15885
- e = q;
15886
- q = q.nextZ;
15887
- qSize--;
15888
- }
15889
-
15890
- if (tail) tail.nextZ = e;
15891
- else list = e;
15892
-
15893
- e.prevZ = tail;
15894
- tail = e;
15592
+ /**
15593
+ * Returns true if vertex v is an ear: the triangle (prevIdx, v, nextIdx) contains
15594
+ * no other polygon vertex strictly inside it.
15595
+ */
15596
+ function isEar(vertices, prev, next, prevIdx, v, nextIdx) {
15597
+ const ax = vertices[prevIdx * 2], ay = vertices[prevIdx * 2 + 1];
15598
+ const bx = vertices[v * 2], by = vertices[v * 2 + 1];
15599
+ const cx = vertices[nextIdx * 2], cy = vertices[nextIdx * 2 + 1];
15600
+ // Walk all remaining vertices and check if any lie strictly inside the ear triangle.
15601
+ // Skip the three ear vertices themselves — they can never be "inside" by strict test,
15602
+ // but we exclude them explicitly for clarity and to avoid floating-point edge cases.
15603
+ let node = next[nextIdx];
15604
+ while (node !== prevIdx) {
15605
+ if (node !== v) {
15606
+ const px = vertices[node * 2];
15607
+ const py = vertices[node * 2 + 1];
15608
+ if (pointInTriangle(px, py, ax, ay, bx, by, cx, cy)) {
15609
+ return false;
15895
15610
  }
15896
-
15897
- p = q;
15898
15611
  }
15899
-
15900
- tail.nextZ = null;
15901
- inSize *= 2;
15902
-
15903
- } while (numMerges > 1);
15904
-
15905
- return list;
15906
- }
15907
-
15908
- // z-order of a point given coords and inverse of the longer side of data bbox
15909
- function zOrder(x, y, minX, minY, invSize) {
15910
- // coords are transformed into non-negative 15-bit integer range
15911
- x = (x - minX) * invSize | 0;
15912
- y = (y - minY) * invSize | 0;
15913
-
15914
- x = (x | (x << 8)) & 0x00FF00FF;
15915
- x = (x | (x << 4)) & 0x0F0F0F0F;
15916
- x = (x | (x << 2)) & 0x33333333;
15917
- x = (x | (x << 1)) & 0x55555555;
15918
-
15919
- y = (y | (y << 8)) & 0x00FF00FF;
15920
- y = (y | (y << 4)) & 0x0F0F0F0F;
15921
- y = (y | (y << 2)) & 0x33333333;
15922
- y = (y | (y << 1)) & 0x55555555;
15923
-
15924
- return x | (y << 1);
15925
- }
15926
-
15927
- // find the leftmost node of a polygon ring
15928
- function getLeftmost(start) {
15929
- let p = start,
15930
- leftmost = start;
15931
- do {
15932
- if (p.x < leftmost.x || (p.x === leftmost.x && p.y < leftmost.y)) leftmost = p;
15933
- p = p.next;
15934
- } while (p !== start);
15935
-
15936
- return leftmost;
15937
- }
15938
-
15939
- // check if a point lies within a convex triangle
15940
- function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
15941
- return (cx - px) * (ay - py) >= (ax - px) * (cy - py) &&
15942
- (ax - px) * (by - py) >= (bx - px) * (ay - py) &&
15943
- (bx - px) * (cy - py) >= (cx - px) * (by - py);
15944
- }
15945
-
15946
- // check if a point lies within a convex triangle but false if its equal to the first point of the triangle
15947
- function pointInTriangleExceptFirst(ax, ay, bx, by, cx, cy, px, py) {
15948
- return !(ax === px && ay === py) && pointInTriangle(ax, ay, bx, by, cx, cy, px, py);
15949
- }
15950
-
15951
- // check if a diagonal between two polygon nodes is valid (lies in polygon interior)
15952
- function isValidDiagonal(a, b) {
15953
- return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && // doesn't intersect other edges
15954
- (locallyInside(a, b) && locallyInside(b, a) && middleInside(a, b) && // locally visible
15955
- (area(a.prev, a, b.prev) || area(a, b.prev, b)) || // does not create opposite-facing sectors
15956
- equals(a, b) && area(a.prev, a, a.next) > 0 && area(b.prev, b, b.next) > 0); // special zero-length case
15957
- }
15958
-
15959
- // signed area of a triangle
15960
- function area(p, q, r) {
15961
- return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
15962
- }
15963
-
15964
- // check if two points are equal
15965
- function equals(p1, p2) {
15966
- return p1.x === p2.x && p1.y === p2.y;
15967
- }
15968
-
15969
- // check if two segments intersect
15970
- function intersects(p1, q1, p2, q2) {
15971
- const o1 = sign(area(p1, q1, p2));
15972
- const o2 = sign(area(p1, q1, q2));
15973
- const o3 = sign(area(p2, q2, p1));
15974
- const o4 = sign(area(p2, q2, q1));
15975
-
15976
- if (o1 !== o2 && o3 !== o4) return true; // general case
15977
-
15978
- if (o1 === 0 && onSegment(p1, p2, q1)) return true; // p1, q1 and p2 are collinear and p2 lies on p1q1
15979
- if (o2 === 0 && onSegment(p1, q2, q1)) return true; // p1, q1 and q2 are collinear and q2 lies on p1q1
15980
- if (o3 === 0 && onSegment(p2, p1, q2)) return true; // p2, q2 and p1 are collinear and p1 lies on p2q2
15981
- if (o4 === 0 && onSegment(p2, q1, q2)) return true; // p2, q2 and q1 are collinear and q1 lies on p2q2
15982
-
15983
- return false;
15984
- }
15985
-
15986
- // for collinear points p, q, r, check if point q lies on segment pr
15987
- function onSegment(p, q, r) {
15988
- return q.x <= Math.max(p.x, r.x) && q.x >= Math.min(p.x, r.x) && q.y <= Math.max(p.y, r.y) && q.y >= Math.min(p.y, r.y);
15989
- }
15990
-
15991
- function sign(num) {
15992
- return num > 0 ? 1 : num < 0 ? -1 : 0;
15993
- }
15994
-
15995
- // check if a polygon diagonal intersects any polygon segments
15996
- function intersectsPolygon(a, b) {
15997
- let p = a;
15998
- do {
15999
- if (p.i !== a.i && p.next.i !== a.i && p.i !== b.i && p.next.i !== b.i &&
16000
- intersects(p, p.next, a, b)) return true;
16001
- p = p.next;
16002
- } while (p !== a);
16003
-
16004
- return false;
16005
- }
16006
-
16007
- // check if a polygon diagonal is locally inside the polygon
16008
- function locallyInside(a, b) {
16009
- return area(a.prev, a, a.next) < 0 ?
16010
- area(a, b, a.next) >= 0 && area(a, a.prev, b) >= 0 :
16011
- area(a, b, a.prev) < 0 || area(a, a.next, b) < 0;
16012
- }
16013
-
16014
- // check if the middle point of a polygon diagonal is inside the polygon
16015
- function middleInside(a, b) {
16016
- let p = a;
16017
- let inside = false;
16018
- const px = (a.x + b.x) / 2;
16019
- const py = (a.y + b.y) / 2;
16020
- do {
16021
- if (((p.y > py) !== (p.next.y > py)) && p.next.y !== p.y &&
16022
- (px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x))
16023
- inside = !inside;
16024
- p = p.next;
16025
- } while (p !== a);
16026
-
16027
- return inside;
16028
- }
16029
-
16030
- // link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two;
16031
- // if one belongs to the outer ring and another to a hole, it merges it into a single ring
16032
- function splitPolygon(a, b) {
16033
- const a2 = createNode(a.i, a.x, a.y),
16034
- b2 = createNode(b.i, b.x, b.y),
16035
- an = a.next,
16036
- bp = b.prev;
16037
-
16038
- a.next = b;
16039
- b.prev = a;
16040
-
16041
- a2.next = an;
16042
- an.prev = a2;
16043
-
16044
- b2.next = a2;
16045
- a2.prev = b2;
16046
-
16047
- bp.next = b2;
16048
- b2.prev = bp;
16049
-
16050
- return b2;
16051
- }
16052
-
16053
- // create a node and optionally link it with previous one (in a circular doubly linked list)
16054
- function insertNode(i, x, y, last) {
16055
- const p = createNode(i, x, y);
16056
-
16057
- if (!last) {
16058
- p.prev = p;
16059
- p.next = p;
16060
-
16061
- } else {
16062
- p.next = last.next;
16063
- p.prev = last;
16064
- last.next.prev = p;
16065
- last.next = p;
16066
- }
16067
- return p;
16068
- }
16069
-
16070
- function removeNode(p) {
16071
- p.next.prev = p.prev;
16072
- p.prev.next = p.next;
16073
-
16074
- if (p.prevZ) p.prevZ.nextZ = p.nextZ;
16075
- if (p.nextZ) p.nextZ.prevZ = p.prevZ;
16076
- }
16077
-
16078
- function createNode(i, x, y) {
16079
- return {
16080
- i, // vertex index in coordinates array
16081
- x, y, // vertex coordinates
16082
- prev: null, // previous and next vertex nodes in a polygon ring
16083
- next: null,
16084
- z: 0, // z-order curve value
16085
- prevZ: null, // previous and next nodes in z-order
16086
- nextZ: null,
16087
- steiner: false // indicates whether this is a steiner point
16088
- };
16089
- }
16090
-
16091
- function signedArea(data, start, end, dim) {
16092
- let sum = 0;
16093
- for (let i = start, j = end - dim; i < end; i += dim) {
16094
- sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]);
16095
- j = i;
15612
+ node = next[node];
16096
15613
  }
16097
- return sum;
15614
+ return true;
16098
15615
  }
16099
15616
 
16100
15617
  const buildLine = (startX, startY, endX, endY, width) => {
@@ -16298,13 +15815,13 @@ const buildPolygon = (points) => {
16298
15815
  throw new Error('At least three X/Y pairs are required to build a polygon.');
16299
15816
  }
16300
15817
  const length = points.length / 2;
16301
- const triangles = earcut(points, [], 2);
15818
+ const triangles = triangulate(points);
16302
15819
  const vertices = new Float32Array(points.length);
16303
15820
  for (let i = 0; i < length; i++) {
16304
15821
  vertices[i * 2] = points[i * 2];
16305
15822
  vertices[(i * 2) + 1] = points[(i * 2) + 1];
16306
15823
  }
16307
- const indices = triangles ? new Uint16Array(triangles) : new Uint16Array(0);
15824
+ const indices = new Uint16Array(triangles);
16308
15825
  return { vertices, indices, points };
16309
15826
  };
16310
15827
  const buildRectangle = (x, y, width, height) => {
@@ -17629,14 +17146,227 @@ class AnimatedSprite extends Sprite {
17629
17146
  }
17630
17147
  }
17631
17148
 
17149
+ const glyphPadding = 2;
17150
+ class ShelfPacker {
17151
+ _shelves = [];
17152
+ _width;
17153
+ _height;
17154
+ constructor(width, height) {
17155
+ this._width = width;
17156
+ this._height = height;
17157
+ }
17158
+ insert(width, height) {
17159
+ // Try existing shelves in order (ascending y)
17160
+ for (const shelf of this._shelves) {
17161
+ if (shelf.height >= height && shelf.cursorX + width <= this._width) {
17162
+ const x = shelf.cursorX;
17163
+ shelf.cursorX += width;
17164
+ return { x, y: shelf.y };
17165
+ }
17166
+ }
17167
+ // Create a new shelf at the bottom
17168
+ const last = this._shelves[this._shelves.length - 1];
17169
+ const bottomY = last === undefined ? 0 : last.y + last.height;
17170
+ if (bottomY + height > this._height) {
17171
+ throw new Error(`GlyphAtlas full — clear() and re-render, or instantiate with larger dims`);
17172
+ }
17173
+ this._shelves.push({ y: bottomY, height, cursorX: width });
17174
+ return { x: 0, y: bottomY };
17175
+ }
17176
+ reset() {
17177
+ this._shelves.length = 0;
17178
+ }
17179
+ }
17180
+ /**
17181
+ * A shared atlas that rasterizes glyphs on demand into an offscreen canvas
17182
+ * and wraps it as a Texture for use by the Mesh-based Text renderer.
17183
+ *
17184
+ * Glyphs are always rasterized in white so that runtime tinting via
17185
+ * `Mesh.tint` applies the fill color without requiring re-rasterization.
17186
+ *
17187
+ * Use `getDefaultGlyphAtlas()` from `atlas-singleton.ts` rather than
17188
+ * constructing directly.
17189
+ */
17190
+ class DynamicGlyphAtlas {
17191
+ texture;
17192
+ _canvas;
17193
+ _ctx;
17194
+ _packer;
17195
+ _cache = new Map();
17196
+ _width;
17197
+ _height;
17198
+ constructor(width = 1024, height = 1024) {
17199
+ this._width = width;
17200
+ this._height = height;
17201
+ // Use OffscreenCanvas when available, fall back to HTMLCanvasElement.
17202
+ // In jsdom / Node the global may be absent; createCanvas falls through
17203
+ // to document.createElement which jsdom provides.
17204
+ const canvas = typeof OffscreenCanvas !== 'undefined'
17205
+ ? new OffscreenCanvas(width, height)
17206
+ : document.createElement('canvas');
17207
+ if ('width' in canvas) {
17208
+ canvas.width = width;
17209
+ canvas.height = height;
17210
+ }
17211
+ this._canvas = canvas;
17212
+ const ctx = canvas.getContext('2d');
17213
+ if (ctx === null) {
17214
+ throw new Error('DynamicGlyphAtlas: could not obtain a 2D context.');
17215
+ }
17216
+ this._ctx = ctx;
17217
+ this._packer = new ShelfPacker(width, height);
17218
+ this.texture = new Texture(canvas);
17219
+ this.texture.setSize(width, height);
17220
+ }
17221
+ /**
17222
+ * Returns the cached GlyphInfo for the given character + font parameters,
17223
+ * rasterizing it into the atlas if not already present.
17224
+ */
17225
+ getGlyph(char, family, size, weight, style) {
17226
+ const key = `${char}:${family}:${size}:${weight}:${style}`;
17227
+ const cached = this._cache.get(key);
17228
+ if (cached !== undefined) {
17229
+ return cached;
17230
+ }
17231
+ const info = this._rasterize(char, family, size, weight, style, key);
17232
+ this._cache.set(key, info);
17233
+ // Bump texture version so GPU backends re-upload the canvas data.
17234
+ this.texture.updateSource();
17235
+ return info;
17236
+ }
17237
+ /**
17238
+ * Clears all cached glyphs and resets the atlas packer.
17239
+ * The underlying canvas pixels are also cleared.
17240
+ */
17241
+ clear() {
17242
+ this._cache.clear();
17243
+ this._packer.reset();
17244
+ this._ctx.clearRect(0, 0, this._width, this._height);
17245
+ this.texture.updateSource();
17246
+ }
17247
+ // -----------------------------------------------------------------------
17248
+ _rasterize(char, family, size, weight, fontStyle, _key) {
17249
+ const ctx = this._ctx;
17250
+ const padding = glyphPadding;
17251
+ ctx.font = `${fontStyle} ${weight} ${size}px ${family}`;
17252
+ ctx.textBaseline = 'alphabetic';
17253
+ ctx.fillStyle = '#ffffff';
17254
+ const metrics = ctx.measureText(char);
17255
+ const ascent = Math.ceil(metrics.fontBoundingBoxAscent
17256
+ ?? metrics.actualBoundingBoxAscent
17257
+ ?? size * 0.8);
17258
+ const descent = Math.ceil(metrics.fontBoundingBoxDescent
17259
+ ?? metrics.actualBoundingBoxDescent
17260
+ ?? size * 0.2);
17261
+ const advance = metrics.width;
17262
+ const glyphWidth = Math.max(1, Math.ceil((metrics.actualBoundingBoxLeft ?? 0) + (metrics.actualBoundingBoxRight ?? 0)) || Math.ceil(advance));
17263
+ const glyphHeight = Math.max(1, ascent + descent);
17264
+ const slotW = glyphWidth + padding * 2;
17265
+ const slotH = glyphHeight + padding * 2;
17266
+ const slot = this._packer.insert(slotW, slotH);
17267
+ // Draw the glyph white into the atlas slot
17268
+ ctx.fillText(char, slot.x + padding + (metrics.actualBoundingBoxLeft ?? 0), slot.y + padding + ascent);
17269
+ const info = {
17270
+ x: slot.x,
17271
+ y: slot.y,
17272
+ width: glyphWidth,
17273
+ height: glyphHeight,
17274
+ advance,
17275
+ ascent,
17276
+ uvLeft: slot.x / this._width,
17277
+ uvTop: slot.y / this._height,
17278
+ uvRight: (slot.x + slotW) / this._width,
17279
+ uvBottom: (slot.y + slotH) / this._height,
17280
+ };
17281
+ return info;
17282
+ }
17283
+ }
17284
+
17285
+ let _defaultAtlas = null;
17286
+ /**
17287
+ * Returns the shared process-wide glyph atlas, creating it lazily on first
17288
+ * call. All `Text` instances share this atlas so that identical glyph shapes
17289
+ * (same char + family + size + weight + style) are rasterized only once.
17290
+ */
17291
+ function getDefaultGlyphAtlas() {
17292
+ if (_defaultAtlas === null) {
17293
+ _defaultAtlas = new DynamicGlyphAtlas();
17294
+ }
17295
+ return _defaultAtlas;
17296
+ }
17297
+
17298
+ /**
17299
+ * Computes per-glyph quad placements for the given text and style.
17300
+ *
17301
+ * Handles `\n` line breaks and left/center/right alignment. No word-wrap,
17302
+ * no RTL, no ligature shaping — Unicode/diacritics are delegated to the
17303
+ * browser's font engine via canvas `fillText`.
17304
+ *
17305
+ * Returns an empty array for empty text.
17306
+ */
17307
+ function layoutText(text, style, atlas) {
17308
+ if (text.length === 0) {
17309
+ return [];
17310
+ }
17311
+ const { fontSize, fontFamily, fontWeight, fontStyle, lineHeight, align } = style;
17312
+ const computedLineHeight = fontSize * lineHeight;
17313
+ const lines = text.split('\n');
17314
+ // Pass 1: gather glyph info per line, track line widths
17315
+ const linePlacements = [];
17316
+ let maxLineWidth = 0;
17317
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
17318
+ const line = lines[lineIndex];
17319
+ const y = lineIndex * computedLineHeight;
17320
+ let cursorX = 0;
17321
+ const placements = [];
17322
+ for (const char of line) {
17323
+ const info = atlas.getGlyph(char, fontFamily, fontSize, fontWeight, fontStyle);
17324
+ placements.push({ info, x: cursorX, y });
17325
+ cursorX += info.advance;
17326
+ }
17327
+ const lineWidth = cursorX;
17328
+ if (lineWidth > maxLineWidth) {
17329
+ maxLineWidth = lineWidth;
17330
+ }
17331
+ linePlacements.push({ placements, width: lineWidth });
17332
+ }
17333
+ // Pass 2: apply alignment and build final GlyphPlacement array
17334
+ const result = [];
17335
+ for (const line of linePlacements) {
17336
+ let offsetX = 0;
17337
+ if (align === 'right') {
17338
+ offsetX = maxLineWidth - line.width;
17339
+ }
17340
+ else if (align === 'center') {
17341
+ offsetX = (maxLineWidth - line.width) / 2;
17342
+ }
17343
+ for (const { info, x, y } of line.placements) {
17344
+ result.push({
17345
+ x: x + offsetX,
17346
+ y,
17347
+ width: info.width,
17348
+ height: info.height,
17349
+ uvLeft: info.uvLeft,
17350
+ uvTop: info.uvTop,
17351
+ uvRight: info.uvRight,
17352
+ uvBottom: info.uvBottom,
17353
+ });
17354
+ }
17355
+ }
17356
+ return result;
17357
+ }
17358
+
17632
17359
  class TextStyle {
17633
17360
  _align;
17634
17361
  _fill;
17362
+ _fillColor;
17363
+ _lineHeight;
17635
17364
  _stroke;
17636
17365
  _strokeThickness;
17637
17366
  _fontSize;
17638
17367
  _fontWeight;
17639
17368
  _fontFamily;
17369
+ _fontStyle;
17640
17370
  _wordWrap;
17641
17371
  _wordWrapWidth;
17642
17372
  _baseline;
@@ -17647,11 +17377,14 @@ class TextStyle {
17647
17377
  constructor(options = {}) {
17648
17378
  this._align = options.align ?? 'left';
17649
17379
  this._fill = options.fill ?? 'black';
17380
+ this._fillColor = options.fillColor ?? Color.white.clone();
17381
+ this._lineHeight = options.lineHeight ?? 1.2;
17650
17382
  this._stroke = options.stroke ?? 'black';
17651
17383
  this._strokeThickness = options.strokeThickness ?? 1;
17652
17384
  this._fontSize = options.fontSize ?? 20;
17653
17385
  this._fontWeight = options.fontWeight ?? 'bold';
17654
17386
  this._fontFamily = options.fontFamily ?? 'Arial';
17387
+ this._fontStyle = options.fontStyle ?? 'normal';
17655
17388
  this._wordWrap = options.wordWrap ?? false;
17656
17389
  this._wordWrapWidth = options.wordWrapWidth ?? 100;
17657
17390
  this._baseline = options.baseline ?? 'alphabetic';
@@ -17677,6 +17410,31 @@ class TextStyle {
17677
17410
  this._dirty = true;
17678
17411
  }
17679
17412
  }
17413
+ /**
17414
+ * Runtime fill color applied via `Mesh.tint`. Glyphs are always
17415
+ * rasterized white; this color multiplies them at draw time so that
17416
+ * changing the color never requires re-rasterizing cached glyphs.
17417
+ */
17418
+ get fillColor() {
17419
+ return this._fillColor;
17420
+ }
17421
+ set fillColor(color) {
17422
+ this._fillColor = color;
17423
+ this._dirty = true;
17424
+ }
17425
+ /**
17426
+ * Line-height multiplier applied to `fontSize` when computing vertical
17427
+ * spacing between lines. Defaults to 1.2.
17428
+ */
17429
+ get lineHeight() {
17430
+ return this._lineHeight;
17431
+ }
17432
+ set lineHeight(lineHeight) {
17433
+ if (this._lineHeight !== lineHeight) {
17434
+ this._lineHeight = lineHeight;
17435
+ this._dirty = true;
17436
+ }
17437
+ }
17680
17438
  get stroke() {
17681
17439
  return this._stroke;
17682
17440
  }
@@ -17722,6 +17480,15 @@ class TextStyle {
17722
17480
  this._dirty = true;
17723
17481
  }
17724
17482
  }
17483
+ get fontStyle() {
17484
+ return this._fontStyle;
17485
+ }
17486
+ set fontStyle(fontStyle) {
17487
+ if (this._fontStyle !== fontStyle) {
17488
+ this._fontStyle = fontStyle;
17489
+ this._dirty = true;
17490
+ }
17491
+ }
17725
17492
  get wordWrap() {
17726
17493
  return this._wordWrap;
17727
17494
  }
@@ -17799,11 +17566,14 @@ class TextStyle {
17799
17566
  if (style !== this) {
17800
17567
  this.align = style.align;
17801
17568
  this.fill = style.fill;
17569
+ this._fillColor = style.fillColor.clone();
17570
+ this.lineHeight = style.lineHeight;
17802
17571
  this.stroke = style.stroke;
17803
17572
  this.strokeThickness = style.strokeThickness;
17804
17573
  this.fontSize = style.fontSize;
17805
17574
  this.fontWeight = style.fontWeight;
17806
17575
  this.fontFamily = style.fontFamily;
17576
+ this.fontStyle = style.fontStyle;
17807
17577
  this.wordWrap = style.wordWrap;
17808
17578
  this.wordWrapWidth = style.wordWrapWidth;
17809
17579
  this.baseline = style.baseline;
@@ -17819,26 +17589,81 @@ class TextStyle {
17819
17589
  }
17820
17590
  }
17821
17591
 
17822
- const newLinePattern = /(?:\r\n|\r|\n)/;
17823
- class Text extends Sprite {
17592
+ function buildMesh(placements, style) {
17593
+ const n = placements.length;
17594
+ const vertices = new Float32Array(n * 4 * 2);
17595
+ const uvs = new Float32Array(n * 4 * 2);
17596
+ const indices = new Uint16Array(n * 6);
17597
+ for (let i = 0; i < n; i++) {
17598
+ const p = placements[i];
17599
+ const v = i * 8;
17600
+ const u = i * 8;
17601
+ const idx = i * 6;
17602
+ const baseV = i * 4;
17603
+ // Vertices: TL, TR, BR, BL
17604
+ vertices[v + 0] = p.x;
17605
+ vertices[v + 1] = p.y;
17606
+ vertices[v + 2] = p.x + p.width;
17607
+ vertices[v + 3] = p.y;
17608
+ vertices[v + 4] = p.x + p.width;
17609
+ vertices[v + 5] = p.y + p.height;
17610
+ vertices[v + 6] = p.x;
17611
+ vertices[v + 7] = p.y + p.height;
17612
+ // UVs: TL, TR, BR, BL
17613
+ uvs[u + 0] = p.uvLeft;
17614
+ uvs[u + 1] = p.uvTop;
17615
+ uvs[u + 2] = p.uvRight;
17616
+ uvs[u + 3] = p.uvTop;
17617
+ uvs[u + 4] = p.uvRight;
17618
+ uvs[u + 5] = p.uvBottom;
17619
+ uvs[u + 6] = p.uvLeft;
17620
+ uvs[u + 7] = p.uvBottom;
17621
+ // Indices: [TL, TR, BR, TL, BR, BL]
17622
+ indices[idx + 0] = baseV + 0;
17623
+ indices[idx + 1] = baseV + 1;
17624
+ indices[idx + 2] = baseV + 2;
17625
+ indices[idx + 3] = baseV + 0;
17626
+ indices[idx + 4] = baseV + 2;
17627
+ indices[idx + 5] = baseV + 3;
17628
+ }
17629
+ const atlas = getDefaultGlyphAtlas();
17630
+ const mesh = new Mesh({
17631
+ vertices,
17632
+ uvs,
17633
+ indices,
17634
+ texture: atlas.texture,
17635
+ });
17636
+ mesh.tint = style.fillColor;
17637
+ return mesh;
17638
+ }
17639
+ /**
17640
+ * GPU-accelerated text node that rasterizes individual glyphs into a shared
17641
+ * atlas ({@link DynamicGlyphAtlas}) and renders them as a single quad-per-
17642
+ * glyph {@link Mesh} (one draw call per Text instance).
17643
+ *
17644
+ * Glyphs are always rasterized in white and tinted at runtime via
17645
+ * `Mesh.tint`; changing `style.fillColor` only updates the mesh tint —
17646
+ * no atlas re-rasterization is needed.
17647
+ *
17648
+ * The internal {@link Mesh} is the sole child of this {@link Container}.
17649
+ * All transform properties (position, rotation, scale, origin) are
17650
+ * inherited from {@link Container} → {@link RenderNode}.
17651
+ */
17652
+ class Text extends Container {
17824
17653
  _text;
17825
17654
  _style;
17826
- _canvas;
17827
- _context;
17828
- _dirty = true;
17829
- constructor(text, style, samplerOptions, canvas = document.createElement('canvas')) {
17830
- super(new Texture(canvas, samplerOptions));
17655
+ _mesh = null;
17656
+ constructor(text, style) {
17657
+ super();
17831
17658
  this._text = text;
17832
17659
  this._style = (style && style instanceof TextStyle) ? style : new TextStyle(style);
17833
- this._canvas = canvas;
17834
- this._context = canvas.getContext('2d');
17835
- this.updateTexture();
17660
+ this._rebuild();
17836
17661
  }
17837
17662
  get text() {
17838
17663
  return this._text;
17839
17664
  }
17840
- set text(text) {
17841
- this.setText(text);
17665
+ set text(value) {
17666
+ this.setText(value);
17842
17667
  }
17843
17668
  get style() {
17844
17669
  return this._style;
@@ -17846,98 +17671,43 @@ class Text extends Sprite {
17846
17671
  set style(style) {
17847
17672
  this.setStyle(style);
17848
17673
  }
17849
- get canvas() {
17850
- return this._canvas;
17851
- }
17852
- set canvas(canvas) {
17853
- this.setCanvas(canvas);
17854
- }
17855
17674
  setText(text) {
17856
17675
  if (this._text !== text) {
17857
17676
  this._text = text;
17858
- this._dirty = true;
17677
+ this._rebuild();
17859
17678
  }
17860
17679
  return this;
17861
17680
  }
17862
17681
  setStyle(style) {
17863
17682
  this._style = (style instanceof TextStyle) ? style : new TextStyle(style);
17864
- this._dirty = true;
17865
- return this;
17866
- }
17867
- setCanvas(canvas) {
17868
- if (this._canvas !== canvas) {
17869
- this._canvas = canvas;
17870
- this._context = this._getContext(canvas);
17871
- this._dirty = true;
17872
- this.texture.setSource.call(this.texture, canvas);
17873
- this.setTextureFrame(Rectangle.temp.set(0, 0, canvas.width, canvas.height));
17874
- }
17875
- return this;
17876
- }
17877
- updateTexture() {
17878
- if (this._style && (this._dirty || this._style.dirty)) {
17879
- const canvas = this._canvas, context = this._context, style = this._style.apply(context), text = style.wordWrap ? this.getWordWrappedText() : this._text, lineHeight = determineFontHeight(context.font) + style.strokeThickness, lines = text.split(newLinePattern), lineMetrics = lines.map((line) => context.measureText(line)), maxLineWidth = lineMetrics.reduce((max, measure) => Math.max(max, measure.width), 0), canvasWidth = Math.ceil((maxLineWidth + style.strokeThickness) + (style.padding * 2)), canvasHeight = Math.ceil((lineHeight * lines.length) + (style.padding * 2));
17880
- if (canvasWidth !== canvas.width || canvasHeight !== canvas.height) {
17881
- canvas.width = canvasWidth;
17882
- canvas.height = canvasHeight;
17883
- this.setTextureFrame(Rectangle.temp.set(0, 0, canvasWidth, canvasHeight));
17884
- }
17885
- else {
17886
- context.clearRect(0, 0, canvasWidth, canvasHeight);
17887
- }
17888
- style.apply(context);
17889
- for (let i = 0; i < lines.length; i++) {
17890
- const metrics = lineMetrics[i], lineWidth = (maxLineWidth - metrics.width), offset = (style.align === 'right') ? lineWidth : lineWidth / 2, padding = style.padding + (style.strokeThickness / 2), lineX = metrics.actualBoundingBoxLeft + (style.align === 'left' ? 0 : offset) + padding, lineY = metrics.actualBoundingBoxAscent + (lineHeight * i) + padding;
17891
- if (style.stroke && style.strokeThickness) {
17892
- context.strokeText(lines[i], lineX, lineY);
17893
- }
17894
- if (style.fill) {
17895
- context.fillText(lines[i], lineX, lineY);
17896
- }
17897
- }
17898
- this.texture.updateSource();
17899
- this._dirty = false;
17900
- this._style.dirty = false;
17901
- }
17683
+ this._rebuild();
17902
17684
  return this;
17903
17685
  }
17904
- getWordWrappedText() {
17905
- const context = this._context, wrapWidth = this._style.wordWrapWidth, lines = this._text.split('\n'), spaceWidth = context.measureText(' ').width;
17906
- let spaceLeft = wrapWidth, result = '';
17907
- for (let y = 0; y < lines.length; y++) {
17908
- const words = lines[y].split(' ');
17909
- if (y > 0) {
17910
- result += '\n';
17911
- }
17912
- for (let x = 0; x < words.length; x++) {
17913
- const word = words[x], wordWidth = context.measureText(word).width, pairWidth = wordWidth + spaceWidth;
17914
- if (pairWidth > spaceLeft) {
17915
- if (x > 0) {
17916
- result += '\n';
17917
- }
17918
- spaceLeft = wrapWidth;
17919
- }
17920
- else {
17921
- spaceLeft -= pairWidth;
17922
- }
17923
- result += `${word} `;
17924
- }
17686
+ destroy() {
17687
+ if (this._mesh !== null) {
17688
+ this._mesh.destroy();
17689
+ this._mesh = null;
17925
17690
  }
17926
- return result;
17691
+ super.destroy();
17927
17692
  }
17928
- render(backend) {
17929
- if (this.visible) {
17930
- this.updateTexture();
17931
- super.render(backend);
17693
+ // -----------------------------------------------------------------------
17694
+ _rebuild() {
17695
+ // Remove and discard the old mesh (if any).
17696
+ if (this._mesh !== null) {
17697
+ this.removeChild(this._mesh);
17698
+ this._mesh.destroy();
17699
+ this._mesh = null;
17700
+ }
17701
+ if (this._text.length === 0) {
17702
+ return;
17932
17703
  }
17933
- return this;
17934
- }
17935
- _getContext(canvas) {
17936
- const context = canvas.getContext('2d');
17937
- if (context === null) {
17938
- throw new Error('Could not create a 2D canvas context.');
17704
+ const atlas = getDefaultGlyphAtlas();
17705
+ const placements = layoutText(this._text, this._style, atlas);
17706
+ if (placements.length === 0) {
17707
+ return;
17939
17708
  }
17940
- return context;
17709
+ this._mesh = buildMesh(placements, this._style);
17710
+ this.addChild(this._mesh);
17941
17711
  }
17942
17712
  }
17943
17713
 
@@ -18519,5 +18289,5 @@ class IndexedDbStore {
18519
18289
  }
18520
18290
  }
18521
18291
 
18522
- export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, AudioAnalyser, BinaryFactory, BlendModes, BlurFilter, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, Circle, Clock, CollisionType, Color, ColorAffector, ColorFilter, Container, Drawable, Ellipse, FactoryRegistry, Filter, Flags, FontFactory, ForceAffector, GameCubeGamepadMapping, Gamepad, GamepadChannel, GamepadControl, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Graphics, ImageFactory, IndexedDbDatabase, IndexedDbStore, Input, InputManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, Loader, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, Particle, ParticleOptions, ParticleSystem, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, Sampler, ScaleAffector, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, TorqueAffector, UniversalEmitter, Vector, Video, VideoFactory, View, ViewFlags, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuSpriteRenderer, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, normalizeIds, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign$1 as sign, stopEvent, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, tau, trimRotation, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
18292
+ export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, AudioAnalyser, BinaryFactory, BlendModes, BlurFilter, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, Circle, Clock, CollisionType, Color, ColorAffector, ColorFilter, Container, Drawable, DynamicGlyphAtlas, Ellipse, FactoryRegistry, Filter, Flags, FontFactory, ForceAffector, GameCubeGamepadMapping, Gamepad, GamepadChannel, GamepadControl, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Graphics, ImageFactory, IndexedDbDatabase, IndexedDbStore, Input, InputManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, Loader, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, Particle, ParticleOptions, ParticleSystem, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, Sampler, ScaleAffector, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, TorqueAffector, UniversalEmitter, Vector, Video, VideoFactory, View, ViewFlags, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuSpriteRenderer, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, normalizeIds, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, tau, trimRotation, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
18523
18293
  //# sourceMappingURL=exo.esm.js.map