@vectojs/core 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -7,13 +7,16 @@ import {
7
7
  isSafeUrl,
8
8
  parseColorToRGBA,
9
9
  sanitizeUrl
10
- } from "./chunk-L5BCKFQE.mjs";
10
+ } from "./chunk-JFU56BX4.mjs";
11
11
  import {
12
12
  Entity,
13
13
  MSDFTextEntity,
14
14
  SVGEntity,
15
15
  VectoJSEvent
16
- } from "./chunk-64UFEHOJ.mjs";
16
+ } from "./chunk-AQTO7OSU.mjs";
17
+
18
+ // src/tree/Scene.ts
19
+ import { SpringDriver, TweenDriver } from "@vectojs/animation";
17
20
 
18
21
  // src/tree/ComputeParticleEntity.ts
19
22
  var PARTICLE_STRIDE_FLOATS = 8;
@@ -48,6 +51,10 @@ var ComputeParticleEntity = class extends Entity {
48
51
  computeBindGroup = null;
49
52
  /** WebGPU bind group for the render pass (usually same as compute). */
50
53
  renderBindGroup = null;
54
+ /** When the last simulation step ran through the WASM backend, its fused
55
+ * pending-animation flag; `null` when the last step used the JS `updateCPU`
56
+ * path (so `hasPendingAnimations` falls back to its own scan). */
57
+ _wasmPending = null;
51
58
  constructor(options = {}) {
52
59
  super();
53
60
  this.maxParticles = options.maxParticles ?? 1e4;
@@ -177,6 +184,9 @@ var ComputeParticleEntity = class extends Entity {
177
184
  * always return `true` and defeat the idle throttle entirely.
178
185
  */
179
186
  hasPendingAnimations() {
187
+ if (this._wasmPending !== null) {
188
+ return this._wasmPending || this.pendingExplosion !== null;
189
+ }
180
190
  const EPS_VELOCITY = 0.5;
181
191
  const EPS_DISTANCE = 0.5;
182
192
  for (let i = 0; i < this.maxParticles; i++) {
@@ -202,6 +212,7 @@ var ComputeParticleEntity = class extends Entity {
202
212
  * @param height - Boundary height.
203
213
  */
204
214
  updateCPU(dt, mouseX, mouseY, width, height) {
215
+ this._wasmPending = null;
205
216
  const safeDt = isNaN(dt) ? 0.016 : Math.max(0, Math.min(dt, 0.1));
206
217
  const explosion = this.pendingExplosion;
207
218
  const safeWidth = Math.max(1, width);
@@ -284,6 +295,37 @@ var ComputeParticleEntity = class extends Entity {
284
295
  }
285
296
  this.pendingExplosion = null;
286
297
  }
298
+ /**
299
+ * Advance the simulation one step through the WASM particle kernel: transpose
300
+ * this entity's AoS buffer into the backend's SoA views, run `particle_step`,
301
+ * and scatter position/velocity/life back. Produces an f32 result (matching
302
+ * the WGSL shader) that differs from {@link updateCPU}'s f64 by <1 ULP/step —
303
+ * the accepted CPU-vs-GPU-class divergence. Caches the kernel's fused
304
+ * pending-animation flag so {@link hasPendingAnimations} needs no second scan.
305
+ *
306
+ * The backend holds one resident SoA store, so a Scene with multiple particle
307
+ * entities reuses it sequentially — origin is therefore re-gathered each call
308
+ * (not upload-once), a couple of extra f32 reads per particle.
309
+ */
310
+ stepWithBackend(backend, dt, mouseX, mouseY, width, height) {
311
+ const count = this.maxParticles;
312
+ backend.ensure(count);
313
+ backend.gather(this.particleData, count, true);
314
+ this._wasmPending = backend.step(count, {
315
+ dt,
316
+ mouseX,
317
+ mouseY,
318
+ width,
319
+ height,
320
+ springK: this.springK,
321
+ damping: this.damping,
322
+ bounceDamping: this.bounceDamping,
323
+ maxVelocity: this.maxVelocity,
324
+ explosion: this.pendingExplosion
325
+ });
326
+ backend.scatter(this.particleData, count);
327
+ this.pendingExplosion = null;
328
+ }
287
329
  destroy() {
288
330
  this.destroyGPUResources();
289
331
  super.destroy();
@@ -309,6 +351,749 @@ var ComputeParticleEntity = class extends Entity {
309
351
  }
310
352
  };
311
353
 
354
+ // src/wasm/soa.ts
355
+ var PAD = 8;
356
+ function buildStore(nodes) {
357
+ const count = nodes.length;
358
+ const capacity = count + PAD;
359
+ const childrenOf = Array.from({ length: count }, () => []);
360
+ let rootInput = -1;
361
+ for (let k = 0; k < count; k++) {
362
+ const p = nodes[k].parent;
363
+ if (p === -1) {
364
+ if (rootInput !== -1) throw new Error("buildStore: more than one root (parent === -1)");
365
+ rootInput = k;
366
+ } else {
367
+ if (p < 0 || p >= count)
368
+ throw new Error(`buildStore: node ${k} has out-of-range parent ${p}`);
369
+ childrenOf[p].push(k);
370
+ }
371
+ }
372
+ if (rootInput === -1) throw new Error("buildStore: no root (need exactly one parent === -1)");
373
+ const store = {
374
+ count,
375
+ capacity,
376
+ x: new Float64Array(capacity),
377
+ y: new Float64Array(capacity),
378
+ sx: new Float64Array(capacity),
379
+ sy: new Float64Array(capacity),
380
+ cos: new Float64Array(capacity),
381
+ sin: new Float64Array(capacity),
382
+ opacity: new Float64Array(capacity),
383
+ wa: new Float64Array(capacity),
384
+ wb: new Float64Array(capacity),
385
+ wc: new Float64Array(capacity),
386
+ wd: new Float64Array(capacity),
387
+ we: new Float64Array(capacity),
388
+ wf: new Float64Array(capacity),
389
+ wo: new Float64Array(capacity),
390
+ bx: new Float64Array(capacity),
391
+ by: new Float64Array(capacity),
392
+ bw: new Float64Array(capacity),
393
+ bh: new Float64Array(capacity),
394
+ aminx: new Float64Array(capacity),
395
+ aminy: new Float64Array(capacity),
396
+ amaxx: new Float64Array(capacity),
397
+ amaxy: new Float64Array(capacity),
398
+ runParent: new Int32Array(count),
399
+ runStart: new Int32Array(count),
400
+ runLen: new Int32Array(count),
401
+ runCount: 0,
402
+ storeIndexOf: new Int32Array(count).fill(-1)
403
+ };
404
+ store.storeIndexOf[rootInput] = 0;
405
+ writeInput(store, 0, nodes[rootInput]);
406
+ let next = 1;
407
+ const queue = [rootInput];
408
+ let head = 0;
409
+ while (head < queue.length) {
410
+ const parentInput = queue[head++];
411
+ const kids = childrenOf[parentInput];
412
+ if (kids.length === 0) continue;
413
+ const runStart = next;
414
+ for (const kid of kids) {
415
+ store.storeIndexOf[kid] = next;
416
+ writeInput(store, next, nodes[kid]);
417
+ next++;
418
+ }
419
+ const r = store.runCount++;
420
+ store.runParent[r] = store.storeIndexOf[parentInput];
421
+ store.runStart[r] = runStart;
422
+ store.runLen[r] = kids.length;
423
+ for (const kid of kids) queue.push(kid);
424
+ }
425
+ return store;
426
+ }
427
+ function writeInput(s, i, n) {
428
+ s.x[i] = n.x;
429
+ s.y[i] = n.y;
430
+ s.sx[i] = n.scaleX;
431
+ s.sy[i] = n.scaleY;
432
+ s.cos[i] = Math.cos(n.rotation);
433
+ s.sin[i] = Math.sin(n.rotation);
434
+ s.opacity[i] = n.opacity;
435
+ s.bx[i] = n.bx ?? 0;
436
+ s.by[i] = n.by ?? 0;
437
+ s.bw[i] = n.bw ?? 0;
438
+ s.bh[i] = n.bh ?? 0;
439
+ }
440
+
441
+ // src/wasm/scene-store.ts
442
+ function buildTreeStore(root) {
443
+ const entities = [];
444
+ const inputIndex = /* @__PURE__ */ new Map();
445
+ const collect = (e) => {
446
+ inputIndex.set(e, entities.length);
447
+ entities.push(e);
448
+ const kids = e.children;
449
+ for (let i = 0; i < kids.length; i++) collect(kids[i]);
450
+ };
451
+ collect(root);
452
+ const nodes = entities.map((e) => {
453
+ const parent = e.parent;
454
+ const p = parent !== null && inputIndex.has(parent) ? inputIndex.get(parent) : -1;
455
+ return {
456
+ parent: e === root ? -1 : p,
457
+ x: e.x,
458
+ y: e.y,
459
+ scaleX: e.scaleX,
460
+ scaleY: e.scaleY,
461
+ rotation: e.rotation,
462
+ opacity: e.opacity
463
+ };
464
+ });
465
+ const store = buildStore(nodes);
466
+ const indexOf = /* @__PURE__ */ new Map();
467
+ for (const [e, ii] of inputIndex) indexOf.set(e, store.storeIndexOf[ii]);
468
+ return { store, indexOf };
469
+ }
470
+
471
+ // src/wasm/backend.ts
472
+ var PAD2 = 8;
473
+ var WasmTransformBackend = class {
474
+ available = true;
475
+ ex;
476
+ cap = 0;
477
+ runCap = 0;
478
+ // Views over wasm linear memory, valid until the next init().
479
+ vx;
480
+ vy;
481
+ vsx;
482
+ vsy;
483
+ vcos;
484
+ vsin;
485
+ vop;
486
+ vwa;
487
+ vwb;
488
+ vwc;
489
+ vwd;
490
+ vwe;
491
+ vwf;
492
+ vwo;
493
+ vbx;
494
+ vby;
495
+ vbw;
496
+ vbh;
497
+ vaminx;
498
+ vaminy;
499
+ vamaxx;
500
+ vamaxy;
501
+ vrp;
502
+ vrs;
503
+ vrl;
504
+ constructor(instance) {
505
+ this.ex = instance.exports;
506
+ }
507
+ /** Compose world matrices for `store` in WASM, writing back into its
508
+ * `wa..wo` arrays. Result is bit-identical to `composeJS(store)`. */
509
+ compose(store, kernel = "simd") {
510
+ this.ensure(store.count, store.runCount);
511
+ const n = store.count;
512
+ this.vx.set(store.x.subarray(0, n));
513
+ this.vy.set(store.y.subarray(0, n));
514
+ this.vsx.set(store.sx.subarray(0, n));
515
+ this.vsy.set(store.sy.subarray(0, n));
516
+ this.vcos.set(store.cos.subarray(0, n));
517
+ this.vsin.set(store.sin.subarray(0, n));
518
+ this.vop.set(store.opacity.subarray(0, n));
519
+ const rc = store.runCount;
520
+ this.vrp.set(store.runParent.subarray(0, rc));
521
+ this.vrs.set(store.runStart.subarray(0, rc));
522
+ this.vrl.set(store.runLen.subarray(0, rc));
523
+ this.ex.set_run_count(rc);
524
+ if (kernel === "scalar") this.ex.compose_scalar();
525
+ else this.ex.compose_simd();
526
+ store.wa.set(this.vwa.subarray(0, n));
527
+ store.wb.set(this.vwb.subarray(0, n));
528
+ store.wc.set(this.vwc.subarray(0, n));
529
+ store.wd.set(this.vwd.subarray(0, n));
530
+ store.we.set(this.vwe.subarray(0, n));
531
+ store.wf.set(this.vwf.subarray(0, n));
532
+ store.wo.set(this.vwo.subarray(0, n));
533
+ }
534
+ /**
535
+ * Run the kernel only, over data already resident in WASM memory — no upload,
536
+ * no readback. This is the per-frame cost the *designed* integration pays:
537
+ * entity accessors write `x/y/rotation` straight into the wasm input views
538
+ * (via {@link inputView}) and the renderer reads world matrices straight from
539
+ * the wasm output views (via {@link worldView}), so the batch copies in
540
+ * {@link compose} do not happen every frame. `compose` must have run at least
541
+ * once at the current capacity to size the store and set the run count; call
542
+ * {@link uploadRuns} after a topology change.
543
+ */
544
+ runKernel(kernel = "simd") {
545
+ if (kernel === "scalar") this.ex.compose_scalar();
546
+ else this.ex.compose_simd();
547
+ }
548
+ /** Upload only the run table + count (topology), leaving per-entity inputs to
549
+ * the resident views. Call when the tree structure changes, not per frame. */
550
+ uploadRuns(store) {
551
+ this.ensure(store.count, store.runCount);
552
+ const rc = store.runCount;
553
+ this.vrp.set(store.runParent.subarray(0, rc));
554
+ this.vrs.set(store.runStart.subarray(0, rc));
555
+ this.vrl.set(store.runLen.subarray(0, rc));
556
+ this.ex.set_run_count(rc);
557
+ }
558
+ /** The resident wasm input views (`x,y,sx,sy,cos,sin,opacity`), valid until
559
+ * the next capacity growth. Writing here is what makes uploads unnecessary. */
560
+ inputView() {
561
+ return {
562
+ x: this.vx,
563
+ y: this.vy,
564
+ sx: this.vsx,
565
+ sy: this.vsy,
566
+ cos: this.vcos,
567
+ sin: this.vsin,
568
+ opacity: this.vop
569
+ };
570
+ }
571
+ /** The resident wasm world-matrix output views (`wa..wo`). Reading here is
572
+ * what makes readback unnecessary. */
573
+ worldView() {
574
+ return {
575
+ wa: this.vwa,
576
+ wb: this.vwb,
577
+ wc: this.vwc,
578
+ wd: this.vwd,
579
+ we: this.vwe,
580
+ wf: this.vwf,
581
+ wo: this.vwo
582
+ };
583
+ }
584
+ /**
585
+ * Compute world-space AABBs for `store` in WASM (G1+), writing back into its
586
+ * `aminx/aminy/amaxx/amaxy` arrays. Uploads the local bounds, runs the AABB
587
+ * pass, reads results back. Result is bit-identical to `computeAabbsJS(store)`.
588
+ * `compose` (or `runKernel`) must have populated the world matrices first —
589
+ * this pass reads them. For the resident (no-copy) integration, write bounds
590
+ * via {@link boundsView} and read via {@link aabbView} + call
591
+ * {@link runAabbs} instead.
592
+ */
593
+ computeAabbs(store) {
594
+ this.ensure(store.count, store.runCount);
595
+ const n = store.count;
596
+ this.vbx.set(store.bx.subarray(0, n));
597
+ this.vby.set(store.by.subarray(0, n));
598
+ this.vbw.set(store.bw.subarray(0, n));
599
+ this.vbh.set(store.bh.subarray(0, n));
600
+ this.ex.compute_aabbs(n);
601
+ store.aminx.set(this.vaminx.subarray(0, n));
602
+ store.aminy.set(this.vaminy.subarray(0, n));
603
+ store.amaxx.set(this.vamaxx.subarray(0, n));
604
+ store.amaxy.set(this.vamaxy.subarray(0, n));
605
+ }
606
+ /** Run the AABB pass only, over `count` entities already resident in wasm
607
+ * memory (bounds written via {@link boundsView}, world matrices already
608
+ * composed). No upload/readback — the per-frame resident path. */
609
+ runAabbs(count) {
610
+ this.ex.compute_aabbs(count);
611
+ }
612
+ /** Resident wasm local-bounds input views (`bx,by,bw,bh`) for the AABB pass. */
613
+ boundsView() {
614
+ return { bx: this.vbx, by: this.vby, bw: this.vbw, bh: this.vbh };
615
+ }
616
+ /** Resident wasm world-AABB output views (`aminx,aminy,amaxx,amaxy`). */
617
+ aabbView() {
618
+ return {
619
+ aminx: this.vaminx,
620
+ aminy: this.vaminy,
621
+ amaxx: this.vamaxx,
622
+ amaxy: this.vamaxy
623
+ };
624
+ }
625
+ ensure(count, runCount) {
626
+ if (count + PAD2 <= this.cap && runCount <= this.runCap) return;
627
+ this.cap = count + PAD2;
628
+ this.runCap = Math.max(runCount, count, 1);
629
+ this.ex.init(count, this.runCap);
630
+ this.refreshViews();
631
+ }
632
+ /** Rebuild typed-array views after an init() (which may have grown, and thus
633
+ * detached, the memory buffer). */
634
+ refreshViews() {
635
+ const buf = this.ex.memory.buffer;
636
+ const cap = this.cap;
637
+ const rc = this.runCap;
638
+ const f64 = (ptr) => new Float64Array(buf, ptr, cap);
639
+ const i32 = (ptr) => new Int32Array(buf, ptr, rc);
640
+ this.vx = f64(this.ex.p_x());
641
+ this.vy = f64(this.ex.p_y());
642
+ this.vsx = f64(this.ex.p_sx());
643
+ this.vsy = f64(this.ex.p_sy());
644
+ this.vcos = f64(this.ex.p_cos());
645
+ this.vsin = f64(this.ex.p_sin());
646
+ this.vop = f64(this.ex.p_opacity());
647
+ this.vwa = f64(this.ex.p_wa());
648
+ this.vwb = f64(this.ex.p_wb());
649
+ this.vwc = f64(this.ex.p_wc());
650
+ this.vwd = f64(this.ex.p_wd());
651
+ this.vwe = f64(this.ex.p_we());
652
+ this.vwf = f64(this.ex.p_wf());
653
+ this.vwo = f64(this.ex.p_wo());
654
+ this.vbx = f64(this.ex.p_bx());
655
+ this.vby = f64(this.ex.p_by());
656
+ this.vbw = f64(this.ex.p_bw());
657
+ this.vbh = f64(this.ex.p_bh());
658
+ this.vaminx = f64(this.ex.p_aminx());
659
+ this.vaminy = f64(this.ex.p_aminy());
660
+ this.vamaxx = f64(this.ex.p_amaxx());
661
+ this.vamaxy = f64(this.ex.p_amaxy());
662
+ this.vrp = i32(this.ex.p_run_parent());
663
+ this.vrs = i32(this.ex.p_run_start());
664
+ this.vrl = i32(this.ex.p_run_len());
665
+ }
666
+ };
667
+ async function instantiateAsync(bytes) {
668
+ try {
669
+ const { instance } = await WebAssembly.instantiate(bytes, {});
670
+ return new WasmTransformBackend(instance);
671
+ } catch {
672
+ return null;
673
+ }
674
+ }
675
+ async function instantiateStreaming(source) {
676
+ try {
677
+ const resp = typeof source === "string" || source instanceof URL ? await fetch(String(source)) : await source;
678
+ if (typeof WebAssembly.instantiateStreaming === "function") {
679
+ const buffered = resp.clone();
680
+ try {
681
+ const { instance: instance2 } = await WebAssembly.instantiateStreaming(resp, {});
682
+ return new WasmTransformBackend(instance2);
683
+ } catch {
684
+ const { instance: instance2 } = await WebAssembly.instantiate(await buffered.arrayBuffer(), {});
685
+ return new WasmTransformBackend(instance2);
686
+ }
687
+ }
688
+ const { instance } = await WebAssembly.instantiate(await resp.arrayBuffer(), {});
689
+ return new WasmTransformBackend(instance);
690
+ } catch {
691
+ return null;
692
+ }
693
+ }
694
+
695
+ // src/wasm/hit-store.ts
696
+ function gatherHitAABBs(root, currentFrame) {
697
+ const slotEntity = [];
698
+ const boundless = [];
699
+ const minxs = [];
700
+ const minys = [];
701
+ const maxxs = [];
702
+ const maxys = [];
703
+ const scratch = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
704
+ const visit = (node) => {
705
+ const index = slotEntity.length;
706
+ slotEntity.push(node);
707
+ const bounds = node.getBounds();
708
+ if (bounds === null) {
709
+ boundless.push({ entity: node, index });
710
+ minxs.push(0);
711
+ minys.push(0);
712
+ maxxs.push(0);
713
+ maxys.push(0);
714
+ } else {
715
+ if (!node._readWorldCache(currentFrame, scratch)) {
716
+ const t = node.getWorldTransform();
717
+ scratch.a = t.a;
718
+ scratch.b = t.b;
719
+ scratch.c = t.c;
720
+ scratch.d = t.d;
721
+ scratch.e = t.e;
722
+ scratch.f = t.f;
723
+ }
724
+ const { a, b, c, d, e, f } = scratch;
725
+ let minX = Infinity;
726
+ let minY = Infinity;
727
+ let maxX = -Infinity;
728
+ let maxY = -Infinity;
729
+ for (let i = 0; i < 4; i++) {
730
+ const lx = i & 1 ? bounds.x + bounds.width : bounds.x;
731
+ const ly = i & 2 ? bounds.y + bounds.height : bounds.y;
732
+ const wx = a * lx + c * ly + e;
733
+ const wy = b * lx + d * ly + f;
734
+ if (wx < minX) minX = wx;
735
+ if (wx > maxX) maxX = wx;
736
+ if (wy < minY) minY = wy;
737
+ if (wy > maxY) maxY = wy;
738
+ }
739
+ minxs.push(minX);
740
+ minys.push(minY);
741
+ maxxs.push(maxX);
742
+ maxys.push(maxY);
743
+ }
744
+ const kids = node.children;
745
+ for (let i = 0; i < kids.length; i++) visit(kids[i]);
746
+ };
747
+ visit(root);
748
+ return {
749
+ count: slotEntity.length,
750
+ slotEntity,
751
+ boundless,
752
+ minx: Float64Array.from(minxs),
753
+ miny: Float64Array.from(minys),
754
+ maxx: Float64Array.from(maxxs),
755
+ maxy: Float64Array.from(maxys)
756
+ };
757
+ }
758
+
759
+ // src/wasm/hit-backend.ts
760
+ var PAD3 = 8;
761
+ var CELLS_PER_ENTITY_HINT = 4;
762
+ var HitTestBackend = class {
763
+ ex;
764
+ entityCap = 0;
765
+ cellCap = 0;
766
+ itemCap = 0;
767
+ vminx;
768
+ vminy;
769
+ vmaxx;
770
+ vmaxy;
771
+ vCellStart;
772
+ vCellCount;
773
+ vItems;
774
+ /** Grid geometry from the last {@link ensure} call. */
775
+ gridW = 0;
776
+ gridH = 0;
777
+ cellSize = 64;
778
+ constructor(instance) {
779
+ this.ex = instance.exports;
780
+ }
781
+ /** The resident AABB input views (`minx/miny/maxx/maxy`), valid until the
782
+ * next capacity growth. Writing here is what {@link build} reads from. */
783
+ inputView() {
784
+ return { minx: this.vminx, miny: this.vminy, maxx: this.vmaxx, maxy: this.vmaxy };
785
+ }
786
+ /**
787
+ * Size (and grow, if needed) capacity for `count` entities over
788
+ * `[0,vw] x [0,vh]` at `cellSize`, and record the grid geometry
789
+ * {@link candidatesAt} needs. Call this BEFORE writing AABBs into
790
+ * {@link inputView} — a capacity growth detaches the previous views, so
791
+ * writing first and sizing after would write into a stale buffer.
792
+ */
793
+ ensure(count, vw, vh, cellSize) {
794
+ const gw = Math.max(1, Math.ceil(vw / cellSize));
795
+ const gh = Math.max(1, Math.ceil(vh / cellSize));
796
+ this.growIfNeeded(count, gw * gh, count * CELLS_PER_ENTITY_HINT);
797
+ this.gridW = gw;
798
+ this.gridH = gh;
799
+ this.cellSize = cellSize;
800
+ }
801
+ /**
802
+ * Run the kernel's bucketing over whatever is currently resident in
803
+ * {@link inputView} (write the AABBs there, and call {@link ensure} first).
804
+ * Returns `false` if the build overflowed its item budget — the caller must
805
+ * not trust {@link candidatesAt} results for this build and should fall back
806
+ * to the JS walk instead (never return a wrong hit).
807
+ */
808
+ runBuild(count, vw, vh, cellSize) {
809
+ this.ex.hit_build(count, vw, vh, cellSize);
810
+ return this.ex.hit_overflow() === 0;
811
+ }
812
+ /**
813
+ * Entity indices whose AABB overlaps the cell containing `(px, py)`, in
814
+ * ascending index order (scan from the end for topmost/highest-index
815
+ * first), or `null` if the point falls outside the built grid. This is a
816
+ * coarse candidate LIST, not a hit result — the caller must still confirm
817
+ * each candidate's AABB contains the point and re-check its precise
818
+ * `isPointInside`.
819
+ */
820
+ candidatesAt(px, py) {
821
+ if (px < 0 || py < 0) return null;
822
+ const cx = Math.floor(px / this.cellSize);
823
+ const cy = Math.floor(py / this.cellSize);
824
+ if (cx < 0 || cy < 0 || cx >= this.gridW || cy >= this.gridH) return null;
825
+ const c = cy * this.gridW + cx;
826
+ if (c >= this.cellCap) return null;
827
+ const start = this.vCellStart[c];
828
+ const count = this.vCellCount[c];
829
+ return this.vItems.subarray(start, start + count);
830
+ }
831
+ growIfNeeded(count, cellCount, itemCount) {
832
+ if (count + PAD3 <= this.entityCap && cellCount <= this.cellCap && itemCount <= this.itemCap) {
833
+ return;
834
+ }
835
+ this.entityCap = count + PAD3;
836
+ this.cellCap = Math.max(cellCount, 1);
837
+ this.itemCap = Math.max(itemCount, count, 1);
838
+ this.ex.hit_init(count, this.cellCap, this.itemCap);
839
+ this.refreshViews();
840
+ }
841
+ /** Rebuild typed-array views after a growing `hit_init` (which detaches the
842
+ * memory buffer). */
843
+ refreshViews() {
844
+ const buf = this.ex.memory.buffer;
845
+ const eCap = this.entityCap;
846
+ const cCap = this.cellCap;
847
+ const iCap = this.itemCap;
848
+ this.vminx = new Float64Array(buf, this.ex.p_h_minx(), eCap);
849
+ this.vminy = new Float64Array(buf, this.ex.p_h_miny(), eCap);
850
+ this.vmaxx = new Float64Array(buf, this.ex.p_h_maxx(), eCap);
851
+ this.vmaxy = new Float64Array(buf, this.ex.p_h_maxy(), eCap);
852
+ this.vCellStart = new Int32Array(buf, this.ex.p_h_cell_start(), cCap);
853
+ this.vCellCount = new Int32Array(buf, this.ex.p_h_cell_count(), cCap);
854
+ this.vItems = new Int32Array(buf, this.ex.p_h_items(), iCap);
855
+ }
856
+ };
857
+ async function instantiateAsync2(bytes) {
858
+ try {
859
+ const { instance } = await WebAssembly.instantiate(bytes, {});
860
+ return new HitTestBackend(instance);
861
+ } catch {
862
+ return null;
863
+ }
864
+ }
865
+ async function instantiateStreaming2(source) {
866
+ try {
867
+ const resp = typeof source === "string" || source instanceof URL ? await fetch(String(source)) : await source;
868
+ if (typeof WebAssembly.instantiateStreaming === "function") {
869
+ const buffered = resp.clone();
870
+ try {
871
+ const { instance: instance2 } = await WebAssembly.instantiateStreaming(resp, {});
872
+ return new HitTestBackend(instance2);
873
+ } catch {
874
+ const { instance: instance2 } = await WebAssembly.instantiate(await buffered.arrayBuffer(), {});
875
+ return new HitTestBackend(instance2);
876
+ }
877
+ }
878
+ const { instance } = await WebAssembly.instantiate(await resp.arrayBuffer(), {});
879
+ return new HitTestBackend(instance);
880
+ } catch {
881
+ return null;
882
+ }
883
+ }
884
+
885
+ // src/wasm/anim-backend.ts
886
+ var PAD4 = 8;
887
+ var AnimBackend = class {
888
+ ex;
889
+ springCap = 0;
890
+ tweenCap = 0;
891
+ sv;
892
+ tv;
893
+ constructor(instance) {
894
+ this.ex = instance.exports;
895
+ }
896
+ /** The resident spring SoA input/output views, valid until the next capacity
897
+ * growth. Write gathered driver state here before calling {@link stepSprings}. */
898
+ springView() {
899
+ return this.sv;
900
+ }
901
+ /** The resident tween SoA input/output views, valid until the next capacity
902
+ * growth. Write gathered driver state here before calling {@link stepTweens}. */
903
+ tweenView() {
904
+ return this.tv;
905
+ }
906
+ /**
907
+ * Size (and grow, if needed) capacity for `springCount` springs and
908
+ * `tweenCount` tweens. Call this BEFORE writing into {@link springView}/
909
+ * {@link tweenView} — a capacity growth detaches the previous views, so
910
+ * writing first and sizing after would write into a stale buffer.
911
+ */
912
+ ensure(springCount, tweenCount) {
913
+ if (springCount + PAD4 <= this.springCap && tweenCount + PAD4 <= this.tweenCap) return;
914
+ this.springCap = springCount + PAD4;
915
+ this.tweenCap = tweenCount + PAD4;
916
+ this.ex.anim_init(this.springCap, this.tweenCap);
917
+ this.refreshViews();
918
+ }
919
+ /** Advance `count` springs (from index 0) by `dtMs` milliseconds, in place. */
920
+ stepSprings(dtMs, count) {
921
+ this.ex.spring_step(dtMs / 1e3, count);
922
+ }
923
+ /** Advance `count` tweens (from index 0) by `dtMs` milliseconds, writing `val`. */
924
+ stepTweens(dtMs, count) {
925
+ this.ex.tween_step(dtMs, count);
926
+ }
927
+ refreshViews() {
928
+ const buf = this.ex.memory.buffer;
929
+ const sCap = this.springCap;
930
+ const tCap = this.tweenCap;
931
+ this.sv = {
932
+ val: new Float64Array(buf, this.ex.p_s_val(), sCap),
933
+ target: new Float64Array(buf, this.ex.p_s_target(), sCap),
934
+ vel: new Float64Array(buf, this.ex.p_s_vel(), sCap),
935
+ stiff: new Float64Array(buf, this.ex.p_s_stiff(), sCap),
936
+ damp: new Float64Array(buf, this.ex.p_s_damp(), sCap),
937
+ mass: new Float64Array(buf, this.ex.p_s_mass(), sCap)
938
+ };
939
+ this.tv = {
940
+ from: new Float64Array(buf, this.ex.p_t_from(), tCap),
941
+ to: new Float64Array(buf, this.ex.p_t_to(), tCap),
942
+ elapsed: new Float64Array(buf, this.ex.p_t_elapsed(), tCap),
943
+ dur: new Float64Array(buf, this.ex.p_t_dur(), tCap),
944
+ delay: new Float64Array(buf, this.ex.p_t_delay(), tCap),
945
+ ease: new Float64Array(buf, this.ex.p_t_ease(), tCap),
946
+ val: new Float64Array(buf, this.ex.p_t_val(), tCap)
947
+ };
948
+ }
949
+ };
950
+ async function instantiateAsync3(bytes) {
951
+ try {
952
+ const { instance } = await WebAssembly.instantiate(bytes, {});
953
+ return new AnimBackend(instance);
954
+ } catch {
955
+ return null;
956
+ }
957
+ }
958
+ async function instantiateStreaming3(source) {
959
+ try {
960
+ const resp = typeof source === "string" || source instanceof URL ? await fetch(String(source)) : await source;
961
+ if (typeof WebAssembly.instantiateStreaming === "function") {
962
+ const buffered = resp.clone();
963
+ try {
964
+ const { instance: instance2 } = await WebAssembly.instantiateStreaming(resp, {});
965
+ return new AnimBackend(instance2);
966
+ } catch {
967
+ const { instance: instance2 } = await WebAssembly.instantiate(await buffered.arrayBuffer(), {});
968
+ return new AnimBackend(instance2);
969
+ }
970
+ }
971
+ const { instance } = await WebAssembly.instantiate(await resp.arrayBuffer(), {});
972
+ return new AnimBackend(instance);
973
+ } catch {
974
+ return null;
975
+ }
976
+ }
977
+
978
+ // src/wasm/particle-backend.ts
979
+ var PAD5 = 8;
980
+ var ParticleBackend = class {
981
+ ex;
982
+ cap = 0;
983
+ view;
984
+ constructor(instance) {
985
+ this.ex = instance.exports;
986
+ }
987
+ /** The resident SoA views, valid until the next capacity growth. */
988
+ particleView() {
989
+ return this.view;
990
+ }
991
+ /**
992
+ * Size (and grow, if needed) capacity for `count` particles. Call BEFORE
993
+ * writing into {@link particleView} — a growth detaches the previous views.
994
+ */
995
+ ensure(count) {
996
+ if (count + PAD5 <= this.cap) return;
997
+ this.cap = count + PAD5;
998
+ this.ex.particle_init(this.cap);
999
+ this.refreshViews();
1000
+ }
1001
+ /**
1002
+ * Advance `count` particles one step in place. Returns `true` when at least
1003
+ * one live particle is still moving or off-origin beyond epsilon (the fused
1004
+ * `hasPendingAnimations` flag), so the caller need not re-scan the buffer.
1005
+ */
1006
+ step(count, p) {
1007
+ const e = p.explosion;
1008
+ const flag = this.ex.particle_step(
1009
+ p.dt,
1010
+ p.mouseX,
1011
+ p.mouseY,
1012
+ p.width,
1013
+ p.height,
1014
+ p.springK,
1015
+ p.damping,
1016
+ p.bounceDamping,
1017
+ p.maxVelocity,
1018
+ e ? 1 : 0,
1019
+ e ? e.x : 0,
1020
+ e ? e.y : 0,
1021
+ e ? e.force : 0,
1022
+ count
1023
+ );
1024
+ return flag !== 0;
1025
+ }
1026
+ /** Transpose the AoS stride-8 buffer into the SoA views (position/velocity/
1027
+ * life every frame; origin upload-once when `withOrigin`). */
1028
+ gather(data, count, withOrigin) {
1029
+ const v = this.view;
1030
+ for (let i = 0; i < count; i++) {
1031
+ const o = i * PARTICLE_STRIDE_FLOATS;
1032
+ v.px[i] = data[o + PARTICLE_OFFSET_POSITION_X];
1033
+ v.py[i] = data[o + PARTICLE_OFFSET_POSITION_Y];
1034
+ v.vx[i] = data[o + PARTICLE_OFFSET_VELOCITY_X];
1035
+ v.vy[i] = data[o + PARTICLE_OFFSET_VELOCITY_Y];
1036
+ v.life[i] = data[o + PARTICLE_OFFSET_LIFE];
1037
+ if (withOrigin) {
1038
+ v.ox[i] = data[o + PARTICLE_OFFSET_ORIGIN_X];
1039
+ v.oy[i] = data[o + PARTICLE_OFFSET_ORIGIN_Y];
1040
+ }
1041
+ }
1042
+ }
1043
+ /** Scatter the mutated position/velocity/life back into the AoS buffer. */
1044
+ scatter(data, count) {
1045
+ const v = this.view;
1046
+ for (let i = 0; i < count; i++) {
1047
+ const o = i * PARTICLE_STRIDE_FLOATS;
1048
+ data[o + PARTICLE_OFFSET_POSITION_X] = v.px[i];
1049
+ data[o + PARTICLE_OFFSET_POSITION_Y] = v.py[i];
1050
+ data[o + PARTICLE_OFFSET_VELOCITY_X] = v.vx[i];
1051
+ data[o + PARTICLE_OFFSET_VELOCITY_Y] = v.vy[i];
1052
+ data[o + PARTICLE_OFFSET_LIFE] = v.life[i];
1053
+ }
1054
+ }
1055
+ refreshViews() {
1056
+ const buf = this.ex.memory.buffer;
1057
+ const c = this.cap;
1058
+ this.view = {
1059
+ px: new Float32Array(buf, this.ex.pp_px(), c),
1060
+ py: new Float32Array(buf, this.ex.pp_py(), c),
1061
+ vx: new Float32Array(buf, this.ex.pp_vx(), c),
1062
+ vy: new Float32Array(buf, this.ex.pp_vy(), c),
1063
+ ox: new Float32Array(buf, this.ex.pp_ox(), c),
1064
+ oy: new Float32Array(buf, this.ex.pp_oy(), c),
1065
+ life: new Float32Array(buf, this.ex.pp_life(), c)
1066
+ };
1067
+ }
1068
+ };
1069
+ async function instantiateAsync4(bytes) {
1070
+ try {
1071
+ const { instance } = await WebAssembly.instantiate(bytes, {});
1072
+ return new ParticleBackend(instance);
1073
+ } catch {
1074
+ return null;
1075
+ }
1076
+ }
1077
+ async function instantiateStreaming4(source) {
1078
+ try {
1079
+ const resp = typeof source === "string" || source instanceof URL ? await fetch(String(source)) : await source;
1080
+ if (typeof WebAssembly.instantiateStreaming === "function") {
1081
+ const buffered = resp.clone();
1082
+ try {
1083
+ const { instance: instance2 } = await WebAssembly.instantiateStreaming(resp, {});
1084
+ return new ParticleBackend(instance2);
1085
+ } catch {
1086
+ const { instance: instance2 } = await WebAssembly.instantiate(await buffered.arrayBuffer(), {});
1087
+ return new ParticleBackend(instance2);
1088
+ }
1089
+ }
1090
+ const { instance } = await WebAssembly.instantiate(await resp.arrayBuffer(), {});
1091
+ return new ParticleBackend(instance);
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+
312
1097
  // src/tree/Scene.ts
313
1098
  import { clearCssLineBoxMetrics, cssLineBoxBaseline } from "@vectojs/text";
314
1099
  var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
@@ -625,6 +1410,10 @@ function extendSelection(selection, anchor, focus) {
625
1410
  var Scene = class _Scene {
626
1411
  static webglCreator = null;
627
1412
  static webgpuManagerClass = null;
1413
+ /** Upper bound (ms) on a single frame's `dt`. Caps the giant elapsed gap a
1414
+ * backgrounded/refocused tab produces so physics advances at most one slow
1415
+ * frame instead of the whole idle duration (~100ms ≈ 6 frames at 60fps). */
1416
+ static MAX_FRAME_DT = 100;
628
1417
  static registerWebGLPointRendererCreator(creator) {
629
1418
  _Scene.webglCreator = creator;
630
1419
  }
@@ -635,6 +1424,13 @@ var Scene = class _Scene {
635
1424
  overlayRoot;
636
1425
  renderer;
637
1426
  isRunning = false;
1427
+ /** Whether the canvas is at least partially in the viewport. When it scrolls
1428
+ * fully off-screen the rAF loop pauses (stops rescheduling) instead of
1429
+ * burning frames on a scene nobody can see; an IntersectionObserver resumes
1430
+ * it on re-entry. Defaults true (and stays true where IntersectionObserver
1431
+ * is unavailable, e.g. SSR/jsdom, so behavior is unchanged there). */
1432
+ _canvasOnScreen = true;
1433
+ _canvasObserver = null;
638
1434
  lastTime = 0;
639
1435
  canvas;
640
1436
  /**
@@ -669,12 +1465,45 @@ var Scene = class _Scene {
669
1465
  maxFPS = 60;
670
1466
  /** Whether the OS prefers-reduced-motion setting auto-caps the loop. */
671
1467
  respectReducedMotion = true;
1468
+ /**
1469
+ * Reading direction for accessibility tab/traversal order (`'ltr'` default,
1470
+ * `'rtl'`). Controls the inline sort within a visual row in
1471
+ * {@link enforceA11yDomOrder}. Set at runtime to re-flow tab order on the
1472
+ * next sync (also trips a reorder).
1473
+ */
1474
+ get readingDirection() {
1475
+ return this._readingDirection;
1476
+ }
1477
+ set readingDirection(dir) {
1478
+ if (dir !== this._readingDirection) {
1479
+ this._readingDirection = dir;
1480
+ this.a11yNeedsReorder = true;
1481
+ }
1482
+ }
1483
+ _readingDirection = "ltr";
672
1484
  /** Cached media-query list; `.matches` is read live each frame. */
673
1485
  reducedMotionQuery = null;
1486
+ /** Cached `(forced-colors: active)` query (Windows High Contrast etc.). A
1487
+ * canvas gets NO automatic forced-colors treatment from the browser (it's
1488
+ * opaque pixels), so components must read {@link forcedColors} and repaint
1489
+ * with system colors themselves; a change listener repaints idle scenes. */
1490
+ forcedColorsQuery = null;
1491
+ forcedColorsChangeHandler = null;
674
1492
  /** True when the OS asks for reduced motion and we respect it. Read by the animation drivers. */
675
1493
  get prefersReducedMotion() {
676
1494
  return this.respectReducedMotion && !!this.reducedMotionQuery?.matches;
677
1495
  }
1496
+ /**
1497
+ * True when the OS is in a forced-colors mode (Windows High Contrast, and the
1498
+ * `forced-colors: active` media feature generally). Canvas pixels are exempt
1499
+ * from the browser's forced-colors remapping, so accessible components should
1500
+ * read this and draw with CSS system colors (`CanvasText`, `Canvas`,
1501
+ * `Highlight`, …) instead of their themed palette. Re-rendered automatically
1502
+ * when the setting toggles.
1503
+ */
1504
+ get forcedColors() {
1505
+ return !!this.forcedColorsQuery?.matches;
1506
+ }
678
1507
  /**
679
1508
  * Throttle interval (ms) for the a11y/automation shadow sync. `0` = every
680
1509
  * frame. See {@link SceneOptions.a11ySyncInterval}.
@@ -719,7 +1548,32 @@ var Scene = class _Scene {
719
1548
  frameHadAnimation = true;
720
1549
  frameHadInteractive = true;
721
1550
  resizeHandler;
1551
+ /** Active `(resolution: Ndppx)` media query watching for a runtime DPR change
1552
+ * (window moved between monitors, browser zoom) so the canvas backing store
1553
+ * can be re-scaled — otherwise it stays rasterized at the old DPR and blurs.
1554
+ * A resolution media query only fires when leaving its exact value, so the
1555
+ * handler re-arms a fresh query for the new DPR each time. */
1556
+ dprMediaQuery = null;
1557
+ /** For embedded (`disableWindowResize`) scenes: observes the canvas element so
1558
+ * a CSS/layout-driven size change re-runs `resize()`. A window `resize`
1559
+ * listener never fires for these (the window isn't what changed), so without
1560
+ * this an embedded canvas stayed at its initial size forever. */
1561
+ canvasResizeObserver = null;
1562
+ dprChangeHandler = null;
722
1563
  focusedA11yElement = null;
1564
+ /** Last geometry `syncOverlayGeometry` wrote, so an unchanged frame can skip the
1565
+ * style writes entirely. Reset to `null` to force the next sync (a new overlay
1566
+ * layer was created and has never been positioned). */
1567
+ _overlayGeometry = null;
1568
+ /** Shadow elements the pointer is currently inside. Lets a removal that happens
1569
+ * mid-hover synthesize the `pointerleave` the browser never sends for a
1570
+ * detached element, so the entity doesn't keep its hover state. */
1571
+ hoveredA11yElements = /* @__PURE__ */ new WeakSet();
1572
+ /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is
1573
+ * pruned (virtualization/streaming/removal) while it holds focus, we move
1574
+ * focus here instead of letting the browser drop it to <body> — keeping the
1575
+ * screen-reader virtual cursor inside the scene's a11y region. */
1576
+ focusSentinel = null;
723
1577
  caretBlinkTimer = null;
724
1578
  a11yNeedsReorder = true;
725
1579
  portalRoot = null;
@@ -730,6 +1584,510 @@ var Scene = class _Scene {
730
1584
  activePortalsPrevFrame = /* @__PURE__ */ new Set();
731
1585
  portalEntities = /* @__PURE__ */ new Map();
732
1586
  renderOrderCounter = 0;
1587
+ /**
1588
+ * Monotonic render-frame counter, bumped once per authoritative `render()`
1589
+ * pass. Entities stamp their per-frame world-matrix cache with this value and
1590
+ * {@link Entity.getWorldTransform} trusts that cache only while it still
1591
+ * matches, so a query outside the frame that produced it transparently falls
1592
+ * back to the ancestor walk. Public for the same reason `Entity._getTrig`/
1593
+ * `_setWorldCache` are: it is a cross-class render-internal contract.
1594
+ */
1595
+ currentFrame = 0;
1596
+ // ── WASM transform backend (invisible accelerator) ──────────────────────────
1597
+ // When `_transformBackend === 'wasm'`, the main render walk sources each
1598
+ // entity's world matrix from an SoA store composed by `_wasm` (see
1599
+ // `renderNode`), instead of composing it in JS. The JS path is the permanent
1600
+ // fallback and the default: a null backend, a non-main renderer, or any entity
1601
+ // absent from the store all fall back to the JS composition, so WASM can only
1602
+ // ever change *how fast* a world matrix is produced, never *what* it is.
1603
+ _wasm = null;
1604
+ _transformBackend = "js";
1605
+ // Resident store state (Stage 3). The store layout — slot assignment + sibling
1606
+ // runs — depends only on tree TOPOLOGY, so it is rebuilt only when the
1607
+ // structure changes (add/remove/reparent bump `_structureVersion`). Between
1608
+ // rebuilds the per-frame cost is: gather each entity's transform into the
1609
+ // resident wasm input view + run the kernel — no reallocation, no readback.
1610
+ _treeStore = null;
1611
+ _slotEntity = [];
1612
+ // store slot -> entity (also validates slots)
1613
+ _wasmInputs = null;
1614
+ _wasmWorld = null;
1615
+ _structureVersion = 0;
1616
+ _storeStructureVersion = -1;
1617
+ // Cached list of ComputeParticleEntity instances in the tree, keyed by the
1618
+ // structure version it was gathered at. Rebuilt only on a topology change.
1619
+ _computeEntities = [];
1620
+ _computeEntitiesVersion = -1;
1621
+ /** Invalidate the resident WASM store layout; the next wasm-mode frame rebuilds
1622
+ * it. Called by `Entity.add`/`remove` (topology changes only). */
1623
+ markStructureChanged() {
1624
+ this._structureVersion++;
1625
+ }
1626
+ /** The tree's ComputeParticleEntity instances, cached per structure version so
1627
+ * a compute-free scene doesn't re-walk the whole tree every frame. */
1628
+ _computeEntitiesFor(version) {
1629
+ if (this._computeEntitiesVersion === version) return this._computeEntities;
1630
+ const list = [];
1631
+ const collect = (node) => {
1632
+ if (node instanceof ComputeParticleEntity) list.push(node);
1633
+ for (const child of node.children) collect(child);
1634
+ };
1635
+ collect(this.root);
1636
+ for (const overlay of this.overlayRoot.children) collect(overlay);
1637
+ this._computeEntities = list;
1638
+ this._computeEntitiesVersion = version;
1639
+ return list;
1640
+ }
1641
+ /** Which backend composes world matrices for the main render walk. */
1642
+ get transformBackend() {
1643
+ return this._transformBackend;
1644
+ }
1645
+ /**
1646
+ * Install (or clear) a WASM transform backend. Passing a backend switches the
1647
+ * main render walk onto it; passing `null` reverts to the JS path. Synchronous
1648
+ * and safe to call between frames — the next `render()` picks it up. Prefer
1649
+ * {@link enableWasmTransforms} for the normal async hot-swap.
1650
+ */
1651
+ setTransformBackend(backend) {
1652
+ this._wasm = backend;
1653
+ this._transformBackend = backend ? "wasm" : "js";
1654
+ }
1655
+ /**
1656
+ * Asynchronously instantiate the WASM transform core and, on success, hot-swap
1657
+ * the render walk onto it. Accepts whatever is convenient at the call site:
1658
+ *
1659
+ * ```ts
1660
+ * // The common case — a bundler-emitted, co-located asset URL:
1661
+ * await scene.enableWasmTransforms(new URL('./vectojs_core.wasm', import.meta.url));
1662
+ * // …or a path string, a Response, or raw bytes you already have:
1663
+ * await scene.enableWasmTransforms('/assets/vectojs_core.wasm');
1664
+ * await scene.enableWasmTransforms(await fetch(url));
1665
+ * await scene.enableWasmTransforms(myUint8Array);
1666
+ * ```
1667
+ *
1668
+ * A URL/Response streams (compiles while it downloads, with a buffered
1669
+ * fallback for a wrong MIME type); raw bytes instantiate directly. The Scene
1670
+ * keeps rendering on the JS path until this resolves, and stays on JS if
1671
+ * instantiation fails (CSP `wasm-unsafe-eval`, unsupported SIMD, corrupt or
1672
+ * missing bytes, a 404) — failure is the default state, not an error path.
1673
+ * Resolves `true` if WASM is now active, `false` if the JS path remains.
1674
+ */
1675
+ async enableWasmTransforms(source) {
1676
+ const isBytes = source instanceof ArrayBuffer || ArrayBuffer.isView(source);
1677
+ const backend = isBytes ? await instantiateAsync(source) : await instantiateStreaming(source);
1678
+ if (!backend) return false;
1679
+ this.setTransformBackend(backend);
1680
+ return true;
1681
+ }
1682
+ // ── WASM hit-test backend (invisible accelerator, G3) ───────────────────────
1683
+ // A separate WASM module instance from the transform backend (each crate
1684
+ // export lives in independent linear memory per instance, so there is no
1685
+ // shared-state hazard in running both) that indexes the main tree's world
1686
+ // AABBs into a dense viewport grid for findEntityAt. The JS depth-first walk
1687
+ // (findHitRecursively) is the permanent fallback: a null backend, a build
1688
+ // that overflows its item budget, or the overlay tree (never indexed — small
1689
+ // and rare, not worth accelerating) all fall through to it, so WASM can only
1690
+ // ever change *how fast* a hit is found, never *which* entity is returned —
1691
+ // every grid candidate is re-confirmed against its own precise
1692
+ // isPointInside before being trusted (see hit-store.ts / hit-backend.ts).
1693
+ _hitWasm = null;
1694
+ // Cache key: which frame + structure version the grid was last (successfully,
1695
+ // non-overflowing) built for. findEntityAt is called ad-hoc (pointer
1696
+ // hover/click), not every frame, so the grid is refreshed lazily on demand
1697
+ // rather than proactively every render() — unlike the transform store, which
1698
+ // every frame's draw depends on.
1699
+ _hitGridFrame = -1;
1700
+ _hitGridOk = false;
1701
+ _hitSlotEntity = [];
1702
+ _hitBoundless = [];
1703
+ /** Which backend answers `findEntityAt` for the main tree. */
1704
+ get hitTestBackend() {
1705
+ return this._hitWasm ? "wasm" : "js";
1706
+ }
1707
+ /** Install (or clear) a WASM hit-test backend directly. Prefer
1708
+ * {@link enableWasmHitTest} for the normal async hot-swap. */
1709
+ setHitTestBackend(backend) {
1710
+ this._hitWasm = backend;
1711
+ this._hitGridFrame = -1;
1712
+ }
1713
+ /**
1714
+ * Asynchronously instantiate the WASM hit-test core and, on success, hot-swap
1715
+ * `findEntityAt` onto it. Accepts the same source shapes as
1716
+ * {@link enableWasmTransforms} (URL, path string, Response, or raw bytes).
1717
+ * Stays on the JS walk if instantiation fails — failure is the default
1718
+ * state, not an error path. Resolves `true` if WASM is now active.
1719
+ */
1720
+ async enableWasmHitTest(source) {
1721
+ const isBytes = source instanceof ArrayBuffer || ArrayBuffer.isView(source);
1722
+ const backend = isBytes ? await instantiateAsync2(source) : await instantiateStreaming2(source);
1723
+ if (!backend) return false;
1724
+ this.setHitTestBackend(backend);
1725
+ return true;
1726
+ }
1727
+ /**
1728
+ * Refresh the hit-test grid for the CURRENT tree state if it is stale (a
1729
+ * structural or transform change may have happened since the last build —
1730
+ * there is no cheap "nothing moved" shortcut for a spatial index the way
1731
+ * there is for the transform store's topology-only run table, since ANY
1732
+ * entity moving invalidates its AABB, not just add/remove/reparent; the
1733
+ * measured build cost is cheap enough to redo per call). Returns `false`
1734
+ * (grid untrustworthy — caller must use the JS walk) when there is no
1735
+ * backend or the build overflowed its item budget.
1736
+ */
1737
+ _ensureHitGrid() {
1738
+ const backend = this._hitWasm;
1739
+ if (!backend) return false;
1740
+ if (this._hitGridFrame === this.currentFrame) return this._hitGridOk;
1741
+ const gathered = gatherHitAABBs(this.root, this.currentFrame);
1742
+ backend.ensure(gathered.count, this.width, this.height, 64);
1743
+ const view = backend.inputView();
1744
+ view.minx.set(gathered.minx.subarray(0, gathered.count));
1745
+ view.miny.set(gathered.miny.subarray(0, gathered.count));
1746
+ view.maxx.set(gathered.maxx.subarray(0, gathered.count));
1747
+ view.maxy.set(gathered.maxy.subarray(0, gathered.count));
1748
+ const ok = backend.runBuild(gathered.count, this.width, this.height, 64);
1749
+ this._hitSlotEntity = gathered.slotEntity;
1750
+ this._hitBoundless = gathered.boundless;
1751
+ this._hitGridFrame = this.currentFrame;
1752
+ this._hitGridOk = ok;
1753
+ return ok;
1754
+ }
1755
+ /**
1756
+ * `findEntityAt`'s WASM-accelerated path for the main tree. Scans only the
1757
+ * queried cell's candidates (confirming each against its own AABB and precise
1758
+ * `isPointInside`) merged against the (typically empty or tiny) list of
1759
+ * entities with no `getBounds()`, taking whichever confirmed match has the
1760
+ * higher pre-order index — see hit-store.ts for why that is exactly
1761
+ * equivalent to findHitRecursively's topmost-hit priority. Always
1762
+ * conclusive: returns the correct entity or `null`, never "inconclusive".
1763
+ */
1764
+ _findEntityAtWasm(x, y) {
1765
+ const backend = this._hitWasm;
1766
+ const { minx, miny, maxx, maxy } = backend.inputView();
1767
+ let bestIndex = -1;
1768
+ let bestEntity = null;
1769
+ const cell = backend.candidatesAt(x, y);
1770
+ if (cell) {
1771
+ for (let k = cell.length - 1; k >= 0; k--) {
1772
+ const idx = cell[k];
1773
+ if (x < minx[idx] || x > maxx[idx] || y < miny[idx] || y > maxy[idx]) continue;
1774
+ const entity = this._hitSlotEntity[idx];
1775
+ if (entity?.isPointInside(x, y) && this.isHitEligible(entity, x, y)) {
1776
+ bestIndex = idx;
1777
+ bestEntity = entity;
1778
+ break;
1779
+ }
1780
+ }
1781
+ }
1782
+ for (const { entity, index } of this._hitBoundless) {
1783
+ if (index > bestIndex && entity.isPointInside(x, y) && this.isHitEligible(entity, x, y)) {
1784
+ bestIndex = index;
1785
+ bestEntity = entity;
1786
+ }
1787
+ }
1788
+ return bestEntity;
1789
+ }
1790
+ // ── WASM batched-animation backend (invisible accelerator, G2) ──────────────
1791
+ // Advances every currently-active SpringDriver/TweenDriver in one WASM call
1792
+ // each (spring_step/tween_step) instead of Entity.tickDrivers()'s per-driver
1793
+ // JS loop. The JS tick loop is the permanent fallback: a null backend, a
1794
+ // driver count below `animDriverGateCount`, or a TweenDriver using a custom
1795
+ // EasingFn (which cannot cross into WASM) all fall through to it — WASM can
1796
+ // only ever change *how* a driver is advanced, never *what* value it lands
1797
+ // on.
1798
+ _animWasm = null;
1799
+ // Entities with at least one active driver, added by Entity._spawnDriver.
1800
+ // Self-pruning: _tickBatchedDrivers drops an entry the first time it visits
1801
+ // an entity whose drivers have since all completed or been removed. This is
1802
+ // what lets the batch pass find its candidates in O(active drivers), not
1803
+ // O(tree size) — the exact mistake G3's first integrated benchmark made.
1804
+ _activeDriverEntities = /* @__PURE__ */ new Set();
1805
+ // Reused across frames instead of allocating a fresh array + N {entity,prop,
1806
+ // driver} objects every call — the integrated benchmark
1807
+ // (benchmarks/anim-wasm-scene) found that allocation churn was the
1808
+ // dominant integrated cost, not the wasm kernel itself. Parallel arrays,
1809
+ // truncated to the live count after each use so a stale tail slot never
1810
+ // pins a no-longer-active entity/driver in memory.
1811
+ _springEntities = [];
1812
+ _springProps = [];
1813
+ _springDrivers = [];
1814
+ _tweenEntities = [];
1815
+ _tweenProps = [];
1816
+ _tweenDrivers = [];
1817
+ /**
1818
+ * Minimum number of batchable (spring, or named-easing tween) active drivers
1819
+ * before a frame engages the WASM batch path at all; below it, every driver
1820
+ * ticks on the normal JS per-entity path, unmodified.
1821
+ *
1822
+ * Re-measured on the INTEGRATED path (benchmarks/anim-wasm-scene, real
1823
+ * Chrome 150 / Firefox 153, 2026-07-24 — correctness verified 0 mismatches
1824
+ * across all three kinds before any of these numbers were trusted): the
1825
+ * isolated kernel spike's "<100 drivers, wins everywhere" verdict did NOT
1826
+ * survive integration, and neither did the first integrated pass's single
1827
+ * gate-count verdict once broken out by driver kind. On Chrome, spring and
1828
+ * mixed drivers are a real ~1.4–2.3× win from n=128 up through the tested
1829
+ * ceiling of 16384, but pure-tween drivers are a LOSS at n=128 (0.71×,
1830
+ * i.e. ~40% slower than the JS path) and only turn net-positive around
1831
+ * n≈256 (1.52×). A single scalar gate can't be tight for spring/mixed
1832
+ * without occasionally opening early on a tween-heavy scene and making it
1833
+ * slower — 256 is chosen to keep the gate net-positive across all three
1834
+ * kinds rather than optimal for any one of them; a kind-aware gate (see
1835
+ * `_tickBatchedDrivers`'s per-kind arrays, which already separate spring
1836
+ * from tween) would recover the 128–255 spring/mixed win without the
1837
+ * tween regression, but that's a larger change than this measurement pass
1838
+ * covers. On Firefox it is a net loss at every driver count measured, up
1839
+ * to 16384 — not an allocation artifact (confirmed after removing all
1840
+ * per-frame allocation from the gather/scatter path); SpiderMonkey's
1841
+ * wasm-boundary/property-dispatch cost for this shape of call appears to
1842
+ * structurally exceed the saving, at least at the scales tested here.
1843
+ *
1844
+ * 256 is set as a Chrome-oriented default so an app that opts in (this
1845
+ * path is never engaged without an explicit {@link enableWasmAnimBatching}
1846
+ * call) sees the gate open only where it reliably helps on Chromium,
1847
+ * regardless of whether the scene's active drivers are spring, tween, or
1848
+ * a mix of both. Unlike G1 (safe to default on everywhere) and G3 (opt-in,
1849
+ * but a reliable win once its own gate condition holds), G2 has no
1850
+ * threshold that is safe on every engine — raise or lower this per your
1851
+ * own target browser mix and driver-kind distribution, or leave WASM
1852
+ * animation batching disabled entirely on a Firefox-heavy audience.
1853
+ */
1854
+ animDriverGateCount = 256;
1855
+ /** Which backend advances active property drivers on the current gate
1856
+ * decision. Reflects only whether a backend is installed — the per-frame
1857
+ * gate can still choose the JS path even when this reads `'wasm'`. */
1858
+ get animBackend() {
1859
+ return this._animWasm ? "wasm" : "js";
1860
+ }
1861
+ /** Install (or clear) a WASM batched-animation backend directly. Prefer
1862
+ * {@link enableWasmAnimBatching} for the normal async hot-swap. */
1863
+ setAnimBackend(backend) {
1864
+ this._animWasm = backend;
1865
+ }
1866
+ /**
1867
+ * Asynchronously instantiate the WASM batched-animation core and, on
1868
+ * success, make it available to the per-frame gate (see
1869
+ * {@link animDriverGateCount}). Accepts the same source shapes as
1870
+ * {@link enableWasmTransforms}. Stays on the JS tick loop if instantiation
1871
+ * fails — failure is the default state, not an error path. Resolves `true`
1872
+ * if WASM is now available (not necessarily active every frame).
1873
+ */
1874
+ async enableWasmAnimBatching(source) {
1875
+ const isBytes = source instanceof ArrayBuffer || ArrayBuffer.isView(source);
1876
+ const backend = isBytes ? await instantiateAsync3(source) : await instantiateStreaming3(source);
1877
+ if (!backend) return false;
1878
+ this.setAnimBackend(backend);
1879
+ return true;
1880
+ }
1881
+ // ── WASM particle CPU-sim backend (invisible accelerator, G4) ───────────────
1882
+ // Advances a ComputeParticleEntity's whole buffer in one `particle_step` call
1883
+ // (spring/mouse/explosion/integrate/bounce/life), replacing the per-particle
1884
+ // JS `updateCPU` loop on the GPU-less fallback path. Measured ~2.1-2.5x on
1885
+ // Chrome and ~1.4-2.0x on Firefox including the per-frame AoS<->SoA transpose
1886
+ // (benchmarks/particle-wasm). f32 (matches the WGSL shader), bit-identical to
1887
+ // a JS f32 reference oracle; updateCPU (f64) stays the permanent fallback when
1888
+ // no backend is installed or a scene runs on WebGPU.
1889
+ _particleWasm = null;
1890
+ /** Which backend runs the CPU particle simulation. Reflects only whether a
1891
+ * backend is installed (the WebGPU compute path, when active, is used first
1892
+ * regardless). */
1893
+ get particleSimBackend() {
1894
+ return this._particleWasm ? "wasm" : "js";
1895
+ }
1896
+ /** Install (or clear) a WASM particle backend directly. Prefer
1897
+ * {@link enableWasmParticles} for the normal async hot-swap. */
1898
+ setParticleBackend(backend) {
1899
+ this._particleWasm = backend;
1900
+ }
1901
+ /**
1902
+ * Asynchronously instantiate the WASM particle core and, on success, use it
1903
+ * for the CPU particle fallback. Accepts the same source shapes as
1904
+ * {@link enableWasmTransforms}. Stays on the JS `updateCPU` path if
1905
+ * instantiation fails — failure is the default state, not an error path.
1906
+ * Resolves `true` if WASM is now active.
1907
+ */
1908
+ async enableWasmParticles(source) {
1909
+ const isBytes = source instanceof ArrayBuffer || ArrayBuffer.isView(source);
1910
+ const backend = isBytes ? await instantiateAsync4(source) : await instantiateStreaming4(source);
1911
+ if (!backend) return false;
1912
+ this.setParticleBackend(backend);
1913
+ return true;
1914
+ }
1915
+ /** Internal: called by `Entity._spawnDriver` when a new property driver
1916
+ * starts. See {@link _activeDriverEntities}. */
1917
+ _registerActiveDriverEntity(entity) {
1918
+ this._activeDriverEntities.add(entity);
1919
+ }
1920
+ /**
1921
+ * Drop `entity` and its whole subtree from the batched-driver candidate set.
1922
+ * Called by {@link remove}/{@link hideOverlay} on detach: without this a
1923
+ * removed-but-still-animating entity stays pinned in the Set (a leak) and its
1924
+ * drivers keep ticking every frame even though it is off-tree. If it is later
1925
+ * re-added, {@link registerActiveDriverSubtree} re-registers any node that
1926
+ * still has live drivers, so the motion resumes.
1927
+ */
1928
+ unregisterActiveDriverSubtree(entity) {
1929
+ if (this._activeDriverEntities.size === 0) return;
1930
+ const stack = [entity];
1931
+ while (stack.length > 0) {
1932
+ const node = stack.pop();
1933
+ this._activeDriverEntities.delete(node);
1934
+ for (const child of node.children) stack.push(child);
1935
+ }
1936
+ }
1937
+ /**
1938
+ * Re-register every node in `entity`'s subtree that still has live property
1939
+ * drivers. Called by {@link add}/{@link showOverlay} so re-attaching a subtree
1940
+ * that was removed mid-animation resumes its batched drivers (they were
1941
+ * dropped from the candidate set on removal, but the driver state still lives
1942
+ * on each entity).
1943
+ */
1944
+ registerActiveDriverSubtree(entity) {
1945
+ const stack = [entity];
1946
+ while (stack.length > 0) {
1947
+ const node = stack.pop();
1948
+ const entries = node._driverEntries();
1949
+ if (entries && entries.size > 0) this._activeDriverEntities.add(node);
1950
+ for (const child of node.children) stack.push(child);
1951
+ }
1952
+ }
1953
+ /**
1954
+ * Advance every registered entity's active drivers for this frame, batching
1955
+ * whichever are batchable (`SpringDriver`; `TweenDriver` with a named
1956
+ * easing) through one WASM call each when the driver-count gate is open, and
1957
+ * ticking the rest (a `TweenDriver` using a custom `EasingFn`) directly in
1958
+ * JS regardless of the gate. A "claimed" entity must have ALL its drivers
1959
+ * advanced here so it can be safely stamped `_driversTickedFrame` — leaving
1960
+ * one unclaimed would silently stall it, since `tickDrivers()` skips the
1961
+ * whole entity once stamped.
1962
+ *
1963
+ * Must run before ANY entity's `update()`/`tickDrivers()` this frame (see
1964
+ * the call site in {@link render}) — the same ordering constraint G1 Stage 4
1965
+ * discovered: a value this pass writes must be final before anything reads
1966
+ * it, including the JS-mode interleaved walk and the WASM-mode transform
1967
+ * pre-pass.
1968
+ */
1969
+ _tickBatchedDrivers(dt) {
1970
+ if (this._activeDriverEntities.size === 0) return;
1971
+ let batchable = 0;
1972
+ for (const entity of this._activeDriverEntities) {
1973
+ const entries = entity._driverEntries();
1974
+ if (!entries || entries.size === 0) {
1975
+ this._activeDriverEntities.delete(entity);
1976
+ continue;
1977
+ }
1978
+ for (const driver of entries.values()) {
1979
+ if (driver instanceof SpringDriver) batchable++;
1980
+ else if (driver instanceof TweenDriver && driver.wasmEasingId !== null) batchable++;
1981
+ }
1982
+ }
1983
+ const backend = this._animWasm;
1984
+ if (!backend || batchable < this.animDriverGateCount) return;
1985
+ const sE = this._springEntities;
1986
+ const sP = this._springProps;
1987
+ const sD = this._springDrivers;
1988
+ const tE = this._tweenEntities;
1989
+ const tP = this._tweenProps;
1990
+ const tD = this._tweenDrivers;
1991
+ let springCount = 0;
1992
+ let tweenCount = 0;
1993
+ for (const entity of this._activeDriverEntities) {
1994
+ const entries = entity._driverEntries();
1995
+ for (const [prop, driver] of entries) {
1996
+ if (driver instanceof SpringDriver) {
1997
+ sE[springCount] = entity;
1998
+ sP[springCount] = prop;
1999
+ sD[springCount] = driver;
2000
+ springCount++;
2001
+ } else if (driver instanceof TweenDriver && driver.wasmEasingId !== null) {
2002
+ tE[tweenCount] = entity;
2003
+ tP[tweenCount] = prop;
2004
+ tD[tweenCount] = driver;
2005
+ tweenCount++;
2006
+ } else {
2007
+ driver.tick(dt);
2008
+ entity._applyDriverTick(prop, driver);
2009
+ }
2010
+ }
2011
+ entity._driversTickedFrame = this.currentFrame;
2012
+ }
2013
+ sE.length = springCount;
2014
+ sP.length = springCount;
2015
+ sD.length = springCount;
2016
+ tE.length = tweenCount;
2017
+ tP.length = tweenCount;
2018
+ tD.length = tweenCount;
2019
+ backend.ensure(springCount, tweenCount);
2020
+ if (springCount > 0) {
2021
+ const sv = backend.springView();
2022
+ for (let i = 0; i < springCount; i++) {
2023
+ const phys = sD[i].physics;
2024
+ sv.val[i] = phys.value;
2025
+ sv.target[i] = phys.target;
2026
+ sv.vel[i] = phys.velocity;
2027
+ sv.stiff[i] = phys.stiffness;
2028
+ sv.damp[i] = phys.damping;
2029
+ sv.mass[i] = phys.mass;
2030
+ }
2031
+ backend.stepSprings(dt, springCount);
2032
+ for (let i = 0; i < springCount; i++) sD[i].syncExternal(sv.val[i], sv.vel[i]);
2033
+ }
2034
+ if (tweenCount > 0) {
2035
+ const tv = backend.tweenView();
2036
+ for (let i = 0; i < tweenCount; i++) {
2037
+ const d = tD[i];
2038
+ tv.from[i] = d.fromValue;
2039
+ tv.to[i] = d.target;
2040
+ tv.elapsed[i] = d.elapsedMs;
2041
+ tv.dur[i] = d.durationMs;
2042
+ tv.delay[i] = d.delayMs;
2043
+ tv.ease[i] = d.wasmEasingId;
2044
+ }
2045
+ backend.stepTweens(dt, tweenCount);
2046
+ for (let i = 0; i < tweenCount; i++) tD[i].syncExternal(tv.val[i], tv.elapsed[i]);
2047
+ }
2048
+ for (let i = 0; i < springCount; i++) sE[i]._applyDriverTick(sP[i], sD[i]);
2049
+ for (let i = 0; i < tweenCount; i++) tE[i]._applyDriverTick(tP[i], tD[i]);
2050
+ }
2051
+ /**
2052
+ * Compose the whole main tree's world matrices through the resident WASM store
2053
+ * and return the world-matrix views for the render walk to read. Rebuilds the
2054
+ * store layout (slots + runs) only when the tree structure changed since the
2055
+ * last rebuild; otherwise it just gathers current transforms into the resident
2056
+ * input view and runs the kernel. Returns `null` if there is no backend.
2057
+ */
2058
+ _syncWasmStore() {
2059
+ const backend = this._wasm;
2060
+ if (!backend) return null;
2061
+ if (this._treeStore === null || this._storeStructureVersion !== this._structureVersion) {
2062
+ const built = buildTreeStore(this.root);
2063
+ const slotEntity2 = Array.from({ length: built.store.count });
2064
+ for (const [entity, slot] of built.indexOf) {
2065
+ slotEntity2[slot] = entity;
2066
+ entity._storeSlot = slot;
2067
+ }
2068
+ backend.uploadRuns(built.store);
2069
+ this._treeStore = built.store;
2070
+ this._slotEntity = slotEntity2;
2071
+ this._wasmInputs = backend.inputView();
2072
+ this._wasmWorld = backend.worldView();
2073
+ this._storeStructureVersion = this._structureVersion;
2074
+ }
2075
+ const inp = this._wasmInputs;
2076
+ const slotEntity = this._slotEntity;
2077
+ for (let slot = 1; slot < slotEntity.length; slot++) {
2078
+ const e = slotEntity[slot];
2079
+ inp.x[slot] = e.x;
2080
+ inp.y[slot] = e.y;
2081
+ inp.sx[slot] = e.scaleX;
2082
+ inp.sy[slot] = e.scaleY;
2083
+ const trig = e._getTrig();
2084
+ inp.cos[slot] = trig.cos;
2085
+ inp.sin[slot] = trig.sin;
2086
+ inp.opacity[slot] = e.opacity;
2087
+ }
2088
+ backend.runKernel("simd");
2089
+ return this._wasmWorld;
2090
+ }
733
2091
  /**
734
2092
  * Authoritative paint order for semantic nodes discovered during the main
735
2093
  * render. A node may not have a DOM projection until the following a11y
@@ -740,6 +2098,8 @@ var Scene = class _Scene {
740
2098
  // Optional WebGL point-cloud layer (see SceneOptions.pointBackend).
741
2099
  pointRenderer = null;
742
2100
  glCanvas = null;
2101
+ glContextLostHandler = null;
2102
+ glContextRestoredHandler = null;
743
2103
  debugA11y;
744
2104
  width;
745
2105
  height;
@@ -769,6 +2129,9 @@ var Scene = class _Scene {
769
2129
  mouseY = -9999;
770
2130
  pointerMoveListener = null;
771
2131
  pointerLeaveListener = null;
2132
+ /** Element the pointer listeners are bound to (parent container if present,
2133
+ * else the canvas). Stored so `destroy()` detaches from the same element. */
2134
+ pointerEventTarget = null;
772
2135
  hasWarnedZeroSize = false;
773
2136
  fontLoadHandler = null;
774
2137
  // ── Dev-mode warning infrastructure ──────────────────────────────
@@ -855,8 +2218,14 @@ var Scene = class _Scene {
855
2218
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
856
2219
  this.contentProjectionEnabled = options.contentProjection ?? true;
857
2220
  this.contentProjectionMargin = options.contentProjectionMargin;
2221
+ this.readingDirection = options.readingDirection ?? "ltr";
858
2222
  this._devActive = _Scene._devModeDetected();
859
2223
  this.reducedMotionQuery = typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
2224
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
2225
+ this.forcedColorsQuery = window.matchMedia("(forced-colors: active)");
2226
+ this.forcedColorsChangeHandler = () => this.markDirty();
2227
+ this.forcedColorsQuery.addEventListener?.("change", this.forcedColorsChangeHandler);
2228
+ }
860
2229
  this.root = new class RootEntity extends Entity {
861
2230
  isPointInside() {
862
2231
  return false;
@@ -883,6 +2252,10 @@ var Scene = class _Scene {
883
2252
  this.maxDPR
884
2253
  );
885
2254
  }
2255
+ this.renderer.onContextRestored?.(() => {
2256
+ this.markDirty();
2257
+ if (this.renderer.isContextLost?.() !== true) this.render(this.renderer);
2258
+ });
886
2259
  if (typeof document !== "undefined") {
887
2260
  this.a11yRoot = document.createElement("div");
888
2261
  this.a11yRoot.style.position = "absolute";
@@ -894,6 +2267,15 @@ var Scene = class _Scene {
894
2267
  this.a11yRoot.style.overflow = "hidden";
895
2268
  this.a11yRoot.style.zIndex = "10";
896
2269
  this.a11yRoot.style.userSelect = "text";
2270
+ this.focusSentinel = document.createElement("div");
2271
+ this.focusSentinel.setAttribute("data-vecto-focus-sentinel", "");
2272
+ this.focusSentinel.tabIndex = -1;
2273
+ this.focusSentinel.style.position = "absolute";
2274
+ this.focusSentinel.style.width = "0";
2275
+ this.focusSentinel.style.height = "0";
2276
+ this.focusSentinel.style.outline = "none";
2277
+ this.focusSentinel.style.overflow = "hidden";
2278
+ this.a11yRoot.appendChild(this.focusSentinel);
897
2279
  this.a11yRoot.addEventListener("mousedown", (e) => {
898
2280
  if (e.button !== 0) return;
899
2281
  const target = e.target;
@@ -1010,6 +2392,8 @@ var Scene = class _Scene {
1010
2392
  pr.resize(this.width, this.height);
1011
2393
  this.glCanvas = gl;
1012
2394
  this.pointRenderer = pr;
2395
+ this._overlayGeometry = null;
2396
+ this.setupGLContextRecovery(gl);
1013
2397
  } else {
1014
2398
  gl.remove();
1015
2399
  }
@@ -1028,6 +2412,61 @@ var Scene = class _Scene {
1028
2412
  }
1029
2413
  this.setupEvents();
1030
2414
  }
2415
+ /**
2416
+ * Arm a `(resolution: Ndppx)` media query for the current devicePixelRatio and
2417
+ * re-apply the canvas scale when it changes. Such a query only fires when the
2418
+ * DPR leaves its exact value, so on each change the old query is detached and
2419
+ * a fresh one is armed for the new DPR. Re-runs `resize(width, height)` (which
2420
+ * re-scales the backing store via the renderer) so text/vectors stay crisp
2421
+ * after a monitor move or zoom. No-op without `matchMedia`.
2422
+ */
2423
+ watchDevicePixelRatio() {
2424
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
2425
+ if (this.dprMediaQuery && this.dprChangeHandler) {
2426
+ this.dprMediaQuery.removeEventListener?.("change", this.dprChangeHandler);
2427
+ }
2428
+ const dpr = window.devicePixelRatio || 1;
2429
+ const query = window.matchMedia(`(resolution: ${dpr}dppx)`);
2430
+ const handler = () => {
2431
+ this.resize(this.width, this.height);
2432
+ this.watchDevicePixelRatio();
2433
+ };
2434
+ query.addEventListener?.("change", handler);
2435
+ this.dprMediaQuery = query;
2436
+ this.dprChangeHandler = handler;
2437
+ }
2438
+ /**
2439
+ * Recover the WebGL point layer from a GPU context loss (driver TDR reset,
2440
+ * tab backgrounded on mobile, GPU switch). Two things are required:
2441
+ *
2442
+ * 1. The `webglcontextlost` handler MUST call `preventDefault()`, or the
2443
+ * browser never fires `webglcontextrestored` and the layer is blank
2444
+ * forever. While lost, the old renderer's GL calls are silently ignored,
2445
+ * so we drop it and the render loop simply skips the point layer.
2446
+ * 2. On `webglcontextrestored`, all GL objects (programs, buffers, textures)
2447
+ * are gone, so we rebuild the renderer from scratch via `Scene.webglCreator`
2448
+ * on the same canvas, restore DPR/size, and repaint.
2449
+ */
2450
+ setupGLContextRecovery(gl) {
2451
+ if (typeof gl.addEventListener !== "function") return;
2452
+ this.glContextLostHandler = (e) => {
2453
+ e.preventDefault();
2454
+ this.pointRenderer?.destroy();
2455
+ this.pointRenderer = null;
2456
+ };
2457
+ this.glContextRestoredHandler = () => {
2458
+ if (this.destroyed || !this.glCanvas) return;
2459
+ const pr = _Scene.webglCreator ? _Scene.webglCreator(this.glCanvas) : null;
2460
+ if (pr) {
2461
+ pr.maxDPR = this.maxDPR;
2462
+ pr.resize(this.width, this.height);
2463
+ this.pointRenderer = pr;
2464
+ this.markDirty();
2465
+ }
2466
+ };
2467
+ gl.addEventListener("webglcontextlost", this.glContextLostHandler);
2468
+ gl.addEventListener("webglcontextrestored", this.glContextRestoredHandler);
2469
+ }
1031
2470
  endContentSelectionDrag() {
1032
2471
  this.blankRegionSelectionDrag = false;
1033
2472
  this.contentSelectionAnchor = null;
@@ -1040,6 +2479,53 @@ var Scene = class _Scene {
1040
2479
  this.endContentSelectionDrag();
1041
2480
  selection?.removeAllRanges();
1042
2481
  }
2482
+ /**
2483
+ * Rebuild a content-projection element's DOM (`rebuild`) while preserving a
2484
+ * text selection the user made inside it. A streaming message replaces its
2485
+ * projection children on every appended chunk; without this, a selection in
2486
+ * the UNCHANGED prefix is wiped on each frame ("can't select text in a
2487
+ * message still receiving tokens"). We snapshot the selection's anchor/focus
2488
+ * as linear character offsets within `el` before the rebuild and re-resolve
2489
+ * them against the new DOM after, clamped to the new text length.
2490
+ *
2491
+ * Only fires when `el` owns the current selection and there is no active drag
2492
+ * (mid-drag the browser is authoritative). The virtualization case — where
2493
+ * `el` itself is removed from the DOM — is out of scope here (the node is
2494
+ * genuinely freed; the browser clears the selection and there is nothing to
2495
+ * restore against).
2496
+ */
2497
+ preserveContentSelectionAcrossRebuild(el, rebuild) {
2498
+ const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
2499
+ const owns = !!selection && !this.blankRegionSelectionDrag && ((selection.anchorNode ? el.contains(selection.anchorNode) : false) || (selection.focusNode ? el.contains(selection.focusNode) : false));
2500
+ if (!owns || !selection.anchorNode || !selection.focusNode) {
2501
+ this.releaseContentSelectionForRebuild(el);
2502
+ rebuild();
2503
+ return;
2504
+ }
2505
+ const anchorNode = selection.anchorNode;
2506
+ const focusNode = selection.focusNode;
2507
+ const anchorOffset = anchorNode instanceof Text ? projectionAbsoluteOffset(el, {
2508
+ node: anchorNode,
2509
+ offset: selection.anchorOffset
2510
+ }) : null;
2511
+ const focusOffset = focusNode instanceof Text ? projectionAbsoluteOffset(el, {
2512
+ node: focusNode,
2513
+ offset: selection.focusOffset
2514
+ }) : null;
2515
+ this.endContentSelectionDrag();
2516
+ selection.removeAllRanges();
2517
+ rebuild();
2518
+ if (anchorOffset === null || focusOffset === null) return;
2519
+ const textLen = (el.textContent ?? "").length;
2520
+ if (anchorOffset > textLen || focusOffset > textLen) return;
2521
+ const anchor = projectionCaretAt(el, anchorOffset, "forward");
2522
+ const focus = projectionCaretAt(el, focusOffset, "backward");
2523
+ if (!anchor || !focus) return;
2524
+ try {
2525
+ selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
2526
+ } catch {
2527
+ }
2528
+ }
1043
2529
  /**
1044
2530
  * Expose the underlying {@link IRenderer} for advanced direct-draw operations.
1045
2531
  *
@@ -1068,6 +2554,7 @@ var Scene = class _Scene {
1068
2554
  */
1069
2555
  add(entity) {
1070
2556
  this.root.add(entity);
2557
+ this.registerActiveDriverSubtree(entity);
1071
2558
  return this;
1072
2559
  }
1073
2560
  clearContentGridState(entityId, el) {
@@ -1090,6 +2577,7 @@ var Scene = class _Scene {
1090
2577
  }
1091
2578
  removeA11yRecursively(node) {
1092
2579
  if (node.isDOMPortal) {
2580
+ node.releaseDOMBindings();
1093
2581
  node.domElement.remove();
1094
2582
  this.portalEntities.delete(node.id);
1095
2583
  this.activePortalsThisFrame.delete(node.id);
@@ -1111,6 +2599,11 @@ var Scene = class _Scene {
1111
2599
  this.caretBlinkTimer = null;
1112
2600
  }
1113
2601
  }
2602
+ if (this.hoveredA11yElements.has(el)) {
2603
+ this.hoveredA11yElements.delete(el);
2604
+ node.dispatchEvent(new VectoJSEvent("pointerleave", node, void 0, false));
2605
+ }
2606
+ this.preserveFocusOnRemoval(el);
1114
2607
  el.remove();
1115
2608
  this.a11yElements.delete(node.id);
1116
2609
  this.a11yNeedsReorder = true;
@@ -1119,6 +2612,18 @@ var Scene = class _Scene {
1119
2612
  this.removeA11yRecursively(child);
1120
2613
  }
1121
2614
  }
2615
+ /**
2616
+ * If `el` is about to be removed from the DOM while it holds browser focus,
2617
+ * move focus to the a11y focus sentinel first. Removing the active element
2618
+ * otherwise drops focus to `<body>`, which pulls a screen reader out of the
2619
+ * scene's a11y region and back to the top of the page — the classic
2620
+ * "lost my place on scroll/stream" bug for virtualized/recycled controls.
2621
+ */
2622
+ preserveFocusOnRemoval(el) {
2623
+ if (!this.focusSentinel || typeof document === "undefined") return;
2624
+ if (document.activeElement !== el) return;
2625
+ this.focusSentinel.focus({ preventScroll: true });
2626
+ }
1122
2627
  /**
1123
2628
  * Remove a top-level entity from the scene graph and clean up its
1124
2629
  * accessibility shadow elements recursively.
@@ -1129,6 +2634,7 @@ var Scene = class _Scene {
1129
2634
  remove(entity) {
1130
2635
  this.root.remove(entity);
1131
2636
  this.removeA11yRecursively(entity);
2637
+ this.unregisterActiveDriverSubtree(entity);
1132
2638
  return this;
1133
2639
  }
1134
2640
  /**
@@ -1148,6 +2654,7 @@ var Scene = class _Scene {
1148
2654
  */
1149
2655
  showOverlay(overlay) {
1150
2656
  this.overlayRoot.add(overlay);
2657
+ this.registerActiveDriverSubtree(overlay);
1151
2658
  this.markDirty();
1152
2659
  }
1153
2660
  /**
@@ -1156,10 +2663,10 @@ var Scene = class _Scene {
1156
2663
  hideOverlay(overlay) {
1157
2664
  this.overlayRoot.remove(overlay);
1158
2665
  this.removeA11yRecursively(overlay);
2666
+ this.unregisterActiveDriverSubtree(overlay);
1159
2667
  this.markDirty();
1160
2668
  }
1161
2669
  destroyEntitySubtree(entity) {
1162
- while (entity.children.length > 0) this.destroyEntitySubtree(entity.children.at(-1));
1163
2670
  entity.destroy();
1164
2671
  }
1165
2672
  /**
@@ -1179,20 +2686,36 @@ var Scene = class _Scene {
1179
2686
  if (typeof window !== "undefined" && !this.disableWindowResize) {
1180
2687
  window.removeEventListener("resize", this.resizeHandler);
1181
2688
  }
2689
+ if (this.canvasResizeObserver) {
2690
+ this.canvasResizeObserver.disconnect();
2691
+ this.canvasResizeObserver = null;
2692
+ }
2693
+ if (this.dprMediaQuery && this.dprChangeHandler) {
2694
+ this.dprMediaQuery.removeEventListener?.("change", this.dprChangeHandler);
2695
+ this.dprMediaQuery = null;
2696
+ this.dprChangeHandler = null;
2697
+ }
2698
+ if (this.forcedColorsQuery && this.forcedColorsChangeHandler) {
2699
+ this.forcedColorsQuery.removeEventListener?.("change", this.forcedColorsChangeHandler);
2700
+ this.forcedColorsQuery = null;
2701
+ this.forcedColorsChangeHandler = null;
2702
+ }
1182
2703
  if (typeof window !== "undefined" && this.contentSelectionEndListener) {
1183
2704
  window.removeEventListener("mouseup", this.contentSelectionEndListener);
1184
2705
  window.removeEventListener("blur", this.contentSelectionEndListener);
1185
2706
  this.contentSelectionEndListener = null;
1186
2707
  }
1187
- if (typeof window !== "undefined" && this.canvas && typeof this.canvas.removeEventListener === "function") {
2708
+ if (typeof window !== "undefined" && this.pointerEventTarget && typeof this.pointerEventTarget.removeEventListener === "function") {
1188
2709
  if (this.pointerMoveListener) {
1189
- this.canvas.removeEventListener("pointermove", this.pointerMoveListener);
2710
+ this.pointerEventTarget.removeEventListener("pointermove", this.pointerMoveListener);
1190
2711
  }
1191
2712
  if (this.pointerLeaveListener) {
1192
- this.canvas.removeEventListener("pointerleave", this.pointerLeaveListener);
2713
+ this.pointerEventTarget.removeEventListener("pointerleave", this.pointerLeaveListener);
1193
2714
  }
2715
+ this.pointerEventTarget = null;
1194
2716
  }
1195
2717
  this.a11yRoot?.remove();
2718
+ this.focusSentinel = null;
1196
2719
  this.portalRoot?.remove();
1197
2720
  this.a11yElements.clear();
1198
2721
  for (const el of this.contentElements.values()) el.remove();
@@ -1206,6 +2729,16 @@ var Scene = class _Scene {
1206
2729
  for (const probe of this.contentGridCalibrationProbes.values()) probe.remove();
1207
2730
  this.contentGridCalibrationProbes.clear();
1208
2731
  this.endContentSelectionDrag();
2732
+ if (this.glCanvas) {
2733
+ if (this.glContextLostHandler) {
2734
+ this.glCanvas.removeEventListener("webglcontextlost", this.glContextLostHandler);
2735
+ }
2736
+ if (this.glContextRestoredHandler) {
2737
+ this.glCanvas.removeEventListener("webglcontextrestored", this.glContextRestoredHandler);
2738
+ }
2739
+ }
2740
+ this.glContextLostHandler = null;
2741
+ this.glContextRestoredHandler = null;
1209
2742
  this.pointRenderer?.destroy();
1210
2743
  this.renderer.dispose?.();
1211
2744
  this.glCanvas?.remove();
@@ -1228,7 +2761,20 @@ var Scene = class _Scene {
1228
2761
  setupEvents() {
1229
2762
  if (typeof window !== "undefined" && !this.disableWindowResize) {
1230
2763
  window.addEventListener("resize", this.resizeHandler);
2764
+ } else if (this.disableWindowResize && typeof ResizeObserver !== "undefined" && this.canvas && typeof this.canvas.getBoundingClientRect === "function") {
2765
+ this.canvasResizeObserver = new ResizeObserver((entries) => {
2766
+ const entry = entries[0];
2767
+ if (!entry) return;
2768
+ const box = entry.contentRect;
2769
+ const w = Math.round(box.width);
2770
+ const h = Math.round(box.height);
2771
+ if (w > 0 && h > 0 && (w !== this.width || h !== this.height)) {
2772
+ this.resize(w, h);
2773
+ }
2774
+ });
2775
+ this.canvasResizeObserver.observe(this.canvas);
1231
2776
  }
2777
+ this.watchDevicePixelRatio();
1232
2778
  if (typeof window !== "undefined" && this.canvas && typeof this.canvas.addEventListener === "function") {
1233
2779
  this.pointerMoveListener = (e) => {
1234
2780
  const point = this.clientToScene(e.clientX, e.clientY);
@@ -1239,8 +2785,9 @@ var Scene = class _Scene {
1239
2785
  this.mouseX = -9999;
1240
2786
  this.mouseY = -9999;
1241
2787
  };
1242
- this.canvas.addEventListener("pointermove", this.pointerMoveListener);
1243
- this.canvas.addEventListener("pointerleave", this.pointerLeaveListener);
2788
+ this.pointerEventTarget = this.canvas.parentElement ?? this.canvas;
2789
+ this.pointerEventTarget.addEventListener("pointermove", this.pointerMoveListener);
2790
+ this.pointerEventTarget.addEventListener("pointerleave", this.pointerLeaveListener);
1244
2791
  }
1245
2792
  }
1246
2793
  /**
@@ -1258,6 +2805,7 @@ var Scene = class _Scene {
1258
2805
  }
1259
2806
  this.isRunning = true;
1260
2807
  this.lastTime = typeof performance !== "undefined" ? performance.now() : 0;
2808
+ this.watchCanvasVisibility();
1261
2809
  this.scheduleFrame();
1262
2810
  const isTextFocused = this.focusedA11yElement instanceof HTMLInputElement || this.focusedA11yElement instanceof HTMLTextAreaElement;
1263
2811
  if (isTextFocused && this.renderMode === "onDemand" && !this.caretBlinkTimer) {
@@ -1272,6 +2820,29 @@ var Scene = class _Scene {
1272
2820
  requestAnimationFrame((t) => this.loop(t));
1273
2821
  }
1274
2822
  }
2823
+ /**
2824
+ * Observe whether the canvas is on-screen so the rAF loop can pause when it
2825
+ * scrolls fully out of view (a dashboard tab, a chart below the fold) and
2826
+ * resume when it returns — instead of running the full update/render every
2827
+ * frame for a scene nobody can see. No-op (stays "on screen") where
2828
+ * `IntersectionObserver` is unavailable, so SSR/jsdom behavior is unchanged.
2829
+ */
2830
+ watchCanvasVisibility() {
2831
+ if (this._canvasObserver || typeof IntersectionObserver === "undefined") return;
2832
+ if (!this.canvas || typeof this.canvas.getBoundingClientRect !== "function") return;
2833
+ this._canvasObserver = new IntersectionObserver((entries) => {
2834
+ const entry = entries[entries.length - 1];
2835
+ if (!entry) return;
2836
+ const nowOnScreen = entry.isIntersecting;
2837
+ const wasOffScreen = !this._canvasOnScreen;
2838
+ this._canvasOnScreen = nowOnScreen;
2839
+ if (nowOnScreen && wasOffScreen && this.isRunning) {
2840
+ this.lastTime = typeof performance !== "undefined" ? performance.now() : 0;
2841
+ this.scheduleFrame();
2842
+ }
2843
+ });
2844
+ this._canvasObserver.observe(this.canvas);
2845
+ }
1275
2846
  /**
1276
2847
  * Halt the render loop after the current frame completes.
1277
2848
  *
@@ -1283,6 +2854,11 @@ var Scene = class _Scene {
1283
2854
  clearInterval(this.caretBlinkTimer);
1284
2855
  this.caretBlinkTimer = null;
1285
2856
  }
2857
+ if (this._canvasObserver) {
2858
+ this._canvasObserver.disconnect();
2859
+ this._canvasObserver = null;
2860
+ }
2861
+ this._canvasOnScreen = true;
1286
2862
  }
1287
2863
  /**
1288
2864
  * Manually advance the scene clock by `dt` milliseconds and render synchronously.
@@ -1373,6 +2949,7 @@ var Scene = class _Scene {
1373
2949
  }
1374
2950
  }
1375
2951
  if (el.parentNode === this.a11yRoot) {
2952
+ this.preserveFocusOnRemoval(el);
1376
2953
  this.a11yRoot.removeChild(el);
1377
2954
  }
1378
2955
  this.a11yElements.delete(node.id);
@@ -1407,10 +2984,12 @@ var Scene = class _Scene {
1407
2984
  });
1408
2985
  el.addEventListener("mouseenter", (e) => {
1409
2986
  if (this.debugA11y) el.style.backgroundColor = "rgba(56, 189, 248, 0.2)";
2987
+ this.hoveredA11yElements.add(el);
1410
2988
  node.dispatchEvent(new VectoJSEvent("hover", node, e, false));
1411
2989
  });
1412
2990
  el.addEventListener("mouseleave", (e) => {
1413
2991
  if (this.debugA11y) el.style.backgroundColor = "rgba(56, 189, 248, 0.05)";
2992
+ this.hoveredA11yElements.delete(el);
1414
2993
  node.dispatchEvent(new VectoJSEvent("pointerleave", node, e, false));
1415
2994
  });
1416
2995
  const capEl = el;
@@ -1474,12 +3053,18 @@ var Scene = class _Scene {
1474
3053
  el.addEventListener("click", forward);
1475
3054
  el.addEventListener("select", forward);
1476
3055
  el.addEventListener("compositionstart", () => {
1477
- composition = { start: input.selectionStart ?? input.value.length, length: 0 };
3056
+ composition = {
3057
+ start: input.selectionStart ?? input.value.length,
3058
+ length: 0
3059
+ };
1478
3060
  forward();
1479
3061
  });
1480
3062
  el.addEventListener("compositionupdate", (e) => {
1481
3063
  const data = e.data ?? "";
1482
- composition = { start: composition?.start ?? 0, length: data.length };
3064
+ composition = {
3065
+ start: composition?.start ?? 0,
3066
+ length: data.length
3067
+ };
1483
3068
  forward();
1484
3069
  });
1485
3070
  el.addEventListener("compositionend", () => {
@@ -1593,6 +3178,31 @@ var Scene = class _Scene {
1593
3178
  this.syncOptionalAttribute(el, "aria-activedescendant", attrs.activedescendant);
1594
3179
  this.syncOptionalAttribute(el, "aria-valuemin", attrs.valuemin);
1595
3180
  this.syncOptionalAttribute(el, "aria-valuemax", attrs.valuemax);
3181
+ this.syncOptionalAttribute(el, "aria-live", attrs.live);
3182
+ this.syncOptionalAttribute(
3183
+ el,
3184
+ "aria-atomic",
3185
+ attrs.atomic === void 0 ? void 0 : String(attrs.atomic)
3186
+ );
3187
+ this.syncOptionalAttribute(el, "aria-relevant", attrs.relevant);
3188
+ this.syncOptionalAttribute(el, "aria-modal", attrs.ariaModal);
3189
+ this.syncOptionalAttribute(el, "aria-labelledby", attrs.labelledby);
3190
+ this.syncOptionalAttribute(el, "aria-describedby", attrs.describedby);
3191
+ this.syncOptionalAttribute(
3192
+ el,
3193
+ "aria-required",
3194
+ attrs.required === void 0 ? void 0 : String(attrs.required)
3195
+ );
3196
+ this.syncOptionalAttribute(
3197
+ el,
3198
+ "aria-invalid",
3199
+ attrs.invalid === void 0 ? void 0 : String(attrs.invalid)
3200
+ );
3201
+ this.syncOptionalAttribute(
3202
+ el,
3203
+ "aria-level",
3204
+ attrs.level === void 0 ? void 0 : String(attrs.level)
3205
+ );
1596
3206
  if (attrs.value !== void 0) {
1597
3207
  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
1598
3208
  if (el.value !== attrs.value) {
@@ -1624,13 +3234,18 @@ var Scene = class _Scene {
1624
3234
  el.style.width = `${this.width}px`;
1625
3235
  el.style.height = `${this.height}px`;
1626
3236
  el.style.transform = "";
3237
+ if (el.style.display === "none") el.style.display = "";
1627
3238
  } else {
1628
- const { a, b, c, d, e, f } = node.getWorldTransform();
3239
+ const worldTf = node.getWorldTransform();
3240
+ const { a, b, c, d, e, f } = worldTf;
1629
3241
  el.style.left = `${e + node.a11yOffsetX}px`;
1630
3242
  el.style.top = `${f + node.a11yOffsetY}px`;
1631
3243
  el.style.width = `${node.width}px`;
1632
3244
  el.style.height = `${node.height}px`;
1633
3245
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
3246
+ const visible = this.projectionBoxVisible(node, worldTf, 0);
3247
+ const display = visible ? "" : "none";
3248
+ if (el.style.display !== display) el.style.display = display;
1634
3249
  }
1635
3250
  }
1636
3251
  this.syncContentProjection(node);
@@ -1698,26 +3313,24 @@ var Scene = class _Scene {
1698
3313
  }
1699
3314
  syncContentProjection(node) {
1700
3315
  if (!this.contentProjectionEnabled || !this.a11yRoot) return;
1701
- const projection = node.getContentProjection();
1702
3316
  let el = this.contentElements.get(node.id);
1703
- if (!projection || !projection.text) {
3317
+ const releaseProjectionEl = () => {
1704
3318
  if (el) {
1705
3319
  this.clearContentGridState(node.id, el);
1706
3320
  el.remove();
1707
3321
  this.contentElements.delete(node.id);
1708
3322
  this.a11yNeedsReorder = true;
1709
3323
  }
1710
- return;
1711
- }
3324
+ };
1712
3325
  const worldTf = node.getWorldTransform();
1713
3326
  const margin = this.contentProjectionMargin ?? this.height;
1714
3327
  if (Number.isFinite(margin) && !this.projectionBoxVisible(node, worldTf, margin)) {
1715
- if (el) {
1716
- this.clearContentGridState(node.id, el);
1717
- el.remove();
1718
- this.contentElements.delete(node.id);
1719
- this.a11yNeedsReorder = true;
1720
- }
3328
+ releaseProjectionEl();
3329
+ return;
3330
+ }
3331
+ const projection = node.getContentProjection();
3332
+ if (!projection || !projection.text) {
3333
+ releaseProjectionEl();
1721
3334
  return;
1722
3335
  }
1723
3336
  if (!el) {
@@ -1757,35 +3370,48 @@ var Scene = class _Scene {
1757
3370
  fallbackLineHeight: projection.lineHeight ?? 16
1758
3371
  });
1759
3372
  if (el.dataset.vectoProjectionLines !== signature) {
1760
- this.releaseContentSelectionForRebuild(el);
1761
- el.replaceChildren();
1762
- for (let index = 0; index < lines.length; index++) {
1763
- const line = lines[index];
1764
- const lineElement = document.createElement("span");
1765
- const lineFont = line.font ?? projection.font ?? "";
1766
- const lineHeight2 = line.lineHeight ?? projection.lineHeight ?? 16;
1767
- lineElement.style.position = "absolute";
1768
- lineElement.dir = "auto";
1769
- lineElement.style.left = `${line.x}px`;
1770
- lineElement.style.top = `${line.y + line.baseline - cssLineBoxBaseline(lineFont, lineHeight2)}px`;
1771
- lineElement.style.whiteSpace = "pre";
1772
- if (lineFont) lineElement.style.font = lineFont;
1773
- lineElement.style.lineHeight = `${lineHeight2}px`;
1774
- const separator = line.separatorAfter ?? (index < lines.length - 1 ? "\n" : "");
1775
- if (line.runs && line.runs.length > 0) {
1776
- for (let runIndex = 0; runIndex < line.runs.length; runIndex++) {
1777
- const run = line.runs[runIndex];
1778
- const runElement = document.createElement("span");
1779
- runElement.textContent = run.text + (runIndex === line.runs.length - 1 ? separator : "");
1780
- if (run.font) runElement.style.font = run.font;
1781
- runElement.style.lineHeight = `${lineHeight2}px`;
1782
- lineElement.appendChild(runElement);
3373
+ this.preserveContentSelectionAcrossRebuild(el, () => {
3374
+ el.replaceChildren();
3375
+ for (let index = 0; index < lines.length; index++) {
3376
+ const line = lines[index];
3377
+ const lineElement = document.createElement("span");
3378
+ const lineFont = line.font ?? projection.font ?? "";
3379
+ const lineHeight2 = line.lineHeight ?? projection.lineHeight ?? 16;
3380
+ const hasPositionedRuns = !!line.runs && line.runs.some((run) => run.x !== void 0);
3381
+ lineElement.style.position = "absolute";
3382
+ lineElement.dir = hasPositionedRuns ? "ltr" : "auto";
3383
+ lineElement.style.left = `${line.x}px`;
3384
+ lineElement.style.top = `${line.y + line.baseline - cssLineBoxBaseline(lineFont, lineHeight2)}px`;
3385
+ lineElement.style.whiteSpace = "pre";
3386
+ if (lineFont) lineElement.style.font = lineFont;
3387
+ lineElement.style.lineHeight = `${lineHeight2}px`;
3388
+ const separator = line.separatorAfter ?? (index < lines.length - 1 ? "\n" : "");
3389
+ if (line.runs && line.runs.length > 0) {
3390
+ const positioned = line.runs.some((run) => run.x !== void 0);
3391
+ for (let runIndex = 0; runIndex < line.runs.length; runIndex++) {
3392
+ const run = line.runs[runIndex];
3393
+ const runElement = document.createElement("span");
3394
+ runElement.textContent = run.text + (runIndex === line.runs.length - 1 ? separator : "");
3395
+ if (run.font) runElement.style.font = run.font;
3396
+ runElement.style.lineHeight = `${lineHeight2}px`;
3397
+ if (positioned && run.x !== void 0) {
3398
+ runElement.style.position = "absolute";
3399
+ runElement.style.left = `${run.x - line.x}px`;
3400
+ runElement.style.top = "0";
3401
+ if (run.width !== void 0) runElement.style.width = `${run.width}px`;
3402
+ runElement.style.whiteSpace = "pre";
3403
+ runElement.style.verticalAlign = "top";
3404
+ runElement.style.unicodeBidi = "isolate";
3405
+ runElement.dir = "ltr";
3406
+ }
3407
+ lineElement.appendChild(runElement);
3408
+ }
3409
+ } else {
3410
+ lineElement.textContent = line.text + separator;
1783
3411
  }
1784
- } else {
1785
- lineElement.textContent = line.text + separator;
3412
+ el.appendChild(lineElement);
1786
3413
  }
1787
- el.appendChild(lineElement);
1788
- }
3414
+ });
1789
3415
  el.dataset.vectoProjectionLines = signature;
1790
3416
  }
1791
3417
  } else {
@@ -2008,7 +3634,12 @@ var Scene = class _Scene {
2008
3634
  const source = document.createTextNode(sourceText);
2009
3635
  carrier.appendChild(source);
2010
3636
  probe.appendChild(carrier);
2011
- const measurement = { targets: [target], targetWidth, sourceLength, source };
3637
+ const measurement = {
3638
+ targets: [target],
3639
+ targetWidth,
3640
+ sourceLength,
3641
+ source
3642
+ };
2012
3643
  measurements.push(measurement);
2013
3644
  measurementsByKey.set(measurementKey, measurement);
2014
3645
  }
@@ -2107,6 +3738,7 @@ var Scene = class _Scene {
2107
3738
  }
2108
3739
  }
2109
3740
  if (el.parentNode === this.a11yRoot) {
3741
+ this.preserveFocusOnRemoval(el);
2110
3742
  this.a11yRoot.removeChild(el);
2111
3743
  }
2112
3744
  this.a11yElements.delete(id);
@@ -2116,6 +3748,7 @@ var Scene = class _Scene {
2116
3748
  this.a11yNeedsReorder = true;
2117
3749
  }
2118
3750
  if (!this.a11yNeedsReorder) return;
3751
+ this.sortNormalElementsVisually();
2119
3752
  const fullLen = this.fullViewportElements.length;
2120
3753
  const normalLen = this.normalElements.length;
2121
3754
  const totalLen = fullLen + normalLen;
@@ -2128,6 +3761,49 @@ var Scene = class _Scene {
2128
3761
  }
2129
3762
  this.a11yNeedsReorder = false;
2130
3763
  }
3764
+ /**
3765
+ * Reorder `normalElements` (in place) into visual reading order using the
3766
+ * world positions `syncA11y` already wrote to each element's inline style
3767
+ * (`top`/`left`/`height`). Elements are grouped into rows top-to-bottom (an
3768
+ * element belongs to the current row while its top is above the row's
3769
+ * running bottom edge), then sorted within a row by `left` — ascending for
3770
+ * `'ltr'`, descending for `'rtl'`. The sort is stable, so entities at the
3771
+ * same position keep their scene-graph (collection) order as a tiebreak.
3772
+ */
3773
+ sortNormalElementsVisually() {
3774
+ const els = this.normalElements;
3775
+ if (els.length < 2) return;
3776
+ const rtl = this._readingDirection === "rtl";
3777
+ const topOf = (el) => Number.parseFloat(el.style.top) || 0;
3778
+ const leftOf = (el) => Number.parseFloat(el.style.left) || 0;
3779
+ const heightOf = (el) => Math.max(Number.parseFloat(el.style.height) || 0, 4);
3780
+ const order = els.map((el, i) => ({
3781
+ el,
3782
+ i,
3783
+ top: topOf(el),
3784
+ left: leftOf(el)
3785
+ }));
3786
+ order.sort((p, q) => p.top - q.top || p.i - q.i);
3787
+ const sorted = [];
3788
+ let rowStart = 0;
3789
+ let rowBottom = order.length ? order[0].top + heightOf(order[0].el) : 0;
3790
+ const flushRow = (end) => {
3791
+ const row = order.slice(rowStart, end);
3792
+ row.sort((p, q) => (rtl ? q.left - p.left : p.left - q.left) || p.i - q.i);
3793
+ for (const r of row) sorted.push(r.el);
3794
+ };
3795
+ for (let k = 1; k < order.length; k++) {
3796
+ if (order[k].top < rowBottom) {
3797
+ rowBottom = Math.max(rowBottom, order[k].top + heightOf(order[k].el));
3798
+ } else {
3799
+ flushRow(k);
3800
+ rowStart = k;
3801
+ rowBottom = order[k].top + heightOf(order[k].el);
3802
+ }
3803
+ }
3804
+ flushRow(order.length);
3805
+ for (let i = 0; i < sorted.length; i++) els[i] = sorted[i];
3806
+ }
2131
3807
  /** Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. */
2132
3808
  syncOverlayGeometry() {
2133
3809
  const parent = this.canvas.parentElement;
@@ -2140,6 +3816,18 @@ var Scene = class _Scene {
2140
3816
  const top = (canvasRect?.top ?? 0) - (parentRect?.top ?? 0) - (parent.clientTop || 0) + parent.scrollTop;
2141
3817
  const scaleX = this.width > 0 ? cssWidth / this.width : 1;
2142
3818
  const scaleY = this.height > 0 ? cssHeight / this.height : 1;
3819
+ const prev = this._overlayGeometry;
3820
+ if (prev !== null && prev.left === left && prev.top === top && prev.cssWidth === cssWidth && prev.cssHeight === cssHeight && prev.width === this.width && prev.height === this.height) {
3821
+ return;
3822
+ }
3823
+ this._overlayGeometry = {
3824
+ left,
3825
+ top,
3826
+ cssWidth,
3827
+ cssHeight,
3828
+ width: this.width,
3829
+ height: this.height
3830
+ };
2143
3831
  for (const root of [this.a11yRoot, this.portalRoot]) {
2144
3832
  if (!root) continue;
2145
3833
  root.style.left = `${left}px`;
@@ -2207,6 +3895,7 @@ var Scene = class _Scene {
2207
3895
  if (portal.domElement.parentElement !== this.portalRoot) {
2208
3896
  this.portalRoot.appendChild(portal.domElement);
2209
3897
  }
3898
+ portal.attachDOMBindings();
2210
3899
  if (!portal.domElement.hasAttribute("data-vecto-id")) {
2211
3900
  portal.domElement.setAttribute("data-vecto-id", portal.id);
2212
3901
  }
@@ -2247,6 +3936,7 @@ var Scene = class _Scene {
2247
3936
  const portal = this.portalEntities.get(oldId);
2248
3937
  if (portal) {
2249
3938
  if (portal.domElement.parentElement === this.portalRoot && (!portal.scene || portal.scene === this)) {
3939
+ portal.releaseDOMBindings();
2250
3940
  portal.domElement.remove();
2251
3941
  }
2252
3942
  this.portalEntities.delete(oldId);
@@ -2269,6 +3959,7 @@ var Scene = class _Scene {
2269
3959
  }
2270
3960
  loop(time) {
2271
3961
  if (!this.isRunning) return;
3962
+ if (!this._canvasOnScreen) return;
2272
3963
  let cap = this.effectiveMaxFPS();
2273
3964
  const isIdle = !this.dirty && !this.frameHadAnimation;
2274
3965
  if (isIdle && this.autoThrottle && this.renderMode === "always" && this.maxFPS > 0) {
@@ -2284,6 +3975,7 @@ var Scene = class _Scene {
2284
3975
  const nominal = 1e3 / cap;
2285
3976
  if (Math.abs(dt - nominal) < nominal * 0.3) dt = nominal;
2286
3977
  }
3978
+ if (dt > _Scene.MAX_FRAME_DT) dt = _Scene.MAX_FRAME_DT;
2287
3979
  this.lastTime = time;
2288
3980
  if (this.renderMode === "onDemand" && isIdle) {
2289
3981
  this._skippedFrames++;
@@ -2327,6 +4019,7 @@ var Scene = class _Scene {
2327
4019
  * @param time - Current absolute time in milliseconds (default 0).
2328
4020
  */
2329
4021
  render(renderer, dt = 0, time = 0) {
4022
+ if (renderer.isContextLost?.()) return;
2330
4023
  const isMainRenderer = renderer === this.renderer;
2331
4024
  if (isMainRenderer && this.a11yRoot && this.canvas.parentElement) {
2332
4025
  const parentStyle = this.canvas.parentElement.style;
@@ -2336,23 +4029,13 @@ var Scene = class _Scene {
2336
4029
  this.syncOverlayGeometry();
2337
4030
  }
2338
4031
  if (isMainRenderer) {
4032
+ this.currentFrame++;
2339
4033
  this.renderOrderCounter = 0;
2340
4034
  this.a11yRenderOrders.clear();
2341
4035
  this.activePortalsThisFrame.clear();
4036
+ this._tickBatchedDrivers(dt);
2342
4037
  }
2343
- const computeEntities = [];
2344
- const collectComputeEntities = (node) => {
2345
- if (node instanceof ComputeParticleEntity) {
2346
- computeEntities.push(node);
2347
- }
2348
- for (const child of node.children) {
2349
- collectComputeEntities(child);
2350
- }
2351
- };
2352
- collectComputeEntities(this.root);
2353
- for (const overlay of this.overlayRoot.children) {
2354
- collectComputeEntities(overlay);
2355
- }
4038
+ const computeEntities = this._computeEntitiesFor(this._structureVersion);
2356
4039
  if (computeEntities.length > 0) {
2357
4040
  const isMainRenderPath = renderer === this.renderer;
2358
4041
  if (isMainRenderPath && !this.device && !this.webgpuDisabled && !this.initializingWebGPU && !this.deviceLost) {
@@ -2450,7 +4133,11 @@ var Scene = class _Scene {
2450
4133
  my = -9999;
2451
4134
  }
2452
4135
  }
2453
- entity.updateCPU(dt / 1e3, mx, my, this.width, this.height);
4136
+ if (this._particleWasm) {
4137
+ entity.stepWithBackend(this._particleWasm, dt / 1e3, mx, my, this.width, this.height);
4138
+ } else {
4139
+ entity.updateCPU(dt / 1e3, mx, my, this.width, this.height);
4140
+ }
2454
4141
  }
2455
4142
  }
2456
4143
  } else if (isMainRenderer) {
@@ -2464,31 +4151,69 @@ var Scene = class _Scene {
2464
4151
  const vh = this.height;
2465
4152
  let walkHadAnimation = false;
2466
4153
  let walkHadInteractive = false;
2467
- const renderNode = (node, pa, pb, pc, pd, pe, pf, parentOpacity) => {
2468
- if (isMainRenderer) {
4154
+ const runUpdate = (node) => {
4155
+ const overridesUpdate = node.update !== Entity.prototype.update;
4156
+ let pending = node.hasPendingAnimations();
4157
+ if (pending || overridesUpdate) {
2469
4158
  node.update(dt, time);
2470
- if (!walkHadAnimation && node.hasPendingAnimations()) walkHadAnimation = true;
2471
- if (!walkHadInteractive && node.interactive) walkHadInteractive = true;
2472
- if (this._devActive && this._devFrameCount % 120 === 0) {
2473
- if (node.update !== Entity.prototype.update && node.hasPendingAnimations === Entity.prototype.hasPendingAnimations) {
2474
- this._devWarn(
2475
- `Entity "${node.id}" overrides update() but not hasPendingAnimations(). Custom motion in update() without overriding hasPendingAnimations() causes the idle throttle to drop the animation to ~2fps. Override hasPendingAnimations() to return true while motion is in flight.`
2476
- );
2477
- }
4159
+ pending = node.hasPendingAnimations();
4160
+ }
4161
+ if (pending) walkHadAnimation = true;
4162
+ if (!walkHadInteractive && node.interactive) walkHadInteractive = true;
4163
+ if (this._devActive && this._devFrameCount % 120 === 0) {
4164
+ if (overridesUpdate && node.hasPendingAnimations === Entity.prototype.hasPendingAnimations) {
4165
+ this._devWarn(
4166
+ `Entity "${node.id}" overrides update() but not hasPendingAnimations(). Custom motion in update() without overriding hasPendingAnimations() causes the idle throttle to drop the animation to ~2fps. Override hasPendingAnimations() to return true while motion is in flight.`
4167
+ );
2478
4168
  }
2479
4169
  }
2480
- const cos = Math.cos(node.rotation);
2481
- const sin = Math.sin(node.rotation);
2482
- const te = pa * node.x + pc * node.y + pe;
2483
- const tf = pb * node.x + pd * node.y + pf;
2484
- const sxCos = node.scaleX * cos;
2485
- const sxSin = node.scaleX * sin;
2486
- const syCos = node.scaleY * cos;
2487
- const sySin = node.scaleY * sin;
2488
- const a = pa * sxCos + pc * sySin;
2489
- const b = pb * sxCos + pd * sySin;
2490
- const c = pa * -sxSin + pc * syCos;
2491
- const d = pb * -sxSin + pd * syCos;
4170
+ };
4171
+ const wasmMain = isMainRenderer && this._wasm !== null && this._transformBackend === "wasm";
4172
+ if (wasmMain) {
4173
+ const updateWalk = (node) => {
4174
+ runUpdate(node);
4175
+ const kids = node.children;
4176
+ for (let i = 0; i < kids.length; i++) updateWalk(kids[i]);
4177
+ };
4178
+ updateWalk(this.root);
4179
+ for (const overlay of this.overlayRoot.children) updateWalk(overlay);
4180
+ }
4181
+ const wasmWorld = wasmMain ? this._syncWasmStore() : null;
4182
+ const wasmSlotEntity = this._slotEntity;
4183
+ const renderNode = (node, pa, pb, pc, pd, pe, pf, parentOpacity) => {
4184
+ if (isMainRenderer && !wasmMain) {
4185
+ runUpdate(node);
4186
+ }
4187
+ let a;
4188
+ let b;
4189
+ let c;
4190
+ let d;
4191
+ let te;
4192
+ let tf;
4193
+ const slot = node._storeSlot;
4194
+ if (wasmWorld !== null && slot >= 0 && wasmSlotEntity[slot] === node) {
4195
+ a = wasmWorld.wa[slot];
4196
+ b = wasmWorld.wb[slot];
4197
+ c = wasmWorld.wc[slot];
4198
+ d = wasmWorld.wd[slot];
4199
+ te = wasmWorld.we[slot];
4200
+ tf = wasmWorld.wf[slot];
4201
+ } else {
4202
+ const trig = node._getTrig();
4203
+ const cos = trig.cos;
4204
+ const sin = trig.sin;
4205
+ te = pa * node.x + pc * node.y + pe;
4206
+ tf = pb * node.x + pd * node.y + pf;
4207
+ const sxCos = node.scaleX * cos;
4208
+ const sxSin = node.scaleX * sin;
4209
+ const syCos = node.scaleY * cos;
4210
+ const sySin = node.scaleY * sin;
4211
+ a = pa * sxCos + pc * sySin;
4212
+ b = pb * sxCos + pd * sySin;
4213
+ c = pa * -sxSin + pc * syCos;
4214
+ d = pb * -sxSin + pd * syCos;
4215
+ }
4216
+ if (isMainRenderer) node._setWorldCache(a, b, c, d, te, tf, this.currentFrame);
2492
4217
  const worldScaleX = Math.hypot(a, b);
2493
4218
  const worldScaleY = Math.hypot(c, d);
2494
4219
  const worldOpacity = parentOpacity * node.opacity;
@@ -2637,12 +4362,25 @@ var Scene = class _Scene {
2637
4362
  this.pointRenderer.maxDPR = this.maxDPR;
2638
4363
  this.pointRenderer.resize(width, height);
2639
4364
  }
2640
- if (this.gpuCanvas) {
2641
- this.gpuCanvas.width = width;
2642
- this.gpuCanvas.height = height;
2643
- }
4365
+ if (this.gpuCanvas) this.sizeGpuCanvas(this.gpuCanvas, width, height);
2644
4366
  this.markDirty();
2645
4367
  }
4368
+ /** Effective device pixel ratio, matching CanvasRenderer: real DPR clamped to
4369
+ * `maxDPR` when set. */
4370
+ effectiveDPR() {
4371
+ const real = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
4372
+ return this.maxDPR !== void 0 ? Math.min(real, this.maxDPR) : real;
4373
+ }
4374
+ /** Size the WebGPU particle canvas: backing store at logical × DPR, CSS box at
4375
+ * the logical size. Sizing the backing store in logical px (the old
4376
+ * behavior) left it rasterized at 1× and CSS-stretched — blurry on HiDPI. */
4377
+ sizeGpuCanvas(gpuCanvas, width, height) {
4378
+ const dpr = this.effectiveDPR();
4379
+ gpuCanvas.width = Math.max(1, Math.round(width * dpr));
4380
+ gpuCanvas.height = Math.max(1, Math.round(height * dpr));
4381
+ gpuCanvas.style.width = `${width}px`;
4382
+ gpuCanvas.style.height = `${height}px`;
4383
+ }
2646
4384
  /**
2647
4385
  * Gets the accessibility DOM element projected for the given entity ID.
2648
4386
  */
@@ -2665,6 +4403,9 @@ var Scene = class _Scene {
2665
4403
  findEntityAt(x, y) {
2666
4404
  const overlayHit = this.findHitRecursively(this.overlayRoot, x, y);
2667
4405
  if (overlayHit) return overlayHit;
4406
+ if (this._hitWasm && this._ensureHitGrid()) {
4407
+ return this._findEntityAtWasm(x, y);
4408
+ }
2668
4409
  return this.findHitRecursively(this.root, x, y);
2669
4410
  }
2670
4411
  /** Submit one transparent clear pass when particle content lingers on the GPU canvas. */
@@ -2699,8 +4440,7 @@ var Scene = class _Scene {
2699
4440
  const device = await adapter.requestDevice();
2700
4441
  if (typeof document !== "undefined" && !this.gpuCanvas) {
2701
4442
  const gpuCanvas = document.createElement("canvas");
2702
- gpuCanvas.width = this.width;
2703
- gpuCanvas.height = this.height;
4443
+ this.sizeGpuCanvas(gpuCanvas, this.width, this.height);
2704
4444
  gpuCanvas.style.position = "absolute";
2705
4445
  gpuCanvas.style.top = "0";
2706
4446
  gpuCanvas.style.left = "0";
@@ -2710,6 +4450,7 @@ var Scene = class _Scene {
2710
4450
  this.canvas.parentElement.appendChild(gpuCanvas);
2711
4451
  }
2712
4452
  this.gpuCanvas = gpuCanvas;
4453
+ this._overlayGeometry = null;
2713
4454
  this.gpuContext = gpuCanvas.getContext("webgpu");
2714
4455
  }
2715
4456
  if (this.gpuContext) {
@@ -2806,17 +4547,68 @@ var Scene = class _Scene {
2806
4547
  }
2807
4548
  }
2808
4549
  }
2809
- findHitRecursively(node, x, y) {
4550
+ findHitRecursively(node, x, y, clip = null) {
4551
+ if (node.opacity <= 0) return null;
4552
+ let childClip = clip;
4553
+ if (node.clipChildren) {
4554
+ const box = node.getWorldBounds();
4555
+ childClip = clip ? intersectBounds(clip, box) : box;
4556
+ }
2810
4557
  for (let i = node.children.length - 1; i >= 0; i--) {
2811
- const hit = this.findHitRecursively(node.children[i], x, y);
4558
+ const hit = this.findHitRecursively(node.children[i], x, y, childClip);
2812
4559
  if (hit) return hit;
2813
4560
  }
2814
- if (node.isPointInside && node.isPointInside(x, y)) {
4561
+ if (node.isPointInside && node.isPointInside(x, y) && (!clip || pointInBounds(clip, x, y)) && !this.isPointerTransparent(node)) {
2815
4562
  return node;
2816
4563
  }
2817
4564
  return null;
2818
4565
  }
4566
+ /** Whether `node` opts out of being a pointer hit target: a disabled control
4567
+ * or an explicit `pointerEvents: 'none'` in its a11y attributes. Its children
4568
+ * are still walked (a transparent container can hold hittable descendants). */
4569
+ isPointerTransparent(node) {
4570
+ const attrs = node.getA11yAttributes();
4571
+ return attrs.disabled === true || attrs.pointerEvents === "none";
4572
+ }
4573
+ /**
4574
+ * Whether a confirmed geometric hit on `node` at world `(x, y)` is a REAL hit,
4575
+ * applying the same visibility/input gating as {@link findHitRecursively} but
4576
+ * from a flat candidate (the WASM grid has no recursion clip-stack): the node
4577
+ * and all ancestors are visible (`opacity > 0`), the point lies inside every
4578
+ * `clipChildren` ancestor's world box, and the node isn't pointer-transparent
4579
+ * (disabled / `pointerEvents: 'none'`). Keeps the WASM and JS hit paths in
4580
+ * lockstep so they return the same entity.
4581
+ */
4582
+ isHitEligible(node, x, y) {
4583
+ if (this.isPointerTransparent(node)) return false;
4584
+ if (node.opacity <= 0) return false;
4585
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
4586
+ if (ancestor.opacity <= 0) return false;
4587
+ if (ancestor.clipChildren && ancestor.width > 0 && ancestor.height > 0) {
4588
+ const local = ancestor.worldToLocal(x, y);
4589
+ if (!local || local.x < 0 || local.y < 0 || local.x > ancestor.width || local.y > ancestor.height) {
4590
+ return false;
4591
+ }
4592
+ }
4593
+ }
4594
+ return true;
4595
+ }
2819
4596
  };
4597
+ function intersectBounds(a, b) {
4598
+ const x = Math.max(a.x, b.x);
4599
+ const y = Math.max(a.y, b.y);
4600
+ const right = Math.min(a.x + a.width, b.x + b.width);
4601
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
4602
+ return {
4603
+ x,
4604
+ y,
4605
+ width: Math.max(0, right - x),
4606
+ height: Math.max(0, bottom - y)
4607
+ };
4608
+ }
4609
+ function pointInBounds(b, x, y) {
4610
+ return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
4611
+ }
2820
4612
 
2821
4613
  // src/components/TextEntity.ts
2822
4614
  import {
@@ -3456,6 +5248,7 @@ var DOMPortalEntity = class extends Entity {
3456
5248
  isDOMPortal = true;
3457
5249
  domListeners = [];
3458
5250
  resizeObserver = null;
5251
+ domBound = false;
3459
5252
  cachedWidth = 100;
3460
5253
  cachedHeight = 100;
3461
5254
  lastWidth = "";
@@ -3474,50 +5267,84 @@ var DOMPortalEntity = class extends Entity {
3474
5267
  this.domElement.style.pointerEvents = "auto";
3475
5268
  this.cachedWidth = parseFloat(domElement.style.width) || domElement.offsetWidth || 100;
3476
5269
  this.cachedHeight = parseFloat(domElement.style.height) || domElement.offsetHeight || 100;
3477
- if (typeof ResizeObserver !== "undefined") {
3478
- this.resizeObserver = new ResizeObserver((entries) => {
3479
- for (const entry of entries) {
3480
- this.cachedWidth = entry.contentRect.width || entry.target.offsetWidth;
3481
- this.cachedHeight = entry.contentRect.height || entry.target.offsetHeight;
3482
- }
3483
- });
3484
- this.resizeObserver.observe(this.domElement);
3485
- }
3486
- const events = [
3487
- "click",
3488
- "pointerdown",
3489
- "pointerup",
3490
- "pointercancel",
3491
- "pointermove",
3492
- "wheel"
3493
- ];
3494
- for (const type of events) {
3495
- const handler = (e) => {
3496
- this.dispatchEvent(new VectoJSEvent(type, this, e));
3497
- };
3498
- this.domElement.addEventListener(type, handler);
3499
- this.domListeners.push({ type, handler, capture: false });
3500
- }
3501
- const hoverEvents = [
3502
- { native: "mouseenter", vecto: "hover" },
3503
- { native: "mouseleave", vecto: "pointerleave" }
3504
- ];
3505
- for (const { native, vecto } of hoverEvents) {
3506
- const handler = (e) => {
3507
- this.dispatchEvent(new VectoJSEvent(vecto, this, e, false));
3508
- };
3509
- this.domElement.addEventListener(native, handler);
3510
- this.domListeners.push({ type: native, handler, capture: false });
3511
- }
3512
- const focusEvents = ["focus", "blur"];
3513
- for (const type of focusEvents) {
3514
- const handler = (e) => {
3515
- this.dispatchEvent(new VectoJSEvent(type, this, e, true));
3516
- };
3517
- this.domElement.addEventListener(type, handler, true);
3518
- this.domListeners.push({ type, handler, capture: true });
5270
+ this.attachDOMBindings();
5271
+ }
5272
+ }
5273
+ /**
5274
+ * Attach the ResizeObserver + DOM event listeners that bridge native DOM
5275
+ * events into the Vecto event system. Idempotent: safe to call every frame
5276
+ * from the projection path (`Scene.syncPortalGeometry`), which is what lets a
5277
+ * portal survive a `scene.remove()` -> re-add cycle — {@link releaseDOMBindings}
5278
+ * tears these down on removal and the next projection re-attaches them.
5279
+ */
5280
+ attachDOMBindings() {
5281
+ if (this.domBound || typeof window === "undefined") return;
5282
+ this.domBound = true;
5283
+ if (typeof ResizeObserver !== "undefined") {
5284
+ this.resizeObserver = new ResizeObserver((entries) => {
5285
+ for (const entry of entries) {
5286
+ this.cachedWidth = entry.contentRect.width || entry.target.offsetWidth;
5287
+ this.cachedHeight = entry.contentRect.height || entry.target.offsetHeight;
5288
+ }
5289
+ });
5290
+ this.resizeObserver.observe(this.domElement);
5291
+ }
5292
+ const events = [
5293
+ "click",
5294
+ "pointerdown",
5295
+ "pointerup",
5296
+ "pointercancel",
5297
+ "pointermove",
5298
+ "wheel"
5299
+ ];
5300
+ for (const type of events) {
5301
+ const handler = (e) => {
5302
+ this.dispatchEvent(new VectoJSEvent(type, this, e));
5303
+ };
5304
+ this.domElement.addEventListener(type, handler);
5305
+ this.domListeners.push({ type, handler, capture: false });
5306
+ }
5307
+ const hoverEvents = [
5308
+ { native: "mouseenter", vecto: "hover" },
5309
+ { native: "mouseleave", vecto: "pointerleave" }
5310
+ ];
5311
+ for (const { native, vecto } of hoverEvents) {
5312
+ const handler = (e) => {
5313
+ this.dispatchEvent(new VectoJSEvent(vecto, this, e, false));
5314
+ };
5315
+ this.domElement.addEventListener(native, handler);
5316
+ this.domListeners.push({ type: native, handler, capture: false });
5317
+ }
5318
+ const focusEvents = ["focus", "blur"];
5319
+ for (const type of focusEvents) {
5320
+ const handler = (e) => {
5321
+ this.dispatchEvent(new VectoJSEvent(type, this, e, true));
5322
+ };
5323
+ this.domElement.addEventListener(type, handler, true);
5324
+ this.domListeners.push({ type, handler, capture: true });
5325
+ }
5326
+ }
5327
+ /**
5328
+ * Disconnect the ResizeObserver and remove the DOM event listeners, without
5329
+ * touching the scene graph or the element's DOM parentage. Called on the
5330
+ * `scene.remove()` path so a detached portal doesn't leak an observer that
5331
+ * keeps its element alive and firing; the element itself is removed from the
5332
+ * document by the scene's portal pruning. Re-attached lazily on the next
5333
+ * projection frame via {@link attachDOMBindings}.
5334
+ */
5335
+ releaseDOMBindings() {
5336
+ if (!this.domBound) return;
5337
+ this.domBound = false;
5338
+ if (this.resizeObserver) {
5339
+ this.resizeObserver.disconnect();
5340
+ this.resizeObserver = null;
5341
+ }
5342
+ if (this.domElement) {
5343
+ for (const { type, handler, capture } of this.domListeners) {
5344
+ this.domElement.removeEventListener(type, handler, capture);
3519
5345
  }
3520
5346
  }
5347
+ this.domListeners = [];
3521
5348
  }
3522
5349
  isPointInside(globalX, globalY) {
3523
5350
  const w = this.width > 0 ? this.width : this.cachedWidth;
@@ -3533,18 +5360,9 @@ var DOMPortalEntity = class extends Entity {
3533
5360
  render() {
3534
5361
  }
3535
5362
  destroy() {
3536
- if (typeof window !== "undefined") {
3537
- if (this.resizeObserver) {
3538
- this.resizeObserver.disconnect();
3539
- this.resizeObserver = null;
3540
- }
3541
- if (this.domElement) {
3542
- for (const { type, handler, capture } of this.domListeners) {
3543
- this.domElement.removeEventListener(type, handler, capture);
3544
- }
3545
- this.domListeners = [];
3546
- this.domElement.remove();
3547
- }
5363
+ this.releaseDOMBindings();
5364
+ if (typeof window !== "undefined" && this.domElement) {
5365
+ this.domElement.remove();
3548
5366
  }
3549
5367
  super.destroy();
3550
5368
  }