/** * Pointer hit-testing: which entity is under a point, by two paths that must * agree. * * Extraction 4 of the `Scene.ts` decomposition * (`forge/decisions/file-decomposition-2026-08.md` §2). `Scene.findEntityAt` * keeps its name and signature and delegates here. * * ## The two paths, and why both exist * * The **JS depth-first walk** ({@link findHitRecursively}) is the permanent * fallback and the definition of correct: children in reverse draw order, * topmost hit wins, clipped by every `clipChildren` ancestor. * * The **WASM broad phase** ({@link findEntityAt}'s accelerated arm) asks a * spatial grid for one cell's candidates. It is flat, so it has no recursion * clip-stack, which is why {@link isHitEligible} re-applies the same * visibility/clip/pointer gating the walk gets structurally. Keeping those two * in lockstep is the whole correctness argument for having a second path — if * they can disagree, the accelerator is a bug generator rather than an * optimisation. * * ## What this owns * * The hit-grid *contents* — the slot→entity table, the boundless list, and the * reused fused-gather buffer. The cache *key* (`hitGridFrame`, `hitGridOk`) stays * on {@link WasmBackendFacade}, because installing a backend has to invalidate * it and that is the facade's business. * * ## What it deliberately does not own * * `clientToScene` and `setupEvents` sit under hit-test banners but are not hit * testing: * * - `clientToScene` maps browser viewport coordinates to logical ones from * `canvas`, `width` and `height` — all `ContextAndResize` state (extraction 5). * - `setupEvents` wires the window resize listener, the embedded-canvas * `ResizeObserver`, the DPR watch, and the pointer listeners that write * `mouseX`/`mouseY`. Almost all of it is resize and canvas lifecycle. * * That is a fifth instance of `DEC-0016`'s finding that the domain banners * expose wrong cuts — the same shape as `syncOverlayGeometry` in extraction 2. * * ## What is passed in, and why * * `root` and `overlayRoot` are held: `Scene` assigns both once in its constructor * and never reassigns them. The facade is held because the hit backend is reached * only through its public surface (`hit`, `hitReason`, `hitGridFrame`, * `hitGridOk`, `ensureAabbs()`, `slotEntity`, `hitFusedGather`, `transform`). * * The frame counter and the viewport size are **per-call arguments** (`DEC-0019` * rule 5): `currentFrame` belongs to the render scheduler (extraction 6) and * `width`/`height` are mutated by `resize` (extraction 5), so neither can be * captured at construction without going stale. */ import type { Bounds, Entity } from '../Entity'; import type { WasmBackendFacade } from './WasmBackendFacade'; export declare class HitTester { private readonly root; private readonly overlayRoot; private readonly backends; /** Slot index → entity, as built by the most recent grid build. */ private slotEntity; /** Entities with no `getBounds()`, which the grid cannot index. */ private boundless; /** Reused buffer for the fused gather, so a pointer query allocates nothing. */ private gatherBuffer; constructor(root: Entity, overlayRoot: Entity, backends: WasmBackendFacade); /** * Finds the topmost interactive entity at the given coordinates. * * @param frame - `Scene.currentFrame`, the grid cache key's frame stamp. * @param width - Scene logical width, for the grid's extent. * @param height - Scene logical height, for the grid's extent. */ findEntityAt(x: number, y: number, frame: number, width: number, height: number): Entity | null; /** * Refresh the hit-test grid for the CURRENT tree state if it is stale (a * structural or transform change may have happened since the last build — * there is no cheap "nothing moved" shortcut for a spatial index the way * there is for the transform store's topology-only run table, since ANY * entity moving invalidates its AABB, not just add/remove/reparent; the * measured build cost is cheap enough to redo per call). Returns `false` * (grid untrustworthy — caller must use the JS walk) when there is no * backend or the build overflowed its item budget. */ ensureHitGrid(frame: number, width: number, height: number): boolean; /** * `findEntityAt`'s WASM-accelerated path for the main tree. Scans only the * queried cell's candidates (confirming each against its own AABB and precise * `isPointInside`) merged against the (typically empty or tiny) list of * entities with no `getBounds()`, taking whichever confirmed match has the * higher pre-order index — see hit-store.ts for why that is exactly * equivalent to findHitRecursively's topmost-hit priority. Always * conclusive: returns the correct entity or `null`, never "inconclusive". */ private findEntityAtWasm; findHitRecursively(node: Entity, x: number, y: number, clip?: Bounds | null): Entity | null; /** Whether `node` opts out of being a pointer hit target: a disabled control * or an explicit `pointerEvents: 'none'` in its a11y attributes. Its children * are still walked (a transparent container can hold hittable descendants). */ private isPointerTransparent; /** * Whether a confirmed geometric hit on `node` at world `(x, y)` is a REAL hit, * applying the same visibility/input gating as {@link findHitRecursively} but * from a flat candidate (the WASM grid has no recursion clip-stack): the node * and all ancestors are visible (`opacity > 0`), the point lies inside every * `clipChildren` ancestor's world box, and the node isn't pointer-transparent * (disabled / `pointerEvents: 'none'`). Keeps the WASM and JS hit paths in * lockstep so they return the same entity. */ private isHitEligible; }