export interface IWebGLPointRenderer { resize(width: number, height: number): void; } export type WebGLPointRendererCreator = (canvas: HTMLCanvasElement) => any; export interface IWebGPUParticleSystemManager { new (device: GPUDevice): any; initPipelines(format: GPUTextureFormat): Promise | void; setupEntityResources(entity: any): void; recordComputePass(pass: GPUComputePassEncoder, entity: any, dt: number, mouseX: number, mouseY: number, width: number, height: number): void; recordRenderPass(renderPassEncoder: GPURenderPassEncoder, entity: any): void; destroy(): void; } import { Entity } from './Entity'; import { IRenderer } from '../renderer/IRenderer'; import type { WebGLDrawStats } from '../renderer/WebGLPointRenderer'; import { type WasmModuleSource, type WasmTransformBackend } from '../wasm/backend'; import type { CoreWasmRuntime } from '../wasm/runtime'; import { type HitModuleSource, type HitTestBackend } from '../wasm/hit-backend'; import { type AnimModuleSource, type AnimBackend } from '../wasm/anim-backend'; import { type ParticleModuleSource, type ParticleBackend } from '../wasm/particle-backend'; import { type OverlayGeometry } from './scene/CanvasGeometry'; import { type DirtyReasonEntry, type DirtySource } from './scene/DirtyTracker'; import { type RenderPhase, type RenderPhaseEntry } from './scene/PhaseTimer'; import { type AcceleratorReason, type AcceleratorReport, type AcceleratorStatus } from './scene/WasmBackendFacade'; export type { RenderPhase, RenderPhaseEntry }; export type { DirtyReasonEntry, DirtySource }; /** * Options for {@link Scene}. */ export interface SceneOptions { /** * Backend for `getBatchCircle()` point-cloud entities: * - `'canvas'` (default): the Canvas2D order-preserving same-color batch. * - `'webgl'`: a stacked WebGL2 layer drawing all such circles in one draw * call (10–100× throughput for 100k+). Auto-falls back to `'canvas'` when * WebGL2 is unavailable. The GL layer composites above the 2D content, so its * points don't interleave per-entity with 2D draws. */ pointBackend?: 'canvas' | 'webgl'; /** * Backend for particle simulation and rendering: * - `'auto'` (default): tries WebGPU first, falls back to CPU if WebGPU is unavailable or fails. * - `'webgpu'`: explicitly requests WebGPU; the current runtime still falls back to CPU if initialization fails. * - `'cpu'`: forces CPU simulation and rendering (disabling WebGPU completely). */ particleBackend?: 'auto' | 'webgpu' | 'cpu'; /** * Render the accessibility/automation shadow nodes with a visible blue dashed * outline (development aid). Default `false`: shadow nodes are transparent * (`opacity:0`) — still operable by Playwright/assistive tech, but the canvas * is the only thing seen. */ debugA11y?: boolean; /** * Cap the render loop to at most this many frames per second (power saving — * e.g. a quieter fan in a library). `0` means uncapped (native refresh * rate). Defaults to `60` (`0` under test runners). Continuous animations * still run, just less often. Also settable later via {@link Scene.maxFPS}. */ maxFPS?: number; /** * When `true` (default), a system **prefers-reduced-motion** setting auto-caps * the loop to {@link REDUCED_MOTION_FPS} (or the lower of that and `maxFPS`). * Set `false` to ignore the OS setting. */ respectReducedMotion?: boolean; /** * Throttle the accessibility/automation shadow-DOM sync to at most once per this * many milliseconds. `0` (default) syncs every rendered frame. During heavy * animation, a small value (e.g. `100`) keeps the a11y layer eventually * consistent while sparing the per-frame DOM writes that can drag Canvas FPS. * Also settable later via {@link Scene.a11ySyncInterval}. */ a11ySyncInterval?: number; /** * Custom renderer implementation (e.g., ThreeRenderer from @vectojs/three). * If provided, this renderer will be used for drawing rather than the default CanvasRenderer. */ renderer?: IRenderer; /** * Disable the automatic registration of window resize listener. * Useful when Vecto is running inside a custom layout container or offscreen canvas. */ disableWindowResize?: boolean; /** * Cap the effective device pixel ratio used to size the Canvas2D and WebGL * point-layer backing stores. `undefined` (default) reads the real, * uncapped `window.devicePixelRatio` — unchanged from prior versions. * Backing-store render cost scales with `logical size × dpr²`, so a * full-screen HiDPI scene (`pointBackend: 'webgl'` in particular) can * overrun its frame budget on a DPR-3 display while running fine on the * DPR-1 dev machine it was tuned on (findings.md, 2026-07-16). `maxDPR: 2` * keeps the display retina-crisp (2x already exceeds what most eyes * resolve) while roughly halving the backing-store pixel count at DPR 3. * Applied at construction and re-applied on every {@link resize} call * (including the automatic window-resize listener), since the real DPR * can change at runtime (a window dragged between displays). */ maxDPR?: number; /** * Enable automatic throttling of the `'always'` loop when the scene is * static (no active transitions and not marked dirty) to save power/CPU. * The idle floor is 60 FPS by default; raise or lower it with * {@link SceneOptions.idleFPS}. Default is `true`. */ autoThrottle?: boolean; /** * Frame-rate floor for an idle `'always'` scene when * {@link SceneOptions.autoThrottle} is on. Default `60` — an idle scene * keeps animating smoothly instead of stuttering at the old 2 FPS floor. * Set e.g. `2` to restore the aggressive deep sleep for battery-critical * scenes (the developer's explicit choice); `0` means "keep the scene's * `maxFPS` cadence while idle". Ignored when `autoThrottle` is `false` or * `renderMode` is `'onDemand'` (onDemand already renders zero idle frames). */ idleFPS?: number; /** * Emit User Timing marks and measures for render phases. Default `false`. * Intended for short profiler captures; enable only while collecting one. */ userTiming?: boolean; /** * Mirror static text from entities implementing * {@link Entity.getContentProjection} as transparent, position-synced DOM * nodes, so find-in-page, screen readers, crawlers, and translation work on * canvas-rendered text. Default is `true`; disable for purely decorative * scenes to skip the sync walk. */ contentProjection?: boolean; /** * How far outside the viewport (in CSS px, each side) content projections are * materialized as DOM. Projections whose box is farther than this are not * created — and are removed when they scroll past it — so a document taller * than the viewport keeps only a bounded, near-viewport set of DOM nodes * instead of one element (plus a `` per line) per block for the whole * document. A larger margin keeps more off-screen text ready for native * find-in-page / selection at the cost of more DOM; `Infinity` restores the * legacy "materialize the entire document" behavior. Default: one viewport * height (`undefined` → resolved to `Scene.height` at sync time). */ contentProjectionMargin?: number; /** * Virtualization margin (px) for the *semantic* tier of content projection — * whether a block has **any** projected DOM at all, as opposed to * {@link SceneOptions.contentProjectionMargin}, which decides whether that * block's per-line **carriers** are windowed. * * Splitting the two makes a coarse resident tier expressible: with * `contentSemanticMargin: Infinity` and a finite `contentProjectionMargin`, * every block in the document keeps an element holding its full text — so * find-in-page and screen-reader read-ahead see the whole document — while * only blocks near the viewport pay for per-line carriers. One scalar could * not express that, because a finite value freed off-band blocks entirely and * `Infinity` also unwindowed every carrier, which is O(total document glyphs). * * `Infinity` is safe **here** and remains unsupported for * `contentProjectionMargin`: the cost that made it unsupported comes from an * unwindowed carrier band, not from resident text. * * Note the one-time cost. A resident tier materializes one element per block * on the first sync — measured unbudgeted at 21.3ms for 1000 blocks and 139.5ms * for 10000 on Chrome — as one synchronous block. Steady state is cheap * (unchanged blocks skip via {@link Entity.getContentEpoch}), so this is a * document-open stall, not a per-frame cost. That stall is what * {@link SceneOptions.contentSemanticBudget} spreads across frames. * * Default: whatever `contentProjectionMargin` resolves to, so omitting this * leaves behaviour unchanged. */ contentSemanticMargin?: number; /** * How many resident (coarse-tier) blocks may be materialized in **one** sync, * bounding the document-open stall a wide {@link * SceneOptions.contentSemanticMargin} otherwise pays all at once. * * The cost of a resident tier is per node **created**, not per node held: 10000 * resident blocks cost ~3.0 ms/sync at steady state, while creating them costs * ~0.03 ms each plus a per-pass floor that grows with how many are already * resident. So the front-load is a *scheduling* problem, and this is the * schedule — remaining blocks materialize on subsequent syncs, a few per frame, * until the document is fully resident. * * What it does **not** change is the end state: the same blocks end up with the * same DOM, only later. Nothing is dropped, so the reachability the semantic * tier exists for is preserved; a block still waiting is simply not yet in the * DOM, exactly as a block beyond the margin is not. * * Applies **only** to the coarse tier. A block inside the interaction margin is * on screen and materializes immediately regardless of this budget — deferring * visible text would make it briefly unselectable, which is a user-visible * regression rather than a cost saving. * * `Infinity` disables the budget and restores one synchronous pass. Default: * {@link DEFAULT_CONTENT_SEMANTIC_BUDGET}. Because the coarse tier exists only * when `contentSemanticMargin` is wider than `contentProjectionMargin`, a scene * that does not opt into a resident tier has no coarse blocks and is therefore * unaffected by any value here. */ contentSemanticBudget?: number; /** * Reading direction used to order the accessibility/automation shadow tree so * keyboard **tab order** and screen-reader traversal follow the *visual* * reading order (top-to-bottom, then inline) rather than scene-graph * insertion order — two entities added in any order but drawn left/right of * each other should Tab left→right (`'ltr'`, default) or right→left * (`'rtl'`). Also settable later via {@link Scene.readingDirection}. */ readingDirection?: 'ltr' | 'rtl'; /** * When to repaint: * - `'always'` (default): drive a continuous rAF loop, throttling to the * {@link SceneOptions.idleFPS} floor (default 60) while the scene is idle * if {@link SceneOptions.autoThrottle} is on. * - `'onDemand'`: paint only after {@link Scene.markDirty} (or an active * transition), so a genuinely static scene costs zero frames. * * Also settable later via {@link Scene.renderMode}. Prefer this option when * the mode is known at construction: it applies before the first frame, so an * `onDemand` scene never pays for the initial always-on frames. */ renderMode?: 'always' | 'onDemand'; } /** * Every recognized {@link SceneOptions} key, used only to warn about unknown * ones in dev mode. * * This exists because `SceneOptions` is structural: passing a key it does not * declare is a **silent** no-op, and TypeScript only catches it when the object * is written inline at the call site. Code that builds options dynamically, or * plain untranspiled JS, gets no diagnostic at all. `renderMode` was a public * field with no matching option for several releases, and four `@vectojs` demos * shipped `new Scene(canvas, { renderMode: 'onDemand' })` — reading correctly, * doing nothing, and sitting on the idle FPS floor. * * Kept as a literal rather than derived from a type: `keyof SceneOptions` does * not survive to runtime, so this list is the only form a constructor can check * against. A new option must be added here too — the test suite asserts the two * stay in sync. */ export declare const SCENE_OPTION_KEYS: readonly ['a11ySyncInterval', 'autoThrottle', 'contentProjection', 'contentProjectionMargin', 'contentSemanticBudget', 'contentSemanticMargin', 'debugA11y', 'disableWindowResize', 'idleFPS', 'maxDPR', 'maxFPS', 'particleBackend', 'pointBackend', 'readingDirection', 'renderer', 'renderMode', 'respectReducedMotion', 'userTiming']; /** Frame-rate the loop is capped to when the OS requests reduced motion. */ export declare const REDUCED_MOTION_FPS = 30; export type { AcceleratorReason, AcceleratorStatus, AcceleratorReport }; /** * Live render-loop telemetry, read from {@link Scene.frameStats}. See that * getter for how each field is measured. */ export interface FrameStats { /** Rendered-frame cadence (Hz), clamped to `maxFPS`. `0` before the first pair of rendered frames. */ fps: number; /** Wall-clock ms of the last `render()` pass (excludes a11y/content sync). */ frameTimeMs: number; /** Smoothed interval between rendered frames, in ms (EMA). */ frameIntervalMs: number; /** dt (ms) handed to the last rendered frame. */ dt: number; /** Total frames rendered since `start()`. */ renderedFrames: number; /** Total rAF ticks skipped (idle/onDemand/capped) since `start()`. */ skippedFrames: number; /** The scene's current render mode. */ renderMode: 'always' | 'onDemand'; /** Whether a redraw is currently pending (the boolean dirty flag). */ dirty: boolean; } export interface A11yTreeNode { id: string; tag: string; role?: string; label?: string; value?: string; checked?: boolean; expanded?: boolean; valuemin?: string; valuemax?: string; children: A11yTreeNode[]; } /** * Default {@link SceneOptions.contentSemanticBudget}: resident blocks * materialized per sync. * * Sized against the two costs a pass actually pays, both measured in real headed * Chrome on a 240Hz panel. Per created block is cheap and flat (~0.03ms). What * dominates is style+layout of the projection subtree, which scales with how many * blocks are already RESIDENT and is paid once per pass: traced at 10000 blocks, * `UpdateLayoutTree` 391.7ms + `Layout` 305.8ms over 40 passes (~17ms each), with * per-pass cost roughly doubling from the first pass to the last while the number * created stayed constant. * * So total drain cost is approximately `passes × f(resident)`, and a SMALLER * budget multiplies the term that does not shrink. Measured to completion, 3 * repeats, medians: * * ```text * 1000 blocks budget 32 → 67.1ms total, 4.3ms worst pass * budget 64 → 54.0ms total, 5.1ms worst pass * budget 256 → 27.7ms total, 7.7ms worst pass * Infinity → 24.1ms total, 23.6ms worst pass * 10000 blocks budget 32 → 3773.2ms total, 42.6ms worst pass * budget 64 → 1896.2ms total, 41.6ms worst pass * budget 256 → 648.1ms total, 35.2ms worst pass * Infinity → 319.4ms total, 307.3ms worst pass * ``` * * 256 is where the two goals stop trading against each other. Below it there is no * frame-bound improvement at 10000 blocks — every budget lands at 35-43ms, because * the worst pass is the LAST one laying out the complete subtree — while total time * rises 6x. At 1000 blocks it still holds 7.7ms, inside a 60Hz frame, for less than * half the total time of 64. * * This replaces an earlier default of 64, which was sized against a per-block cost * of ~0.4ms. That figure was inflated by a forced layout per materialized block * (see `contentSelectionPresentThisSync`); with that removed, 64 spends 6x the * total time for no frame-bound gain. */ export declare const DEFAULT_CONTENT_SEMANTIC_BUDGET = 256; /** * Top-level orchestrator that owns the entity tree, drive the render loop, * and maintains the accessibility/automation shadow layer. * * Create one `Scene` per `` element. Add {@link Entity} objects via * {@link add}, then call {@link start} to begin the 60-FPS render loop. * * @example * const scene = new Scene(document.querySelector('canvas')!); * scene.add(new CircleEntity().setPosition(100, 100)); * scene.start(); */ export declare class Scene { private static webglCreator; private static webgpuManagerClass; /** Upper bound (ms) on a single frame's `dt`. Caps the giant elapsed gap a * backgrounded/refocused tab produces so physics advances at most one slow * frame instead of the whole idle duration (~100ms ≈ 6 frames at 60fps). */ private static readonly MAX_FRAME_DT; static registerWebGLPointRendererCreator(creator: WebGLPointRendererCreator): void; static registerWebGPUParticleSystemManager(managerClass: any): void; private root; overlayRoot: Entity; private renderer; private isRunning; /** Whether the canvas is at least partially in the viewport. When it scrolls * fully off-screen the rAF loop pauses (stops rescheduling) instead of * burning frames on a scene nobody can see; an IntersectionObserver resumes * it on re-entry. Defaults true (and stays true where IntersectionObserver * is unavailable, e.g. SSR/jsdom, so behavior is unchanged there). */ private _canvasOnScreen; private _canvasObserver; private lastTime; canvas: HTMLCanvasElement; /** * Redraw strategy: * - `'always'` (default): re-render every animation frame (legacy behavior). * - `'onDemand'`: only re-render when the scene is marked dirty (via * {@link markDirty}) or while an animation is pending. Ideal for static / * event-driven UIs where idle frames should cost ~0. */ renderMode: 'always' | 'onDemand'; /** * Per-phase frame timing and the browser User Timing flag. * * Extracted ahead of extraction 3 because the content-grid calibration pass is * instrumented (`calibScan`, `calibProbeBuild`) and could not move while its * only remaining dependency was a `Scene` private. Nine methods across four * domains write phases, so this is a shared leaf rather than any one domain's * property — see `DEC-0021`. */ private readonly phases; /** * Start or stop per-phase render timing. * * Off by default, and the probes compile to a single boolean test when off: * these sit on the frame path, so the disabled cost has to be nothing. Enable, * run the scene, then read {@link renderPhases}. * * Exists because a frame total cannot tell you where the time went. The * markdown streaming benchmark put render at 85-99% of an append's cost, and * there was no way to decompose that number further — which is exactly the * position that led to two wrong optimisation guesses earlier * (`CodeBlock` reuse, hit-grid fusion), both of which measured as no change. */ setPhaseTiming(enabled: boolean): void; /** Whether per-phase render timing is being recorded. */ get phaseTiming(): boolean; /** * Enable or disable browser User Timing phase instrumentation. * * Off by default. The disabled frame path performs only boolean checks and * emits no Performance Timeline entries. */ setUserTiming(enabled: boolean): void; /** Whether browser User Timing phase instrumentation is enabled. */ get userTiming(): boolean; /** * Recorded phase timings, most expensive first, with each phase's share of the * measured total. * * `share` is the number that matters: a phase at 4% cannot be worth optimising * however inefficient it looks in isolation. */ get renderPhases(): RenderPhaseEntry[]; /** Drop recorded phase timings, keeping timing enabled. */ clearRenderPhases(): void; /** * The dirty flag and its opt-in attribution (extraction 6, `DEC-0025`). * * A field initializer rather than a constructor assignment: it needs no * injected input, so it follows {@link phases} and {@link a11yOrder} rather * than the definite-assignment collaborators. */ private readonly _dirty; /** * The redraw-pending flag, under its original name. * * `Scene.test.ts:2153` assigns `false` and `:2158` reads it, and the suite is * unedited (`DEC-0019` rule 4). `private` is correct here rather than * `protected` — unlike the extraction 1/3/4 accessors, this one still has * in-class readers (`loop`, `step`, `frameStats`). */ private get dirty(); private set dirty(value); /** * Whether to throttle the `'always'` loop when the scene is static to save * power. The idle floor is {@link Scene.idleFPS} (default 60). */ autoThrottle: boolean; /** * Frame-rate floor for an idle `'always'` scene when {@link Scene.autoThrottle} * is on. Default `60`. Set `2` for the legacy aggressive idle sleep, `0` to * keep the scene's `maxFPS` cadence while idle. */ idleFPS: number; /** Wall-clock ms spent inside the last `render()` call. */ private _lastFrameMs; /** Rolling exponential average of rendered-frame intervals, in ms. */ private _avgFrameIntervalMs; /** dt (ms) handed to the last rendered frame. */ private _lastDt; /** Count of frames actually rendered since the loop started. */ private _renderedFrames; /** Count of rAF ticks skipped (idle / capped) since the loop started. */ private _skippedFrames; /** `time` of the previous *rendered* frame, for interval measurement. */ private _lastRenderTick; /** * Frame-rate cap (power saving). `0` = uncapped (native refresh). When set, * the loop renders at most `maxFPS` times per second; animations still run, * just less often. See {@link SceneOptions.maxFPS}. */ maxFPS: number; /** Whether the OS prefers-reduced-motion setting auto-caps the loop. */ respectReducedMotion: boolean; /** * Reading direction for accessibility tab/traversal order (`'ltr'` default, * `'rtl'`). Controls the inline sort within a visual row in * {@link enforceA11yDomOrder}. Set at runtime to re-flow tab order on the * next sync (also trips a reorder). */ get readingDirection(): 'ltr' | 'rtl'; set readingDirection(dir: 'ltr' | 'rtl'); private _readingDirection; /** Cached media-query list; `.matches` is read live each frame. */ private reducedMotionQuery; /** Cached `(forced-colors: active)` query (Windows High Contrast etc.). A * canvas gets NO automatic forced-colors treatment from the browser (it's * opaque pixels), so components must read {@link forcedColors} and repaint * with system colors themselves; a change listener repaints idle scenes. */ private forcedColorsQuery; private forcedColorsChangeHandler; /** True when the OS asks for reduced motion and we respect it. Read by the animation drivers. */ get prefersReducedMotion(): boolean; /** * True when the OS is in a forced-colors mode (Windows High Contrast, and the * `forced-colors: active` media feature generally). Canvas pixels are exempt * from the browser's forced-colors remapping, so accessible components should * read this and draw with CSS system colors (`CanvasText`, `Canvas`, * `Highlight`, …) instead of their themed palette. Re-rendered automatically * when the setting toggles. */ get forcedColors(): boolean; /** * Throttle interval (ms) for the a11y/automation shadow sync. `0` = every * frame. See {@link SceneOptions.a11ySyncInterval}. */ a11ySyncInterval: number; /** Timestamp of the last a11y sync, for throttling. */ private lastA11ySync; /** True if we skipped an a11y sync during animation and need to sync when at rest. */ private a11yPendingSyncAfterAnimation; private a11yRoot; private a11yElements; /** DOM nodes mirroring static text content, keyed by entity id. */ private contentElements; /** * What the last completed content-projection sync was built from, per entity. * * Compared at the top of {@link syncContentProjection} to skip a block whose * content AND geometry are both unchanged, before the O(glyphs) projection * build. Only populated for entities that opt in via * {@link Entity.getContentEpoch}. (carryctx CTX-0199) */ private contentSyncState; /** Invalidates grid font calibration after browser font availability changes. */ private contentFontEpoch; /** * Bumped whenever the viewport itself changes shape, which re-tiers blocks * without moving any of them. * * The settled-walk fast path in {@link syncContentProjection} decides a block * is unchanged from its own local transform plus its parent's world transform. * Both are viewport-independent, so a resize alone would slip past it and leave * every block holding DOM built for the old viewport — a block that should have * been promoted to the fine tier keeping no carriers, or a demoted one keeping * carriers it no longer needs. Scrolling needs no such epoch: it moves the root, * so every block's parent world transform changes and the check fails honestly. */ private contentViewportEpoch; /** * One-entry memo for a parent's world transform, held as scalars. * * The a11y walk visits all of one parent's children consecutively, so a * one-entry memo hits for every child after the first — turning an * O(children) sequence of `getWorldTransform()` calls, each of which * allocates a fresh object on its cache-hit path * ({@link Entity.getWorldTransform}), into one call per parent. Flattened to * scalars rather than a cached object so the memo itself never allocates. * * Keyed on `(entity, syncSerial)`: the serial is bumped once per top-level * sync, so a memo can never survive into a frame in which the parent has * moved. */ private _pwNode; private _pwSerial; private _pwa; private _pwb; private _pwc; private _pwd; private _pwe; private _pwf; /** Incremented once per top-level `syncA11y`, invalidating `_pwNode`. */ private _syncSerial; /** * Disables the settled-walk fast path in {@link syncContentProjection}. * * Exists so a benchmark can measure both arms in ONE run on ONE commit, rather * than comparing two builds and inheriting every difference between them. Also * lets a test assert that a behaviour is genuinely unchanged by the fast path * rather than merely unobserved. * * Not part of the public API and not documented as an option: turning it on only * makes a settled document slower, never more correct. */ disableSettledFastPath: boolean; /** Cached Canvas-to-client scale for the current font/viewport epoch. */ private contentMetricScaleEpoch; private contentMetricScaleX; private contentProjectionEnabled; private contentProjectionMargin; private contentSemanticMargin; private contentSemanticBudget; private contentSemanticBudgetLeft; private contentSemanticDeferred; private contentSelectionEndListener; private frameHadAnimation; private frameHadInteractive; private resizeHandler; /** Active `(resolution: Ndppx)` media query watching for a runtime DPR change * (window moved between monitors, browser zoom) so the canvas backing store * can be re-scaled — otherwise it stays rasterized at the old DPR and blurs. * A resolution media query only fires when leaving its exact value, so the * handler re-arms a fresh query for the new DPR each time. */ private dprMediaQuery; /** For embedded (`disableWindowResize`) scenes: observes the canvas element so * a CSS/layout-driven size change re-runs `resize()`. A window `resize` * listener never fires for these (the window isn't what changed), so without * this an embedded canvas stayed at its initial size forever. */ private canvasResizeObserver; private dprChangeHandler; private focusedA11yElement; /** * Canvas box geometry: the CSS↔logical mapping, overlay layer alignment, and * the DPR math (extraction 5, `DEC-0024`). * * Definite-assignment because it holds `a11yRoot` and `portalRoot`, which the * constructor only decides partway through — the same shape as * {@link _contentProjection}. */ private _geometry; /** * The overlay memo, for the unedited suite. * * `OverlayGeometrySkip.test.ts:75,143` assigns `null` to force the next sync, * so the setter delegates to `invalidateOverlay()`. `protected` rather than * `private` because it has no in-class reader and `noUnusedLocals` fails the * build on a private with none (`DEC-0019` rule 4). */ protected get _overlayGeometry(): OverlayGeometry | null; protected set _overlayGeometry(value: OverlayGeometry | null); /** Shadow elements the pointer is currently inside. Lets a removal that happens * mid-hover synthesize the `pointerleave` the browser never sends for a * detached element, so the entity doesn't keep its hover state. */ private readonly hoveredA11yElements; /** * Entity ids the application has pinned via {@link requestA11yProjection}. * * Ids rather than entities so a removed entity cannot be retained by this set; * a stale id simply never matches. Cleared per-entity by * {@link releaseA11yProjection}. */ private readonly a11yProjectionRequests; /** Persistent tabindex=-1 element in a11yRoot. When the focused a11y mirror is * pruned (virtualization/streaming/removal) while it holds focus, we move * focus here instead of letting the browser drop it to — keeping the * screen-reader virtual cursor inside the scene's a11y region. */ private focusSentinel; private caretBlinkTimer; /** * The projected DOM's ordering engine — the reorder flag, the per-pass scratch * collections, the visual reading-order sort and the cursor-based * `insertBefore` pass (extraction 2, `DEC-0020`). * * `a11yNeedsReorder` and `enforceA11yDomOrder` keep their names on `Scene` and * delegate here; the collect-and-prune walk stays behind because it needs * {@link shouldProjectA11y} and the focus/caret state. */ private readonly a11yOrder; /** * The content projection's selection preservation and grid calibration * (extraction 3, `DEC-0022`). * * Definite-assignment because it needs `a11yRoot`, which the constructor * creates partway through — the same shape as {@link _wasmBackend}. Every * constructor use is inside a deferred event listener, so it is always * assigned before anything can reach it. */ private _contentProjection; /** Grid carrier materialization (the projection walk's separable leaf). */ private _gridProjector; /** * Pending grid-calibration frames, keyed by entity id. * * Delegates to {@link ContentProjectionManager}. Kept on `Scene` under its * original name because the text-projection e2e reads * `scene.contentGridCalibrationFrames.size` to assert calibration is not left * in flight after a rebuild. `protected` rather than `private`: there is no * in-class reader, and `private` plus no reader fails `noUnusedLocals` * (`DEC-0019` rule 4). */ protected get contentGridCalibrationFrames(): ReadonlyMap; /** * Index of the carrier line holding a selection inside `el`, or `null`. * * Delegates to {@link ContentProjectionManager}. Kept under its original name * because `ContentGridSelectionWindow.test.ts` calls it through a cast to * assert the selection-window behaviour. `protected` per `DEC-0019` rule 4 — * the in-class callers now go through the manager directly. */ protected contentGridSelectionLine(el: HTMLElement): number | null; /** * Whether the projected a11y DOM needs reordering on the next pass. * * Delegates to {@link A11yProjectionManager}. Kept on `Scene` under its * original name — and with a setter — because `Entity` assigns to * `scene.a11yNeedsReorder` as a public cross-class contract (`Entity.ts` sets * it when `interactive` flips and when a child is added or removed), and the * unedited suite writes it directly too. */ get a11yNeedsReorder(): boolean; set a11yNeedsReorder(value: boolean); private portalRoot; private activePortalsThisFrame; private activePortalsPrevFrame; private portalEntities; private renderOrderCounter; /** * Monotonic render-frame counter, bumped once per authoritative `render()` * pass. Entities stamp their per-frame world-matrix cache with this value and * {@link Entity.getWorldTransform} trusts that cache only while it still * matches, so a query outside the frame that produced it transparently falls * back to the ancestor walk. Public for the same reason `Entity._getTrig`/ * `_setWorldCache` are: it is a cross-class render-internal contract. */ currentFrame: number; /** * The four invisible WASM accelerators and the resident transform store. * * Extraction 1 of this file's decomposition. Every public member below keeps * its name, signature and behaviour and delegates here, so the public API is * byte-identical; only where the state lives changed. * * Constructed in the constructor rather than initialized here because it needs * {@link root}, which is itself assigned there. */ private _wasmBackend; /** * The transform backend object itself. * * Kept under its original name and delegating, rather than deleted: * `test/wasm/scene-accelerators.test.ts` reaches for `scene._wasm` to * monkey-patch a kernel into rejecting, and that unedited suite is this * refactor's gate — rewriting it to match the new shape would be marking our * own homework. Same reasoning for `_animWasm`, `_wasmUploadRejections`, * `_storeStructureVersion` and `_structureVersion` below. * * `protected` rather than `private` since extraction 4: the hit-grid build was * this getter's last in-class reader, and `private` plus no reader fails * `noUnusedLocals` (`DEC-0019` rule 4). * @internal */ protected get _wasm(): WasmTransformBackend | null; /** @internal Read by the WASM anim suites; see {@link _wasm}. */ private get _animWasm(); /** * @internal * `protected`, not `private`, and that is the one deliberate access change in * this extraction. Both of these are read by a test and by nothing inside this * class, so `private` makes them dead code and `noUnusedLocals` rejects the * build. `protected` is the honest encoding of "exists to be read from * outside, never from in here" — and it is invisible to API consumers, who * cannot reach a protected member either. `Scene` has no subclasses in this * repo and is not documented as subclassable. * * Read by the run-table fallback suite; see {@link _wasm}. */ protected get _wasmUploadRejections(): number; /** @internal Read by the resident-store suite; see {@link _wasmUploadRejections}. */ protected get _storeStructureVersion(): number; /** * Tree topology version, bumped by every add/remove/reparent. * * Lives on the facade because the resident store layout is keyed by it, but is * read here by the compute-entity cache below and by `Scene.test.ts`. * @internal */ private get _structureVersion(); private _computeEntities; private _computeEntitiesVersion; /** Invalidate the resident WASM store layout; the next wasm-mode frame rebuilds * it. Called by `Entity.add`/`remove` (topology changes only). */ markStructureChanged(): void; /** The tree's ComputeParticleEntity instances, cached per structure version so * a compute-free scene doesn't re-walk the whole tree every frame. */ private _computeEntitiesFor; /** Which backend composes world matrices for the main render walk. */ get transformBackend(): 'js' | 'wasm'; /** * Install (or clear) a WASM transform backend. Passing a backend switches the * main render walk onto it; passing `null` reverts to the JS path. Synchronous * and safe to call between frames — the next `render()` picks it up. Prefer * {@link enableWasmTransforms} for the normal async hot-swap. */ setTransformBackend(backend: WasmTransformBackend | null): void; /** * Asynchronously instantiate the WASM transform core and, on success, hot-swap * the render walk onto it. Accepts whatever is convenient at the call site: * * ```ts * // The common case — a bundler-emitted, co-located asset URL: * await scene.enableWasmTransforms(new URL('./vectojs_core.wasm', import.meta.url)); * // …or a path string, a Response, or raw bytes you already have: * await scene.enableWasmTransforms('/assets/vectojs_core.wasm'); * await scene.enableWasmTransforms(await fetch(url)); * await scene.enableWasmTransforms(myUint8Array); * ``` * * A URL/Response streams (compiles while it downloads, with a buffered * fallback for a wrong MIME type); raw bytes instantiate directly. The Scene * keeps rendering on the JS path until this resolves, and stays on JS if * instantiation fails (CSP `wasm-unsafe-eval`, unsupported SIMD, corrupt or * missing bytes, a 404) — failure is the default state, not an error path. * Resolves `true` if WASM is now active, `false` if the JS path remains. */ enableWasmTransforms(source: WasmModuleSource): Promise; /** * Install a pre-built runtime, so several Scenes can share one compile while * each keeps its own stores. Pass `null` to detach (backends already installed * keep working; only subsequent `enableWasm*` calls re-load). */ setWasmRuntime(runtime: CoreWasmRuntime | null): void; /** The shared WASM runtime, if one has been loaded. */ get wasmRuntime(): CoreWasmRuntime | null; /** * The pointer hit-test: the WASM broad-phase grid, the permanent JS * depth-first walk, and the eligibility gating that keeps the two in lockstep * (extraction 4, `DEC-0023`). * * Definite-assignment because it needs {@link _wasmBackend}, which is built * partway through the constructor. */ private _hitTester; /** Did the last hit-grid build use the fused (WASM-store) gather? */ get hitGatherPath(): 'fused' | 'js'; /** * Per-frame status of every invisible accelerator: whether each is installed, * whether it actually ran on the most recent frame, and why. * * This exists because the older per-accelerator getters * ({@link transformBackend}, {@link animBackend}, {@link hitTestBackend}, * {@link particleBackend}) report only that a backend is INSTALLED. Reading * `'wasm'` from one of those and concluding the accelerator is doing work is * wrong whenever a gate never opens, a kernel rejects its arguments, or a * faster backend takes the pass instead. Read {@link AcceleratorStatus.reason} * for which of those happened. * * Reflects the most recent main-renderer frame; a secondary renderer (SVG * export, offscreen snapshot) does not overwrite it. * * `webgpuActive` is handed to the facade rather than read by it: WebGPU device * state belongs to the context/resize domain (extraction 5), and this getter * is the only place the two domains have to agree. */ get accelerators(): AcceleratorReport; /** Which backend answers `findEntityAt` for the main tree. */ get hitTestBackend(): 'js' | 'wasm'; /** Install (or clear) a WASM hit-test backend directly. Prefer * {@link enableWasmHitTest} for the normal async hot-swap. */ setHitTestBackend(backend: HitTestBackend | null): void; /** * Asynchronously instantiate the WASM hit-test core and, on success, hot-swap * `findEntityAt` onto it. Accepts the same source shapes as * {@link enableWasmTransforms} (URL, path string, Response, or raw bytes). * Stays on the JS walk if instantiation fails — failure is the default * state, not an error path. Resolves `true` if WASM is now active. */ enableWasmHitTest(source: HitModuleSource): Promise; /** * The batched driver tick and its candidate registry (extraction 6, * `DEC-0025`). * * Definite-assignment because it holds {@link _wasmBackend}, which is built * partway through the constructor — the same shape as {@link _hitTester}. */ private _driverTicker; /** * The candidate set, for the unedited suite. * * `test/wasm/scene-anim-batch.test.ts` reads it through a cast at four sites to * assert the registry self-prunes, unregisters a removed subtree, and * re-registers a re-added one. `protected` rather than `private` because it has * no in-class reader and `noUnusedLocals` fails the build on a private with * none (`DEC-0019` rule 4). */ protected get _activeDriverEntities(): Set; /** * Minimum number of batchable (spring, or named-easing tween) active drivers * before a frame engages the WASM batch path at all; below it, every driver * ticks on the normal JS per-entity path, unmodified. * * Re-measured on the INTEGRATED path (benchmarks/anim-wasm-scene, real * Chrome 150 / Firefox 153, 2026-07-24 — correctness verified 0 mismatches * across all three kinds before any of these numbers were trusted): the * isolated kernel spike's "<100 drivers, wins everywhere" verdict did NOT * survive integration, and neither did the first integrated pass's single * gate-count verdict once broken out by driver kind. On Chrome, spring and * mixed drivers are a real ~1.4–2.3× win from n=128 up through the tested * ceiling of 16384, but pure-tween drivers are a LOSS at n=128 (0.71×, * i.e. ~40% slower than the JS path) and only turn net-positive around * n≈256 (1.52×). A single scalar gate can't be tight for spring/mixed * without occasionally opening early on a tween-heavy scene and making it * slower — 256 is chosen to keep the gate net-positive across all three * kinds rather than optimal for any one of them; a kind-aware gate (see * `_tickBatchedDrivers`'s per-kind arrays, which already separate spring * from tween) would recover the 128–255 spring/mixed win without the * tween regression, but that's a larger change than this measurement pass * covers. On Firefox it is a net loss at every driver count measured, up * to 16384 — not an allocation artifact (confirmed after removing all * per-frame allocation from the gather/scatter path); SpiderMonkey's * wasm-boundary/property-dispatch cost for this shape of call appears to * structurally exceed the saving, at least at the scales tested here. * * 256 is set as a Chrome-oriented default so an app that opts in (this * path is never engaged without an explicit {@link enableWasmAnimBatching} * call) sees the gate open only where it reliably helps on Chromium, * regardless of whether the scene's active drivers are spring, tween, or * a mix of both. Unlike G1 (safe to default on everywhere) and G3 (opt-in, * but a reliable win once its own gate condition holds), G2 has no * threshold that is safe on every engine — raise or lower this per your * own target browser mix and driver-kind distribution, or leave WASM * animation batching disabled entirely on a Firefox-heavy audience. */ /** * Back-compat alias for {@link animGate}. Reading it returns the tween gate * (the conservative one the single knob used to represent); writing it sets all * three, so code that tuned one number keeps behaving as before. * * Prefer {@link animGate} — a single threshold cannot be right for both kinds, * which is why this exists as an alias rather than the primary control. */ get animDriverGateCount(): number; set animDriverGateCount(n: number); /** * Per-kind driver gates, in active batchable drivers. * * Measured on the integrated path (`benchmarks/anim-wasm-scene`, real Chrome * 150 / Firefox 153): spring and mixed workloads are a ~1.4-2.3x win from 128 * drivers up through 16384, while pure tween is a **0.71x loss** at 128 and * only turns net-positive near 256. One scalar threshold therefore had to be * set for the worst kind, discarding the 128-255 spring win to avoid making a * tween-heavy scene slower. * * Firefox is a net loss at every count measured up to 16384 — not an * allocation artifact (confirmed after removing all per-frame allocation from * gather/scatter); SpiderMonkey's wasm-boundary cost for this call shape * appears to structurally exceed the saving at these scales. These defaults are * Chrome-oriented; on a Firefox-heavy audience, leave * {@link enableWasmAnimBatching} off entirely rather than tuning these. * * Setting {@link animDriverGateCount} overwrites all three, so existing code * that tuned the single knob keeps working unchanged. */ /** * Whether the WASM batch path actually ran on the most recent frame. * * Distinct from {@link animBackend}, which reports only that a backend is * installed — a gate below the driver count means the frame still ticked in JS. * Conflating the two makes it easy to believe an accelerator is active when it * never opens. */ get animBatchedLastFrame(): boolean; animGate: { spring: number; tween: number; mixed: number; }; /** Which backend advances active property drivers on the current gate * decision. Reflects only whether a backend is installed — the per-frame * gate can still choose the JS path even when this reads `'wasm'`. */ get animBackend(): 'js' | 'wasm'; /** Install (or clear) a WASM batched-animation backend directly. Prefer * {@link enableWasmAnimBatching} for the normal async hot-swap. */ setAnimBackend(backend: AnimBackend | null): void; /** * Asynchronously instantiate the WASM batched-animation core and, on * success, make it available to the per-frame gate (see * {@link animDriverGateCount}). Accepts the same source shapes as * {@link enableWasmTransforms}. Stays on the JS tick loop if instantiation * fails — failure is the default state, not an error path. Resolves `true` * if WASM is now available (not necessarily active every frame). */ enableWasmAnimBatching(source: AnimModuleSource): Promise; /** Which backend runs the CPU particle simulation. Reflects only whether a * backend is installed (the WebGPU compute path, when active, is used first * regardless). */ get particleSimBackend(): 'js' | 'wasm'; /** Install (or clear) a WASM particle backend directly. Prefer * {@link enableWasmParticles} for the normal async hot-swap. */ setParticleBackend(backend: ParticleBackend | null): void; /** * Asynchronously instantiate the WASM particle core and, on success, use it * for the CPU particle fallback. Accepts the same source shapes as * {@link enableWasmTransforms}. Stays on the JS `updateCPU` path if * instantiation fails — failure is the default state, not an error path. * Resolves `true` if WASM is now active. */ enableWasmParticles(source: ParticleModuleSource): Promise; /** Internal: called by `Entity._spawnDriver` when a new property driver * starts. See {@link _activeDriverEntities}. */ _registerActiveDriverEntity(entity: Entity): void; /** * Drop `entity` and its whole subtree from the batched-driver candidate set. * Called by `Entity.remove` on detach (which `remove`/`hideOverlay` route * through): without this a removed-but-still-animating entity stays pinned in * the Set (a leak) and its drivers keep ticking every frame even though it is * off-tree. If it is later re-added, {@link _registerActiveDriverSubtree} * re-registers any node that still has live drivers, so the motion resumes. */ _unregisterActiveDriverSubtree(entity: Entity): void; /** * Re-register every node in `entity`'s subtree that still has live property * drivers. Called by `Entity._addOne` so re-attaching a subtree that was * removed mid-animation resumes its batched drivers (they were dropped from * the candidate set on removal, but the driver state still lives on each * entity). */ _registerActiveDriverSubtree(entity: Entity): void; /** * Advance every registered entity's active drivers for this frame, batching * whichever are batchable (`SpringDriver`; `TweenDriver` with a named * easing) through one WASM call each when the driver-count gate is open, and * ticking the rest (a `TweenDriver` using a custom `EasingFn`) directly in * JS regardless of the gate. A "claimed" entity must have ALL its drivers * advanced here so it can be safely stamped `_driversTickedFrame` — leaving * one unclaimed would silently stall it, since `tickDrivers()` skips the * whole entity once stamped. * * Must run before ANY entity's `update()`/`tickDrivers()` this frame (see * the call site in {@link render}) — the same ordering constraint G1 Stage 4 * discovered: a value this pass writes must be final before anything reads * it, including the JS-mode interleaved walk and the WASM-mode transform * pre-pass. */ private _tickBatchedDrivers; /** * Authoritative paint order for semantic nodes discovered during the main * render. A node may not have a DOM projection until the following a11y * sync, so retaining the order prevents a newly opened overlay from spending * its first frame below previously projected controls. */ private a11yRenderOrders; private pointRenderer; private glCanvas; private glContextLostHandler; private glContextRestoredHandler; private debugA11y; width: number; height: number; private disableWindowResize; /** See {@link SceneOptions.maxDPR}. `undefined` = uncapped (real DPR). */ maxDPR?: number; private destroyed; private device; private deviceLost; particleBackend: 'auto' | 'webgpu' | 'cpu'; private _webgpuDisabled; get webgpuDisabled(): boolean; /** * Draw accounting for the WebGL point layer, or null when that layer is not in * use. * * Null and all-zero mean different things: null is "this backend is not * running", zero is "it ran and drew nothing". A readout that conflates them * sends someone looking for a performance problem in a backend that was never * active. */ get webglDrawStats(): WebGLDrawStats | null; /** * Whether a WebGPU device is currently live for particle compute. * * The WebGPU path only activates when a `ComputeParticleEntity` is present, so * most scenes never touch it. */ get webgpuActive(): boolean; set webgpuDisabled(value: boolean); private recoveryTimerId; private manager; private initializingWebGPU; private gpuCanvas; private gpuContext; /** True while the GPU canvas holds a presented particle frame (needs clearing when they leave). */ private gpuHasContent; private mouseX; private mouseY; private pointerMoveListener; private pointerLeaveListener; /** Element the pointer listeners are bound to (parent container if present, * else the canvas). Stored so `destroy()` detaches from the same element. */ private pointerEventTarget; private hasWarnedZeroSize; /** * Latch for {@link Scene.resize}'s invalid-dimension warning. * * Separate from `hasWarnedZeroSize`: that one fires for a tolerated zero-size * scene at `start()`, this one for a rejected resize, and sharing the flag * would let either suppress the other's first (and only) report. Latched * because `resize()` is commonly driven from a `ResizeObserver`, which would * otherwise warn on every frame of a drag. */ private hasWarnedInvalidResize; private fontLoadHandler; private static _devMode; /** * Toggle development-mode runtime warnings globally. * * An accessor rather than a plain field so the renderer layer learns about it * immediately: renderers cannot import `Scene` (the dependency runs * `Scene → renderer`), and their diagnostics are installed per instance at * construction. A plain field would only reach them the next time a `Scene` * happened to be built, which made a directly-constructed `CanvasRenderer` * silently untrapped. */ static get devMode(): boolean; static set devMode(active: boolean); private static _devModeDetected; private _devActive; private _devFrameCount; private _devWarn; /** * Warn (dev mode only) about `SceneOptions` keys this version does not read. * * A structural type makes an unrecognized key a silent no-op, and TypeScript * only rejects one when the object literal sits inline at the call site — not * when options are built dynamically, and never in plain JS. Since the * failure mode is "the option appears to work", a runtime check is the only * thing that surfaces it. * * Dev-mode only on purpose: the loop is O(keys × known keys) with an edit * distance per pair, which is nothing at construction but is still pure * overhead in production, where the value has already been shipped. */ private _warnUnknownOptions; /** @internal Periodic dev checks — called once per frame in dev mode. */ private _devRunChecks; constructor(canvas: HTMLCanvasElement, options?: SceneOptions); /** * Arm a `(resolution: Ndppx)` media query for the current devicePixelRatio and * re-apply the canvas scale when it changes. Such a query only fires when the * DPR leaves its exact value, so on each change the old query is detached and * a fresh one is armed for the new DPR. Re-runs `resize(width, height)` (which * re-scales the backing store via the renderer) so text/vectors stay crisp * after a monitor move or zoom. No-op without `matchMedia`. */ private watchDevicePixelRatio; /** * Recover the WebGL point layer from a GPU context loss (driver TDR reset, * tab backgrounded on mobile, GPU switch). Two things are required: * * 1. The `webglcontextlost` handler MUST call `preventDefault()`, or the * browser never fires `webglcontextrestored` and the layer is blank * forever. While lost, the old renderer's GL calls are silently ignored, * so we drop it and the render loop simply skips the point layer. * 2. On `webglcontextrestored`, all GL objects (programs, buffers, textures) * are gone, so we rebuild the renderer from scratch via `Scene.webglCreator` * on the same canvas, restore DPR/size, and repaint. */ private setupGLContextRecovery; /** * Expose the underlying {@link IRenderer} for advanced direct-draw operations. * * @returns The active renderer instance. */ getRenderer(): IRenderer; /** * Finds the topmost interactive entity at the given coordinates. * * Delegates to {@link HitTester}. The frame stamp and the logical size are * passed in rather than reached for: `currentFrame` is the scheduler's * (extraction 6) and `width`/`height` are mutated by `resize` * (extraction 5), so neither can be captured at construction * (`DEC-0019` rule 5). */ findEntityAt(x: number, y: number): Entity | null; /** Convert browser viewport coordinates into this Scene's logical coordinates. */ clientToScene(clientX: number, clientY: number): { x: number; y: number; }; /** * Add a top-level entity to the scene graph. * * @param entity - The entity to attach to the scene root. * @returns `this` for method chaining. * @example scene.add(new CircleEntity()); */ add(entity: Entity): this; /** * Drop any projected elements under `node` without touching the entity tree. * * Used when the walk reaches an invisible subtree: the entities stay put (a * later `show()` re-projects them), but nothing under here may remain * focusable or announced while hidden. */ private pruneA11ySubtree; private removeA11yRecursively; /** * If `el` is about to be removed from the DOM while it holds browser focus, * move focus to the a11y focus sentinel first. Removing the active element * otherwise drops focus to ``, which pulls a screen reader out of the * scene's a11y region and back to the top of the page — the classic * "lost my place on scroll/stream" bug for virtualized/recycled controls. */ private preserveFocusOnRemoval; /** * Remove a top-level entity from the scene graph and clean up its * accessibility shadow elements recursively. * * @param entity - The entity to detach from the scene root. * @returns `this` for method chaining. */ remove(entity: Entity): this; /** * Tear down the a11y/automation shadow nodes for `entity` and its descendants * without removing it from the scene graph. Components that manage dynamic * interactive *child* entities (e.g. a {@link Entity}'s per-link hotspots) call * this before discarding those children so their shadow ``/controls don't * leak. * * `syncA11y` itself only creates and updates, never prunes — but it is always * followed by `enforceA11yDomOrder`, whose prune pass removes any element * whose entity is no longer reachable in the tree or no longer satisfies * {@link shouldProjectA11y}. So an entity that is `remove()`d, or whose * `interactive` flips to `false`, has its element torn down on the next synced * frame without any explicit call. * * This method is for the case that pass cannot see: a child dropped from a * component's own bookkeeping while still parented, or one discarded before * the next sync runs. Calling it is always safe and is the right habit for * pooled children. * * @param entity - The subtree whose shadow nodes should be removed. */ detachA11y(entity: Entity): void; /** * Add an overlay entity to the overlay root, bypassing main tree clipping bounds. */ showOverlay(overlay: Entity): void; /** * Remove an overlay entity from the overlay root. */ hideOverlay(overlay: Entity): void; private destroyEntitySubtree; /** * Tear down the Scene, halt the loop, and clean up event listeners and DOM elements. */ destroy(): void; private setupEvents; /** * Begin the `requestAnimationFrame` render loop. * * Idempotent — calling `start()` on an already-running scene is a no-op, and so * is calling it on a destroyed one. */ start(): void; /** Schedule the next frame, or no-op where `requestAnimationFrame` is absent (SSR). */ private scheduleFrame; /** * Observe whether the canvas is on-screen so the rAF loop can pause when it * scrolls fully out of view (a dashboard tab, a chart below the fold) and * resume when it returns — instead of running the full update/render every * frame for a scene nobody can see. No-op (stays "on screen") where * `IntersectionObserver` is unavailable, so SSR/jsdom behavior is unchanged. * * Also a no-op for a canvas that is not in the document. An offscreen canvas * used purely as a texture source — `@vectojs/three`'s `ThreeAdapter` wraps * one in a `CanvasTexture`, and the same pattern shows up in any * render-to-texture setup — is never appended anywhere, and an * `IntersectionObserver` reports a detached element as not intersecting. * Observing it would therefore set `_canvasOnScreen = false` on the first * callback, and since {@link loop} returns without rescheduling in that * state, the loop would stop permanently: the only resume path is an * `isIntersecting` transition, which a detached element can never produce. * Such a canvas is always "visible" as far as this scene is concerned, * because whether its output is seen depends on the consumer sampling the * texture, which this scene cannot observe. */ private watchCanvasVisibility; /** * Halt the render loop after the current frame completes. * * Call {@link start} again to resume rendering. */ stop(): void; /** * Manually advance the scene clock by `dt` milliseconds and render synchronously. * Essential for deterministic rendering (e.g. video export). * Note: You should call `scene.stop()` before using this to avoid conflict with the rAF loop. */ /** * The scene-graph root entity. Exposed read-only for tooling — the devtools * inspector walks it to build the Virtual Math Tree view. Mutate the graph * through {@link add}/{@link remove}, not by editing this node directly. */ get rootEntity(): Entity; /** The overlay layer root (see {@link showOverlay}), read-only for tooling. */ get overlayRootEntity(): Entity; /** * Advance and render exactly one frame, synchronously. * * This renders UNCONDITIONALLY: it consults neither {@link renderMode} nor * {@link dirty}, and it does not apply the `always`-mode idle auto-throttle. * That is deliberate — a deterministic driver (video export, a test, a * fixed-step benchmark) asks for a frame because it wants that frame, not a * scheduler opinion about whether it is needed. * * The consequence is a measurement footgun worth stating explicitly: a * benchmark that drives frames through `step()` CANNOT observe frame skipping, * so `always` and `onDemand` produce byte-identical draw counts through this * path. An investigation into whether `onDemand` skips redundant repaints once * concluded "it does not" on exactly that basis; on the real rAF loop the same * workload rendered ~1.0 frames per content change. To measure anything about * scheduling, use {@link start} and let `requestAnimationFrame` drive. * * @param dt Milliseconds to advance (same unit as the rAF timestamps `loop` * feeds `render`). Not clamped by `MAX_FRAME_DT` — the caller chooses the * step, since determinism is the point. */ step(dt: number): void; /** * Mark the scene as needing a redraw on the next frame. * * Only meaningful in `onDemand` {@link renderMode}: call it after mutating * entity state outside of {@link Entity.animate} so the change is rendered. */ markDirty(source?: DirtySource): void; /** * Increments whenever the tree's shape changes: add, remove or reparent. * * Already maintained for the resident WASM transform store (see * {@link markStructureChanged}, called from `Entity.add`/`remove`), and exposed * here because a cache of the tree's shape — a DevTools tree model, a serialized * snapshot — is valid exactly as long as this value is unchanged. Comparing it is * O(1) against re-walking the tree, which is what it replaces: DevTools rebuilt * both trees on a fixed 500 ms interval, a constant cost proportional to entity * count, purely because it had no way to ask whether the shape had changed. * * Property changes do NOT bump it. Moving or restyling an entity leaves the * shape intact, so a consumer that also cares about values must read those * directly rather than rebuilding a tree. */ get structureVersion(): number; /** * Start or stop recording dirty attributions. * * Off by default. `renderMode: 'onDemand'` silently degrades to always-on when * something marks the scene dirty every frame, and until now there was no way * to find out what — `dirty === true` said nothing about the cause. Enable * this, run the scene, then read {@link dirtyReasons}. */ setDirtyTracking(enabled: boolean): void; /** Whether dirty attribution is currently being recorded. */ get dirtyTracking(): boolean; /** * Recorded dirty attributions, most frequent first. * * `count` is what matters for the `onDemand` diagnosis: a reason appearing once * per frame over hundreds of frames is the thing keeping the scene awake. */ get dirtyReasons(): DirtyReasonEntry[]; /** Drop recorded attributions, keeping tracking enabled. */ clearDirtyReasons(): void; /** * Live frame telemetry for profilers and devtools overlays. All timings are * measured on the `requestAnimationFrame` loop; a scene driven only by * {@link step} (e.g. deterministic video export) leaves these at their zero * defaults. * * `fps` is derived from the interval between *rendered* frames, so idle * `onDemand` scenes and frames skipped by the {@link maxFPS} cap or the * static auto-throttle do not deflate it — it reports the cadence of actual * redraws, not the raw rAF rate. `frameTimeMs` is the wall-clock cost of the * last `render()` pass alone (excludes a11y/content-projection sync). * * The renderer always repaints the full canvas, so there is no partial * dirty-rectangle to expose; `dirty` is the boolean redraw-pending flag and * `pendingRedraw` reflects whether the next `onDemand` tick will actually * render. */ get frameStats(): FrameStats; /** True when any node in the subtree has a pending animation. */ /** True when any node in the subtree is interactive (drives a11y sync). */ private syncOptionalAttribute; /** * Whether `node` should have an a11y shadow element projected for it. * * The single authority for that decision. It was previously inlined verbatim * at four call sites — `syncA11y` (create/update), `enforceA11yDomOrder` * (which ids survive pruning), `getA11yTree` (the public snapshot) and * `render` (z-index / reading-order assignment). Four copies of one predicate * is a standing correctness hazard: if any of them drifts, elements either * leak (created but never marked active, so pruned every frame and rebuilt) or * go missing from the semantic tree while still present in the DOM. * * A box is required because a zero-size element is unfocusable and * unhittable; `a11yFullViewport` is the deliberate exception, since those * nodes are boundless interaction surfaces mounted behind everything else. * * Keep this the only place the rule is written. A planned per-entity * `a11yProjection` mode ('eager' | 'onDemand' | 'never') extends exactly this * predicate, which is only tractable while it has one home. */ private shouldProjectA11y; /** * Whether an `a11yProjection: 'onDemand'` entity is currently engaged enough to * deserve a shadow node. * * Deliberately **not** hover alone. A keyboard or assistive-technology user * generates no pointer events, so a hover-only trigger would withhold the * semantic node from precisely the users it exists for. Three signals, any of * which counts: * * - **Focus.** Covers keyboard traversal and AT-driven focus. Checked against * the live element so a node keeps its own focus rather than being pruned out * from under the user mid-interaction. * - **Pointer target.** The entity under the pointer, so a mouse user gets the * same node a hover-gated design would have given them. * - **Explicit request.** {@link Scene.requestA11yProjection}, for anything the * app knows is significant — the selected item, a search hit, a * just-announced element. This is the escape hatch that keeps the mode usable * when neither focus nor pointer applies. * * The entity stays hit-testable on canvas regardless, so a click always reaches * it and promotes it on the next sync. */ private a11yEngaged; /** * Whether `node` mirrors selectable text of its own. * * Such an entity must not be promoted by the pointer: its interactive a11y node * would sit above the text mirror and eat the mousedown that starts a native * selection. */ private projectsSelectableText; /** * Keep `entity`'s a11y shadow node projected while it has * `a11yProjection: 'onDemand'`. * * For anything the application knows matters but the engine cannot infer — the * selected danmaku, a search hit, a node just announced in a live region. * Without this, `'onDemand'` would be reachable only by focus or pointer, and * an app-driven selection change would leave the selected entity semantically * invisible. * * Idempotent. Has no effect on an `'eager'` entity, which is always projected. */ requestA11yProjection(entity: Entity | string): void; /** * Drop a projection request made by {@link requestA11yProjection}. * * The node is not removed immediately: it survives while it is focused or under * the pointer, and is pruned on the next sync that finds it unengaged. Releasing * a request the scene does not hold is a no-op. */ releaseA11yProjection(entity: Entity | string): void; private syncA11y; /** * Mirror one entity's static text ({@link Entity.getContentProjection}) as a * transparent DOM node positioned over the drawn glyphs. Runs on the a11y * sync cadence; all writes are dirty-checked. Off-viewport projections are * hidden (`display: none`) so text-heavy scenes only materialize what is * visible to the browser's text machinery anyway — except in the coarse * (resident) tier, which stays displayed because hiding it would make its text * unfindable and remove it from the accessibility tree, defeating the tier. */ /** * Whether `node`'s world-space box, expanded by `margin` px on every side, * overlaps the scene viewport AND every `clipChildren` ancestor's box. Used * both to virtualize content projection (materialize only near-viewport text, * at `margin = contentProjectionMargin`) and for the exact `display:none` * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of * culling and always count as visible, matching the legacy behavior. * * `viewportOnly` skips the `clipChildren` ancestor walk, answering the narrower * question "does this box overlap the viewport at all". The coarse content tier * needs the two apart: text that is merely off-viewport is clipped by * `a11yRoot`'s own `overflow: hidden` and can safely stay displayed, while text * rejected by an ancestor clip box that itself overlaps the viewport would sit * transparently on top of whatever is really drawn there. */ /** * Load `parent`'s world transform into the `_pw*` scalar memo. * * Exists so the settled-walk fast path costs no allocation. The walk visits a * parent's children consecutively, so this recomputes at most once per parent * per sync and is a serial + identity comparison for every child after the * first. See the `_pwNode` field for why it is keyed the way it is. */ private readParentWorld; private projectionBoxVisible; /** * The band of an entity's own y coordinates that is worth projecting, or * `null` to project everything. * * {@link projectionBoxVisible} answers "is this entity near the viewport", * which frees whole blocks that scroll away. It cannot help a single entity * *taller* than the viewport: that entity's box always intersects, so every * one of its visual lines was materialized — a `` per line and, on the * grid path, a `` per glyph cluster. That is where "14.8k elements for a * 346KB Markdown doc" comes from, and it is O(document) rather than * O(viewport) in both element count and per-frame walk cost. * * Measured on one entity scrolled to its middle, real headed browsers * (`benchmarks/projection-per-line/`): at 4000 lines, materializing every line * costs 6.28 ms/frame on Chrome and 6.51 ms on Firefox with 36,000 child * elements, against 0.28/0.16 ms and 963 elements when only the visible band * is emitted. The gated cost is *flat* across a 20x document-size range, so * this converts an asymptote rather than shaving a constant. * * Returns local-y bounds in the entity's own coordinate space, already * expanded by `margin` and intersected with every `clipChildren` ancestor, so * a line inside a scrolled container is measured against the container rather * than the window. `null` means "no useful bound" — a degenerate transform, a * rotation/skew that makes a y-band meaningless, or a boundless entity — and * the caller must then project every line, because emitting nothing would * silently drop text from selection, find-in-page and screen readers. */ private projectionVisibleLocalYBand; private syncContentProjection; private getContentMetricScaleX; private enforceA11yDomOrder; /** * Keep DOM/WebGL overlay layers aligned with the canvas's CSS box. * * The logical size and the lazily-created layers are threaded through rather * than held: `resize` mutates `width`/`height`, and `gpuCanvas` does not exist * until the WebGPU particle path first runs (`DEC-0019` rule 5). */ private syncOverlayGeometry; getA11yTree(): A11yTreeNode[]; private renderPortalDOM; private reconcilePortals; /** * The frame-rate cap actually in effect: the explicit {@link maxFPS}, further * lowered to {@link REDUCED_MOTION_FPS} when the OS requests reduced motion * (and {@link respectReducedMotion} is on). `0` means uncapped. */ private effectiveMaxFPS; private loop; /** * Render the entire scene graph onto the specified renderer. * * Main-frame causal order is a correctness contract: * * 1. Browser/input callbacks finish before the scheduled frame begins. * 2. Batched property drivers and particle simulation advance. * 3. Entity `update()` hooks run. * 4. Transform inputs are gathered and world matrices are composed. * 5. Updated world bounds are tested for culling. * 6. Visible entities paint in scene-graph order. * 7. Canvas/GPU batches flush and retained renderers present. * 8. The rAF loop synchronizes content and accessibility projections after * this method returns. * * The causal order is fixed; physical walks may stay fused. The JavaScript * transform path interleaves update → compose → cull → paint per node in * pre-order. The WASM path updates the whole tree first, then gathers and * composes it in one store pass before the same cull/paint walk. Both must * expose an update's transform mutation in that same rendered frame. * Secondary renderers are read-only snapshots: they skip simulation and * updates, then compose/cull/paint/flush the current state. * * @param renderer - The renderer instance to draw to. * @param dt - Delta time in milliseconds (default 0). * @param time - Current absolute time in milliseconds (default 0). */ render(renderer: IRenderer, dt?: number, time?: number): void; /** * Export the current scene state to a lightweight, flat SVG XML string. */ toSVG(): string; /** * Manually resize the Scene's viewport. * * Rejects a negative or non-finite dimension, keeping the last known-good * size. `0` is accepted: a zero-size scene is tolerated and warned about at * {@link start} instead, and rejecting it here would contradict that. */ resize(width: number, height: number): void; /** Size the WebGPU particle canvas: backing store at logical × DPR, CSS box at * the logical size. Sizing the backing store in logical px (the old * behavior) left it rasterized at 1× and CSS-stretched — blurry on HiDPI. */ private sizeGpuCanvas; /** * Gets the accessibility DOM element projected for the given entity ID. */ getA11yElement(entityId: string): HTMLElement | undefined; /** Gets the static-content DOM projection for an entity ID, when materialized. */ getContentElement(entityId: string): HTMLElement | undefined; /** * Gets the root entity of the scene. */ getRoot(): Entity; /** Submit one transparent clear pass when particle content lingers on the GPU canvas. */ private clearGPUCanvasIfStale; private initWebGPUContext; private setupDeviceLostHandler; private recreateWebGPUDeviceWithRetry; private renderCPUParticles; }