@vectojs/core 1.14.0 → 1.16.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.
Files changed (52) hide show
  1. package/dist/{chunk-XIEQHSBB.mjs → chunk-AQTO7OSU.mjs} +253 -496
  2. package/dist/{chunk-2Z23LTH3.js → chunk-DUYB4GX4.js} +310 -551
  3. package/dist/{chunk-BEUIB3U7.js → chunk-IYPLG4Z4.js} +93 -43
  4. package/dist/{chunk-L5BCKFQE.mjs → chunk-JFU56BX4.mjs} +48 -0
  5. package/dist/index.d.ts +4 -12
  6. package/dist/index.js +2155 -456
  7. package/dist/index.mjs +1977 -279
  8. package/dist/layout/index.d.ts +1 -3
  9. package/dist/layout.js +3 -15
  10. package/dist/layout.mjs +2 -16
  11. package/dist/renderer/CanvasRenderer.d.ts +22 -0
  12. package/dist/renderer/IRenderer.d.ts +13 -0
  13. package/dist/renderer.js +4 -3
  14. package/dist/renderer.mjs +1 -1
  15. package/dist/text/MSDFTextEntity.d.ts +30 -1
  16. package/dist/text/index.d.ts +1 -5
  17. package/dist/text.js +5 -15
  18. package/dist/text.mjs +6 -17
  19. package/dist/tree/ComputeParticleEntity.d.ts +18 -0
  20. package/dist/tree/DOMPortalEntity.d.ts +18 -0
  21. package/dist/tree/Entity.d.ts +122 -6
  22. package/dist/tree/Scene.d.ts +380 -0
  23. package/dist/wasm/anim-backend.d.ts +86 -0
  24. package/dist/wasm/asset.d.ts +25 -0
  25. package/dist/wasm/asset.js +7 -0
  26. package/dist/wasm/asset.mjs +5 -0
  27. package/dist/wasm/backend.d.ts +154 -0
  28. package/dist/wasm/hit-backend.d.ts +92 -0
  29. package/dist/wasm/hit-store.d.ts +48 -0
  30. package/dist/wasm/particle-backend.d.ts +104 -0
  31. package/dist/wasm/scene-store.d.ts +28 -0
  32. package/dist/wasm/soa.d.ts +146 -0
  33. package/dist/wasm/vectojs_core.wasm +0 -0
  34. package/package.json +17 -8
  35. package/dist/animation/drivers.d.ts +0 -48
  36. package/dist/animation/easing.d.ts +0 -16
  37. package/dist/chunk-4AR425AR.js +0 -1121
  38. package/dist/chunk-BA5HUUDF.js +0 -760
  39. package/dist/chunk-IESDTEJ4.mjs +0 -1121
  40. package/dist/chunk-X7I465AQ.mjs +0 -760
  41. package/dist/layout/LayoutEngine.d.ts +0 -289
  42. package/dist/layout/LayoutWorker.d.ts +0 -23
  43. package/dist/layout/LayoutWorkerManager.d.ts +0 -26
  44. package/dist/layout/LayoutWorkerSource.d.ts +0 -1
  45. package/dist/layout/measure.d.ts +0 -20
  46. package/dist/math/SpatialHashGrid.d.ts +0 -53
  47. package/dist/math/SpringPhysics.d.ts +0 -13
  48. package/dist/text/ArabicShaper.d.ts +0 -10
  49. package/dist/text/BidiResolver.d.ts +0 -5
  50. package/dist/text/MSDFFont.d.ts +0 -129
  51. package/dist/text/PreparedContentGrid.d.ts +0 -60
  52. package/dist/text/Typography.d.ts +0 -11
@@ -1,137 +1,9 @@
1
- import {
2
- ArabicShaper,
3
- BidiResolver,
4
- LayoutWorkerManager
5
- } from "./chunk-IESDTEJ4.mjs";
6
-
7
- // src/math/SpringPhysics.ts
8
- var MAX_FRAME_DT = 0.25;
9
- var MAX_STEP_DT = 1 / 120;
10
- var SpringPhysics = class {
11
- value;
12
- target;
13
- velocity = 0;
14
- stiffness = 180;
15
- damping = 12;
16
- mass = 1;
17
- valEpsilon = 5e-3;
18
- velEpsilon = 5e-3;
19
- constructor(initial) {
20
- this.value = initial;
21
- this.target = initial;
22
- }
23
- update(dt) {
24
- if (this.isAtRest()) {
25
- this.value = this.target;
26
- this.velocity = 0;
27
- return;
28
- }
29
- if (!(dt > 0)) return;
30
- let remaining = dt < MAX_FRAME_DT ? dt : MAX_FRAME_DT;
31
- while (remaining > 0) {
32
- const step = remaining < MAX_STEP_DT ? remaining : MAX_STEP_DT;
33
- const forceSpring = -this.stiffness * (this.value - this.target);
34
- const forceDamping = -this.damping * this.velocity;
35
- const acceleration = (forceSpring + forceDamping) / this.mass;
36
- this.velocity += acceleration * step;
37
- this.value += this.velocity * step;
38
- remaining -= step;
39
- if (this.isAtRest()) {
40
- this.value = this.target;
41
- this.velocity = 0;
42
- return;
43
- }
44
- }
45
- }
46
- isAtRest() {
47
- return Math.abs(this.value - this.target) < this.valEpsilon && Math.abs(this.velocity) < this.velEpsilon;
48
- }
49
- };
50
-
51
- // src/animation/easing.ts
52
- var c1 = 1.70158;
53
- var c3 = c1 + 1;
54
- var Easing = {
55
- linear: (t) => t,
56
- easeInQuad: (t) => t * t,
57
- easeOutQuad: (t) => t * (2 - t),
58
- easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
59
- easeInCubic: (t) => t * t * t,
60
- easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
61
- easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
62
- easeOutBack: (t) => 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2),
63
- easeInOutBack: (t) => {
64
- const c2 = c1 * 1.525;
65
- return t < 0.5 ? Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2) / 2 : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
66
- }
67
- };
68
-
69
- // src/animation/drivers.ts
70
- function isTweenConfig(c) {
71
- return typeof c === "object" && "duration" in c;
72
- }
73
- var TweenDriver = class {
74
- value;
75
- from;
76
- to;
77
- elapsed = 0;
78
- duration;
79
- delay;
80
- ease;
81
- constructor(from, to, cfg) {
82
- this.value = from;
83
- this.from = from;
84
- this.to = to;
85
- this.duration = Math.max(1, cfg.duration);
86
- this.delay = cfg.delay ?? 0;
87
- this.ease = typeof cfg.easing === "function" ? cfg.easing : Easing[cfg.easing ?? "easeOutQuad"];
88
- }
89
- get target() {
90
- return this.to;
91
- }
92
- retarget(to) {
93
- this.from = this.value;
94
- this.to = to;
95
- this.elapsed = 0;
96
- }
97
- tick(dtMs) {
98
- this.elapsed += dtMs;
99
- const active = this.elapsed - this.delay;
100
- if (active <= 0) return;
101
- const p = Math.min(active / this.duration, 1);
102
- this.value = this.from + (this.to - this.from) * this.ease(p);
103
- }
104
- isDone() {
105
- return this.elapsed - this.delay >= this.duration;
106
- }
107
- };
108
- var SpringDriver = class {
109
- spring;
110
- constructor(from, to, cfg) {
111
- this.spring = new SpringPhysics(from);
112
- if (cfg.stiffness !== void 0) this.spring.stiffness = cfg.stiffness;
113
- if (cfg.damping !== void 0) this.spring.damping = cfg.damping;
114
- if (cfg.mass !== void 0) this.spring.mass = cfg.mass;
115
- this.spring.target = to;
116
- }
117
- get value() {
118
- return this.spring.value;
119
- }
120
- get target() {
121
- return this.spring.target;
122
- }
123
- retarget(to) {
124
- this.spring.target = to;
125
- }
126
- tick(dtMs) {
127
- this.spring.update(dtMs / 1e3);
128
- }
129
- isDone() {
130
- return this.spring.isAtRest();
131
- }
132
- };
133
-
134
1
  // src/tree/Entity.ts
2
+ import {
3
+ TweenDriver,
4
+ SpringDriver,
5
+ isTweenConfig
6
+ } from "@vectojs/animation";
135
7
  var ANIMATABLE_PROPS = /* @__PURE__ */ new Set([
136
8
  "x",
137
9
  "y",
@@ -274,8 +146,42 @@ var Entity = class {
274
146
  // Danmaku hot loop), so a bare `entity.x = v` is one boolean check + field write.
275
147
  _hasTransitions = false;
276
148
  _transitions = null;
277
- _drivers = /* @__PURE__ */ new Map();
149
+ // Lazily allocated: null until first use. A scene of many passive entities
150
+ // (particles, data points) never touches these, so paying an empty-Map/array
151
+ // allocation per entity in the constructor is pure waste at scale.
152
+ _drivers = null;
278
153
  _mounted = false;
154
+ _destroyed = false;
155
+ // Frame this entity's active drivers were last advanced by Scene's batched
156
+ // WASM animation pass (see Scene._tickBatchedDrivers), or -1 if never. When
157
+ // it equals the current frame, tickDrivers() must skip its own tick loop —
158
+ // otherwise a driver already advanced by the batch pass would be ticked a
159
+ // second time by the normal per-entity update() walk in the same frame.
160
+ // Irrelevant (and harmless) for entities never touched by WASM batching.
161
+ _driversTickedFrame = -1;
162
+ // Cached cos/sin, recomputed only when rotation actually changes. renderNode
163
+ // and getWorldTransform() both read this instead of calling Math.cos/sin per
164
+ // entity per frame (V8's are ~2.5x slower than other engines).
165
+ _trig = { cos: 1, sin: 0 };
166
+ _trigRotation = Number.NaN;
167
+ // NaN !== any rotation -> first read computes
168
+ // Per-frame world-matrix cache. Written by Scene during the render walk;
169
+ // getWorldTransform() returns it only while `_worldFrame === scene.currentFrame`
170
+ // and otherwise falls back to the full ancestor walk, so it can never return a
171
+ // stale/wrong transform — only sometimes miss the fast path.
172
+ _wa = 1;
173
+ _wb = 0;
174
+ _wc = 0;
175
+ _wd = 1;
176
+ _we = 0;
177
+ _wf = 0;
178
+ _worldFrame = -1;
179
+ // Slot in the Scene's resident WASM transform store, or -1 when this entity is
180
+ // not in that store (JS transform path, overlay/detached, or before the first
181
+ // structural rebuild). Assigned by Scene on a structural rebuild; the Scene
182
+ // validates it against its slot table before trusting it, so a stale value can
183
+ // only cost a JS-path fallback, never a wrong read.
184
+ _storeSlot = -1;
279
185
  get x() {
280
186
  return this._x;
281
187
  }
@@ -352,10 +258,12 @@ var Entity = class {
352
258
  * fixed viewport. Off by default (children render unclipped). Canvas2D only.
353
259
  */
354
260
  clipChildren = false;
355
- listeners = /* @__PURE__ */ new Map();
261
+ // Lazily allocated (see _drivers above). Most entities never register a
262
+ // listener or an imperative animate() tween.
263
+ listeners = null;
356
264
  /** Capture-phase listeners (fired root→target before bubble). */
357
- captureListeners = /* @__PURE__ */ new Map();
358
- animations = [];
265
+ captureListeners = null;
266
+ animations = null;
359
267
  constructor(id) {
360
268
  this.id = id || `entity_${Math.random().toString(36).substring(2, 9)}`;
361
269
  }
@@ -390,6 +298,7 @@ var Entity = class {
390
298
  const s = this.scene;
391
299
  if (s) {
392
300
  s.a11yNeedsReorder = true;
301
+ s.markStructureChanged?.();
393
302
  s.markDirty();
394
303
  child._notifyMounted();
395
304
  }
@@ -419,6 +328,7 @@ var Entity = class {
419
328
  if (s) {
420
329
  s.detachA11y(child);
421
330
  s.a11yNeedsReorder = true;
331
+ s.markStructureChanged?.();
422
332
  s.markDirty();
423
333
  }
424
334
  }
@@ -467,7 +377,7 @@ var Entity = class {
467
377
  * @example entity.animate({ x: 400, opacity: 0 }, 500);
468
378
  */
469
379
  animate(targetProps, durationMs) {
470
- this.animations.push({
380
+ (this.animations ??= []).push({
471
381
  target: targetProps,
472
382
  duration: durationMs,
473
383
  startTime: -1,
@@ -520,9 +430,9 @@ var Entity = class {
520
430
  * that need to seed a starting state (e.g. the presence helper's enter `from`).
521
431
  */
522
432
  setImmediate(prop, v) {
523
- const existing = this._drivers.get(prop);
433
+ const existing = this._drivers?.get(prop);
524
434
  if (existing) this._settleDriver(existing);
525
- this._drivers.delete(prop);
435
+ this._drivers?.delete(prop);
526
436
  this._applyAnimated(prop, v);
527
437
  }
528
438
  _settleDriver(driver) {
@@ -533,13 +443,13 @@ var Entity = class {
533
443
  }
534
444
  _spawnDriver(prop, to, cfg) {
535
445
  if (prop !== "opacity" && this.scene?.prefersReducedMotion) {
536
- const existing2 = this._drivers.get(prop);
446
+ const existing2 = this._drivers?.get(prop);
537
447
  if (existing2) this._settleDriver(existing2);
538
- this._drivers.delete(prop);
448
+ this._drivers?.delete(prop);
539
449
  this._applyAnimated(prop, to);
540
450
  return;
541
451
  }
542
- const existing = this._drivers.get(prop);
452
+ const existing = this._drivers?.get(prop);
543
453
  if (existing) {
544
454
  this._settleDriver(existing);
545
455
  existing.retarget(to);
@@ -547,8 +457,9 @@ var Entity = class {
547
457
  }
548
458
  const from = this._currentOf(prop);
549
459
  const driver = isTweenConfig(cfg) ? new TweenDriver(from, to, cfg) : new SpringDriver(from, to, cfg === "spring" ? {} : cfg);
550
- this._drivers.set(prop, driver);
460
+ (this._drivers ??= /* @__PURE__ */ new Map()).set(prop, driver);
551
461
  this.scene?.markDirty();
462
+ this.scene?._registerActiveDriverEntity(this);
552
463
  }
553
464
  /** Assignment path when a declarative transition is configured for `prop`. */
554
465
  _animateProp(prop, to) {
@@ -581,7 +492,7 @@ var Entity = class {
581
492
  entries.map(
582
493
  (e) => new Promise((resolve) => {
583
494
  this._spawnDriver(e[0], e[1], cfg);
584
- const d = this._drivers.get(e[0]);
495
+ const d = this._drivers?.get(e[0]);
585
496
  if (!d)
586
497
  resolve();
587
498
  else d.onDone = resolve;
@@ -591,7 +502,8 @@ var Entity = class {
591
502
  }
592
503
  /** Advance active property drivers one frame. Call from update(). */
593
504
  tickDrivers(dt) {
594
- if (this._drivers.size === 0) return;
505
+ if (!this._drivers || this._drivers.size === 0) return;
506
+ if (this.scene && this._driversTickedFrame === this.scene.currentFrame) return;
595
507
  for (const [prop, driver] of this._drivers) {
596
508
  driver.tick(dt);
597
509
  if (driver.isDone()) {
@@ -604,6 +516,38 @@ var Entity = class {
604
516
  }
605
517
  this.scene?.markDirty();
606
518
  }
519
+ /**
520
+ * Internal: this entity's active-driver map (read-only view), or `null` if
521
+ * it has none. Called only by Scene's batched WASM animation pass, never
522
+ * application code. Returns the Map directly (not a callback iteration) so
523
+ * a caller can `for...of` it with zero per-entity closure allocation — the
524
+ * integrated benchmark (benchmarks/anim-wasm-scene) found a fresh callback
525
+ * per entity per frame was a real cost, not a negligible one.
526
+ */
527
+ _driverEntries() {
528
+ return this._drivers;
529
+ }
530
+ /**
531
+ * Internal: finalize one driver that was ALREADY advanced externally this
532
+ * frame (e.g. by Scene's batched WASM tick via `driver.syncExternal`, or a
533
+ * direct `driver.tick()` call for a driver the batch can't offload) —
534
+ * exactly mirrors tickDrivers()'s own per-driver completion logic, so a
535
+ * driver behaves identically regardless of which path ticked it. Called
536
+ * only by Scene, never application code.
537
+ */
538
+ _applyDriverTick(prop, driver) {
539
+ if (driver.isDone()) {
540
+ this._applyAnimated(prop, driver.target);
541
+ this._settleDriver(driver);
542
+ this._drivers?.delete(prop);
543
+ } else {
544
+ this._applyAnimated(prop, driver.value);
545
+ }
546
+ }
547
+ /** Internal: true if this entity currently has any active property driver. */
548
+ _hasActiveDrivers() {
549
+ return !!this._drivers && this._drivers.size > 0;
550
+ }
607
551
  /**
608
552
  * Advance the entity's internal state for one frame.
609
553
  *
@@ -615,7 +559,7 @@ var Entity = class {
615
559
  */
616
560
  update(dt, time) {
617
561
  this.tickDrivers(dt);
618
- if (this.animations.length > 0) {
562
+ if (this.animations && this.animations.length > 0) {
619
563
  const anim = this.animations[0];
620
564
  if (anim.startTime === -1) {
621
565
  anim.startTime = time;
@@ -656,7 +600,7 @@ var Entity = class {
656
600
  * @example entity.on('click', (e) => console.log('clicked', e));
657
601
  */
658
602
  on(event, callback, options) {
659
- const map = options?.capture ? this.captureListeners : this.listeners;
603
+ const map = options?.capture ? this.captureListeners ??= /* @__PURE__ */ new Map() : this.listeners ??= /* @__PURE__ */ new Map();
660
604
  if (!map.has(event)) {
661
605
  map.set(event, []);
662
606
  }
@@ -672,7 +616,7 @@ var Entity = class {
672
616
  * @returns `this` for method chaining.
673
617
  */
674
618
  off(event, callback, options) {
675
- const handlers = (options?.capture ? this.captureListeners : this.listeners).get(event);
619
+ const handlers = (options?.capture ? this.captureListeners : this.listeners)?.get(event);
676
620
  if (handlers) {
677
621
  const idx = handlers.indexOf(callback);
678
622
  if (idx !== -1) handlers.splice(idx, 1);
@@ -680,17 +624,37 @@ var Entity = class {
680
624
  return this;
681
625
  }
682
626
  /**
683
- * Tear down this entity: clear all animations, event listeners, and detach
684
- * from parent. Call before discarding an entity to prevent memory leaks.
627
+ * Tear down this entity **and its entire subtree**: recursively destroy every
628
+ * descendant (leaf-first), clear all animations, event listeners, and property
629
+ * drivers, then detach from the parent. Call before discarding an entity to
630
+ * prevent memory leaks.
631
+ *
632
+ * Recursing here is what frees a subtree's GPU buffers, layout workers, and DOM
633
+ * observers when an app does `entity.destroy()` or `scene.remove(subtree)` on a
634
+ * route change — without it, only the root's own state was released and every
635
+ * descendant (and its subclass resources) was stranded. Subclasses that own
636
+ * external resources override `destroy()`, free their resource, then call
637
+ * `super.destroy()`; because children don't depend on a parent's resource, the
638
+ * order (parent resource first, then descendants) is safe.
639
+ *
640
+ * Idempotent and re-entrancy safe: a second call is a no-op, and because each
641
+ * child is detached as it is destroyed, destroying a subtree never double-frees.
685
642
  */
686
643
  destroy() {
687
- this.animations = [];
688
- for (const driver of this._drivers.values()) {
689
- this._settleDriver(driver);
644
+ if (this._destroyed) return;
645
+ this._destroyed = true;
646
+ while (this.children.length > 0) {
647
+ this.children.at(-1).destroy();
648
+ }
649
+ this.animations = null;
650
+ if (this._drivers) {
651
+ for (const driver of this._drivers.values()) {
652
+ this._settleDriver(driver);
653
+ }
654
+ this._drivers.clear();
690
655
  }
691
- this._drivers.clear();
692
- this.listeners.clear();
693
- this.captureListeners.clear();
656
+ this.listeners?.clear();
657
+ this.captureListeners?.clear();
694
658
  if (this.parent) {
695
659
  this.parent.remove(this);
696
660
  }
@@ -705,7 +669,7 @@ var Entity = class {
705
669
  * @param payload - Arbitrary data forwarded to each listener.
706
670
  */
707
671
  emit(event, payload) {
708
- const handlers = this.listeners.get(event);
672
+ const handlers = this.listeners?.get(event);
709
673
  if (handlers) {
710
674
  handlers.forEach((h) => h(payload));
711
675
  }
@@ -731,7 +695,7 @@ var Entity = class {
731
695
  }
732
696
  /** Run one node's listeners for the event, honoring stopImmediatePropagation. */
733
697
  fireListeners(node, map, event) {
734
- const handlers = map.get(event.type);
698
+ const handlers = map?.get(event.type);
735
699
  if (!handlers) return;
736
700
  event.currentTarget = node;
737
701
  for (const h of handlers.slice()) {
@@ -770,10 +734,45 @@ var Entity = class {
770
734
  getGlobalPosition() {
771
735
  return this.localToWorld(0, 0);
772
736
  }
737
+ /**
738
+ * Internal: read this entity's cached world matrix into `out` — the SAME
739
+ * cache {@link getWorldTransform} reads — without allocating a wrapper
740
+ * object. Returns `false` (leaving `out` untouched) when the cache isn't
741
+ * valid for `frame` (typically the caller's `scene.currentFrame`), exactly
742
+ * mirroring {@link getWorldTransform}'s own validity check; the caller
743
+ * falls back to {@link getWorldTransform}'s full walk in that case. Exists
744
+ * so a per-entity gather that runs every entity through this (e.g. G3's
745
+ * `gatherHitAABBs`) pays for six scalar reads instead of one object
746
+ * allocation per entity per call — the exact class of per-frame garbage
747
+ * the G2 integrated benchmark found dominating its own gather cost.
748
+ */
749
+ _readWorldCache(frame, out) {
750
+ if (this._worldFrame < 0 || this._worldFrame !== frame) return false;
751
+ out.a = this._wa;
752
+ out.b = this._wb;
753
+ out.c = this._wc;
754
+ out.d = this._wd;
755
+ out.e = this._we;
756
+ out.f = this._wf;
757
+ return true;
758
+ }
773
759
  /**
774
760
  * Return the exact accumulated Canvas `T * S * R` transform for this entity.
775
761
  */
776
762
  getWorldTransform() {
763
+ if (this._worldFrame >= 0) {
764
+ const s = this.scene;
765
+ if (s && this._worldFrame === s.currentFrame) {
766
+ return {
767
+ a: this._wa,
768
+ b: this._wb,
769
+ c: this._wc,
770
+ d: this._wd,
771
+ e: this._we,
772
+ f: this._wf
773
+ };
774
+ }
775
+ }
777
776
  const path = [this];
778
777
  let ancestor = this.parent;
779
778
  while (ancestor) {
@@ -788,8 +787,9 @@ var Entity = class {
788
787
  let f = 0;
789
788
  for (let i = path.length - 1; i >= 0; i--) {
790
789
  const node = path[i];
791
- const cos = Math.cos(node.rotation);
792
- const sin = Math.sin(node.rotation);
790
+ const trig = node._getTrig();
791
+ const cos = trig.cos;
792
+ const sin = trig.sin;
793
793
  const la = node.scaleX * cos;
794
794
  const lb = node.scaleY * sin;
795
795
  const lc = -node.scaleX * sin;
@@ -811,6 +811,37 @@ var Entity = class {
811
811
  }
812
812
  return { a, b, c, d, e, f };
813
813
  }
814
+ /**
815
+ * Return this entity's cached `{ cos, sin }` of its current rotation,
816
+ * recomputing only when `rotation` has actually changed since the last call.
817
+ * The same object identity is returned across calls with an unchanged
818
+ * rotation, so callers must treat it as read-only. Used by the render walk
819
+ * and {@link getWorldTransform} to avoid V8's comparatively slow Math.cos/sin
820
+ * (a software libm call, ~2.5x slower than other engines) per entity/frame.
821
+ */
822
+ _getTrig() {
823
+ if (this._trigRotation !== this._rotation) {
824
+ this._trig.cos = Math.cos(this._rotation);
825
+ this._trig.sin = Math.sin(this._rotation);
826
+ this._trigRotation = this._rotation;
827
+ }
828
+ return this._trig;
829
+ }
830
+ /**
831
+ * Store the world matrix Scene computed for this entity during the render
832
+ * walk, stamped with the frame it belongs to. {@link getWorldTransform}
833
+ * returns it verbatim while `frame === scene.currentFrame`. Internal: called
834
+ * only by Scene's renderer, never by application code.
835
+ */
836
+ _setWorldCache(a, b, c, d, e, f, frame) {
837
+ this._wa = a;
838
+ this._wb = b;
839
+ this._wc = c;
840
+ this._wd = d;
841
+ this._we = e;
842
+ this._wf = f;
843
+ this._worldFrame = frame;
844
+ }
814
845
  /** Convert a point from this entity's local space to Scene/world space. */
815
846
  localToWorld(localX, localY) {
816
847
  const { a, b, c, d, e, f } = this.getWorldTransform();
@@ -840,7 +871,12 @@ var Entity = class {
840
871
  * does not provide a render-specific box.
841
872
  */
842
873
  getWorldBounds() {
843
- const bounds = this.getBounds() ?? { x: 0, y: 0, width: this.width, height: this.height };
874
+ const bounds = this.getBounds() ?? {
875
+ x: 0,
876
+ y: 0,
877
+ width: this.width,
878
+ height: this.height
879
+ };
844
880
  const { a, b, c, d, e, f } = this.getWorldTransform();
845
881
  let minX = Infinity;
846
882
  let minY = Infinity;
@@ -981,133 +1017,12 @@ var Entity = class {
981
1017
  * @returns `true` if at least one animation or property driver remains.
982
1018
  */
983
1019
  hasPendingAnimations() {
984
- return this.animations.length > 0 || this._drivers.size > 0;
985
- }
986
- };
987
-
988
- // src/text/Typography.ts
989
- var typographyContext;
990
- var baselineCache = /* @__PURE__ */ new Map();
991
- function cssLineBoxBaseline(font, lineHeight) {
992
- if (typeof document === "undefined") return lineHeight * 0.8;
993
- const key = `${font}\0${lineHeight}`;
994
- const cached = baselineCache.get(key);
995
- if (cached !== void 0) return cached;
996
- if (typographyContext === void 0) {
997
- typographyContext = document.createElement("canvas").getContext("2d");
998
- }
999
- if (!typographyContext) return lineHeight * 0.8;
1000
- typographyContext.font = font;
1001
- const metrics = typographyContext.measureText("Mg");
1002
- const ascent = metrics.fontBoundingBoxAscent || metrics.actualBoundingBoxAscent;
1003
- const descent = metrics.fontBoundingBoxDescent || metrics.actualBoundingBoxDescent;
1004
- if (!(ascent > 0) || !(descent >= 0)) return lineHeight * 0.8;
1005
- const baseline = (lineHeight - ascent - descent) / 2 + ascent;
1006
- baselineCache.set(key, baseline);
1007
- return baseline;
1008
- }
1009
- function clearCssLineBoxMetrics() {
1010
- baselineCache.clear();
1011
- }
1012
-
1013
- // src/text/MSDFFont.ts
1014
- function kernKey(a, b) {
1015
- return a * 1114112 + b;
1016
- }
1017
- var MSDFFont = class _MSDFFont {
1018
- static idCounter = 0;
1019
- id;
1020
- data;
1021
- byCode = /* @__PURE__ */ new Map();
1022
- kern = /* @__PURE__ */ new Map();
1023
- constructor(data) {
1024
- this.id = `font-${_MSDFFont.idCounter++}`;
1025
- this.data = data;
1026
- for (const g of data.glyphs) this.byCode.set(g.unicode, g);
1027
- for (const k of data.kerning ?? []) this.kern.set(kernKey(k.unicode1, k.unicode2), k.advance);
1028
- }
1029
- /** Parse the `msdf-atlas-gen` JSON (string or already-parsed object). */
1030
- static parse(json) {
1031
- return new _MSDFFont(typeof json === "string" ? JSON.parse(json) : json);
1032
- }
1033
- /** Get a glyph's definition by its unicode value in O(1) time. */
1034
- getGlyph(unicode) {
1035
- return this.byCode.get(unicode);
1036
- }
1037
- /** Distance field range in atlas pixels (for the shader's `u_distanceRange`). */
1038
- get distanceRange() {
1039
- return this.data.atlas.distanceRange;
1040
- }
1041
- get atlasWidth() {
1042
- return this.data.atlas.width;
1043
- }
1044
- get atlasHeight() {
1045
- return this.data.atlas.height;
1046
- }
1047
- /**
1048
- * Lay `text` out at `fontSizePx`. Returns positioned quads (skipping glyphs the
1049
- * font doesn't contain), the widest line's advance, and the total block height.
1050
- * Honors `\n`, kerning pairs, and `letterSpacing`.
1051
- */
1052
- layout(text, fontSizePx, opts = {}) {
1053
- const { x = 0, y = 0, letterSpacing = 0 } = opts;
1054
- const { width: aw, height: ah, yOrigin } = this.data.atlas;
1055
- const { lineHeight, ascender } = this.data.metrics;
1056
- const glyphs = [];
1057
- let penX = x;
1058
- let line = 0;
1059
- let maxAdvance = 0;
1060
- let prevCode = -1;
1061
- const chars = Array.from(text);
1062
- for (const char of chars) {
1063
- if (char === "\n") {
1064
- maxAdvance = Math.max(maxAdvance, penX - x);
1065
- penX = x;
1066
- line++;
1067
- prevCode = -1;
1068
- continue;
1069
- }
1070
- const code = char.codePointAt(0);
1071
- const def = this.byCode.get(code);
1072
- if (!def) {
1073
- prevCode = -1;
1074
- continue;
1075
- }
1076
- if (prevCode >= 0) {
1077
- const k = this.kern.get(kernKey(prevCode, code));
1078
- if (k) penX += k * fontSizePx;
1079
- }
1080
- const baseline = y + (ascender + line * lineHeight) * fontSizePx;
1081
- const pb = def.planeBounds;
1082
- const ab = def.atlasBounds;
1083
- if (pb && ab) {
1084
- const v0 = yOrigin === "bottom" ? 1 - ab.top / ah : ab.top / ah;
1085
- const v1 = yOrigin === "bottom" ? 1 - ab.bottom / ah : ab.bottom / ah;
1086
- glyphs.push({
1087
- char,
1088
- x: penX + pb.left * fontSizePx,
1089
- y: baseline - pb.top * fontSizePx,
1090
- w: (pb.right - pb.left) * fontSizePx,
1091
- h: (pb.top - pb.bottom) * fontSizePx,
1092
- u0: ab.left / aw,
1093
- v0,
1094
- u1: ab.right / aw,
1095
- v1
1096
- });
1097
- }
1098
- penX += def.advance * fontSizePx + letterSpacing;
1099
- prevCode = code;
1100
- }
1101
- maxAdvance = Math.max(maxAdvance, penX - x);
1102
- return {
1103
- glyphs,
1104
- width: maxAdvance,
1105
- height: (line + 1) * lineHeight * fontSizePx
1106
- };
1020
+ return (this.animations?.length ?? 0) > 0 || (this._drivers?.size ?? 0) > 0;
1107
1021
  }
1108
1022
  };
1109
1023
 
1110
1024
  // src/text/MSDFTextEntity.ts
1025
+ import { LayoutWorkerManager } from "@vectojs/layout";
1111
1026
  var MSDFTextEntity = class extends Entity {
1112
1027
  font;
1113
1028
  texture;
@@ -1118,6 +1033,14 @@ var MSDFTextEntity = class extends Entity {
1118
1033
  lineHeight;
1119
1034
  maxWidth;
1120
1035
  maxHeight;
1036
+ textAlign;
1037
+ // Optional hyphenator. Runs on the MAIN thread (a function can't be
1038
+ // structure-cloned into the layout worker), turning each word into parts
1039
+ // joined by soft hyphens (U+00AD); the worker then treats those as break
1040
+ // opportunities. `text` keeps the original string for a11y/content
1041
+ // projection; `layoutText` is the soft-hyphen-annotated string sent to layout.
1042
+ hyphenator = null;
1043
+ layoutText = "";
1121
1044
  text = "";
1122
1045
  lastRenderedSeqId = 0;
1123
1046
  rgbColorCache = /* @__PURE__ */ new Map();
@@ -1134,6 +1057,7 @@ var MSDFTextEntity = class extends Entity {
1134
1057
  this.lineHeight = options.lineHeight;
1135
1058
  this.maxWidth = options.maxWidth ?? 1e3;
1136
1059
  this.maxHeight = options.maxHeight ?? 1e3;
1060
+ this.textAlign = options.textAlign ?? "left";
1137
1061
  this.setText(text);
1138
1062
  }
1139
1063
  /** Change the wrap boundary and re-run layout for the current text. */
@@ -1142,13 +1066,53 @@ var MSDFTextEntity = class extends Entity {
1142
1066
  this.maxWidth = maxWidth;
1143
1067
  this.queueLayout();
1144
1068
  }
1069
+ /**
1070
+ * Set horizontal alignment (`'justify'` stretches wrapped lines flush to
1071
+ * {@link setMaxWidth}'s width; the last line stays ragged) and re-run layout.
1072
+ */
1073
+ setTextAlign(align) {
1074
+ if (this.textAlign === align) return;
1075
+ this.textAlign = align;
1076
+ this.queueLayout();
1077
+ }
1078
+ /**
1079
+ * Plug a hyphenator (word → parts). Break opportunities are inserted as soft
1080
+ * hyphens (U+00AD) into the string sent to layout, so a word that doesn't fit
1081
+ * can break with a visible hyphen. Soft hyphens already present in the text
1082
+ * work without one. Pass `null` to disable. The original text is preserved
1083
+ * for accessibility — only the layout string carries the hyphens.
1084
+ */
1085
+ setHyphenator(fn) {
1086
+ this.hyphenator = fn;
1087
+ this.rebuildLayoutText();
1088
+ this.queueLayout();
1089
+ }
1145
1090
  setText(text) {
1146
1091
  if (this.text === text && this.layoutResult) return;
1147
1092
  this.text = text;
1093
+ this.rebuildLayoutText();
1148
1094
  this.queueLayout();
1149
1095
  }
1096
+ /**
1097
+ * Recompute {@link layoutText} from {@link text}: with a hyphenator active,
1098
+ * split each whitespace-delimited word and rejoin its parts with U+00AD so
1099
+ * the worker sees the break opportunities. Without one, the layout string is
1100
+ * the text unchanged.
1101
+ */
1102
+ rebuildLayoutText() {
1103
+ if (!this.hyphenator) {
1104
+ this.layoutText = this.text;
1105
+ return;
1106
+ }
1107
+ const SHY = "\xAD";
1108
+ this.layoutText = this.text.replace(/[^\s]+/g, (word) => {
1109
+ if (word.length <= 3 || word.includes(SHY)) return word;
1110
+ const parts = this.hyphenator(word);
1111
+ return parts.length > 1 ? parts.join(SHY) : word;
1112
+ });
1113
+ }
1150
1114
  queueLayout() {
1151
- LayoutWorkerManager.getInstance().queueLayout(this.id, this.text, {
1115
+ LayoutWorkerManager.getInstance().queueLayout(this.id, this.layoutText, {
1152
1116
  fontId: this.font.id,
1153
1117
  fontSize: this.fontSize,
1154
1118
  maxWidth: this.maxWidth,
@@ -1156,6 +1120,7 @@ var MSDFTextEntity = class extends Entity {
1156
1120
  fontData: this.font.data,
1157
1121
  letterSpacing: this.letterSpacing,
1158
1122
  lineHeight: this.lineHeight,
1123
+ textAlign: this.textAlign,
1159
1124
  callback: (res) => {
1160
1125
  if (res.seqId < this.lastRenderedSeqId) return;
1161
1126
  this.lastRenderedSeqId = res.seqId;
@@ -1265,7 +1230,7 @@ var MSDFTextEntity = class extends Entity {
1265
1230
  }
1266
1231
  }
1267
1232
  destroy() {
1268
- LayoutWorkerManager.getInstance().cancelLayout(this.id);
1233
+ LayoutWorkerManager.cancelLayoutForEntity(this.id);
1269
1234
  super.destroy();
1270
1235
  }
1271
1236
  };
@@ -1505,217 +1470,9 @@ var SVGEntity = class extends Entity {
1505
1470
  }
1506
1471
  };
1507
1472
 
1508
- // src/text/PreparedContentGrid.ts
1509
- var nextRevision = 1;
1510
- var graphemeSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
1511
- var MARK = /\p{Mark}/u;
1512
- var EXTENDED_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
1513
- var REGIONAL_INDICATOR = /\p{Regional_Indicator}/u;
1514
- var BIDI_CONTROL = /\p{Bidi_Control}/u;
1515
- var EAST_ASIAN_WIDE = /[ᄀ-ᅟ⌚-⌛⏩-⏬⏰⏳◽-◾⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u;
1516
- function codePointAt(text, index) {
1517
- const point = text.codePointAt(index);
1518
- if (point === void 0) return { value: "", next: index };
1519
- const value = String.fromCodePoint(point);
1520
- return { value, next: index + value.length };
1521
- }
1522
- function fallbackGraphemes(text) {
1523
- const parts = [];
1524
- let index = 0;
1525
- while (index < text.length) {
1526
- const start = index;
1527
- let current = codePointAt(text, index);
1528
- let segment = current.value;
1529
- index = current.next;
1530
- let regionalCount = REGIONAL_INDICATOR.test(segment) ? 1 : 0;
1531
- while (index < text.length) {
1532
- current = codePointAt(text, index);
1533
- const point = current.value.codePointAt(0) ?? 0;
1534
- const isVariation = point >= 65024 && point <= 65039;
1535
- const isEmojiModifier = point >= 127995 && point <= 127999;
1536
- const isKeycap = point === 8419;
1537
- if (MARK.test(current.value) || isVariation || isEmojiModifier || isKeycap) {
1538
- segment += current.value;
1539
- index = current.next;
1540
- continue;
1541
- }
1542
- if (REGIONAL_INDICATOR.test(current.value) && regionalCount === 1) {
1543
- segment += current.value;
1544
- index = current.next;
1545
- regionalCount++;
1546
- continue;
1547
- }
1548
- if (point === 8205) {
1549
- segment += current.value;
1550
- index = current.next;
1551
- if (index < text.length) {
1552
- current = codePointAt(text, index);
1553
- segment += current.value;
1554
- index = current.next;
1555
- }
1556
- continue;
1557
- }
1558
- break;
1559
- }
1560
- parts.push({ segment, index: start });
1561
- }
1562
- return parts;
1563
- }
1564
- function graphemes(text) {
1565
- if (!graphemeSegmenter) return fallbackGraphemes(text);
1566
- return Array.from(graphemeSegmenter.segment(text), (part) => ({
1567
- segment: part.segment,
1568
- index: part.index
1569
- }));
1570
- }
1571
- function lowerBound(values, target) {
1572
- let low = 0;
1573
- let high = values.length;
1574
- while (low < high) {
1575
- const middle = low + high >>> 1;
1576
- if (values[middle] < target) low = middle + 1;
1577
- else high = middle;
1578
- }
1579
- return low;
1580
- }
1581
- function isWideCluster(cluster) {
1582
- if (EXTENDED_PICTOGRAPHIC.test(cluster) || REGIONAL_INDICATOR.test(cluster)) return true;
1583
- if (cluster.includes("\u20E3")) return true;
1584
- if (EAST_ASIAN_WIDE.test(cluster)) return true;
1585
- const point = cluster.codePointAt(0) ?? 0;
1586
- return point >= 131072 && point <= 262141;
1587
- }
1588
- function sourceLines(source) {
1589
- const lines = [];
1590
- let start = 0;
1591
- while (true) {
1592
- let end = start;
1593
- while (end < source.length && source[end] !== "\r" && source[end] !== "\n") end++;
1594
- if (end === source.length) {
1595
- lines.push({
1596
- sourceStart: start,
1597
- sourceEnd: end,
1598
- nextSourceStart: end,
1599
- text: source.slice(start)
1600
- });
1601
- break;
1602
- }
1603
- const next = source[end] === "\r" && source[end + 1] === "\n" ? end + 2 : end + 1;
1604
- lines.push({
1605
- sourceStart: start,
1606
- sourceEnd: end,
1607
- nextSourceStart: next,
1608
- text: source.slice(start, end)
1609
- });
1610
- start = next;
1611
- if (start === source.length) {
1612
- lines.push({ sourceStart: start, sourceEnd: start, nextSourceStart: start, text: "" });
1613
- break;
1614
- }
1615
- }
1616
- return lines;
1617
- }
1618
- function assertPositiveFinite(value, name) {
1619
- if (!Number.isFinite(value) || value <= 0) {
1620
- throw new RangeError(`${name} must be a positive finite number`);
1621
- }
1622
- }
1623
- function prepareContentGrid(source, options) {
1624
- assertPositiveFinite(options.cellWidth, "cellWidth");
1625
- assertPositiveFinite(options.lineHeight, "lineHeight");
1626
- if (!Number.isFinite(options.baseline)) throw new RangeError("baseline must be finite");
1627
- const tabSize = options.tabSize ?? 4;
1628
- if (!Number.isInteger(tabSize) || tabSize <= 0) {
1629
- throw new RangeError("tabSize must be a positive integer");
1630
- }
1631
- const rawLines = sourceLines(source);
1632
- const lines = [];
1633
- for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex++) {
1634
- const sourceLine = rawLines[lineIndex];
1635
- const rawLine = sourceLine.text;
1636
- const { sourceStart: lineStart, sourceEnd, nextSourceStart } = sourceLine;
1637
- const rawCaretBoundaries = [
1638
- 0,
1639
- ...graphemes(rawLine).map((grapheme) => grapheme.index + grapheme.segment.length)
1640
- ];
1641
- const shaped = ArabicShaper.shapeArabic(rawLine);
1642
- const shapedParts = graphemes(shaped.shapedText);
1643
- const levels = BidiResolver.resolveLevels(shaped.shapedText);
1644
- const cells = [];
1645
- let column = 0;
1646
- for (let index = 0; index < shapedParts.length; index++) {
1647
- const part = shapedParts[index];
1648
- const sourceOffset = shaped.indexMap[part.index] ?? part.index;
1649
- const nextPart = shapedParts[index + 1];
1650
- const sourceOffsetEnd = nextPart ? shaped.indexMap[nextPart.index] ?? nextPart.index : rawLine.length;
1651
- const raw = rawLine.slice(sourceOffset, sourceOffsetEnd);
1652
- const sourceCaretOffsets = [0];
1653
- for (let caretIndex = lowerBound(rawCaretBoundaries, sourceOffset + 1); caretIndex < rawCaretBoundaries.length && rawCaretBoundaries[caretIndex] < sourceOffsetEnd; caretIndex++) {
1654
- sourceCaretOffsets.push(rawCaretBoundaries[caretIndex] - sourceOffset);
1655
- }
1656
- if (sourceCaretOffsets.at(-1) !== sourceOffsetEnd - sourceOffset) {
1657
- sourceCaretOffsets.push(sourceOffsetEnd - sourceOffset);
1658
- }
1659
- let columns;
1660
- if (BIDI_CONTROL.test(raw)) columns = 0;
1661
- else if (raw === " ") columns = tabSize - column % tabSize;
1662
- else columns = isWideCluster(raw) ? 2 : 1;
1663
- const advance = columns * options.cellWidth;
1664
- cells.push({
1665
- sourceStart: lineStart + sourceOffset,
1666
- sourceEnd: lineStart + sourceOffsetEnd,
1667
- sourceCaretOffsets: Object.freeze(sourceCaretOffsets),
1668
- glyph: part.segment,
1669
- x: 0,
1670
- advance,
1671
- level: levels[part.index] ?? 0,
1672
- char: part.segment
1673
- });
1674
- column += columns;
1675
- }
1676
- const visualCells = [...cells];
1677
- BidiResolver.reorderVisual(visualCells, BidiResolver.getBaseLevel(shaped.shapedText));
1678
- let visualX = 0;
1679
- for (const cell of visualCells) {
1680
- cell.x = visualX;
1681
- visualX += cell.advance;
1682
- }
1683
- const frozenCells = cells.map(({ char: _char, ...cell }) => Object.freeze(cell));
1684
- lines.push(
1685
- Object.freeze({
1686
- sourceStart: lineStart,
1687
- sourceEnd,
1688
- nextSourceStart,
1689
- width: visualX,
1690
- cells: Object.freeze(frozenCells)
1691
- })
1692
- );
1693
- }
1694
- return Object.freeze({
1695
- kind: "content-grid",
1696
- revision: nextRevision++,
1697
- source,
1698
- font: options.font,
1699
- cellWidth: options.cellWidth,
1700
- lineHeight: options.lineHeight,
1701
- baseline: options.baseline,
1702
- tabSize,
1703
- lines: Object.freeze(lines)
1704
- });
1705
- }
1706
-
1707
1473
  export {
1708
- SpringPhysics,
1709
- Easing,
1710
- isTweenConfig,
1711
- TweenDriver,
1712
- SpringDriver,
1713
1474
  VectoJSEvent,
1714
1475
  Entity,
1715
- cssLineBoxBaseline,
1716
- clearCssLineBoxMetrics,
1717
- MSDFFont,
1718
1476
  MSDFTextEntity,
1719
- SVGEntity,
1720
- prepareContentGrid
1477
+ SVGEntity
1721
1478
  };