Custom Hit-Testers
A HitTester resolves a world point to the topmost node under it. The
strategy is pluggable; the default GeometricHitTester is a reverse-z tree walk with an AABB
broad-phase and a precise per-shape test. Inject an alternative with new Stage({ hitTester }); the
Stage drives it through stage.hitTest / getIntersection, computing and passing
pixelSize = 1 / camera.zoom.
The interface
Section titled “The interface”interface HitTester { hitTest( root: Node, worldPoint: Vec2, pixelSize: number, options?: HitTestOptions, ): HitResult | null}
interface HitResult { node: Node; type: HitType; worldPoint: Vec2; localPoint: Vec2; vertexIndex?: number }type HitType = 'fill' | 'stroke' | 'bounds' | 'vertex'Return the first (topmost) hit or null. options carries tolerance, fill, stroke, bounds,
vertices, deep, and a match predicate — see the Hit-Testing API.
pixelSize and zoom-invariant tolerance
Section titled “pixelSize and zoom-invariant tolerance”options.tolerance is a grab radius in screen pixels. To keep it zoom-invariant a tester must
convert it in two steps:
const worldTolerance = (options.tolerance ?? 0) * pixelSize // screen px → world unitsconst localTolerance = worldTolerance / Math.sqrt(Math.abs(world.determinant()) || 1) // → localpixelSize is 1 / camera.zoom (world units per screen pixel), supplied by the Stage; dividing by
the square root of the world-matrix determinant removes the shape’s own scale. The effect is that a
“6 px grab” stays 6 px at any zoom — the behaviour an editor needs, and the one a naïve tester gets
wrong.
Reuse the precise per-shape test
Section titled “Reuse the precise per-shape test”Whatever broad phase you use, the precise test should stay the shape’s own hitTest — the geometry
lives in the shapes, not the tester. This helper mirrors what GeometricHitTester does per shape:
import { Shape } from '@veyrajs/core'import type { HitResult, HitTestOptions, Vec2 } from '@veyrajs/core'
function testShape( shape: Shape, worldPoint: Vec2, pixelSize: number, options: HitTestOptions,): HitResult | null { const worldTolerance = (options.tolerance ?? 0) * pixelSize
// Broad phase: skip the shape if its (expanded) world AABB misses the point. if (!shape.getWorldBounds().expand(worldTolerance).contains(worldPoint)) return null
// Convert the point + tolerance into the shape's LOCAL space. const world = shape.worldMatrix() const scale = Math.sqrt(Math.abs(world.determinant())) || 1 const localTolerance = worldTolerance / scale const local = world.invert().applyToPoint(worldPoint)
const kind = shape.hitTest(local, { tolerance: localTolerance, fill: options.fill ?? true, stroke: options.stroke ?? true, }) return kind === null ? null : { node: shape, type: kind, worldPoint, localPoint: local }}The default also handles vertices (against shape.getVertices()), the bounds fallback, and the
match predicate — add them the same way if you need those result types.
A spatial-index tester
Section titled “A spatial-index tester”GeometricHitTester is O(n) over candidates, with the AABB prefilter keeping the constant tiny — the
right default. For very large, mostly-static scenes you can swap in a spatial index (uniform grid,
quadtree, R-tree) so a query touches only nearby shapes. The tester owns the index and keeps it in
sync as the scene changes; on a query it asks the index for candidates and delegates the precise test
to the helper above:
import { Shape } from '@veyrajs/core'import type { HitResult, HitTestOptions, HitTester, Node, Vec2 } from '@veyrajs/core'
class SpatialHitTester implements HitTester { // Your own grid/quadtree of Shapes, keyed by `shape.getWorldBounds()`. `query` returns the // shapes whose world AABB overlaps the point, ordered topmost-first (reverse paint order). private index = new SpatialIndex()
// Keep the index in sync as the scene changes (add / remove / transform). insert(shape: Shape): void { this.index.insert(shape) } remove(shape: Shape): void { this.index.remove(shape) } refresh(shape: Shape): void { this.index.update(shape) }
hitTest( root: Node, worldPoint: Vec2, pixelSize: number, options: HitTestOptions = {}, ): HitResult | null { for (const shape of this.index.query(worldPoint)) { if (!shape.visible || !shape.listening) continue if (options.match && !options.match(shape)) continue const hit = testShape(shape, worldPoint, pixelSize, options) if (hit !== null) return hit // first (topmost) hit wins } return null }}SpatialIndex is your data structure, not part of core — the seam only requires the hitTest method.
Keeping the index correct on every move/add/remove is the real cost, which is why the geometric
default is the better choice until profiling says otherwise.
Inject it on the stage:
const tester = new SpatialHitTester()const stage = new Stage({ container: el, hitTester: tester })Wrapping the default
Section titled “Wrapping the default”If you only want to adjust behaviour — force a match, log misses, special-case a layer — compose
the default instead of reimplementing the math:
import { GeometricHitTester } from '@veyrajs/core'import type { HitResult, HitTestOptions, HitTester, Node, Vec2 } from '@veyrajs/core'
class LoggingHitTester implements HitTester { private inner = new GeometricHitTester()
hitTest(root: Node, worldPoint: Vec2, pixelSize: number, options?: HitTestOptions): HitResult | null { const hit = this.inner.hitTest(root, worldPoint, pixelSize, options) if (hit === null) console.debug('miss at', worldPoint) return hit }}Related
Section titled “Related”- Hit-Testing (concept)
- Hit-Testing API —
HitTester,HitResult,HitTestOptions. - Math API —
Matrix.invert/determinant,Bounds.expand/contains. - Custom Renderers — the other Stage-level seam.