@vectojs/core 1.19.0 → 1.21.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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CanvasRenderer,
3
+ GlyphRasterAtlas,
3
4
  SVGRenderer,
4
5
  TextRasterCache,
5
6
  WebGPUParticleSystemManager,
@@ -7,13 +8,13 @@ import {
7
8
  isSafeUrl,
8
9
  parseColorToRGBA,
9
10
  sanitizeUrl
10
- } from "./chunk-QS3CUV7H.mjs";
11
+ } from "./chunk-RTENOAYT.mjs";
11
12
  import {
12
13
  Entity,
13
14
  MSDFTextEntity,
14
15
  SVGEntity,
15
16
  VectoJSEvent
16
- } from "./chunk-XWBVBXFZ.mjs";
17
+ } from "./chunk-FRMLD4PP.mjs";
17
18
 
18
19
  // src/tree/Scene.ts
19
20
  import { SpringDriver, TweenDriver } from "@vectojs/animation";
@@ -1538,6 +1539,70 @@ var Scene = class _Scene {
1538
1539
  renderMode = "always";
1539
1540
  /** Cap on distinct recorded dirty reasons (see `recordDirtyReason`). */
1540
1541
  static MAX_DIRTY_REASONS = 200;
1542
+ _phaseTiming = false;
1543
+ _phaseTotals = /* @__PURE__ */ new Map();
1544
+ /**
1545
+ * Start or stop per-phase render timing.
1546
+ *
1547
+ * Off by default, and the probes compile to a single boolean test when off:
1548
+ * these sit on the frame path, so the disabled cost has to be nothing. Enable,
1549
+ * run the scene, then read {@link renderPhases}.
1550
+ *
1551
+ * Exists because a frame total cannot tell you where the time went. The
1552
+ * markdown streaming benchmark put render at 85-99% of an append's cost, and
1553
+ * there was no way to decompose that number further — which is exactly the
1554
+ * position that led to two wrong optimisation guesses earlier
1555
+ * (`CodeBlock` reuse, hit-grid fusion), both of which measured as no change.
1556
+ */
1557
+ setPhaseTiming(enabled) {
1558
+ this._phaseTiming = enabled;
1559
+ if (!enabled) this.clearRenderPhases();
1560
+ }
1561
+ /** Whether per-phase render timing is being recorded. */
1562
+ get phaseTiming() {
1563
+ return this._phaseTiming;
1564
+ }
1565
+ /**
1566
+ * Accumulate one phase sample.
1567
+ *
1568
+ * Totals rather than a per-frame log: the question is always "which phase owns
1569
+ * the frame", and a log of thousands of samples answers it less directly while
1570
+ * costing far more memory. `maxMs` is kept because a phase that is cheap on
1571
+ * average but spikes is a different problem from one that is uniformly slow.
1572
+ */
1573
+ _recordPhase(phase, ms) {
1574
+ const existing = this._phaseTotals.get(phase);
1575
+ if (existing) {
1576
+ existing.totalMs += ms;
1577
+ existing.calls++;
1578
+ if (ms > existing.maxMs) existing.maxMs = ms;
1579
+ return;
1580
+ }
1581
+ this._phaseTotals.set(phase, { totalMs: ms, calls: 1, maxMs: ms });
1582
+ }
1583
+ /**
1584
+ * Recorded phase timings, most expensive first, with each phase's share of the
1585
+ * measured total.
1586
+ *
1587
+ * `share` is the number that matters: a phase at 4% cannot be worth optimising
1588
+ * however inefficient it looks in isolation.
1589
+ */
1590
+ get renderPhases() {
1591
+ const entries = [...this._phaseTotals.entries()];
1592
+ const denominator = entries.filter(([phase]) => phase !== "render").reduce((sum, [, v]) => sum + v.totalMs, 0);
1593
+ return entries.map(([phase, v]) => ({
1594
+ phase,
1595
+ totalMs: +v.totalMs.toFixed(3),
1596
+ calls: v.calls,
1597
+ avgMs: +(v.totalMs / Math.max(1, v.calls)).toFixed(4),
1598
+ maxMs: +v.maxMs.toFixed(3),
1599
+ share: phase === "render" ? null : +(100 * v.totalMs / Math.max(1e-9, denominator)).toFixed(1)
1600
+ })).sort((a, b) => b.totalMs - a.totalMs);
1601
+ }
1602
+ /** Drop recorded phase timings, keeping timing enabled. */
1603
+ clearRenderPhases() {
1604
+ this._phaseTotals.clear();
1605
+ }
1541
1606
  _dirtyTracking = false;
1542
1607
  _dirtyReasons = /* @__PURE__ */ new Map();
1543
1608
  dirty = true;
@@ -1623,6 +1688,29 @@ var Scene = class _Scene {
1623
1688
  contentGridCalibrationFrames = /* @__PURE__ */ new Map();
1624
1689
  /** Detached, untransformed font probes used by the cold calibration pass. */
1625
1690
  contentGridCalibrationProbes = /* @__PURE__ */ new Map();
1691
+ /**
1692
+ * Monotonic stamp identifying the conditions grid cells were calibrated under.
1693
+ *
1694
+ * Calibration measures the difference between the advance the canvas grid assigns
1695
+ * a cluster and the width the browser lays it out at, then writes a per-cell
1696
+ * `scaleX`. That result stays valid until the font or the page scale changes, and
1697
+ * it lives on the cell element — so a cell carrying this stamp needs no further
1698
+ * work.
1699
+ *
1700
+ * The scan that feeds calibration was O(cells) on every revision bump: for a
1701
+ * streaming code block it re-derived a measurement key for every cell in the
1702
+ * block each frame in order to produce only ~20 distinct keys, costing about
1703
+ * 2.5 ms/frame after the `style.font` fix and still over half of `a11ySync`. Since
1704
+ * carrier reuse (#244) leaves untouched lines — and therefore their calibrated
1705
+ * transforms — in place, cells stamped with the current generation can simply be
1706
+ * skipped, making the scan O(new cells) instead.
1707
+ *
1708
+ * A plain incrementing integer rather than the descriptive calibration key,
1709
+ * because it goes into an attribute selector and must not need escaping.
1710
+ */
1711
+ contentGridCalibrationGeneration = 0;
1712
+ /** The `(fontEpoch, pageScale)` pair the current generation corresponds to. */
1713
+ contentGridCalibrationStamp = "";
1626
1714
  /** Invalidates grid font calibration after browser font availability changes. */
1627
1715
  contentFontEpoch = 0;
1628
1716
  /** Cached Canvas-to-client scale for the current font/viewport epoch. */
@@ -2731,6 +2819,29 @@ var Scene = class _Scene {
2731
2819
  this.contentSelectionAnchor = null;
2732
2820
  if (this.a11yRoot) this.a11yRoot.style.pointerEvents = "none";
2733
2821
  }
2822
+ /**
2823
+ * Index of the carrier line currently holding a selection inside `el`, or
2824
+ * `null`.
2825
+ *
2826
+ * Lets a partial re-materialization decide whether the user's selection is even
2827
+ * affected. Checks the tracked anchor first (it survives a drag) and falls back
2828
+ * to the live DOM selection.
2829
+ */
2830
+ contentGridSelectionLine(el) {
2831
+ const candidates = [this.contentSelectionAnchor?.node];
2832
+ if (typeof window !== "undefined" && typeof window.getSelection === "function") {
2833
+ const selection = window.getSelection();
2834
+ candidates.push(selection?.anchorNode, selection?.focusNode);
2835
+ }
2836
+ for (const candidate of candidates) {
2837
+ if (!candidate || !el.contains(candidate)) continue;
2838
+ let cursor = candidate;
2839
+ while (cursor && cursor.parentNode !== el) cursor = cursor.parentNode;
2840
+ const lineIndex = cursor?.dataset?.vectoGridLine;
2841
+ if (lineIndex !== void 0) return Number(lineIndex);
2842
+ }
2843
+ return null;
2844
+ }
2734
2845
  releaseContentSelectionForRebuild(el) {
2735
2846
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
2736
2847
  const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
@@ -2816,7 +2927,18 @@ var Scene = class _Scene {
2816
2927
  this.registerActiveDriverSubtree(entity);
2817
2928
  return this;
2818
2929
  }
2819
- clearContentGridState(entityId, el) {
2930
+ /**
2931
+ * Reset per-grid calibration and bookkeeping before a (re)materialization.
2932
+ *
2933
+ * @param entityId - Owning entity, keyed into the calibration maps.
2934
+ * @param el - The projection element.
2935
+ * @param releaseSelection - Whether to drop a selection this element owns.
2936
+ * Pass `false` when carrier lines are being reused: the selection's DOM nodes
2937
+ * survive the pass, so tearing it down would wipe a user's selection on every
2938
+ * streamed chunk — the exact bug `preserveContentSelectionAcrossRebuild`
2939
+ * exists to prevent on the non-grid path.
2940
+ */
2941
+ clearContentGridState(entityId, el, releaseSelection = true) {
2820
2942
  const calibrationFrame = this.contentGridCalibrationFrames.get(entityId);
2821
2943
  if (calibrationFrame !== void 0 && typeof cancelAnimationFrame === "function") {
2822
2944
  cancelAnimationFrame(calibrationFrame);
@@ -2832,7 +2954,21 @@ var Scene = class _Scene {
2832
2954
  delete el.dataset.vectoGridMaterializeMs;
2833
2955
  delete el.dataset.vectoGridCalibrationSamples;
2834
2956
  delete el.dataset.vectoGridCalibrationMs;
2835
- this.releaseContentSelectionForRebuild(el);
2957
+ if (releaseSelection) this.releaseContentSelectionForRebuild(el);
2958
+ }
2959
+ /**
2960
+ * Drop any projected elements under `node` without touching the entity tree.
2961
+ *
2962
+ * Used when the walk reaches an invisible subtree: the entities stay put (a
2963
+ * later `show()` re-projects them), but nothing under here may remain
2964
+ * focusable or announced while hidden.
2965
+ */
2966
+ pruneA11ySubtree(node) {
2967
+ if (this.a11yElements.has(node.id) || this.contentElements.has(node.id)) {
2968
+ this.removeA11yRecursively(node);
2969
+ return;
2970
+ }
2971
+ for (const child of node.children) this.pruneA11ySubtree(child);
2836
2972
  }
2837
2973
  removeA11yRecursively(node) {
2838
2974
  if (node.isDOMPortal) {
@@ -3148,10 +3284,32 @@ var Scene = class _Scene {
3148
3284
  get overlayRootEntity() {
3149
3285
  return this.overlayRoot;
3150
3286
  }
3287
+ /**
3288
+ * Advance and render exactly one frame, synchronously.
3289
+ *
3290
+ * This renders UNCONDITIONALLY: it consults neither {@link renderMode} nor
3291
+ * {@link dirty}, and it does not apply the `always`-mode idle auto-throttle.
3292
+ * That is deliberate — a deterministic driver (video export, a test, a
3293
+ * fixed-step benchmark) asks for a frame because it wants that frame, not a
3294
+ * scheduler opinion about whether it is needed.
3295
+ *
3296
+ * The consequence is a measurement footgun worth stating explicitly: a
3297
+ * benchmark that drives frames through `step()` CANNOT observe frame skipping,
3298
+ * so `always` and `onDemand` produce byte-identical draw counts through this
3299
+ * path. An investigation into whether `onDemand` skips redundant repaints once
3300
+ * concluded "it does not" on exactly that basis; on the real rAF loop the same
3301
+ * workload rendered ~1.0 frames per content change. To measure anything about
3302
+ * scheduling, use {@link start} and let `requestAnimationFrame` drive.
3303
+ *
3304
+ * @param dt Seconds to advance. Not clamped by `MAX_FRAME_DT` — the caller
3305
+ * chooses the step, since determinism is the point.
3306
+ */
3151
3307
  step(dt) {
3152
3308
  const time = this.lastTime + dt;
3153
3309
  this.lastTime = time;
3310
+ const t0 = this._phaseTiming ? performance.now() : 0;
3154
3311
  this.render(this.renderer, dt, time);
3312
+ if (this._phaseTiming) this._recordPhase("render", performance.now() - t0);
3155
3313
  this.dirty = false;
3156
3314
  }
3157
3315
  /**
@@ -3164,6 +3322,24 @@ var Scene = class _Scene {
3164
3322
  this.dirty = true;
3165
3323
  if (this._dirtyTracking && source) this.recordDirtyReason(source);
3166
3324
  }
3325
+ /**
3326
+ * Increments whenever the tree's shape changes: add, remove or reparent.
3327
+ *
3328
+ * Already maintained for the resident WASM transform store (see
3329
+ * {@link markStructureChanged}, called from `Entity.add`/`remove`), and exposed
3330
+ * here because a cache of the tree's shape — a DevTools tree model, a serialized
3331
+ * snapshot — is valid exactly as long as this value is unchanged. Comparing it is
3332
+ * O(1) against re-walking the tree, which is what it replaces: DevTools rebuilt
3333
+ * both trees on a fixed 500 ms interval, a constant cost proportional to entity
3334
+ * count, purely because it had no way to ask whether the shape had changed.
3335
+ *
3336
+ * Property changes do NOT bump it. Moving or restyling an entity leaves the
3337
+ * shape intact, so a consumer that also cares about values must read those
3338
+ * directly rather than rebuilding a tree.
3339
+ */
3340
+ get structureVersion() {
3341
+ return this._structureVersion;
3342
+ }
3167
3343
  /**
3168
3344
  * Record who marked the scene dirty and why.
3169
3345
  *
@@ -3286,6 +3462,11 @@ var Scene = class _Scene {
3286
3462
  if (node.isDOMPortal) {
3287
3463
  return;
3288
3464
  }
3465
+ if (node.a11yHidden) {
3466
+ this.pruneA11ySubtree(node);
3467
+ return;
3468
+ }
3469
+ const nodeStart = this._phaseTiming ? performance.now() : 0;
3289
3470
  if (this.shouldProjectA11y(node)) {
3290
3471
  let el = this.a11yElements.get(node.id);
3291
3472
  const attrs = node.getA11yAttributes();
@@ -3628,7 +3809,14 @@ var Scene = class _Scene {
3628
3809
  if (el.style.display !== display) el.style.display = display;
3629
3810
  }
3630
3811
  }
3631
- this.syncContentProjection(node);
3812
+ if (this._phaseTiming) {
3813
+ const projectionStart = performance.now();
3814
+ this.syncContentProjection(node);
3815
+ this._recordPhase("contentProjection", performance.now() - projectionStart);
3816
+ this._recordPhase("a11yNodes", projectionStart - nodeStart);
3817
+ } else {
3818
+ this.syncContentProjection(node);
3819
+ }
3632
3820
  for (const child of node.children) this.syncA11y(child);
3633
3821
  if (node === this.root) {
3634
3822
  for (const overlay of this.overlayRoot.children) this.syncA11y(overlay);
@@ -3742,7 +3930,9 @@ var Scene = class _Scene {
3742
3930
  this.clearContentGridState(node.id, el);
3743
3931
  }
3744
3932
  if (projection.grid) {
3933
+ const gridSyncStart = this._phaseTiming ? performance.now() : 0;
3745
3934
  this.syncContentGridProjection(node, el, projection, projection.grid);
3935
+ if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
3746
3936
  } else if (lines && lines.length > 0) {
3747
3937
  const signature = JSON.stringify({
3748
3938
  lines,
@@ -3848,16 +4038,33 @@ var Scene = class _Scene {
3848
4038
  const signature = `${grid.revision}`;
3849
4039
  if (el.dataset.vectoContentGrid !== signature) {
3850
4040
  const materializeStart = typeof performance !== "undefined" ? performance.now() : 0;
3851
- this.clearContentGridState(node.id, el);
3852
- el.replaceChildren();
4041
+ this.clearContentGridState(node.id, el, false);
3853
4042
  const projectionLines = projection.lines ?? [];
4043
+ const selectionLine = this.contentGridSelectionLine(el);
4044
+ let rebuiltSelectionLine = false;
4045
+ const existingLines = el.children;
3854
4046
  for (let lineIndex = 0; lineIndex < grid.lines.length; lineIndex++) {
3855
4047
  const gridLine = grid.lines[lineIndex];
3856
4048
  const projectedLine = projectionLines[lineIndex];
3857
4049
  const lineHeight = projectedLine?.lineHeight ?? grid.lineHeight;
3858
4050
  const baseline = projectedLine?.baseline ?? grid.baseline;
3859
4051
  const lineFont = projectedLine?.font ?? grid.font;
4052
+ const lineSignature = contentGridLineSignature(
4053
+ grid,
4054
+ gridLine,
4055
+ projectedLine,
4056
+ lineHeight,
4057
+ baseline,
4058
+ lineFont,
4059
+ lineIndex === 0
4060
+ );
4061
+ const reusable = existingLines[lineIndex];
4062
+ if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature && reusable.dataset.vectoGridLine === `${lineIndex}`) {
4063
+ continue;
4064
+ }
4065
+ if (selectionLine !== null && selectionLine === lineIndex) rebuiltSelectionLine = true;
3860
4066
  const lineElement = document.createElement("span");
4067
+ lineElement.dataset.vectoGridLineSig = lineSignature;
3861
4068
  lineElement.dir = "ltr";
3862
4069
  lineElement.dataset.vectoGridLine = `${lineIndex}`;
3863
4070
  lineElement.style.position = "absolute";
@@ -3899,6 +4106,8 @@ var Scene = class _Scene {
3899
4106
  cellElement.style.font = lineFont;
3900
4107
  cellElement.style.lineHeight = `${lineHeight}px`;
3901
4108
  cellElement.style.transformOrigin = "0 50%";
4109
+ cellElement.dataset.vectoGridFont = lineFont;
4110
+ cellElement.dataset.vectoGridLineHeight = `${lineHeight}px`;
3902
4111
  lineElement.appendChild(cellElement);
3903
4112
  logicalX += cell.advance;
3904
4113
  }
@@ -3922,13 +4131,24 @@ var Scene = class _Scene {
3922
4131
  lineElement.appendChild(marker);
3923
4132
  }
3924
4133
  }
3925
- el.appendChild(lineElement);
4134
+ const occupant = el.children[lineIndex];
4135
+ if (occupant) el.replaceChild(lineElement, occupant);
4136
+ else el.appendChild(lineElement);
3926
4137
  }
4138
+ while (el.children.length > grid.lines.length) {
4139
+ if (selectionLine !== null && selectionLine >= grid.lines.length) {
4140
+ rebuiltSelectionLine = true;
4141
+ }
4142
+ el.lastElementChild?.remove();
4143
+ }
4144
+ if (rebuiltSelectionLine) this.releaseContentSelectionForRebuild(el);
3927
4145
  el.dataset.vectoProjectionLines = signature;
3928
4146
  el.dataset.vectoContentGrid = signature;
3929
4147
  el.dataset.vectoGridCarriers = `${el.querySelectorAll("[data-vecto-grid-cell]").length}`;
3930
4148
  if (typeof performance !== "undefined") {
3931
- el.dataset.vectoGridMaterializeMs = `${performance.now() - materializeStart}`;
4149
+ const materializeMs = performance.now() - materializeStart;
4150
+ el.dataset.vectoGridMaterializeMs = `${materializeMs}`;
4151
+ if (this._phaseTiming) this._recordPhase("gridMaterialize", materializeMs);
3932
4152
  }
3933
4153
  delete el.dataset.vectoGridCalibration;
3934
4154
  delete el.dataset.vectoGridReady;
@@ -3936,7 +4156,11 @@ var Scene = class _Scene {
3936
4156
  const pageScaleX = this.getContentMetricScaleX();
3937
4157
  const calibrationKey = `${signature}:${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
3938
4158
  if (el.dataset.vectoGridCalibration !== calibrationKey) {
4159
+ const calibStart = this._phaseTiming ? performance.now() : 0;
3939
4160
  this.scheduleContentGridCalibration(node.id, el, calibrationKey, pageScaleX);
4161
+ if (this._phaseTiming) {
4162
+ this._recordPhase("gridCalibrateSchedule", performance.now() - calibStart);
4163
+ }
3940
4164
  }
3941
4165
  }
3942
4166
  getContentMetricScaleX() {
@@ -3954,6 +4178,27 @@ var Scene = class _Scene {
3954
4178
  scheduleContentGridCalibration(entityId, el, calibrationKey, pageScaleX) {
3955
4179
  if (typeof requestAnimationFrame !== "function") return;
3956
4180
  if (el.dataset.vectoGridCalibrationPending === calibrationKey) return;
4181
+ const stamp = `${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
4182
+ if (this.contentGridCalibrationStamp !== stamp) {
4183
+ this.contentGridCalibrationStamp = stamp;
4184
+ this.contentGridCalibrationGeneration++;
4185
+ }
4186
+ const generation = `${this.contentGridCalibrationGeneration}`;
4187
+ const pendingCells = el.querySelectorAll(
4188
+ `[data-vecto-grid-cell]:not([data-vecto-grid-calib="${generation}"])`
4189
+ );
4190
+ if (pendingCells.length === 0) {
4191
+ el.dataset.vectoGridCalibrationSamples = "0";
4192
+ delete el.dataset.vectoGridCalibrationPending;
4193
+ const readyFrame = requestAnimationFrame(() => {
4194
+ this.contentGridCalibrationFrames.delete(entityId);
4195
+ if (!el.isConnected) return;
4196
+ el.dataset.vectoGridCalibration = calibrationKey;
4197
+ el.dataset.vectoGridReady = "true";
4198
+ });
4199
+ this.contentGridCalibrationFrames.set(entityId, readyFrame);
4200
+ return;
4201
+ }
3957
4202
  const previous = this.contentGridCalibrationFrames.get(entityId);
3958
4203
  if (previous !== void 0 && typeof cancelAnimationFrame === "function") {
3959
4204
  cancelAnimationFrame(previous);
@@ -3984,18 +4229,22 @@ var Scene = class _Scene {
3984
4229
  probe.append(probeOrigin, probeX);
3985
4230
  const measurements = [];
3986
4231
  const measurementsByKey = /* @__PURE__ */ new Map();
3987
- for (const target of el.querySelectorAll("[data-vecto-grid-cell]")) {
4232
+ const scanStart = this._phaseTiming ? performance.now() : 0;
4233
+ for (const target of pendingCells) {
3988
4234
  const sourceLength = Number(target.dataset.vectoGridSourceLength ?? 0);
3989
4235
  const targetWidth = Number(target.dataset.vectoGridAdvance ?? 0);
3990
- if (sourceLength <= 0 || targetWidth <= 0) continue;
4236
+ if (sourceLength <= 0 || targetWidth <= 0) {
4237
+ target.dataset.vectoGridCalib = generation;
4238
+ continue;
4239
+ }
3991
4240
  const sourceText = target.textContent?.slice(0, sourceLength) ?? "";
3992
- if (!sourceText) continue;
3993
- const measurementKey = JSON.stringify([
3994
- target.style.font,
3995
- target.style.lineHeight,
3996
- targetWidth,
3997
- sourceText
3998
- ]);
4241
+ if (!sourceText) {
4242
+ target.dataset.vectoGridCalib = generation;
4243
+ continue;
4244
+ }
4245
+ const cellFont = target.dataset.vectoGridFont ?? "";
4246
+ const cellLineHeight = target.dataset.vectoGridLineHeight ?? "";
4247
+ const measurementKey = JSON.stringify([cellFont, cellLineHeight, targetWidth, sourceText]);
3999
4248
  const shared = measurementsByKey.get(measurementKey);
4000
4249
  if (shared) {
4001
4250
  shared.targets.push(target);
@@ -4007,8 +4256,8 @@ var Scene = class _Scene {
4007
4256
  carrier.style.left = "0";
4008
4257
  carrier.style.top = "0";
4009
4258
  carrier.style.whiteSpace = "pre";
4010
- carrier.style.font = target.style.font;
4011
- carrier.style.lineHeight = target.style.lineHeight;
4259
+ carrier.style.font = cellFont;
4260
+ carrier.style.lineHeight = cellLineHeight;
4012
4261
  carrier.style.fontVariantLigatures = "none";
4013
4262
  carrier.style.fontKerning = "none";
4014
4263
  const source = document.createTextNode(sourceText);
@@ -4023,7 +4272,17 @@ var Scene = class _Scene {
4023
4272
  measurements.push(measurement);
4024
4273
  measurementsByKey.set(measurementKey, measurement);
4025
4274
  }
4275
+ if (this._phaseTiming) this._recordPhase("calibScan", performance.now() - scanStart);
4276
+ if (measurements.length === 0) {
4277
+ el.dataset.vectoGridCalibration = calibrationKey;
4278
+ el.dataset.vectoGridReady = "true";
4279
+ el.dataset.vectoGridCalibrationSamples = "0";
4280
+ delete el.dataset.vectoGridCalibrationPending;
4281
+ return;
4282
+ }
4283
+ const appendStart = this._phaseTiming ? performance.now() : 0;
4026
4284
  (this.a11yRoot ?? document.body ?? document.documentElement).appendChild(probe);
4285
+ if (this._phaseTiming) this._recordPhase("calibProbeBuild", performance.now() - appendStart);
4027
4286
  el.dataset.vectoGridCalibrationSamples = `${measurements.length}`;
4028
4287
  this.contentGridCalibrationProbes.set(entityId, probe);
4029
4288
  el.dataset.vectoGridCalibrationPending = calibrationKey;
@@ -4067,6 +4326,7 @@ var Scene = class _Scene {
4067
4326
  }
4068
4327
  for (const { element, scale } of updates) {
4069
4328
  element.style.transform = Math.abs(scale - 1) <= 1e-3 ? "" : `scaleX(${scale})`;
4329
+ element.dataset.vectoGridCalib = generation;
4070
4330
  }
4071
4331
  el.dataset.vectoGridCalibration = calibrationKey;
4072
4332
  el.dataset.vectoGridReady = "true";
@@ -4372,7 +4632,9 @@ var Scene = class _Scene {
4372
4632
  }
4373
4633
  this._lastRenderTick = time;
4374
4634
  this._lastDt = dt;
4635
+ const phaseClock = this._phaseTiming ? performance.now() : 0;
4375
4636
  this.render(this.renderer, dt, time);
4637
+ if (this._phaseTiming) this._recordPhase("render", performance.now() - phaseClock);
4376
4638
  this._lastFrameMs = (typeof performance !== "undefined" ? performance.now() : time) - now;
4377
4639
  this._renderedFrames++;
4378
4640
  const hasActiveAnimation = this.frameHadAnimation;
@@ -4382,9 +4644,13 @@ var Scene = class _Scene {
4382
4644
  if ((hasInteractive || this.a11yElements.size > 0 || wantsContentSync) && (shouldSyncInterval || this.a11yPendingSyncAfterAnimation)) {
4383
4645
  this.lastA11ySync = time;
4384
4646
  if (hasInteractive || wantsContentSync) {
4647
+ const t0 = this._phaseTiming ? performance.now() : 0;
4385
4648
  this.syncA11y(this.root);
4649
+ if (this._phaseTiming) this._recordPhase("a11ySync", performance.now() - t0);
4386
4650
  }
4651
+ const t1 = this._phaseTiming ? performance.now() : 0;
4387
4652
  this.enforceA11yDomOrder();
4653
+ if (this._phaseTiming) this._recordPhase("a11yOrder", performance.now() - t1);
4388
4654
  this.a11yPendingSyncAfterAnimation = hasActiveAnimation;
4389
4655
  } else if (hasActiveAnimation) {
4390
4656
  this.a11yPendingSyncAfterAnimation = true;
@@ -4558,7 +4824,9 @@ var Scene = class _Scene {
4558
4824
  updateWalk(this.root);
4559
4825
  for (const overlay of this.overlayRoot.children) updateWalk(overlay);
4560
4826
  }
4827
+ const wasmT0 = this._phaseTiming ? performance.now() : 0;
4561
4828
  const wasmWorld = wasmMain ? this._syncWasmStore() : null;
4829
+ if (this._phaseTiming) this._recordPhase("transform", performance.now() - wasmT0);
4562
4830
  const wasmSlotEntity = this._slotEntity;
4563
4831
  const renderNode = (node, pa, pb, pc, pd, pe, pf, parentOpacity) => {
4564
4832
  if (isMainRenderer && !wasmMain) {
@@ -4688,7 +4956,13 @@ var Scene = class _Scene {
4688
4956
  );
4689
4957
  }
4690
4958
  } else {
4691
- node.render(renderer);
4959
+ if (this._phaseTiming) {
4960
+ const t0 = performance.now();
4961
+ node.render(renderer);
4962
+ this._recordPhase("entityPaint", performance.now() - t0);
4963
+ } else {
4964
+ node.render(renderer);
4965
+ }
4692
4966
  }
4693
4967
  }
4694
4968
  if (node.clipChildren) {
@@ -4700,20 +4974,24 @@ var Scene = class _Scene {
4700
4974
  renderer.flush();
4701
4975
  renderer.restore();
4702
4976
  };
4977
+ const drawT0 = this._phaseTiming ? performance.now() : 0;
4703
4978
  renderNode(this.root, 1, 0, 0, 1, 0, 0, 1);
4704
4979
  for (const overlay of this.overlayRoot.children) {
4705
4980
  renderNode(overlay, 1, 0, 0, 1, 0, 0, 1);
4706
4981
  }
4982
+ if (this._phaseTiming) this._recordPhase("drawWalk", performance.now() - drawT0);
4707
4983
  if (isMainRenderer) {
4708
4984
  this.frameHadAnimation = walkHadAnimation;
4709
4985
  this.frameHadInteractive = walkHadInteractive;
4710
4986
  this.reconcilePortals();
4711
4987
  }
4988
+ const flushT0 = this._phaseTiming ? performance.now() : 0;
4712
4989
  renderer.flush();
4713
4990
  if (isMainRenderer) {
4714
4991
  this.pointRenderer?.flush();
4715
4992
  }
4716
4993
  renderer.present?.();
4994
+ if (this._phaseTiming) this._recordPhase("flush", performance.now() - flushT0);
4717
4995
  if (this._devActive) {
4718
4996
  this._devFrameCount++;
4719
4997
  this._devRunChecks();
@@ -4989,6 +5267,41 @@ function intersectBounds(a, b) {
4989
5267
  function pointInBounds(b, x, y) {
4990
5268
  return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
4991
5269
  }
5270
+ function contentGridLineSignature(grid, line, projected, lineHeight, baseline, font, isFirstLine) {
5271
+ const parts = [
5272
+ // Line box: position, size, and the font that resolves its baseline.
5273
+ `${projected?.x ?? 0}`,
5274
+ `${projected?.y ?? ""}`,
5275
+ `${lineHeight}`,
5276
+ `${baseline}`,
5277
+ font,
5278
+ `${line.width}`,
5279
+ // The trailing hard break belongs to this line and lands in the DOM text.
5280
+ grid.source.slice(line.sourceEnd, line.nextSourceStart),
5281
+ // The basis markers are appended only to line 0, so a line moving to or from
5282
+ // index 0 changes its DOM even when nothing else does.
5283
+ isFirstLine ? "1" : "0"
5284
+ ];
5285
+ if (line.cells.length === 0) {
5286
+ parts.push("empty");
5287
+ } else {
5288
+ for (const cell of line.cells) {
5289
+ parts.push(
5290
+ `${cell.sourceStart}`,
5291
+ `${cell.sourceEnd}`,
5292
+ `${cell.x}`,
5293
+ `${cell.advance}`,
5294
+ `${cell.level}`,
5295
+ cell.sourceCaretOffsets.join("."),
5296
+ // Source text, not `cell.glyph`: the carrier holds the original characters
5297
+ // (the shaped glyph is the canvas's business), so a change in shaping alone
5298
+ // must not invalidate a carrier, and a change in source must.
5299
+ grid.source.slice(cell.sourceStart, cell.sourceEnd)
5300
+ );
5301
+ }
5302
+ }
5303
+ return parts.join("");
5304
+ }
4992
5305
 
4993
5306
  // src/components/TextEntity.ts
4994
5307
  import {
@@ -5757,6 +6070,7 @@ export {
5757
6070
  ComputeParticleEntity,
5758
6071
  DOMPortalEntity,
5759
6072
  Entity,
6073
+ GlyphRasterAtlas,
5760
6074
  GridTextEntity,
5761
6075
  Group,
5762
6076
  MSDFTextEntity,
@@ -111,6 +111,8 @@ export declare class CanvasRenderer implements IRenderer {
111
111
  /** @inheritdoc */
112
112
  drawImage(source: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
113
113
  /** @inheritdoc */
114
+ drawImageRect(source: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
115
+ /** @inheritdoc */
114
116
  fillCircle(cx: number, cy: number, radius: number, color: string, alpha?: number): void;
115
117
  /** @inheritdoc */
116
118
  flush(): void;