@vectojs/core 1.32.7 → 1.34.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/{chunk-FQ2565IT.js → chunk-UEIZDNK7.js} +57 -30
- package/dist/{chunk-YTGGL4I4.mjs → chunk-VFQMETOR.mjs} +27 -0
- package/dist/index.js +3082 -2280
- package/dist/index.mjs +2834 -2032
- package/dist/text.js +2 -2
- package/dist/text.mjs +1 -1
- package/dist/tree/Entity.d.ts +41 -0
- package/dist/tree/Scene.d.ts +199 -472
- package/dist/tree/scene/A11yProjectionManager.d.ts +147 -0
- package/dist/tree/scene/CanvasGeometry.d.ts +117 -0
- package/dist/tree/scene/ContentGridProjector.d.ts +63 -0
- package/dist/tree/scene/ContentProjectionManager.d.ts +217 -0
- package/dist/tree/scene/DirtyTracker.d.ts +107 -0
- package/dist/tree/scene/DriverTicker.d.ts +101 -0
- package/dist/tree/scene/HitTester.d.ts +113 -0
- package/dist/tree/scene/PhaseTimer.d.ts +114 -0
- package/dist/tree/scene/WasmBackendFacade.d.ts +271 -0
- package/dist/tree/scene/a11y-dom.d.ts +78 -0
- package/dist/tree/scene/content-break-carrier.d.ts +77 -0
- package/dist/tree/scene/content-caret.d.ts +28 -0
- package/dist/tree/scene/content-line-window.d.ts +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The batched property-driver tick: which entities have drivers in flight, and
|
|
3
|
+
* advancing them for one frame.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 6 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2), shipped at **heavily
|
|
7
|
+
* reduced scope** — see `DEC-0025` for the per-member measurement. The decided
|
|
8
|
+
* `RenderScheduler` scope named nine members and 989 lines; `loop` and `render`
|
|
9
|
+
* are not movable at all, so what ships is the two separable sub-clusters this
|
|
10
|
+
* file and {@link DirtyTracker} own.
|
|
11
|
+
*
|
|
12
|
+
* ## What this owns
|
|
13
|
+
*
|
|
14
|
+
* The candidate registry (`_activeDriverEntities`), the six reused scratch
|
|
15
|
+
* arrays, and the batch pass itself. The registry is the reason the pass is
|
|
16
|
+
* O(active drivers) rather than O(tree size), so registration and ticking are one
|
|
17
|
+
* domain: every write to the set exists to serve the walk that reads it.
|
|
18
|
+
*
|
|
19
|
+
* ## What is held, and what is passed in
|
|
20
|
+
*
|
|
21
|
+
* {@link WasmBackendFacade} is held: it is assigned once in `Scene`'s constructor
|
|
22
|
+
* and the anim backend is reached only through its public surface (`anim`,
|
|
23
|
+
* `animReason`, `animBatchedLastFrame`), exactly as `HitTester` reaches the hit
|
|
24
|
+
* backend.
|
|
25
|
+
*
|
|
26
|
+
* `dt`, the per-kind `gate` and `currentFrame` are per-call arguments
|
|
27
|
+
* (`DEC-0019` rule 5):
|
|
28
|
+
*
|
|
29
|
+
* - `currentFrame` is written by `render` and belongs to the frame loop, which
|
|
30
|
+
* did not move.
|
|
31
|
+
* - `gate` is `Scene.animGate`, a **public mutable field** that tests and
|
|
32
|
+
* benchmarks assign directly (`scene.animGate = { … }` at 8 sites, plus the
|
|
33
|
+
* `animDriverGateCount` alias setter). Holding it would both go stale and force
|
|
34
|
+
* the public field to become an accessor pair, which this sequence exists to
|
|
35
|
+
* avoid.
|
|
36
|
+
*
|
|
37
|
+
* ## What deliberately did not move
|
|
38
|
+
*
|
|
39
|
+
* `_tickBatchedDrivers`'s caller. `render` (575 lines) calls twelve `Scene`
|
|
40
|
+
* methods spanning six domains, and `loop` calls `render`, `syncA11y` and
|
|
41
|
+
* `enforceA11yDomOrder`. Both would need a `Scene` back-edge, which `DEC-0019`
|
|
42
|
+
* rule 1 forbids and which `DEC-0020` and `DEC-0021` already refused in the
|
|
43
|
+
* bound-callback form.
|
|
44
|
+
*/
|
|
45
|
+
import { Entity } from '../Entity';
|
|
46
|
+
import type { WasmBackendFacade } from './WasmBackendFacade';
|
|
47
|
+
/** Per-kind driver gates, in active batchable drivers. See `Scene.animGate`. */
|
|
48
|
+
export interface AnimDriverGate {
|
|
49
|
+
spring: number;
|
|
50
|
+
tween: number;
|
|
51
|
+
mixed: number;
|
|
52
|
+
}
|
|
53
|
+
export declare class DriverTicker {
|
|
54
|
+
private readonly backends;
|
|
55
|
+
private readonly activeEntities;
|
|
56
|
+
private springEntities;
|
|
57
|
+
private springProps;
|
|
58
|
+
private springDrivers;
|
|
59
|
+
private tweenEntities;
|
|
60
|
+
private tweenProps;
|
|
61
|
+
private tweenDrivers;
|
|
62
|
+
constructor(backends: WasmBackendFacade);
|
|
63
|
+
/** The candidate set, for the delegating accessor `Scene` keeps for its tests. */
|
|
64
|
+
get active(): Set<Entity>;
|
|
65
|
+
/** Register a single entity whose driver has just started. */
|
|
66
|
+
register(entity: Entity): void;
|
|
67
|
+
/**
|
|
68
|
+
* Drop `entity` and its whole subtree from the batched-driver candidate set.
|
|
69
|
+
* Called by `Scene.remove`/`Scene.hideOverlay` on detach: without this a
|
|
70
|
+
* removed-but-still-animating entity stays pinned in the Set (a leak) and its
|
|
71
|
+
* drivers keep ticking every frame even though it is off-tree. If it is later
|
|
72
|
+
* re-added, {@link registerSubtree} re-registers any node that still has live
|
|
73
|
+
* drivers, so the motion resumes.
|
|
74
|
+
*/
|
|
75
|
+
unregisterSubtree(entity: Entity): void;
|
|
76
|
+
/**
|
|
77
|
+
* Re-register every node in `entity`'s subtree that still has live property
|
|
78
|
+
* drivers. Called by `Scene.add`/`Scene.showOverlay` so re-attaching a subtree
|
|
79
|
+
* that was removed mid-animation resumes its batched drivers (they were
|
|
80
|
+
* dropped from the candidate set on removal, but the driver state still lives
|
|
81
|
+
* on each entity).
|
|
82
|
+
*/
|
|
83
|
+
registerSubtree(entity: Entity): void;
|
|
84
|
+
/**
|
|
85
|
+
* Advance every registered entity's active drivers for this frame, batching
|
|
86
|
+
* whichever are batchable (`SpringDriver`; `TweenDriver` with a named
|
|
87
|
+
* easing) through one WASM call each when the driver-count gate is open, and
|
|
88
|
+
* ticking the rest (a `TweenDriver` using a custom `EasingFn`) directly in
|
|
89
|
+
* JS regardless of the gate. A "claimed" entity must have ALL its drivers
|
|
90
|
+
* advanced here so it can be safely stamped `_driversTickedFrame` — leaving
|
|
91
|
+
* one unclaimed would silently stall it, since `tickDrivers()` skips the
|
|
92
|
+
* whole entity once stamped.
|
|
93
|
+
*
|
|
94
|
+
* Must run before ANY entity's `update()`/`tickDrivers()` this frame (see
|
|
95
|
+
* the call site in `Scene.render`) — the same ordering constraint G1 Stage 4
|
|
96
|
+
* discovered: a value this pass writes must be final before anything reads
|
|
97
|
+
* it, including the JS-mode interleaved walk and the WASM-mode transform
|
|
98
|
+
* pre-pass.
|
|
99
|
+
*/
|
|
100
|
+
tick(dt: number, gate: AnimDriverGate, currentFrame: number): void;
|
|
101
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer hit-testing: which entity is under a point, by two paths that must
|
|
3
|
+
* agree.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 4 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2). `Scene.findEntityAt`
|
|
7
|
+
* keeps its name and signature and delegates here.
|
|
8
|
+
*
|
|
9
|
+
* ## The two paths, and why both exist
|
|
10
|
+
*
|
|
11
|
+
* The **JS depth-first walk** ({@link findHitRecursively}) is the permanent
|
|
12
|
+
* fallback and the definition of correct: children in reverse draw order,
|
|
13
|
+
* topmost hit wins, clipped by every `clipChildren` ancestor.
|
|
14
|
+
*
|
|
15
|
+
* The **WASM broad phase** ({@link findEntityAt}'s accelerated arm) asks a
|
|
16
|
+
* spatial grid for one cell's candidates. It is flat, so it has no recursion
|
|
17
|
+
* clip-stack, which is why {@link isHitEligible} re-applies the same
|
|
18
|
+
* visibility/clip/pointer gating the walk gets structurally. Keeping those two
|
|
19
|
+
* in lockstep is the whole correctness argument for having a second path — if
|
|
20
|
+
* they can disagree, the accelerator is a bug generator rather than an
|
|
21
|
+
* optimisation.
|
|
22
|
+
*
|
|
23
|
+
* ## What this owns
|
|
24
|
+
*
|
|
25
|
+
* The hit-grid *contents* — the slot→entity table, the boundless list, and the
|
|
26
|
+
* reused fused-gather buffer. The cache *key* (`hitGridFrame`, `hitGridOk`) stays
|
|
27
|
+
* on {@link WasmBackendFacade}, because installing a backend has to invalidate
|
|
28
|
+
* it and that is the facade's business.
|
|
29
|
+
*
|
|
30
|
+
* ## What it deliberately does not own
|
|
31
|
+
*
|
|
32
|
+
* `clientToScene` and `setupEvents` sit under hit-test banners but are not hit
|
|
33
|
+
* testing:
|
|
34
|
+
*
|
|
35
|
+
* - `clientToScene` maps browser viewport coordinates to logical ones from
|
|
36
|
+
* `canvas`, `width` and `height` — all `ContextAndResize` state (extraction 5).
|
|
37
|
+
* - `setupEvents` wires the window resize listener, the embedded-canvas
|
|
38
|
+
* `ResizeObserver`, the DPR watch, and the pointer listeners that write
|
|
39
|
+
* `mouseX`/`mouseY`. Almost all of it is resize and canvas lifecycle.
|
|
40
|
+
*
|
|
41
|
+
* That is a fifth instance of `DEC-0016`'s finding that the domain banners
|
|
42
|
+
* expose wrong cuts — the same shape as `syncOverlayGeometry` in extraction 2.
|
|
43
|
+
*
|
|
44
|
+
* ## What is passed in, and why
|
|
45
|
+
*
|
|
46
|
+
* `root` and `overlayRoot` are held: `Scene` assigns both once in its constructor
|
|
47
|
+
* and never reassigns them. The facade is held because the hit backend is reached
|
|
48
|
+
* only through its public surface (`hit`, `hitReason`, `hitGridFrame`,
|
|
49
|
+
* `hitGridOk`, `ensureAabbs()`, `slotEntity`, `hitFusedGather`, `transform`).
|
|
50
|
+
*
|
|
51
|
+
* The frame counter and the viewport size are **per-call arguments** (`DEC-0019`
|
|
52
|
+
* rule 5): `currentFrame` belongs to the render scheduler (extraction 6) and
|
|
53
|
+
* `width`/`height` are mutated by `resize` (extraction 5), so neither can be
|
|
54
|
+
* captured at construction without going stale.
|
|
55
|
+
*/
|
|
56
|
+
import type { Bounds, Entity } from '../Entity';
|
|
57
|
+
import type { WasmBackendFacade } from './WasmBackendFacade';
|
|
58
|
+
export declare class HitTester {
|
|
59
|
+
private readonly root;
|
|
60
|
+
private readonly overlayRoot;
|
|
61
|
+
private readonly backends;
|
|
62
|
+
/** Slot index → entity, as built by the most recent grid build. */
|
|
63
|
+
private slotEntity;
|
|
64
|
+
/** Entities with no `getBounds()`, which the grid cannot index. */
|
|
65
|
+
private boundless;
|
|
66
|
+
/** Reused buffer for the fused gather, so a pointer query allocates nothing. */
|
|
67
|
+
private gatherBuffer;
|
|
68
|
+
constructor(root: Entity, overlayRoot: Entity, backends: WasmBackendFacade);
|
|
69
|
+
/**
|
|
70
|
+
* Finds the topmost interactive entity at the given coordinates.
|
|
71
|
+
*
|
|
72
|
+
* @param frame - `Scene.currentFrame`, the grid cache key's frame stamp.
|
|
73
|
+
* @param width - Scene logical width, for the grid's extent.
|
|
74
|
+
* @param height - Scene logical height, for the grid's extent.
|
|
75
|
+
*/
|
|
76
|
+
findEntityAt(x: number, y: number, frame: number, width: number, height: number): Entity | null;
|
|
77
|
+
/**
|
|
78
|
+
* Refresh the hit-test grid for the CURRENT tree state if it is stale (a
|
|
79
|
+
* structural or transform change may have happened since the last build —
|
|
80
|
+
* there is no cheap "nothing moved" shortcut for a spatial index the way
|
|
81
|
+
* there is for the transform store's topology-only run table, since ANY
|
|
82
|
+
* entity moving invalidates its AABB, not just add/remove/reparent; the
|
|
83
|
+
* measured build cost is cheap enough to redo per call). Returns `false`
|
|
84
|
+
* (grid untrustworthy — caller must use the JS walk) when there is no
|
|
85
|
+
* backend or the build overflowed its item budget.
|
|
86
|
+
*/
|
|
87
|
+
ensureHitGrid(frame: number, width: number, height: number): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* `findEntityAt`'s WASM-accelerated path for the main tree. Scans only the
|
|
90
|
+
* queried cell's candidates (confirming each against its own AABB and precise
|
|
91
|
+
* `isPointInside`) merged against the (typically empty or tiny) list of
|
|
92
|
+
* entities with no `getBounds()`, taking whichever confirmed match has the
|
|
93
|
+
* higher pre-order index — see hit-store.ts for why that is exactly
|
|
94
|
+
* equivalent to findHitRecursively's topmost-hit priority. Always
|
|
95
|
+
* conclusive: returns the correct entity or `null`, never "inconclusive".
|
|
96
|
+
*/
|
|
97
|
+
private findEntityAtWasm;
|
|
98
|
+
findHitRecursively(node: Entity, x: number, y: number, clip?: Bounds | null): Entity | null;
|
|
99
|
+
/** Whether `node` opts out of being a pointer hit target: a disabled control
|
|
100
|
+
* or an explicit `pointerEvents: 'none'` in its a11y attributes. Its children
|
|
101
|
+
* are still walked (a transparent container can hold hittable descendants). */
|
|
102
|
+
private isPointerTransparent;
|
|
103
|
+
/**
|
|
104
|
+
* Whether a confirmed geometric hit on `node` at world `(x, y)` is a REAL hit,
|
|
105
|
+
* applying the same visibility/input gating as {@link findHitRecursively} but
|
|
106
|
+
* from a flat candidate (the WASM grid has no recursion clip-stack): the node
|
|
107
|
+
* and all ancestors are visible (`opacity > 0`), the point lies inside every
|
|
108
|
+
* `clipChildren` ancestor's world box, and the node isn't pointer-transparent
|
|
109
|
+
* (disabled / `pointerEvents: 'none'`). Keeps the WASM and JS hit paths in
|
|
110
|
+
* lockstep so they return the same entity.
|
|
111
|
+
*/
|
|
112
|
+
private isHitEligible;
|
|
113
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase frame timing: which phase owned the frame, and how the samples add
|
|
3
|
+
* up.
|
|
4
|
+
*
|
|
5
|
+
* A **shared leaf** of the `Scene.ts` decomposition rather than one of the six
|
|
6
|
+
* domain collaborators. It is extracted ahead of extraction 3 because the
|
|
7
|
+
* content-projection calibration pass records two of its own phases
|
|
8
|
+
* (`calibScan`, `calibProbeBuild`) and cannot move while the only way to reach
|
|
9
|
+
* the accumulator is `Scene._recordPhase`. Handing a collaborator
|
|
10
|
+
* `scene._recordPhase.bind(scene)` would put a `Scene` reference inside a
|
|
11
|
+
* closure, which is the violation of `DEC-0019` rule 1 that `DEC-0020` refused
|
|
12
|
+
* for `syncA11y`. A shared object that nobody's `Scene` is reachable through has
|
|
13
|
+
* neither problem: `Scene` owns one of these, and each collaborator that records
|
|
14
|
+
* a phase holds the same instance.
|
|
15
|
+
*
|
|
16
|
+
* Nine methods across four domains read the enable flag today — the render walk,
|
|
17
|
+
* the frame loop, the a11y sync, the content projection and its grid pass, the
|
|
18
|
+
* calibration scheduler — so this would have had to become shared state at
|
|
19
|
+
* whichever extraction reached it first regardless.
|
|
20
|
+
*
|
|
21
|
+
* ## Why totals rather than a log
|
|
22
|
+
*
|
|
23
|
+
* The question is always "which phase owns the frame". A per-frame log of
|
|
24
|
+
* thousands of samples answers that less directly and costs far more memory.
|
|
25
|
+
* `maxMs` is kept because a phase that is cheap on average but spikes is a
|
|
26
|
+
* different problem from one that is uniformly slow.
|
|
27
|
+
*
|
|
28
|
+
* ## The disabled path must cost nothing
|
|
29
|
+
*
|
|
30
|
+
* These probes sit on the frame path, so {@link enabled} is a plain boolean field
|
|
31
|
+
* and every call site is expected to test it before doing any timing work —
|
|
32
|
+
* including the `performance.now()` calls themselves.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* A timed phase of a frame.
|
|
36
|
+
*
|
|
37
|
+
* `render` is the ENCLOSING phase — it contains `transform`, `drawWalk` and
|
|
38
|
+
* `flush` — so it is reported without a share to avoid double-counting.
|
|
39
|
+
* `a11ySync` and `a11yOrder` run after `render` in the frame loop, so they are
|
|
40
|
+
* siblings of it, not children.
|
|
41
|
+
*/
|
|
42
|
+
export type RenderPhase = 'render' | 'transform' | 'drawWalk' | 'flush' | 'a11ySync'
|
|
43
|
+
/**
|
|
44
|
+
* Time inside {@link Scene.syncContentGridProjection} materializing DOM
|
|
45
|
+
* carriers, nested inside `a11ySync`.
|
|
46
|
+
*
|
|
47
|
+
* Split out because `a11ySync` for a streaming code block measured 1661-1875 ms
|
|
48
|
+
* against a 210-671 ms render, and attributing that to grid materialization was
|
|
49
|
+
* an assumption. Nothing should be optimised here on the strength of the parent
|
|
50
|
+
* phase alone.
|
|
51
|
+
*/
|
|
52
|
+
| 'gridMaterialize'
|
|
53
|
+
/**
|
|
54
|
+
* Whole of {@link Scene.syncContentProjection}, nested inside `a11ySync`.
|
|
55
|
+
*
|
|
56
|
+
* Measured at 99.8-99.9% of `a11ySync` for a streaming code block, so per-node
|
|
57
|
+
* a11y attribute and geometry work is not where that phase's cost lives.
|
|
58
|
+
*/
|
|
59
|
+
| 'contentProjection'
|
|
60
|
+
/** Per-node a11y attribute/geometry work, excluding content projection and descendants. */
|
|
61
|
+
| 'a11yNodes'
|
|
62
|
+
/** Whole of `syncContentGridProjection`, of which `gridMaterialize` is one part. */
|
|
63
|
+
| 'gridSync'
|
|
64
|
+
/**
|
|
65
|
+
* Synchronous part of `scheduleContentGridCalibration` — building the probe DOM.
|
|
66
|
+
*
|
|
67
|
+
* The measurement itself is deferred to a rAF, but the probe is constructed
|
|
68
|
+
* here. Measured at 77-80% of `gridSync` on Chrome (3.7-4.5 ms per frame, i.e.
|
|
69
|
+
* the entire 240Hz budget) against about 1 ms on Firefox, making it the largest
|
|
70
|
+
* remaining cost of projecting a streaming code block once carrier reuse landed.
|
|
71
|
+
*/
|
|
72
|
+
| 'gridCalibrateSchedule'
|
|
73
|
+
/** The `querySelectorAll` + per-cell scan inside calibration scheduling. */
|
|
74
|
+
| 'calibScan'
|
|
75
|
+
/** Probe DOM construction and insertion inside calibration scheduling. */
|
|
76
|
+
| 'calibProbeBuild' | 'a11yOrder'
|
|
77
|
+
/** Sum of every entity's own render(), nested inside drawWalk. */
|
|
78
|
+
| 'entityPaint';
|
|
79
|
+
export interface RenderPhaseEntry {
|
|
80
|
+
phase: RenderPhase;
|
|
81
|
+
totalMs: number;
|
|
82
|
+
calls: number;
|
|
83
|
+
avgMs: number;
|
|
84
|
+
/** Worst single sample — a spiky phase is a different problem from a slow one. */
|
|
85
|
+
maxMs: number;
|
|
86
|
+
/** Percent of the measured total, or `null` for the enclosing `render` phase. */
|
|
87
|
+
share: number | null;
|
|
88
|
+
}
|
|
89
|
+
export declare class PhaseTimer {
|
|
90
|
+
/**
|
|
91
|
+
* Whether per-phase timing is being recorded.
|
|
92
|
+
*
|
|
93
|
+
* A field rather than an accessor: it is tested on the frame path once per
|
|
94
|
+
* probe, and the disabled cost has to be a single boolean read.
|
|
95
|
+
*/
|
|
96
|
+
enabled: boolean;
|
|
97
|
+
/** Whether browser User Timing instrumentation is enabled. */
|
|
98
|
+
userTiming: boolean;
|
|
99
|
+
private readonly totals;
|
|
100
|
+
/** Start or stop recording. Disabling also drops what was collected. */
|
|
101
|
+
setEnabled(enabled: boolean): void;
|
|
102
|
+
/** Accumulate one phase sample. */
|
|
103
|
+
record(phase: RenderPhase, ms: number): void;
|
|
104
|
+
/**
|
|
105
|
+
* Recorded phase timings, most expensive first, with each phase's share of the
|
|
106
|
+
* measured total.
|
|
107
|
+
*
|
|
108
|
+
* `share` is the number that matters: a phase at 4% cannot be worth optimising
|
|
109
|
+
* however inefficient it looks in isolation.
|
|
110
|
+
*/
|
|
111
|
+
get entries(): RenderPhaseEntry[];
|
|
112
|
+
/** Drop recorded timings, keeping recording enabled. */
|
|
113
|
+
clear(): void;
|
|
114
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four invisible WASM accelerators, and the per-frame report that describes
|
|
3
|
+
* what each of them actually did.
|
|
4
|
+
*
|
|
5
|
+
* Extraction 1 of the `Scene.ts` decomposition
|
|
6
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2). `Scene` keeps every
|
|
7
|
+
* public method and getter it had — `setTransformBackend`, `enableWasmHitTest`,
|
|
8
|
+
* `accelerators`, and the rest — and each one now delegates here. The public API
|
|
9
|
+
* is byte-identical; only where the state lives changed.
|
|
10
|
+
*
|
|
11
|
+
* ## What this owns, and what it deliberately does not
|
|
12
|
+
*
|
|
13
|
+
* It owns the four backend handles, the shared runtime, the resident transform
|
|
14
|
+
* store, and every field the accelerator report reads.
|
|
15
|
+
*
|
|
16
|
+
* It does NOT own three things that sit inside `Scene.ts`'s WASM comment region
|
|
17
|
+
* but belong to other domains — the domain banners were added first precisely to
|
|
18
|
+
* make that visible before any code moved (carryctx `DEC-0016`):
|
|
19
|
+
*
|
|
20
|
+
* - `_ensureHitGrid` / `_findEntityAtWasm` are the hit-test broad phase
|
|
21
|
+
* (`HitTester`, extraction 4). They *read* this facade.
|
|
22
|
+
* - `_tickBatchedDrivers` is the scheduler's batched driver tick
|
|
23
|
+
* (`RenderScheduler`, extraction 6). It reads the anim backend from here.
|
|
24
|
+
* - `_computeEntitiesFor` walks the tree for `ComputeParticleEntity` instances
|
|
25
|
+
* and is consumed by the render walk. Only its cache key — the structure
|
|
26
|
+
* version — is store state, so the version lives here and the walk stays on
|
|
27
|
+
* `Scene`.
|
|
28
|
+
*
|
|
29
|
+
* ## Why the reporting fields live here rather than with their writers
|
|
30
|
+
*
|
|
31
|
+
* `transformReason` is written by the render walk, `animReason` by the driver
|
|
32
|
+
* tick, `hitReason` by the hit-grid build, `particleReason` by the particle
|
|
33
|
+
* pass — four domains, four different extractions. They are collected here
|
|
34
|
+
* anyway because {@link report} is the single reader that has to make them
|
|
35
|
+
* consistent, and splitting them across four collaborators would leave that one
|
|
36
|
+
* getter reaching into all four. Each writer sets its own field through a public
|
|
37
|
+
* property on this object; none of them touches `Scene`'s privates.
|
|
38
|
+
*
|
|
39
|
+
* ## The one fact this cannot know
|
|
40
|
+
*
|
|
41
|
+
* `particle.available` is true when a WASM particle backend is installed **or**
|
|
42
|
+
* WebGPU is live, and WebGPU device state belongs to `ContextAndResize`
|
|
43
|
+
* (extraction 5). So {@link report} takes it as an argument rather than
|
|
44
|
+
* reaching for it. That is the whole of this facade's dependency on the rest of
|
|
45
|
+
* `Scene`.
|
|
46
|
+
*/
|
|
47
|
+
import type { Entity } from '../Entity';
|
|
48
|
+
import { type WasmTransformBackend } from '../../wasm/backend';
|
|
49
|
+
import { type CoreModuleSource, type CoreWasmRuntime } from '../../wasm/runtime';
|
|
50
|
+
import type { HitTestBackend } from '../../wasm/hit-backend';
|
|
51
|
+
import type { AnimBackend } from '../../wasm/anim-backend';
|
|
52
|
+
import type { ParticleBackend } from '../../wasm/particle-backend';
|
|
53
|
+
/**
|
|
54
|
+
* Why an accelerator did or did not run on the most recent frame.
|
|
55
|
+
*
|
|
56
|
+
* `'active'` is the only value that means the accelerator ran. Everything else
|
|
57
|
+
* is a distinct decline, kept separate because they call for different actions:
|
|
58
|
+
* `'not-installed'` means enable it, `'below-gate'` means the workload is too
|
|
59
|
+
* small to be worth it (working as designed), and `'rejected'` means the kernel
|
|
60
|
+
* refused its arguments — a fault worth reporting, not a tuning outcome.
|
|
61
|
+
*
|
|
62
|
+
* Declared here rather than in `Scene.ts` because this is the class that owns
|
|
63
|
+
* every field of this type. `Scene.ts` re-exports all three accelerator types,
|
|
64
|
+
* so `@vectojs/core`'s barrel keeps publishing them unchanged — `AcceleratorReason`
|
|
65
|
+
* in particular is consumed by `@vectojs/devtools`.
|
|
66
|
+
*/
|
|
67
|
+
export type AcceleratorReason =
|
|
68
|
+
/** Ran on this frame. */
|
|
69
|
+
'active'
|
|
70
|
+
/** No backend installed; the JS path is the permanent fallback. */
|
|
71
|
+
| 'not-installed'
|
|
72
|
+
/** Installed, but the per-frame gate chose JS (workload below threshold). */
|
|
73
|
+
| 'below-gate'
|
|
74
|
+
/** Installed and gated in, but the kernel rejected the call and wrote nothing. */
|
|
75
|
+
| 'rejected'
|
|
76
|
+
/** Not applicable to this pass (e.g. a non-main renderer, or nothing to do). */
|
|
77
|
+
| 'not-applicable';
|
|
78
|
+
/**
|
|
79
|
+
* One accelerator's per-frame status, read from {@link Scene.accelerators}.
|
|
80
|
+
*
|
|
81
|
+
* The pair exists because `available` and `activeThisFrame` genuinely differ:
|
|
82
|
+
* before this shape, `transformBackend`/`animBackend` reported only that a
|
|
83
|
+
* backend was *installed*, which invites concluding an accelerator is doing work
|
|
84
|
+
* when its gate never opens. Read `activeThisFrame` for what actually happened
|
|
85
|
+
* and `reason` for why.
|
|
86
|
+
*/
|
|
87
|
+
export interface AcceleratorStatus {
|
|
88
|
+
/** A backend is installed and could run, gate permitting. */
|
|
89
|
+
available: boolean;
|
|
90
|
+
/** It ran on the most recent frame. */
|
|
91
|
+
activeThisFrame: boolean;
|
|
92
|
+
/** Why it did or did not run. */
|
|
93
|
+
reason: AcceleratorReason;
|
|
94
|
+
/** Which implementation actually did the work on the most recent frame. */
|
|
95
|
+
path: string;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Per-frame status of every invisible accelerator, read from
|
|
99
|
+
* {@link Scene.accelerators}. Each is independent: a scene can compose
|
|
100
|
+
* transforms in WASM while ticking drivers in JS.
|
|
101
|
+
*/
|
|
102
|
+
export interface AcceleratorReport {
|
|
103
|
+
/** World-matrix composition (`compose_simd`). */
|
|
104
|
+
transform: AcceleratorStatus;
|
|
105
|
+
/** Batched property drivers (`spring_step`/`tween_step`). */
|
|
106
|
+
animation: AcceleratorStatus;
|
|
107
|
+
/** Hit-test broad phase (`hit_build`/`hit_query`) and its gather source. */
|
|
108
|
+
hitTest: AcceleratorStatus;
|
|
109
|
+
/** Particle simulation — WebGPU compute, the WASM CPU kernel, or JS. */
|
|
110
|
+
particle: AcceleratorStatus;
|
|
111
|
+
}
|
|
112
|
+
export declare class WasmBackendFacade {
|
|
113
|
+
/**
|
|
114
|
+
* The main tree's root.
|
|
115
|
+
*
|
|
116
|
+
* Held by value rather than as a `Scene` reference: it is assigned exactly
|
|
117
|
+
* once in `Scene`'s constructor and never reassigned, and taking it directly
|
|
118
|
+
* is what keeps this class from holding a back-edge to its facade. Only the
|
|
119
|
+
* main tree is ever composed through the store — overlays render on the JS
|
|
120
|
+
* path, so the overlay root is not needed here.
|
|
121
|
+
*/
|
|
122
|
+
private readonly root;
|
|
123
|
+
constructor(root: Entity);
|
|
124
|
+
private _transform;
|
|
125
|
+
private _mode;
|
|
126
|
+
private _treeStore;
|
|
127
|
+
private _slotEntity;
|
|
128
|
+
private _inputs;
|
|
129
|
+
private _world;
|
|
130
|
+
private _structureVersion;
|
|
131
|
+
private _storeStructureVersion;
|
|
132
|
+
/**
|
|
133
|
+
* Consecutive `uploadRuns` rejections. Reset by any success and by
|
|
134
|
+
* {@link setTransform}; at {@link WASM_UPLOAD_REJECT_LIMIT} the backend mode
|
|
135
|
+
* flips to `'js'` permanently.
|
|
136
|
+
*/
|
|
137
|
+
private _uploadRejections;
|
|
138
|
+
/** Latch for the permanent-fallback warning, which fires from a per-frame path. */
|
|
139
|
+
private hasWarnedUploadFallback;
|
|
140
|
+
/**
|
|
141
|
+
* Whether `compute_aabbs` has run against the current frame's world matrices.
|
|
142
|
+
* The AABB pass is only meaningful after a `compose_*`, so the fused gather
|
|
143
|
+
* must not read the views before then.
|
|
144
|
+
*/
|
|
145
|
+
private _aabbsFresh;
|
|
146
|
+
/** The installed transform backend, or `null` on the JS path. */
|
|
147
|
+
get transform(): WasmTransformBackend | null;
|
|
148
|
+
/** Which backend composes world matrices for the main render walk. */
|
|
149
|
+
get mode(): 'js' | 'wasm';
|
|
150
|
+
/**
|
|
151
|
+
* Whether the render walk should source world matrices from the store this
|
|
152
|
+
* frame: a backend is installed AND the mode has not fallen back to JS.
|
|
153
|
+
*/
|
|
154
|
+
get transformActive(): boolean;
|
|
155
|
+
/** Store slot -> entity, for the render walk and the fused hit gather. */
|
|
156
|
+
get slotEntity(): Entity[];
|
|
157
|
+
/** Bumped by every topology change; the store layout's cache key. */
|
|
158
|
+
get structureVersion(): number;
|
|
159
|
+
/** Which structure version the resident store layout was built for. */
|
|
160
|
+
get storeStructureVersion(): number;
|
|
161
|
+
/** Consecutive `uploadRuns` rejections; see {@link WASM_UPLOAD_REJECT_LIMIT}. */
|
|
162
|
+
get uploadRejections(): number;
|
|
163
|
+
/** Invalidate the resident WASM store layout; the next wasm-mode frame
|
|
164
|
+
* rebuilds it. Called by `Entity.add`/`remove` (topology changes only). */
|
|
165
|
+
markStructureChanged(): void;
|
|
166
|
+
/**
|
|
167
|
+
* Install (or clear) a WASM transform backend. Passing a backend switches the
|
|
168
|
+
* main render walk onto it; passing `null` reverts to the JS path.
|
|
169
|
+
*/
|
|
170
|
+
setTransform(backend: WasmTransformBackend | null): void;
|
|
171
|
+
/**
|
|
172
|
+
* The one WASM instance this Scene's accelerators share.
|
|
173
|
+
*
|
|
174
|
+
* Each `enableWasm*` used to instantiate the binary itself, so enabling all
|
|
175
|
+
* four compiled the same module four times and held four linear memories. The
|
|
176
|
+
* Rust crate already keeps transform/anim/hit/particle in separate statics, so
|
|
177
|
+
* one instance serves all of them without aliasing. The compiled module is
|
|
178
|
+
* cached globally; the instance is per-Scene, which is the isolation that
|
|
179
|
+
* actually matters.
|
|
180
|
+
*/
|
|
181
|
+
private _runtime;
|
|
182
|
+
/** The shared WASM runtime, if one has been loaded. */
|
|
183
|
+
get runtime(): CoreWasmRuntime | null;
|
|
184
|
+
/**
|
|
185
|
+
* Install a pre-built runtime, so several Scenes can share one compile while
|
|
186
|
+
* each keeps its own stores. Pass `null` to detach (backends already installed
|
|
187
|
+
* keep working; only subsequent `enableWasm*` calls re-load).
|
|
188
|
+
*/
|
|
189
|
+
setRuntime(runtime: CoreWasmRuntime | null): void;
|
|
190
|
+
/**
|
|
191
|
+
* Load (or reuse) this Scene's shared WASM runtime.
|
|
192
|
+
*
|
|
193
|
+
* Returns `null` on any failure — CSP `wasm-unsafe-eval`, a 404, corrupt bytes,
|
|
194
|
+
* unsupported SIMD — so every caller keeps its JS path. Failure is the default
|
|
195
|
+
* state here, not an error path.
|
|
196
|
+
*/
|
|
197
|
+
ensureRuntime(source: CoreModuleSource): Promise<CoreWasmRuntime | null>;
|
|
198
|
+
private _hit;
|
|
199
|
+
private _anim;
|
|
200
|
+
private _particle;
|
|
201
|
+
/** The installed hit-test backend, or `null` for the JS depth-first walk. */
|
|
202
|
+
get hit(): HitTestBackend | null;
|
|
203
|
+
/** Install (or clear) a WASM hit-test backend. */
|
|
204
|
+
setHit(backend: HitTestBackend | null): void;
|
|
205
|
+
/** The installed batched-animation backend, or `null` for the JS tick. */
|
|
206
|
+
get anim(): AnimBackend | null;
|
|
207
|
+
/** Install (or clear) a WASM batched-animation backend. */
|
|
208
|
+
setAnim(backend: AnimBackend | null): void;
|
|
209
|
+
/** The installed particle backend, or `null` for the JS `updateCPU` path. */
|
|
210
|
+
get particle(): ParticleBackend | null;
|
|
211
|
+
/** Install (or clear) a WASM particle backend. */
|
|
212
|
+
setParticle(backend: ParticleBackend | null): void;
|
|
213
|
+
/**
|
|
214
|
+
* Why the transform accelerator did or did not run on the most recent frame.
|
|
215
|
+
* Written by the render walk and {@link syncStore}.
|
|
216
|
+
*/
|
|
217
|
+
transformReason: AcceleratorReason;
|
|
218
|
+
/** Why the batched-driver accelerator did or did not run. */
|
|
219
|
+
animReason: AcceleratorReason;
|
|
220
|
+
/**
|
|
221
|
+
* Why the hit-test accelerator did or did not serve the last pointer query.
|
|
222
|
+
* The grid is built lazily on demand, not every frame, so this describes the
|
|
223
|
+
* most recent BUILD. Starts at `'not-installed'` because that is the truth
|
|
224
|
+
* before a backend exists; the grid build moves it to `'not-applicable'` once
|
|
225
|
+
* one is installed but nothing has queried yet.
|
|
226
|
+
*/
|
|
227
|
+
hitReason: AcceleratorReason;
|
|
228
|
+
/** Why the particle accelerator did or did not run. */
|
|
229
|
+
particleReason: AcceleratorReason;
|
|
230
|
+
/** Which particle implementation actually simulated the most recent frame. */
|
|
231
|
+
particlePath: string;
|
|
232
|
+
/**
|
|
233
|
+
* Whether the last grid build sourced its AABBs from the WASM transform store
|
|
234
|
+
* rather than recomputing them in JS. Diagnostic only — both paths must
|
|
235
|
+
* produce the same entity for a given point.
|
|
236
|
+
*/
|
|
237
|
+
hitFusedGather: boolean;
|
|
238
|
+
/** Whether the WASM batch path actually ran on the most recent frame. */
|
|
239
|
+
animBatchedLastFrame: boolean;
|
|
240
|
+
/** Frame the hit grid was last built for; `-1` forces a rebuild. */
|
|
241
|
+
hitGridFrame: number;
|
|
242
|
+
/** Whether that build succeeded (did not overflow its item budget). */
|
|
243
|
+
hitGridOk: boolean;
|
|
244
|
+
/**
|
|
245
|
+
* Per-frame status of every invisible accelerator: whether each is installed,
|
|
246
|
+
* whether it actually ran on the most recent frame, and why.
|
|
247
|
+
*
|
|
248
|
+
* `webgpuActive` is passed in because WebGPU device state belongs to
|
|
249
|
+
* `ContextAndResize`, not here — see the class comment.
|
|
250
|
+
*/
|
|
251
|
+
report(webgpuActive: boolean): AcceleratorReport;
|
|
252
|
+
/**
|
|
253
|
+
* Compose the whole main tree's world matrices through the resident WASM store
|
|
254
|
+
* and return the world-matrix views for the render walk to read. Rebuilds the
|
|
255
|
+
* store layout (slots + runs) only when the tree structure changed since the
|
|
256
|
+
* last rebuild; otherwise it just gathers current transforms into the resident
|
|
257
|
+
* input view and runs the kernel. Returns `null` if there is no backend.
|
|
258
|
+
*/
|
|
259
|
+
syncStore(): ReturnType<WasmTransformBackend['worldView']> | null;
|
|
260
|
+
/**
|
|
261
|
+
* Run the WASM world-AABB pass over the current frame's world matrices, so the
|
|
262
|
+
* fused hit gather can read AABBs straight out of the store.
|
|
263
|
+
*
|
|
264
|
+
* Local bounds are uploaded here rather than in the per-frame transform sync
|
|
265
|
+
* because `getBounds()` is a virtual call that allocates a rect on most
|
|
266
|
+
* entities — paying it every frame for a query that may never come would move
|
|
267
|
+
* cost onto the render path to save it on hover. Returns `false` if any entity
|
|
268
|
+
* cannot supply bounds through the store, so the caller uses the JS gather.
|
|
269
|
+
*/
|
|
270
|
+
ensureAabbs(): boolean;
|
|
271
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless helpers for the a11y projection's DOM mirrors.
|
|
3
|
+
*
|
|
4
|
+
* First module of the `Scene.ts` decomposition
|
|
5
|
+
* (`forge/decisions/file-decomposition-2026-08.md` §2, `TODO.md`'s P3 refactor
|
|
6
|
+
* entry). The decision record calls for extracting the stateless top-level
|
|
7
|
+
* functions before any stateful manager, and these are both: pure functions of
|
|
8
|
+
* their arguments, no `Scene` reference, no back-edge to `Scene.ts`. That keeps
|
|
9
|
+
* the import graph a one-way edge (`Scene.ts → scene/a11y-dom.ts`) and avoids
|
|
10
|
+
* the temporal-dead-zone hazard the Markdown split hit, where a module
|
|
11
|
+
* importing a class back from its facade to `extends` it threw during module
|
|
12
|
+
* initialization.
|
|
13
|
+
*
|
|
14
|
+
* Neither symbol is re-exported from `Scene.ts`. Both were module-private
|
|
15
|
+
* there, and `packages/core/src/index.ts` is `export * from './tree/Scene'`, so
|
|
16
|
+
* re-exporting would silently widen the public API — the opposite of this
|
|
17
|
+
* refactor's byte-identical-surface requirement.
|
|
18
|
+
*
|
|
19
|
+
* When `A11yProjectionManager` (extraction 2) lands, it is the natural owner of
|
|
20
|
+
* both. Until then `Scene` imports them directly.
|
|
21
|
+
*/
|
|
22
|
+
import type { AffineTransform } from '../Entity';
|
|
23
|
+
/**
|
|
24
|
+
* Whether the element takes focus without an explicit `tabindex`.
|
|
25
|
+
*
|
|
26
|
+
* The projection adds `tabindex="0"` only to mirrors that need it: a mirror
|
|
27
|
+
* carrying an interactive ARIA role but rendered as a non-focusable tag is
|
|
28
|
+
* unreachable by keyboard, while adding `tabindex` to an element that is
|
|
29
|
+
* already focusable is redundant and reorders nothing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isNativelyFocusable(element: HTMLElement): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* A nested mirror's box, expressed relative to its projected parent.
|
|
34
|
+
*
|
|
35
|
+
* Reused across calls rather than returned fresh: the geometry write runs for
|
|
36
|
+
* every projected node on every synced frame, and a virtualized table can
|
|
37
|
+
* nest several hundred cells, so a per-node literal here would allocate in the
|
|
38
|
+
* frame loop. The single caller consumes the fields immediately.
|
|
39
|
+
*/
|
|
40
|
+
export interface RebasedBox {
|
|
41
|
+
left: number;
|
|
42
|
+
top: number;
|
|
43
|
+
matrix: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Re-express a child's **world** transform as a box positioned inside its
|
|
47
|
+
* projected parent's box.
|
|
48
|
+
*
|
|
49
|
+
* Mirrors are `position: absolute`, so their `left`/`top` resolve against the
|
|
50
|
+
* nearest positioned ancestor. While the projection is flat that ancestor is
|
|
51
|
+
* always `a11yRoot` and world coordinates are correct as-written. The moment a
|
|
52
|
+
* mirror is nested inside another mirror, its containing block becomes the
|
|
53
|
+
* parent — writing world coordinates then *double-offsets* every descendant,
|
|
54
|
+
* and the parent's `matrix()` compounds on top. Measured in real Chrome and
|
|
55
|
+
* Firefox: a row at world (110, 80) under a grid at (100, 50) landed at
|
|
56
|
+
* (210, 130), and a cell at (120, 90) landed at (330, 220).
|
|
57
|
+
*
|
|
58
|
+
* The correction is not a plain subtraction. `left`/`top` are applied *before*
|
|
59
|
+
* the ancestor's `transform`, so the offset has to be expressed in the parent's
|
|
60
|
+
* pre-transform space: divide the world delta by the parent's linear part
|
|
61
|
+
* rather than subtracting its translation. The linear part is likewise relative
|
|
62
|
+
* — `inv(P) · C`, which collapses to the identity when the child adds no
|
|
63
|
+
* rotation or scale of its own.
|
|
64
|
+
*
|
|
65
|
+
* Both engines reproduce the flat layout exactly under this transform,
|
|
66
|
+
* including a parent rotated 30° and scaled 1.5×. That agreement depends on
|
|
67
|
+
* `transformOrigin: '0 0'` (set on every mirror at creation); with the default
|
|
68
|
+
* `50% 50%` each nested box rotates about its own centre and the results
|
|
69
|
+
* diverge by tens of pixels.
|
|
70
|
+
*
|
|
71
|
+
* A singular parent matrix (zero width or height, or a collapsed scale) has no
|
|
72
|
+
* inverse. Rather than emit `NaN` — which reads as `left: 0` and silently
|
|
73
|
+
* relocates the element to the parent's origin, where the reading-order sort
|
|
74
|
+
* would then treat it as the top-left-most element on screen — the child is
|
|
75
|
+
* pinned to the parent's origin with an identity matrix. A zero-area parent is
|
|
76
|
+
* already invisible, and `projectionBoxVisible` hides it on the same frame.
|
|
77
|
+
*/
|
|
78
|
+
export declare function rebaseChildBox(parent: AffineTransform, parentOriginX: number, parentOriginY: number, child: AffineTransform, childOriginX: number, childOriginY: number): RebasedBox;
|