@vectojs/core 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  isSafeUrl,
9
9
  parseColorToRGBA,
10
10
  sanitizeUrl
11
- } from "./chunk-UPULSLKA.mjs";
11
+ } from "./chunk-ME4LB2HB.mjs";
12
12
  import {
13
13
  Entity,
14
14
  MSDFTextEntity,
@@ -307,12 +307,16 @@ var ComputeParticleEntity = class extends Entity {
307
307
  * The backend holds one resident SoA store, so a Scene with multiple particle
308
308
  * entities reuses it sequentially — origin is therefore re-gathered each call
309
309
  * (not upload-once), a couple of extra f32 reads per particle.
310
+ *
311
+ * Returns `false` if the kernel declined the call and {@link updateCPU} ran
312
+ * instead, so the Scene can report which path actually simulated this frame
313
+ * rather than assuming an installed backend did the work.
310
314
  */
311
315
  stepWithBackend(backend, dt, mouseX, mouseY, width, height) {
312
316
  const count = this.maxParticles;
313
317
  backend.ensure(count);
314
318
  backend.gather(this.particleData, count, true);
315
- this._wasmPending = backend.step(count, {
319
+ const pending = backend.step(count, {
316
320
  dt,
317
321
  mouseX,
318
322
  mouseY,
@@ -324,8 +328,14 @@ var ComputeParticleEntity = class extends Entity {
324
328
  maxVelocity: this.maxVelocity,
325
329
  explosion: this.pendingExplosion
326
330
  });
331
+ if (pending === null) {
332
+ this.updateCPU(dt, mouseX, mouseY, width, height);
333
+ return false;
334
+ }
335
+ this._wasmPending = pending;
327
336
  backend.scatter(this.particleData, count);
328
337
  this.pendingExplosion = null;
338
+ return true;
329
339
  }
330
340
  destroy() {
331
341
  this.destroyGPUResources();
@@ -469,148 +479,6 @@ function buildTreeStore(root) {
469
479
  return { store, indexOf };
470
480
  }
471
481
 
472
- // src/wasm/hit-store.ts
473
- function gatherHitAABBs(root, currentFrame) {
474
- const slotEntity = [];
475
- const boundless = [];
476
- const minxs = [];
477
- const minys = [];
478
- const maxxs = [];
479
- const maxys = [];
480
- const scratch = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
481
- const visit = (node) => {
482
- const index = slotEntity.length;
483
- slotEntity.push(node);
484
- const bounds = node.getBounds();
485
- if (bounds === null) {
486
- boundless.push({ entity: node, index });
487
- minxs.push(0);
488
- minys.push(0);
489
- maxxs.push(0);
490
- maxys.push(0);
491
- } else {
492
- if (!node._readWorldCache(currentFrame, scratch)) {
493
- const t = node.getWorldTransform();
494
- scratch.a = t.a;
495
- scratch.b = t.b;
496
- scratch.c = t.c;
497
- scratch.d = t.d;
498
- scratch.e = t.e;
499
- scratch.f = t.f;
500
- }
501
- const { a, b, c, d, e, f } = scratch;
502
- let minX = Infinity;
503
- let minY = Infinity;
504
- let maxX = -Infinity;
505
- let maxY = -Infinity;
506
- for (let i = 0; i < 4; i++) {
507
- const lx = i & 1 ? bounds.x + bounds.width : bounds.x;
508
- const ly = i & 2 ? bounds.y + bounds.height : bounds.y;
509
- const wx = a * lx + c * ly + e;
510
- const wy = b * lx + d * ly + f;
511
- if (wx < minX) minX = wx;
512
- if (wx > maxX) maxX = wx;
513
- if (wy < minY) minY = wy;
514
- if (wy > maxY) maxY = wy;
515
- }
516
- minxs.push(minX);
517
- minys.push(minY);
518
- maxxs.push(maxX);
519
- maxys.push(maxY);
520
- }
521
- const kids = node.children;
522
- for (let i = 0; i < kids.length; i++) visit(kids[i]);
523
- };
524
- visit(root);
525
- return {
526
- count: slotEntity.length,
527
- slotEntity,
528
- boundless,
529
- minx: Float64Array.from(minxs),
530
- miny: Float64Array.from(minys),
531
- maxx: Float64Array.from(maxxs),
532
- maxy: Float64Array.from(maxys)
533
- };
534
- }
535
-
536
- // src/wasm/hit-store-fused.ts
537
- function gatherHitAABBsFromStore(root, aabbs, storeSlotEntity, out) {
538
- const slotEntity = out.slotEntity;
539
- const boundless = out.boundless;
540
- slotEntity.length = 0;
541
- boundless.length = 0;
542
- let count = 0;
543
- let capacity = out.minx.length;
544
- let minx = out.minx;
545
- let miny = out.miny;
546
- let maxx = out.maxx;
547
- let maxy = out.maxy;
548
- const grow = (needed) => {
549
- if (needed <= capacity) return;
550
- let next = capacity || 256;
551
- while (next < needed) next *= 2;
552
- const nminx = new Float64Array(next);
553
- const nminy = new Float64Array(next);
554
- const nmaxx = new Float64Array(next);
555
- const nmaxy = new Float64Array(next);
556
- nminx.set(minx);
557
- nminy.set(miny);
558
- nmaxx.set(maxx);
559
- nmaxy.set(maxy);
560
- minx = nminx;
561
- miny = nminy;
562
- maxx = nmaxx;
563
- maxy = nmaxy;
564
- capacity = next;
565
- };
566
- let bailed = false;
567
- const visit = (node) => {
568
- if (bailed) return;
569
- const index = count;
570
- slotEntity.push(node);
571
- count++;
572
- grow(count);
573
- if (node.getBounds() === null) {
574
- boundless.push({ entity: node, index });
575
- minx[index] = 0;
576
- miny[index] = 0;
577
- maxx[index] = 0;
578
- maxy[index] = 0;
579
- } else {
580
- const slot = node._storeSlot;
581
- if (slot < 0 || slot >= aabbs.aminx.length || storeSlotEntity[slot] !== node) {
582
- bailed = true;
583
- return;
584
- }
585
- minx[index] = aabbs.aminx[slot];
586
- miny[index] = aabbs.aminy[slot];
587
- maxx[index] = aabbs.amaxx[slot];
588
- maxy[index] = aabbs.amaxy[slot];
589
- }
590
- const children = node.children;
591
- for (let i = 0; i < children.length; i++) visit(children[i]);
592
- };
593
- visit(root);
594
- if (bailed) return null;
595
- out.count = count;
596
- out.minx = minx;
597
- out.miny = miny;
598
- out.maxx = maxx;
599
- out.maxy = maxy;
600
- return out;
601
- }
602
- function createHitGatherBuffer() {
603
- return {
604
- count: 0,
605
- slotEntity: [],
606
- boundless: [],
607
- minx: new Float64Array(256),
608
- miny: new Float64Array(256),
609
- maxx: new Float64Array(256),
610
- maxy: new Float64Array(256)
611
- };
612
- }
613
-
614
482
  // src/wasm/backend.ts
615
483
  var WASM_STATUS = {
616
484
  OK: 0,
@@ -703,7 +571,10 @@ var WasmTransformBackend = class {
703
571
  return status;
704
572
  }
705
573
  /** Upload only the run table + count (topology), leaving per-entity inputs to
706
- * the resident views. Call when the tree structure changes, not per frame. */
574
+ * the resident views. Call when the tree structure changes, not per frame.
575
+ * Returns `false` if the crate rejected the run count, in which case the
576
+ * PREVIOUS topology is still published and any kernel run against it would
577
+ * compose the wrong tree — the caller must not proceed. */
707
578
  uploadRuns(store) {
708
579
  this.ensure(store.count, store.runCount);
709
580
  const rc = store.runCount;
@@ -711,6 +582,7 @@ var WasmTransformBackend = class {
711
582
  this.vrs.set(store.runStart.subarray(0, rc));
712
583
  this.vrl.set(store.runLen.subarray(0, rc));
713
584
  this.lastStatus = this.ex.set_run_count(rc);
585
+ return this.lastStatus === WASM_STATUS.OK;
714
586
  }
715
587
  /**
716
588
  * Status of the most recent kernel or run-table call. `WASM_STATUS.OK` unless
@@ -759,7 +631,8 @@ var WasmTransformBackend = class {
759
631
  this.vby.set(store.by.subarray(0, n));
760
632
  this.vbw.set(store.bw.subarray(0, n));
761
633
  this.vbh.set(store.bh.subarray(0, n));
762
- this.ex.compute_aabbs(n);
634
+ this.lastStatus = this.ex.compute_aabbs(n);
635
+ if (this.lastStatus !== WASM_STATUS.OK) return;
763
636
  store.aminx.set(this.vaminx.subarray(0, n));
764
637
  store.aminy.set(this.vaminy.subarray(0, n));
765
638
  store.amaxx.set(this.vamaxx.subarray(0, n));
@@ -767,9 +640,12 @@ var WasmTransformBackend = class {
767
640
  }
768
641
  /** Run the AABB pass only, over `count` entities already resident in wasm
769
642
  * memory (bounds written via {@link boundsView}, world matrices already
770
- * composed). No upload/readback — the per-frame resident path. */
643
+ * composed). No upload/readback — the per-frame resident path. Returns
644
+ * `false` if the kernel rejected `count` (beyond capacity, or uninitialized),
645
+ * in which case {@link aabbView} still holds the previous frame's bounds. */
771
646
  runAabbs(count) {
772
- this.ex.compute_aabbs(count);
647
+ this.lastStatus = this.ex.compute_aabbs(count);
648
+ return this.lastStatus === WASM_STATUS.OK;
773
649
  }
774
650
  /** Resident wasm local-bounds input views (`bx,by,bw,bh`) for the AABB pass. */
775
651
  boundsView() {
@@ -843,6 +719,148 @@ var WasmTransformBackend = class {
843
719
  }
844
720
  };
845
721
 
722
+ // src/wasm/hit-store.ts
723
+ function gatherHitAABBs(root, currentFrame) {
724
+ const slotEntity = [];
725
+ const boundless = [];
726
+ const minxs = [];
727
+ const minys = [];
728
+ const maxxs = [];
729
+ const maxys = [];
730
+ const scratch = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
731
+ const visit = (node) => {
732
+ const index = slotEntity.length;
733
+ slotEntity.push(node);
734
+ const bounds = node.getBounds();
735
+ if (bounds === null) {
736
+ boundless.push({ entity: node, index });
737
+ minxs.push(0);
738
+ minys.push(0);
739
+ maxxs.push(0);
740
+ maxys.push(0);
741
+ } else {
742
+ if (!node._readWorldCache(currentFrame, scratch)) {
743
+ const t = node.getWorldTransform();
744
+ scratch.a = t.a;
745
+ scratch.b = t.b;
746
+ scratch.c = t.c;
747
+ scratch.d = t.d;
748
+ scratch.e = t.e;
749
+ scratch.f = t.f;
750
+ }
751
+ const { a, b, c, d, e, f } = scratch;
752
+ let minX = Infinity;
753
+ let minY = Infinity;
754
+ let maxX = -Infinity;
755
+ let maxY = -Infinity;
756
+ for (let i = 0; i < 4; i++) {
757
+ const lx = i & 1 ? bounds.x + bounds.width : bounds.x;
758
+ const ly = i & 2 ? bounds.y + bounds.height : bounds.y;
759
+ const wx = a * lx + c * ly + e;
760
+ const wy = b * lx + d * ly + f;
761
+ if (wx < minX) minX = wx;
762
+ if (wx > maxX) maxX = wx;
763
+ if (wy < minY) minY = wy;
764
+ if (wy > maxY) maxY = wy;
765
+ }
766
+ minxs.push(minX);
767
+ minys.push(minY);
768
+ maxxs.push(maxX);
769
+ maxys.push(maxY);
770
+ }
771
+ const kids = node.children;
772
+ for (let i = 0; i < kids.length; i++) visit(kids[i]);
773
+ };
774
+ visit(root);
775
+ return {
776
+ count: slotEntity.length,
777
+ slotEntity,
778
+ boundless,
779
+ minx: Float64Array.from(minxs),
780
+ miny: Float64Array.from(minys),
781
+ maxx: Float64Array.from(maxxs),
782
+ maxy: Float64Array.from(maxys)
783
+ };
784
+ }
785
+
786
+ // src/wasm/hit-store-fused.ts
787
+ function gatherHitAABBsFromStore(root, aabbs, storeSlotEntity, out) {
788
+ const slotEntity = out.slotEntity;
789
+ const boundless = out.boundless;
790
+ slotEntity.length = 0;
791
+ boundless.length = 0;
792
+ let count = 0;
793
+ let capacity = out.minx.length;
794
+ let minx = out.minx;
795
+ let miny = out.miny;
796
+ let maxx = out.maxx;
797
+ let maxy = out.maxy;
798
+ const grow = (needed) => {
799
+ if (needed <= capacity) return;
800
+ let next = capacity || 256;
801
+ while (next < needed) next *= 2;
802
+ const nminx = new Float64Array(next);
803
+ const nminy = new Float64Array(next);
804
+ const nmaxx = new Float64Array(next);
805
+ const nmaxy = new Float64Array(next);
806
+ nminx.set(minx);
807
+ nminy.set(miny);
808
+ nmaxx.set(maxx);
809
+ nmaxy.set(maxy);
810
+ minx = nminx;
811
+ miny = nminy;
812
+ maxx = nmaxx;
813
+ maxy = nmaxy;
814
+ capacity = next;
815
+ };
816
+ let bailed = false;
817
+ const visit = (node) => {
818
+ if (bailed) return;
819
+ const index = count;
820
+ slotEntity.push(node);
821
+ count++;
822
+ grow(count);
823
+ if (node.getBounds() === null) {
824
+ boundless.push({ entity: node, index });
825
+ minx[index] = 0;
826
+ miny[index] = 0;
827
+ maxx[index] = 0;
828
+ maxy[index] = 0;
829
+ } else {
830
+ const slot = node._storeSlot;
831
+ if (slot < 0 || slot >= aabbs.aminx.length || storeSlotEntity[slot] !== node) {
832
+ bailed = true;
833
+ return;
834
+ }
835
+ minx[index] = aabbs.aminx[slot];
836
+ miny[index] = aabbs.aminy[slot];
837
+ maxx[index] = aabbs.amaxx[slot];
838
+ maxy[index] = aabbs.amaxy[slot];
839
+ }
840
+ const children = node.children;
841
+ for (let i = 0; i < children.length; i++) visit(children[i]);
842
+ };
843
+ visit(root);
844
+ if (bailed) return null;
845
+ out.count = count;
846
+ out.minx = minx;
847
+ out.miny = miny;
848
+ out.maxx = maxx;
849
+ out.maxy = maxy;
850
+ return out;
851
+ }
852
+ function createHitGatherBuffer() {
853
+ return {
854
+ count: 0,
855
+ slotEntity: [],
856
+ boundless: [],
857
+ minx: new Float64Array(256),
858
+ miny: new Float64Array(256),
859
+ maxx: new Float64Array(256),
860
+ maxy: new Float64Array(256)
861
+ };
862
+ }
863
+
846
864
  // src/wasm/anim-backend.ts
847
865
  var PAD3 = 8;
848
866
  var AnimBackend = class {
@@ -877,14 +895,33 @@ var AnimBackend = class {
877
895
  this.ex.anim_init(this.springCap, this.tweenCap);
878
896
  this.refreshViews();
879
897
  }
880
- /** Advance `count` springs (from index 0) by `dtMs` milliseconds, in place. */
898
+ /**
899
+ * Advance `count` springs (from index 0) by `dtMs` milliseconds, in place.
900
+ * Returns `true` when the kernel ran. `false` means it rejected the call
901
+ * (count beyond the capacity {@link ensure} allocated, or no `anim_init` yet)
902
+ * and wrote nothing, so the caller must tick those drivers in JS instead of
903
+ * scattering back a pack the kernel never touched. See {@link lastStatus}.
904
+ */
881
905
  stepSprings(dtMs, count) {
882
- this.ex.spring_step(dtMs / 1e3, count);
906
+ this.lastStatus = this.ex.spring_step(dtMs / 1e3, count);
907
+ return this.lastStatus === WASM_STATUS.OK;
883
908
  }
884
- /** Advance `count` tweens (from index 0) by `dtMs` milliseconds, writing `val`. */
909
+ /**
910
+ * Advance `count` tweens (from index 0) by `dtMs` milliseconds, writing `val`.
911
+ * Returns `true` when the kernel ran; `false` means it rejected the call and
912
+ * wrote nothing (see {@link stepSprings}). `elapsed` is kernel-side state, so
913
+ * a rejected tween pack must not be read back — it is unadvanced, not
914
+ * partially advanced.
915
+ */
885
916
  stepTweens(dtMs, count) {
886
- this.ex.tween_step(dtMs, count);
917
+ this.lastStatus = this.ex.tween_step(dtMs, count);
918
+ return this.lastStatus === WASM_STATUS.OK;
887
919
  }
920
+ /**
921
+ * Status of the most recent kernel call — `WASM_STATUS.OK` unless the kernel
922
+ * declined it. Mirrors {@link TransformBackend.lastStatus}.
923
+ */
924
+ lastStatus = WASM_STATUS.OK;
888
925
  refreshViews() {
889
926
  const buf = this.ex.memory.buffer;
890
927
  const sCap = this.springCap;
@@ -1035,6 +1072,12 @@ var ParticleBackend = class {
1035
1072
  * Advance `count` particles one step in place. Returns `true` when at least
1036
1073
  * one live particle is still moving or off-origin beyond epsilon (the fused
1037
1074
  * `hasPendingAnimations` flag), so the caller need not re-scan the buffer.
1075
+ *
1076
+ * Returns `null` when the kernel REJECTED the call — `count` beyond the
1077
+ * capacity {@link ensure} allocated, or no `particle_init` yet. Nothing was
1078
+ * written, so the caller must NOT {@link scatter} (that would write the
1079
+ * gathered pre-step values back and freeze the simulation) and should fall
1080
+ * back to the JS `updateCPU` path for this frame. See {@link lastStatus}.
1038
1081
  */
1039
1082
  step(count, p) {
1040
1083
  const e = p.explosion;
@@ -1054,8 +1097,18 @@ var ParticleBackend = class {
1054
1097
  e ? e.force : 0,
1055
1098
  count
1056
1099
  );
1100
+ if (flag < 0) {
1101
+ this.lastStatus = -flag;
1102
+ return null;
1103
+ }
1104
+ this.lastStatus = WASM_STATUS.OK;
1057
1105
  return flag !== 0;
1058
1106
  }
1107
+ /**
1108
+ * Status of the most recent {@link step} — `WASM_STATUS.OK` unless the kernel
1109
+ * declined it. Mirrors {@link TransformBackend.lastStatus}.
1110
+ */
1111
+ lastStatus = WASM_STATUS.OK;
1059
1112
  /** Transpose the AoS stride-8 buffer into the SoA views (position/velocity/
1060
1113
  * life every frame; origin upload-once when `withOrigin`). */
1061
1114
  gather(data, count, withOrigin) {
@@ -1189,6 +1242,79 @@ async function loadCoreWasmRuntime(source) {
1189
1242
  return createCoreWasmRuntime(module);
1190
1243
  }
1191
1244
 
1245
+ // src/performance/UserTiming.ts
1246
+ var VECTO_USER_TIMING = {
1247
+ scene: {
1248
+ transform: "vecto:scene:transform",
1249
+ drawWalk: "vecto:scene:draw-walk",
1250
+ entityPaint: "vecto:scene:entity-paint",
1251
+ flush: "vecto:scene:flush",
1252
+ a11ySync: "vecto:scene:a11y-sync"
1253
+ },
1254
+ markdown: {
1255
+ parse: "vecto:markdown:parse"
1256
+ }
1257
+ };
1258
+ var nextSpanId = 0;
1259
+ function beginVectoUserTiming(name) {
1260
+ const candidate = globalThis.performance;
1261
+ if (typeof candidate?.mark !== "function" || typeof candidate.measure !== "function") {
1262
+ return null;
1263
+ }
1264
+ const id = nextSpanId++;
1265
+ const startMark = `${name}:start:${id}`;
1266
+ const endMark = `${name}:end:${id}`;
1267
+ try {
1268
+ candidate.mark(startMark);
1269
+ return {
1270
+ name,
1271
+ startMark,
1272
+ endMark,
1273
+ performance: candidate
1274
+ };
1275
+ } catch {
1276
+ return null;
1277
+ }
1278
+ }
1279
+ function endVectoUserTiming(span) {
1280
+ if (!span) return;
1281
+ const timing = span.performance;
1282
+ try {
1283
+ timing.mark(span.endMark);
1284
+ timing.measure(span.name, span.startMark, span.endMark);
1285
+ } catch {
1286
+ } finally {
1287
+ try {
1288
+ timing.clearMarks?.(span.startMark);
1289
+ timing.clearMarks?.(span.endMark);
1290
+ } catch {
1291
+ }
1292
+ }
1293
+ }
1294
+ function measureVectoUserTiming(name, durationMs) {
1295
+ const candidate = globalThis.performance;
1296
+ if (typeof candidate?.now !== "function" || typeof candidate.mark !== "function" || typeof candidate.measure !== "function") {
1297
+ return;
1298
+ }
1299
+ const id = nextSpanId++;
1300
+ const startMark = `${name}:start:${id}`;
1301
+ const endMark = `${name}:end:${id}`;
1302
+ const endTime = candidate.now();
1303
+ const startTime = Math.max(0, endTime - Math.max(0, durationMs));
1304
+ try {
1305
+ candidate.mark(startMark, { startTime });
1306
+ candidate.mark(endMark, { startTime: endTime });
1307
+ candidate.measure(name, startMark, endMark);
1308
+ } catch {
1309
+ } finally {
1310
+ try {
1311
+ candidate.clearMarks?.(startMark);
1312
+ candidate.clearMarks?.(endMark);
1313
+ } catch {
1314
+ }
1315
+ }
1316
+ }
1317
+
1192
1318
  // src/tree/Scene.ts
1193
1319
  import { clearCssLineBoxMetrics, cssLineBoxBaseline } from "@vectojs/text";
1194
1320
  var RANGE_VALUE_ROLES = /* @__PURE__ */ new Set(["slider", "spinbutton", "progressbar", "scrollbar", "meter"]);
@@ -1203,9 +1329,47 @@ var INTERACTIVE_A11Y_ROLES = /* @__PURE__ */ new Set([
1203
1329
  "slider",
1204
1330
  "combobox"
1205
1331
  ]);
1332
+ var A11Y_REQUIRED_OWNED = /* @__PURE__ */ new Map([
1333
+ ["grid", /* @__PURE__ */ new Set(["row", "rowgroup"])],
1334
+ ["table", /* @__PURE__ */ new Set(["row", "rowgroup"])],
1335
+ ["treegrid", /* @__PURE__ */ new Set(["row", "rowgroup"])],
1336
+ ["rowgroup", /* @__PURE__ */ new Set(["row"])],
1337
+ ["row", /* @__PURE__ */ new Set(["cell", "columnheader", "gridcell", "rowheader"])],
1338
+ ["tablist", /* @__PURE__ */ new Set(["tab"])],
1339
+ ["tree", /* @__PURE__ */ new Set(["treeitem", "group"])],
1340
+ ["group", /* @__PURE__ */ new Set(["treeitem", "menuitem", "menuitemcheckbox", "menuitemradio", "option"])],
1341
+ ["menu", /* @__PURE__ */ new Set(["menuitem", "menuitemcheckbox", "menuitemradio", "group", "separator"])],
1342
+ ["menubar", /* @__PURE__ */ new Set(["menuitem", "menuitemcheckbox", "menuitemradio", "group", "separator"])],
1343
+ ["listbox", /* @__PURE__ */ new Set(["option", "group"])],
1344
+ ["list", /* @__PURE__ */ new Set(["listitem"])]
1345
+ ]);
1206
1346
  function isNativelyFocusable(element) {
1207
1347
  return element instanceof HTMLButtonElement || element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement || element instanceof HTMLAnchorElement && element.hasAttribute("href");
1208
1348
  }
1349
+ var REBASED_BOX = { left: 0, top: 0, matrix: "" };
1350
+ function rebaseChildBox(parent, parentOriginX, parentOriginY, child, childOriginX, childOriginY) {
1351
+ const det = parent.a * parent.d - parent.b * parent.c;
1352
+ if (!Number.isFinite(det) || Math.abs(det) < 1e-12) {
1353
+ REBASED_BOX.left = 0;
1354
+ REBASED_BOX.top = 0;
1355
+ REBASED_BOX.matrix = "matrix(1, 0, 0, 1, 0, 0)";
1356
+ return REBASED_BOX;
1357
+ }
1358
+ const ia = parent.d / det;
1359
+ const ib = -parent.b / det;
1360
+ const ic = -parent.c / det;
1361
+ const id = parent.a / det;
1362
+ const dx = childOriginX - parentOriginX;
1363
+ const dy = childOriginY - parentOriginY;
1364
+ REBASED_BOX.left = ia * dx + ic * dy;
1365
+ REBASED_BOX.top = ib * dx + id * dy;
1366
+ const a = ia * child.a + ic * child.b;
1367
+ const b = ib * child.a + id * child.b;
1368
+ const c = ia * child.c + ic * child.d;
1369
+ const d = ib * child.c + id * child.d;
1370
+ REBASED_BOX.matrix = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
1371
+ return REBASED_BOX;
1372
+ }
1209
1373
  var REDUCED_MOTION_FPS = 30;
1210
1374
  function parseInlinePx(value) {
1211
1375
  if (!value || !value.endsWith("px")) return null;
@@ -1540,6 +1704,7 @@ var Scene = class _Scene {
1540
1704
  /** Cap on distinct recorded dirty reasons (see `recordDirtyReason`). */
1541
1705
  static MAX_DIRTY_REASONS = 200;
1542
1706
  _phaseTiming = false;
1707
+ _userTiming = false;
1543
1708
  _phaseTotals = /* @__PURE__ */ new Map();
1544
1709
  /**
1545
1710
  * Start or stop per-phase render timing.
@@ -1562,6 +1727,19 @@ var Scene = class _Scene {
1562
1727
  get phaseTiming() {
1563
1728
  return this._phaseTiming;
1564
1729
  }
1730
+ /**
1731
+ * Enable or disable browser User Timing phase instrumentation.
1732
+ *
1733
+ * Off by default. The disabled frame path performs only boolean checks and
1734
+ * emits no Performance Timeline entries.
1735
+ */
1736
+ setUserTiming(enabled) {
1737
+ this._userTiming = enabled;
1738
+ }
1739
+ /** Whether browser User Timing phase instrumentation is enabled. */
1740
+ get userTiming() {
1741
+ return this._userTiming;
1742
+ }
1565
1743
  /**
1566
1744
  * Accumulate one phase sample.
1567
1745
  *
@@ -1767,6 +1945,8 @@ var Scene = class _Scene {
1767
1945
  fullViewportElements = [];
1768
1946
  normalElements = [];
1769
1947
  activeIds = /* @__PURE__ */ new Set();
1948
+ /** Per-parent insertion cursor, reused by `enforceA11yDomOrder`. */
1949
+ a11yOrderCursors = /* @__PURE__ */ new Map();
1770
1950
  activePortalsThisFrame = /* @__PURE__ */ new Set();
1771
1951
  activePortalsPrevFrame = /* @__PURE__ */ new Set();
1772
1952
  portalEntities = /* @__PURE__ */ new Map();
@@ -1866,10 +2046,13 @@ var Scene = class _Scene {
1866
2046
  return true;
1867
2047
  }
1868
2048
  // ── WASM hit-test backend (invisible accelerator, G3) ───────────────────────
1869
- // A separate WASM module instance from the transform backend (each crate
1870
- // export lives in independent linear memory per instance, so there is no
1871
- // shared-state hazard in running both) that indexes the main tree's world
1872
- // AABBs into a dense viewport grid for findEntityAt. The JS depth-first walk
2049
+ // Served by the same instance as every other accelerator (see
2050
+ // `_wasmRuntime`): the crate keeps transform/anim/hit/particle in distinct
2051
+ // statics, so one instance runs them all without aliasing. It indexes the
2052
+ // main tree's world AABBs into a dense viewport grid for findEntityAt. Note
2053
+ // that sharing one linear memory means an allocation here can grow it and
2054
+ // detach views built over the old buffer, so each backend re-checks buffer
2055
+ // identity (`revalidateViews`) before use. The JS depth-first walk
1873
2056
  // (findHitRecursively) is the permanent fallback: a null backend, a build
1874
2057
  // that overflows its item budget, or the overlay tree (never indexed — small
1875
2058
  // and rare, not worth accelerating) all fall through to it, so WASM can only
@@ -1942,6 +2125,70 @@ var Scene = class _Scene {
1942
2125
  get hitGatherPath() {
1943
2126
  return this._hitFusedGather ? "fused" : "js";
1944
2127
  }
2128
+ /**
2129
+ * Why the transform accelerator did or did not run on the most recent frame.
2130
+ * Written by the render walk and `_syncWasmStore`.
2131
+ */
2132
+ _transformReason = "not-installed";
2133
+ /** Why the batched-driver accelerator did or did not run. */
2134
+ _animReason = "not-installed";
2135
+ /**
2136
+ * Why the hit-test accelerator did or did not serve the last pointer query.
2137
+ * The grid is built lazily on demand, not every frame, so this describes the
2138
+ * most recent BUILD. Starts at `'not-installed'` because that is the truth
2139
+ * before a backend exists; `_ensureHitGrid` moves it to `'not-applicable'`
2140
+ * once one is installed but nothing has queried yet.
2141
+ */
2142
+ _hitReason = "not-installed";
2143
+ /** Why the particle accelerator did or did not run. */
2144
+ _particleReason = "not-applicable";
2145
+ /** Which particle implementation actually simulated the most recent frame. */
2146
+ _particlePath = "none";
2147
+ /**
2148
+ * Per-frame status of every invisible accelerator: whether each is installed,
2149
+ * whether it actually ran on the most recent frame, and why.
2150
+ *
2151
+ * This exists because the older per-accelerator getters
2152
+ * ({@link transformBackend}, {@link animBackend}, {@link hitTestBackend},
2153
+ * {@link particleBackend}) report only that a backend is INSTALLED. Reading
2154
+ * `'wasm'` from one of those and concluding the accelerator is doing work is
2155
+ * wrong whenever a gate never opens, a kernel rejects its arguments, or a
2156
+ * faster backend takes the pass instead. Read {@link AcceleratorStatus.reason}
2157
+ * for which of those happened.
2158
+ *
2159
+ * Reflects the most recent main-renderer frame; a secondary renderer (SVG
2160
+ * export, offscreen snapshot) does not overwrite it.
2161
+ */
2162
+ get accelerators() {
2163
+ return {
2164
+ transform: {
2165
+ available: this._wasm !== null && this._transformBackend === "wasm",
2166
+ activeThisFrame: this._transformReason === "active",
2167
+ reason: this._transformReason,
2168
+ path: this._transformReason === "active" ? "wasm" : "js"
2169
+ },
2170
+ animation: {
2171
+ available: this._animWasm !== null,
2172
+ activeThisFrame: this._animBatchedLastFrame,
2173
+ reason: this._animReason,
2174
+ path: this._animBatchedLastFrame ? "wasm" : "js"
2175
+ },
2176
+ hitTest: {
2177
+ available: this._hitWasm !== null,
2178
+ // The grid is built lazily on a pointer query, not every frame, so this
2179
+ // describes the last BUILD rather than the last frame.
2180
+ activeThisFrame: this._hitReason === "active",
2181
+ reason: this._hitReason,
2182
+ path: this._hitReason !== "active" ? "js" : this._hitFusedGather ? "wasm-fused" : "wasm"
2183
+ },
2184
+ particle: {
2185
+ available: this._particleWasm !== null || this.webgpuActive,
2186
+ activeThisFrame: this._particleReason === "active",
2187
+ reason: this._particleReason,
2188
+ path: this._particlePath
2189
+ }
2190
+ };
2191
+ }
1945
2192
  /** Which backend answers `findEntityAt` for the main tree. */
1946
2193
  get hitTestBackend() {
1947
2194
  return this._hitWasm ? "wasm" : "js";
@@ -1951,6 +2198,7 @@ var Scene = class _Scene {
1951
2198
  setHitTestBackend(backend) {
1952
2199
  this._hitWasm = backend;
1953
2200
  this._hitGridFrame = -1;
2201
+ this._hitReason = backend ? "not-applicable" : "not-installed";
1954
2202
  }
1955
2203
  /**
1956
2204
  * Asynchronously instantiate the WASM hit-test core and, on success, hot-swap
@@ -1977,7 +2225,10 @@ var Scene = class _Scene {
1977
2225
  */
1978
2226
  _ensureHitGrid() {
1979
2227
  const backend = this._hitWasm;
1980
- if (!backend) return false;
2228
+ if (!backend) {
2229
+ this._hitReason = "not-installed";
2230
+ return false;
2231
+ }
1981
2232
  if (this._hitGridFrame === this.currentFrame) return this._hitGridOk;
1982
2233
  let gathered = null;
1983
2234
  if (this._wasm && this._ensureWasmAabbs()) {
@@ -2006,6 +2257,7 @@ var Scene = class _Scene {
2006
2257
  this._hitBoundless = gathered.boundless;
2007
2258
  this._hitGridFrame = this.currentFrame;
2008
2259
  this._hitGridOk = ok;
2260
+ this._hitReason = ok ? "active" : "rejected";
2009
2261
  return ok;
2010
2262
  }
2011
2263
  /**
@@ -2271,7 +2523,10 @@ var Scene = class _Scene {
2271
2523
  * pre-pass.
2272
2524
  */
2273
2525
  _tickBatchedDrivers(dt) {
2274
- if (this._activeDriverEntities.size === 0) return;
2526
+ if (this._activeDriverEntities.size === 0) {
2527
+ this._animReason = "not-applicable";
2528
+ return;
2529
+ }
2275
2530
  let springBatchable = 0;
2276
2531
  let tweenBatchable = 0;
2277
2532
  for (const entity of this._activeDriverEntities) {
@@ -2288,10 +2543,17 @@ var Scene = class _Scene {
2288
2543
  const batchable = springBatchable + tweenBatchable;
2289
2544
  const backend = this._animWasm;
2290
2545
  this._animBatchedLastFrame = false;
2291
- if (!backend) return;
2546
+ if (!backend) {
2547
+ this._animReason = "not-installed";
2548
+ return;
2549
+ }
2292
2550
  const gate = springBatchable > 0 && tweenBatchable > 0 ? this.animGate.mixed : tweenBatchable > 0 ? this.animGate.tween : this.animGate.spring;
2293
- if (batchable < gate) return;
2551
+ if (batchable < gate) {
2552
+ this._animReason = "below-gate";
2553
+ return;
2554
+ }
2294
2555
  this._animBatchedLastFrame = true;
2556
+ this._animReason = "active";
2295
2557
  const sE = this._springEntities;
2296
2558
  const sP = this._springProps;
2297
2559
  const sD = this._springDrivers;
@@ -2338,8 +2600,13 @@ var Scene = class _Scene {
2338
2600
  sv.damp[i] = phys.damping;
2339
2601
  sv.mass[i] = phys.mass;
2340
2602
  }
2341
- backend.stepSprings(dt, springCount);
2342
- for (let i = 0; i < springCount; i++) sD[i].syncExternal(sv.val[i], sv.vel[i]);
2603
+ if (backend.stepSprings(dt, springCount)) {
2604
+ for (let i = 0; i < springCount; i++) sD[i].syncExternal(sv.val[i], sv.vel[i]);
2605
+ } else {
2606
+ for (let i = 0; i < springCount; i++) sD[i].tick(dt);
2607
+ this._animReason = "rejected";
2608
+ this._animBatchedLastFrame = false;
2609
+ }
2343
2610
  }
2344
2611
  if (tweenCount > 0) {
2345
2612
  const tv = backend.tweenView();
@@ -2352,8 +2619,13 @@ var Scene = class _Scene {
2352
2619
  tv.delay[i] = d.delayMs;
2353
2620
  tv.ease[i] = d.wasmEasingId;
2354
2621
  }
2355
- backend.stepTweens(dt, tweenCount);
2356
- for (let i = 0; i < tweenCount; i++) tD[i].syncExternal(tv.val[i], tv.elapsed[i]);
2622
+ if (backend.stepTweens(dt, tweenCount)) {
2623
+ for (let i = 0; i < tweenCount; i++) tD[i].syncExternal(tv.val[i], tv.elapsed[i]);
2624
+ } else {
2625
+ for (let i = 0; i < tweenCount; i++) tD[i].tick(dt);
2626
+ this._animReason = "rejected";
2627
+ this._animBatchedLastFrame = false;
2628
+ }
2357
2629
  }
2358
2630
  for (let i = 0; i < springCount; i++) sE[i]._applyDriverTick(sP[i], sD[i]);
2359
2631
  for (let i = 0; i < tweenCount; i++) tE[i]._applyDriverTick(tP[i], tD[i]);
@@ -2375,7 +2647,10 @@ var Scene = class _Scene {
2375
2647
  slotEntity2[slot] = entity;
2376
2648
  entity._storeSlot = slot;
2377
2649
  }
2378
- backend.uploadRuns(built.store);
2650
+ if (!backend.uploadRuns(built.store)) {
2651
+ this._transformReason = "rejected";
2652
+ return null;
2653
+ }
2379
2654
  this._treeStore = built.store;
2380
2655
  this._slotEntity = slotEntity2;
2381
2656
  this._wasmInputs = backend.inputView();
@@ -2400,8 +2675,12 @@ var Scene = class _Scene {
2400
2675
  inp.sin[slot] = trig.sin;
2401
2676
  inp.opacity[slot] = e.opacity;
2402
2677
  }
2403
- backend.runKernel("simd");
2678
+ if (backend.runKernel("simd") !== WASM_STATUS.OK) {
2679
+ this._transformReason = "rejected";
2680
+ return null;
2681
+ }
2404
2682
  this._wasmAabbsFresh = false;
2683
+ this._transformReason = "active";
2405
2684
  return this._wasmWorld;
2406
2685
  }
2407
2686
  /**
@@ -2431,7 +2710,7 @@ var Scene = class _Scene {
2431
2710
  bounds.bw[slot] = b ? b.width : 0;
2432
2711
  bounds.bh[slot] = b ? b.height : 0;
2433
2712
  }
2434
- backend.runAabbs(slotEntity.length);
2713
+ if (!backend.runAabbs(slotEntity.length)) return false;
2435
2714
  this._wasmAabbsFresh = true;
2436
2715
  return true;
2437
2716
  }
@@ -2582,6 +2861,7 @@ var Scene = class _Scene {
2582
2861
  this.maxFPS = options.maxFPS ?? (isTest ? 0 : 60);
2583
2862
  this.respectReducedMotion = options.respectReducedMotion ?? true;
2584
2863
  this.autoThrottle = options.autoThrottle ?? true;
2864
+ this._userTiming = options.userTiming ?? false;
2585
2865
  this.particleBackend = options.particleBackend ?? "auto";
2586
2866
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
2587
2867
  this.contentProjectionEnabled = options.contentProjection ?? true;
@@ -2626,6 +2906,7 @@ var Scene = class _Scene {
2626
2906
  });
2627
2907
  if (typeof document !== "undefined") {
2628
2908
  this.a11yRoot = document.createElement("div");
2909
+ this.a11yRoot.setAttribute("data-vecto-a11y-root", "");
2629
2910
  this.a11yRoot.style.position = "absolute";
2630
2911
  this.a11yRoot.style.top = "0";
2631
2912
  this.a11yRoot.style.left = "0";
@@ -3478,7 +3759,7 @@ var Scene = class _Scene {
3478
3759
  shouldProjectA11y(node) {
3479
3760
  return node.interactive && (node.width > 0 || node.a11yFullViewport);
3480
3761
  }
3481
- syncA11y(node) {
3762
+ syncA11y(node, container = null) {
3482
3763
  if (!this.a11yRoot) return;
3483
3764
  if (node.isDOMPortal) {
3484
3765
  return;
@@ -3488,6 +3769,7 @@ var Scene = class _Scene {
3488
3769
  return;
3489
3770
  }
3490
3771
  const nodeStart = this._phaseTiming ? performance.now() : 0;
3772
+ let childContainer = container;
3491
3773
  if (this.shouldProjectA11y(node)) {
3492
3774
  let el = this.a11yElements.get(node.id);
3493
3775
  const attrs = node.getA11yAttributes();
@@ -3500,9 +3782,9 @@ var Scene = class _Scene {
3500
3782
  this.caretBlinkTimer = null;
3501
3783
  }
3502
3784
  }
3503
- if (el.parentNode === this.a11yRoot) {
3785
+ if (el.parentNode) {
3504
3786
  this.preserveFocusOnRemoval(el);
3505
- this.a11yRoot.removeChild(el);
3787
+ el.remove();
3506
3788
  }
3507
3789
  this.a11yElements.delete(node.id);
3508
3790
  el = void 0;
@@ -3810,6 +4092,7 @@ var Scene = class _Scene {
3810
4092
  if (el.style.boxSizing !== "border-box") el.style.boxSizing = "border-box";
3811
4093
  if (el instanceof HTMLTextAreaElement) el.style.resize = "none";
3812
4094
  }
4095
+ const nestedIn = container && attrs.role && container.owned.has(attrs.role) ? container : null;
3813
4096
  if (node.a11yFullViewport) {
3814
4097
  el.style.left = "0px";
3815
4098
  el.style.top = "0px";
@@ -3820,11 +4103,36 @@ var Scene = class _Scene {
3820
4103
  } else {
3821
4104
  const worldTf = node.getWorldTransform();
3822
4105
  const { a, b, c, d, e, f } = worldTf;
3823
- el.style.left = `${e + node.a11yOffsetX}px`;
3824
- el.style.top = `${f + node.a11yOffsetY}px`;
4106
+ const originX = e + node.a11yOffsetX;
4107
+ const originY = f + node.a11yOffsetY;
4108
+ const parentEl = nestedIn && nestedIn.el !== el && nestedIn.el.isConnected ? nestedIn.el : this.a11yRoot;
4109
+ if (el.parentNode !== parentEl) {
4110
+ parentEl.appendChild(el);
4111
+ this.a11yNeedsReorder = true;
4112
+ }
4113
+ if (parentEl === this.a11yRoot) {
4114
+ el.style.left = `${originX}px`;
4115
+ el.style.top = `${originY}px`;
4116
+ el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4117
+ } else {
4118
+ const box = rebaseChildBox(
4119
+ nestedIn.transform,
4120
+ nestedIn.originX,
4121
+ nestedIn.originY,
4122
+ worldTf,
4123
+ originX,
4124
+ originY
4125
+ );
4126
+ el.style.left = `${box.left}px`;
4127
+ el.style.top = `${box.top}px`;
4128
+ el.style.transform = box.matrix;
4129
+ }
3825
4130
  el.style.width = `${node.width}px`;
3826
4131
  el.style.height = `${node.height}px`;
3827
- el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
4132
+ const owned = attrs.role ? A11Y_REQUIRED_OWNED.get(attrs.role) : void 0;
4133
+ if (owned) {
4134
+ childContainer = { el, owned, transform: worldTf, originX, originY };
4135
+ }
3828
4136
  const visible = this.projectionBoxVisible(node, worldTf, 0);
3829
4137
  const display = visible ? "" : "none";
3830
4138
  if (el.style.display !== display) el.style.display = display;
@@ -3838,9 +4146,9 @@ var Scene = class _Scene {
3838
4146
  } else {
3839
4147
  this.syncContentProjection(node);
3840
4148
  }
3841
- for (const child of node.children) this.syncA11y(child);
4149
+ for (const child of node.children) this.syncA11y(child, childContainer);
3842
4150
  if (node === this.root) {
3843
- for (const overlay of this.overlayRoot.children) this.syncA11y(overlay);
4151
+ for (const overlay of this.overlayRoot.children) this.syncA11y(overlay, null);
3844
4152
  }
3845
4153
  }
3846
4154
  /**
@@ -4398,9 +4706,9 @@ var Scene = class _Scene {
4398
4706
  this.caretBlinkTimer = null;
4399
4707
  }
4400
4708
  }
4401
- if (el.parentNode === this.a11yRoot) {
4709
+ if (el.parentNode) {
4402
4710
  this.preserveFocusOnRemoval(el);
4403
- this.a11yRoot.removeChild(el);
4711
+ el.remove();
4404
4712
  }
4405
4713
  this.a11yElements.delete(id);
4406
4714
  }
@@ -4413,23 +4721,39 @@ var Scene = class _Scene {
4413
4721
  const fullLen = this.fullViewportElements.length;
4414
4722
  const normalLen = this.normalElements.length;
4415
4723
  const totalLen = fullLen + normalLen;
4724
+ this.a11yOrderCursors.clear();
4416
4725
  for (let i = 0; i < totalLen; i++) {
4417
4726
  const expected = i < fullLen ? this.fullViewportElements[i] : this.normalElements[i - fullLen];
4418
- const current = this.a11yRoot.childNodes[i];
4727
+ const parent = expected.parentNode;
4728
+ if (!parent) continue;
4729
+ const at = this.a11yOrderCursors.get(parent) ?? 0;
4730
+ this.a11yOrderCursors.set(parent, at + 1);
4731
+ const current = parent.childNodes[at];
4419
4732
  if (current !== expected) {
4420
- this.a11yRoot.insertBefore(expected, current || null);
4733
+ parent.insertBefore(expected, current || null);
4421
4734
  }
4422
4735
  }
4423
4736
  this.a11yNeedsReorder = false;
4424
4737
  }
4425
4738
  /**
4426
4739
  * Reorder `normalElements` (in place) into visual reading order using the
4427
- * world positions `syncA11y` already wrote to each element's inline style
4740
+ * positions `syncA11y` already wrote to each element's inline style
4428
4741
  * (`top`/`left`/`height`). Elements are grouped into rows top-to-bottom (an
4429
4742
  * element belongs to the current row while its top is above the row's
4430
4743
  * running bottom edge), then sorted within a row by `left` — ascending for
4431
4744
  * `'ltr'`, descending for `'rtl'`. The sort is stable, so entities at the
4432
4745
  * same position keep their scene-graph (collection) order as a tiebreak.
4746
+ *
4747
+ * Those inline values are world coordinates for a top-level mirror but
4748
+ * PARENT-RELATIVE for a nested one, so this list mixes coordinate spaces.
4749
+ * That is sound because the result is only ever applied per DOM parent
4750
+ * ({@link enforceA11yDomOrder} advances a cursor per parent), and all of one
4751
+ * parent's children share one space: a `grid`'s rows are all grid-relative, a
4752
+ * `row`'s cells all row-relative. Comparisons ACROSS spaces do happen while
4753
+ * banding, but they only affect the relative order of elements in different
4754
+ * parents, which no `insertBefore` ever acts on. Normalizing everything back
4755
+ * to world coordinates here would cost a transform per element per frame to
4756
+ * change nothing observable.
4433
4757
  */
4434
4758
  sortNormalElementsVisually() {
4435
4759
  const els = this.normalElements;
@@ -4665,9 +4989,11 @@ var Scene = class _Scene {
4665
4989
  if ((hasInteractive || this.a11yElements.size > 0 || wantsContentSync) && (shouldSyncInterval || this.a11yPendingSyncAfterAnimation)) {
4666
4990
  this.lastA11ySync = time;
4667
4991
  if (hasInteractive || wantsContentSync) {
4992
+ const userTiming = this._userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.a11ySync) : null;
4668
4993
  const t0 = this._phaseTiming ? performance.now() : 0;
4669
4994
  this.syncA11y(this.root);
4670
4995
  if (this._phaseTiming) this._recordPhase("a11ySync", performance.now() - t0);
4996
+ if (userTiming) endVectoUserTiming(userTiming);
4671
4997
  }
4672
4998
  const t1 = this._phaseTiming ? performance.now() : 0;
4673
4999
  this.enforceA11yDomOrder();
@@ -4681,6 +5007,26 @@ var Scene = class _Scene {
4681
5007
  /**
4682
5008
  * Render the entire scene graph onto the specified renderer.
4683
5009
  *
5010
+ * Main-frame causal order is a correctness contract:
5011
+ *
5012
+ * 1. Browser/input callbacks finish before the scheduled frame begins.
5013
+ * 2. Batched property drivers and particle simulation advance.
5014
+ * 3. Entity `update()` hooks run.
5015
+ * 4. Transform inputs are gathered and world matrices are composed.
5016
+ * 5. Updated world bounds are tested for culling.
5017
+ * 6. Visible entities paint in scene-graph order.
5018
+ * 7. Canvas/GPU batches flush and retained renderers present.
5019
+ * 8. The rAF loop synchronizes content and accessibility projections after
5020
+ * this method returns.
5021
+ *
5022
+ * The causal order is fixed; physical walks may stay fused. The JavaScript
5023
+ * transform path interleaves update → compose → cull → paint per node in
5024
+ * pre-order. The WASM path updates the whole tree first, then gathers and
5025
+ * composes it in one store pass before the same cull/paint walk. Both must
5026
+ * expose an update's transform mutation in that same rendered frame.
5027
+ * Secondary renderers are read-only snapshots: they skip simulation and
5028
+ * updates, then compose/cull/paint/flush the current state.
5029
+ *
4684
5030
  * @param renderer - The renderer instance to draw to.
4685
5031
  * @param dt - Delta time in milliseconds (default 0).
4686
5032
  * @param time - Current absolute time in milliseconds (default 0).
@@ -4705,6 +5051,10 @@ var Scene = class _Scene {
4705
5051
  const computeEntities = this._computeEntitiesFor(this._structureVersion);
4706
5052
  if (computeEntities.length > 0) {
4707
5053
  const isMainRenderPath = renderer === this.renderer;
5054
+ if (isMainRenderPath) {
5055
+ this._particleReason = this._particleWasm ? "active" : "not-installed";
5056
+ this._particlePath = this._particleWasm ? "wasm" : "js";
5057
+ }
4708
5058
  if (isMainRenderPath && !this.device && !this.webgpuDisabled && !this.initializingWebGPU && !this.deviceLost) {
4709
5059
  this.initializingWebGPU = true;
4710
5060
  this.initWebGPUContext(computeEntities).then((newDevice) => {
@@ -4780,6 +5130,8 @@ var Scene = class _Scene {
4780
5130
  }
4781
5131
  this.device.queue.submit([commandEncoder.finish()]);
4782
5132
  if (this.gpuContext) this.gpuHasContent = true;
5133
+ this._particleReason = "active";
5134
+ this._particlePath = "webgpu";
4783
5135
  } catch (e) {
4784
5136
  console.error("WebGPU frame execution failed. Falling back.", e);
4785
5137
  this.deviceLost = true;
@@ -4801,13 +5153,25 @@ var Scene = class _Scene {
4801
5153
  }
4802
5154
  }
4803
5155
  if (this._particleWasm) {
4804
- entity.stepWithBackend(this._particleWasm, dt / 1e3, mx, my, this.width, this.height);
5156
+ if (!entity.stepWithBackend(
5157
+ this._particleWasm,
5158
+ dt / 1e3,
5159
+ mx,
5160
+ my,
5161
+ this.width,
5162
+ this.height
5163
+ )) {
5164
+ this._particleReason = "rejected";
5165
+ this._particlePath = "js";
5166
+ }
4805
5167
  } else {
4806
5168
  entity.updateCPU(dt / 1e3, mx, my, this.width, this.height);
4807
5169
  }
4808
5170
  }
4809
5171
  }
4810
5172
  } else if (isMainRenderer) {
5173
+ this._particleReason = "not-applicable";
5174
+ this._particlePath = "none";
4811
5175
  this.clearGPUCanvasIfStale();
4812
5176
  }
4813
5177
  renderer.clear();
@@ -4836,6 +5200,9 @@ var Scene = class _Scene {
4836
5200
  }
4837
5201
  };
4838
5202
  const wasmMain = isMainRenderer && this._wasm !== null && this._transformBackend === "wasm";
5203
+ if (isMainRenderer) {
5204
+ this._transformReason = wasmMain ? "active" : "not-installed";
5205
+ }
4839
5206
  if (wasmMain) {
4840
5207
  const updateWalk = (node) => {
4841
5208
  runUpdate(node);
@@ -4845,10 +5212,13 @@ var Scene = class _Scene {
4845
5212
  updateWalk(this.root);
4846
5213
  for (const overlay of this.overlayRoot.children) updateWalk(overlay);
4847
5214
  }
5215
+ const transformTiming = this._userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.transform) : null;
4848
5216
  const wasmT0 = this._phaseTiming ? performance.now() : 0;
4849
5217
  const wasmWorld = wasmMain ? this._syncWasmStore() : null;
4850
5218
  if (this._phaseTiming) this._recordPhase("transform", performance.now() - wasmT0);
5219
+ if (transformTiming) endVectoUserTiming(transformTiming);
4851
5220
  const wasmSlotEntity = this._slotEntity;
5221
+ let userEntityPaintMs = 0;
4852
5222
  const renderNode = (node, pa, pb, pc, pd, pe, pf, parentOpacity) => {
4853
5223
  if (isMainRenderer && !wasmMain) {
4854
5224
  runUpdate(node);
@@ -4977,10 +5347,15 @@ var Scene = class _Scene {
4977
5347
  );
4978
5348
  }
4979
5349
  } else {
4980
- if (this._phaseTiming) {
5350
+ if (this._userTiming || this._phaseTiming) {
4981
5351
  const t0 = performance.now();
4982
- node.render(renderer);
4983
- this._recordPhase("entityPaint", performance.now() - t0);
5352
+ try {
5353
+ node.render(renderer);
5354
+ } finally {
5355
+ const elapsed = performance.now() - t0;
5356
+ if (this._userTiming) userEntityPaintMs += elapsed;
5357
+ if (this._phaseTiming) this._recordPhase("entityPaint", elapsed);
5358
+ }
4984
5359
  } else {
4985
5360
  node.render(renderer);
4986
5361
  }
@@ -4995,17 +5370,23 @@ var Scene = class _Scene {
4995
5370
  renderer.flush();
4996
5371
  renderer.restore();
4997
5372
  };
5373
+ const drawTiming = this._userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.drawWalk) : null;
4998
5374
  const drawT0 = this._phaseTiming ? performance.now() : 0;
4999
5375
  renderNode(this.root, 1, 0, 0, 1, 0, 0, 1);
5000
5376
  for (const overlay of this.overlayRoot.children) {
5001
5377
  renderNode(overlay, 1, 0, 0, 1, 0, 0, 1);
5002
5378
  }
5003
5379
  if (this._phaseTiming) this._recordPhase("drawWalk", performance.now() - drawT0);
5380
+ if (drawTiming) endVectoUserTiming(drawTiming);
5381
+ if (this._userTiming) {
5382
+ measureVectoUserTiming(VECTO_USER_TIMING.scene.entityPaint, userEntityPaintMs);
5383
+ }
5004
5384
  if (isMainRenderer) {
5005
5385
  this.frameHadAnimation = walkHadAnimation;
5006
5386
  this.frameHadInteractive = walkHadInteractive;
5007
5387
  this.reconcilePortals();
5008
5388
  }
5389
+ const flushTiming = this._userTiming ? beginVectoUserTiming(VECTO_USER_TIMING.scene.flush) : null;
5009
5390
  const flushT0 = this._phaseTiming ? performance.now() : 0;
5010
5391
  renderer.flush();
5011
5392
  if (isMainRenderer) {
@@ -5013,6 +5394,7 @@ var Scene = class _Scene {
5013
5394
  }
5014
5395
  renderer.present?.();
5015
5396
  if (this._phaseTiming) this._recordPhase("flush", performance.now() - flushT0);
5397
+ if (flushTiming) endVectoUserTiming(flushTiming);
5016
5398
  if (this._devActive) {
5017
5399
  this._devFrameCount++;
5018
5400
  this._devRunChecks();
@@ -6112,11 +6494,15 @@ export {
6112
6494
  SplineEntity,
6113
6495
  TextEntity,
6114
6496
  TextRasterCache,
6497
+ VECTO_USER_TIMING,
6115
6498
  VectoJSEvent,
6116
6499
  WebGPUParticleSystemManager,
6500
+ beginVectoUserTiming,
6117
6501
  createWebGLPointRenderer,
6502
+ endVectoUserTiming,
6118
6503
  isSafeUrl,
6119
6504
  loadSpline,
6505
+ measureVectoUserTiming,
6120
6506
  parseColorToRGBA,
6121
6507
  polySegmentToBezier,
6122
6508
  sanitizeUrl