diff --git a/CHANGELOG.md b/CHANGELOG.md
index c96b35a..8fc6224 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,29 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+
+- **Chrome is pickable.** `SceneBuilder::push_pick_scope` / `pop_pick_scope` record the tree a primitive is drawn in, and `plot::pick` names it: a hit on an axis tick label, gridline, legend key, strip or title reports its plot, region, part and ordinal through `PlotPath`, and the scope stack is the path an event bubbles along.
+- **`PickIndex::hits_at` returns every hit, topmost first**, each carrying its scope chain — it costs the same as topmost-only. `pick_at` remains the one-number convenience.
+- **Rectangle and lasso queries.** `hits_in` (bounds intersect), `hits_within` (bounds enclosed, and exact) and `hits_in_path` (bounds-centre inside a path) for brushing and marquee selection, plus `*_into` variants that reuse a caller's buffer.
+- **`Slot::from_name` and `Slot::ALL`** — the reverse of `Slot::name`, so a region name recovered from a hit round-trips back into `CompositionLayout::get`.
+- **`Plot::index_in_patch` and `Axis::id`** — a plot's position within its patch's attach list, and the handle an axis was attached under. `(patch_id, index_in_patch)` is the pair `update_plot_at` already addresses plots by.
+- **`RecordingScene::draw_ops` and `scope_at`** — the ops that draw something, and the pick-scope stack in effect at an op.
+
+### Changed
+
+- **Picking is a CPU spatial index built while a scene is drawn, not a second rasterization.** Every renderer's `Renderer::Scene` is a `PickIndexScene`, so hit testing rides on the scene and a vector backend or a renderer-free build answers the same way a GPU one does. `with_picking` and `pick_at` keep their names; `pick_at` takes a `Point` and no longer lags the frame it describes. At 100k marks the cost falls from 59 ms to ~7 ms on the sparse-strips backend, and no GPU readback happens at all.
+- **`PickId::Id` spans the full `u32` range with nothing reserved.** Both the 24-bit cap and `Id(0)`'s equivalence to `Block` were artifacts of packing ids into a texture's color channels, where an uncovered pixel decoded to zero. `MAX_PICK_ID`, the build-time panic above `0xFF_FFFF` and `pick::raw_id` are gone, and a `pick_id` channel of row indices no longer has to start at 1. Occlusion is `PickId::Block` alone.
+- **`PickId::Skip` means "no authoring id" rather than "not pickable".** A primitive is indexed when its id is not `Skip` *or* its innermost pick scope is a target — which is how chrome participates without a `PickId` argument at every chrome call site. A geom with no `pick_id` channel is still recorded nowhere.
+- **SVG emits `` for pick scopes** under the existing `SvgConfig::pick_ids` flag, so an exported plot groups by axis, part and item rather than arriving as a flat list of paths.
+- **A dashed stroke is hittable along its gaps, a glyph run picks as its layout box, and stroke caps and joins hit as round.** The index tests geometry rather than rasterized coverage; the differences are sub-pixel to a few pixels and on the generous side.
+
+### Removed
+
+- **The GPU hitmap.** `hitmap`, `try_finish_pick`, `render_to_texture_deferring_pick`, `set_refresh_pick` and `refreshes_pick` on both wgpu renderers; `WindowConfig::pick_interval`; `pick::id_to_color` and `pick::decode`. The pick pass they served no longer exists, and lazy tree construction subsumes the throttle: a window redrawing faster than it is queried builds nothing.
+
## 0.3.0
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 0bb0b32..2f98ce0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,7 @@ cargo +1.86 check --no-default-features --features document-write --ignore-rust-
cargo test # all tests
cargo test --test smoke # the GPU smoke test (requires a working wgpu adapter)
-cargo test --test picking # picking round-trip
+cargo test --test pick_index # hit testing; needs no features at all
cargo test --test image_geom # raster images through PlotComposition
cargo test --no-default-features --features vello-hybrid --test hybrid # the sparse-strips backend, end to end
cargo test --test window_blit # the window presentation blit, headless
@@ -114,7 +114,7 @@ The core types and traits compile with `--no-default-features` (no wgpu pulled i
The following belong in higher layers or other crates and should not land here:
-- **Animation runtime** — picking emits pixel ids (see `src/CLAUDE.md`) and the `window` feature delivers them to an event handler, but tweening states and animation scheduling live in the host.
+- **Animation runtime** — picking reports hits (see `src/CLAUDE.md`) and the `window` feature delivers them to an event handler, but tweening states and animation scheduling live in the host.
- **Filter effects** — blur, drop shadow, etc. Outside the Vello-∩-Blend2D intersection that governs the scene API.
- **Font selection / loading at the `SceneBuilder` level** — the scene API consumes already-positioned glyphs. Shaping and font discovery live in the `text` module (parley-backed); a host that wants its own shaper can replace it behind the `TextRun` / `draw_text` surface.
diff --git a/Cargo.toml b/Cargo.toml
index 4e10af4..8a86c78 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -605,10 +605,6 @@ required-features = ["vello"]
name = "mesh"
required-features = ["vello"]
-[[test]]
-name = "picking"
-required-features = ["vello"]
-
[[test]]
name = "hybrid"
required-features = ["vello-hybrid"]
diff --git a/crates/hephaestus-wasm/js/hephaestus.d.ts b/crates/hephaestus-wasm/js/hephaestus.d.ts
index 693ccc9..b6977a7 100644
--- a/crates/hephaestus-wasm/js/hephaestus.d.ts
+++ b/crates/hephaestus-wasm/js/hephaestus.d.ts
@@ -87,7 +87,7 @@ export interface PlotViewOptions {
colorScheme?: 'light' | 'dark' | 'auto';
/** Track the canvas's CSS box with a ResizeObserver. Default `true`. */
autoResize?: boolean;
- /** Allocate a pick target and read it back per frame. Default `false`. */
+ /** Record a hit index while drawing, enabling `pickAt`. Default `false`. */
picking?: boolean;
/**
* Give the canvas the context menu an ordinary image has. A string names
@@ -147,7 +147,7 @@ export class PlotView {
/**
* The row id under a point, in CSS pixels, or `undefined` for empty space.
- * Needs `picking: true`; may lag the visible frame slightly.
+ * Needs `picking: true`.
*/
pickAt(cssX: number, cssY: number): number | undefined;
diff --git a/crates/hephaestus-wasm/js/hephaestus.js b/crates/hephaestus-wasm/js/hephaestus.js
index cae886d..70e76b2 100644
--- a/crates/hephaestus-wasm/js/hephaestus.js
+++ b/crates/hephaestus-wasm/js/hephaestus.js
@@ -208,8 +208,9 @@ export class PlotView {
* @param {{ colorScheme?: 'light'|'dark'|'auto', autoResize?: boolean,
* picking?: boolean, saveOnRightClick?: boolean|string,
* defaultFont?: boolean, placeholder?: HTMLImageElement|string }} [opts]
- * `picking` allocates a second render target and reads it back after
- * every frame, so leave it off unless `pickAt` is going to be called.
+ * `picking` makes the scene record a hit index as it draws, which costs
+ * CPU per draw call, so leave it off unless `pickAt` is going to be
+ * called.
* `saveOnRightClick` gives the canvas an ordinary image's context menu.
* Pass a string to name the saved file — a bare `true` uses `plot.png`.
* The name is a hint: a `data:` URL has no path for a browser to take a
@@ -567,13 +568,12 @@ export class PlotView {
* otherwise get wrong on a high-density display.
*
* Returns `undefined` unless the view was created with `picking: true`.
- * The hitmap may lag the visible frame slightly, since the readback is
- * never waited on.
+ * Always describes the frame on screen.
*/
pickAt(cssX, cssY) {
if (this._freed) return undefined;
const ratio = this.canvas.width / (this.canvas.clientWidth || this.canvas.width);
- return this.handle.pickAt(Math.round(cssX * ratio), Math.round(cssY * ratio));
+ return this.handle.pickAt(cssX * ratio, cssY * ratio);
}
/**
diff --git a/crates/hephaestus-wasm/src/lib.rs b/crates/hephaestus-wasm/src/lib.rs
index af211f3..9fd5a52 100644
--- a/crates/hephaestus-wasm/src/lib.rs
+++ b/crates/hephaestus-wasm/src/lib.rs
@@ -244,9 +244,10 @@ impl PlotHandle {
/// synchronously. Fails if the document is unreadable or if no WebGPU
/// adapter is available — see [`is_supported`].
///
- /// `picking` allocates a second render target and reads it back after
- /// every frame, so it stays off unless [`Self::pick_at`] is going to be
- /// called.
+ /// `picking` makes the scene record a spatial hit index as it is drawn.
+ /// That costs CPU per draw call whether or not anything is queried, so it
+ /// stays off unless [`Self::pick_at`] is going to be called. Nothing is
+ /// read back from the GPU either way.
#[wasm_bindgen(js_name = create)]
pub async fn create(
canvas: web_sys::HtmlCanvasElement,
@@ -340,11 +341,10 @@ impl PlotHandle {
/// pixels, so scale a pointer event by the device pixel ratio. Always
/// `undefined` unless `picking` was passed to [`Self::create`].
///
- /// The readback is never waited on, so this can answer from a frame or
- /// two ago. That is invisible for hover and is what keeps the call off
- /// the main thread's critical path.
+ /// Answers from the index the scene built while it was drawn, so it
+ /// always describes the frame on screen.
#[wasm_bindgen(js_name = pickAt)]
- pub fn pick_at(&mut self, x: u32, y: u32) -> Option {
+ pub fn pick_at(&self, x: f64, y: f64) -> Option {
self.host.pick_at(x, y)
}
diff --git a/examples/backend_perf.rs b/examples/backend_perf.rs
index 834325a..5a093f5 100644
--- a/examples/backend_perf.rs
+++ b/examples/backend_perf.rs
@@ -9,12 +9,16 @@
//! cargo run --release --example backend_perf --features vello,vello-hybrid,png -- 100000
//! ```
//!
+//! The last two rows are what picking costs: filling the index as the scene
+//! is drawn, and querying it. Neither is a rasterisation.
+//!
//! The first row is the geometry every backend has to build regardless, so
//! subtract it before comparing the rest — otherwise the shared cost reads as
//! though it belonged to whichever backend is listed first.
use hephaestus::backend::hybrid::HybridRenderer;
use hephaestus::backend::vello::VelloRenderer;
use hephaestus::color::rgb8;
+use hephaestus::geometry::Point;
use hephaestus::{Affine, Brush, FillRule, PickId, Renderer, SceneBuilder};
use kurbo::Shape;
use std::time::Instant;
@@ -44,6 +48,37 @@ fn draw(scene: &mut dyn SceneBuilder, n: usize) {
}
}
+/// The same scatter, drawn the way the plot layer draws it: one marker path
+/// shared by every mark, placed by a per-mark transform.
+///
+/// The difference matters only to picking. `draw` builds a fresh path per
+/// mark in absolute coordinates, which is the worst case for the hit index —
+/// nothing can be shared, so every mark's geometry is stored. This is the
+/// case `plot::PointGeom` actually produces, where one stored path serves
+/// them all.
+fn draw_shared_marker(scene: &mut dyn SceneBuilder, n: usize) {
+ let marker = kurbo::Circle::new((0.0, 0.0), 2.0).to_path(0.2);
+ let mut s = 0x2545_f491_4f6c_dd1du64;
+ let mut unit = || {
+ s ^= s << 13;
+ s ^= s >> 7;
+ s ^= s << 17;
+ (s >> 11) as f64 / (1u64 << 53) as f64
+ };
+ for i in 0..n {
+ let x = unit() * W as f64;
+ let y = unit() * H as f64;
+ scene.fill(
+ FillRule::NonZero,
+ Affine::translate((x, y)),
+ &Brush::Solid(rgb8(70, 120, 220)),
+ None,
+ &marker,
+ PickId::Id(i as u32 + 1),
+ );
+ }
+}
+
/// Report the fastest of `iters` runs, after warming up.
///
/// The minimum rather than the mean: GPU clocks ramp, pipelines warm, and the
@@ -113,14 +148,6 @@ fn main() {
hyp.render_to_buffer(W, H, bg, &mut out).unwrap();
});
- let mut hys = HybridRenderer::with_picking().unwrap();
- hys.set_refresh_pick(false);
- time("hybrid: picking on, pick pass SKIPPED", 10, || {
- hys.scene().clear();
- draw(hys.scene(), n);
- hys.render_to_buffer(W, H, bg, &mut out).unwrap();
- });
-
let mut ve = VelloRenderer::new().unwrap();
time("classic: encode + render, no picking", 10, || {
ve.scene().clear();
@@ -134,4 +161,56 @@ fn main() {
draw(vep.scene(), n);
let _ = vep.render_to_buffer(W, H, bg, &mut out);
});
+
+ let mut hys = HybridRenderer::with_picking().unwrap();
+ time("hybrid: shared marker, WITH picking", 10, || {
+ hys.scene().clear();
+ draw_shared_marker(hys.scene(), n);
+ hys.render_to_buffer(W, H, bg, &mut out).unwrap();
+ });
+
+ let mut hysn = HybridRenderer::new().unwrap();
+ time("hybrid: shared marker, no picking", 10, || {
+ hysn.scene().clear();
+ draw_shared_marker(hysn.scene(), n);
+ hysn.render_to_buffer(W, H, bg, &mut out).unwrap();
+ });
+
+ // What picking actually costs now: filling the index while drawing, then
+ // the first query building the tree. Both are CPU-side and neither is a
+ // rasterisation, which is the point of the whole arrangement.
+ let mut ix = HybridRenderer::with_picking().unwrap();
+ ix.scene().clear();
+ draw(ix.scene(), n);
+ ix.render_to_buffer(W, H, bg, &mut out).unwrap();
+ let index = ix.pick_index().expect("picking enabled");
+ // The tree is built lazily, so the first query after a frame pays for it.
+ let t = std::time::Instant::now();
+ let _ = std::hint::black_box(index.pick_at(Point::new(450.0, 280.0)));
+ println!(
+ "{:<46} {:8.2} ms",
+ "pick: first query (builds the tree)",
+ t.elapsed().as_secs_f64() * 1000.0
+ );
+ let mut s = 0x2545_f491_4f6c_dd1du64;
+ let mut unit = || {
+ s ^= s << 13;
+ s ^= s >> 7;
+ s ^= s << 17;
+ (s >> 11) as f64 / (1u64 << 53) as f64
+ };
+ let qs: Vec = (0..10_000)
+ .map(|_| Point::new(unit() * W as f64, unit() * H as f64))
+ .collect();
+ let t = std::time::Instant::now();
+ let mut found = 0usize;
+ for &q in &qs {
+ found += index.pick_at(q).is_some() as usize;
+ }
+ let us = t.elapsed().as_secs_f64() * 1e6 / qs.len() as f64;
+ println!(
+ "{:<46} {us:8.3} us/query ({found}/{} hit)",
+ "pick: warm query",
+ qs.len()
+ );
}
diff --git a/examples/window.rs b/examples/window.rs
index e59428f..108e4ef 100644
--- a/examples/window.rs
+++ b/examples/window.rs
@@ -10,9 +10,9 @@
//! dragging between displays of different densities keeps text crisp and
//! physical sizes constant.
//! - **Picking.** Each point carries a `pick_id`; hovering reports the row
-//! under the cursor and redraws it enlarged. `pick_interval` caps how often
-//! the hitmap is rebuilt, which matters at high point counts because the
-//! pick pass rasterises the scene a second time.
+//! under the cursor and redraws it enlarged. The scene records a spatial
+//! index as it draws, so a hover is a CPU query against the frame on
+//! screen — there is no second rasterisation and nothing to read back.
//!
//! The point count is the first CLI argument, so the same scene scales up for
//! a rough feel of how the pipeline copes:
@@ -29,8 +29,7 @@
//! rows.
//!
//! A second argument names the backend, in a build that compiled both in. The
-//! sparse-strip one has no draw cap at all, and picks without ever reporting an
-//! id that was not drawn:
+//! sparse-strip one has no draw cap at all:
//!
//! ```sh
//! cargo run --release --example window --features window,vello-hybrid -- 200000 hybrid
@@ -43,7 +42,6 @@ use hephaestus::plot::chrome::axis::{Axis, AxisPlacement};
use hephaestus::plot::{scale, Plot, PlotComposition, PointGeom};
use hephaestus::scales::chrome::AxisSide;
use hephaestus::window::{run, Backend, Event, EventCtx, Frame, WindowApp, WindowConfig};
-use std::time::Duration;
const BASE_SIZE: f64 = 1.0;
const HOVER_SIZE: f64 = 7.0;
@@ -89,7 +87,7 @@ impl WindowApp for Demo {
fn event(&mut self, ctx: &mut EventCtx<'_>, event: Event) {
match event {
Event::CursorMoved { position } => {
- let hit = ctx.pick_at(position.x.max(0.0) as u32, position.y.max(0.0) as u32);
+ let hit = ctx.pick_at(position.x, position.y);
if hit != self.hovered {
self.hovered = hit;
self.rebuild_geom();
@@ -211,12 +209,7 @@ fn main() {
.size(900, 560)
.background(rgb8(248, 248, 252))
.picking(true)
- .backend(backend)
- // The pick pass rasterises the whole scene a second time, so at high
- // point counts it can cost as much as the visible frame. Capping how
- // often it runs keeps a resize drag responsive; the hitmap goes at most
- // this stale, which a pointer cannot notice.
- .pick_interval(Duration::from_millis(30));
+ .backend(backend);
if let Err(err) = run(config, Demo::new(points)) {
eprintln!("window: {err}");
diff --git a/src/CLAUDE.md b/src/CLAUDE.md
index 4622ddd..ea351dd 100644
--- a/src/CLAUDE.md
+++ b/src/CLAUDE.md
@@ -16,7 +16,7 @@ The two levels are layered, not parallel. The low-level surface must remain inde
`SceneBuilder` (in `scene/`) and `Renderer` (in `backend/`) are split intentionally:
- `SceneBuilder` is the authoring surface. Pure CPU, infallible, no persistent "current transform / current brush" state.
-- `Renderer` owns backend resources (GPU device, pipelines, readback buffer) and rasterises a built scene. Fallible, resource-owning.
+- `Renderer` owns backend resources (GPU device, pipelines, readback buffer) and rasterises a built scene. Fallible, resource-owning. Its `Scene` is a `PickIndexScene`, so hit testing rides on the scene rather than on the backend.
This split lets recording and vector backends (SVG, PDF) implement `SceneBuilder` without satisfying GPU concerns, and mirrors Vello's own `Scene` / `Renderer` split so wrapping is zero-cost.
@@ -47,24 +47,62 @@ When tempted to add a feature only one backend supports: don't. If it's genuinel
## Picking model
-Picking is **opt-in per renderer**, not a CPU-side post-pass. `VelloRenderer::with_picking()` enables it; `VelloRenderer::new()` allocates nothing in the pick path. When enabled, the renderer rasterises a parallel "pick scene" into a second `HeadlessTarget`, reads it back to CPU once per render, and answers point queries via `pick_at(x, y) -> Option`.
+Picking is a **CPU-side spatial index built while a scene is drawn**, not a second rasterisation. `PickIndexScene` wraps any `SceneBuilder`, forwards every call unchanged, and records each primitive's geometry as it goes past. Nothing is read back from a GPU, and the answer always describes the frame on screen rather than lagging it.
-Every drawing primitive on `SceneBuilder` (`fill`, `stroke`, `draw_image`, `draw_glyphs`, `draw_mesh`) carries a `PickId`. `push_layer` / `pop_layer` do **not** take a `PickId`. Authoring code picks one of three:
+It is a property of the **scene**, not of a backend: every renderer's `Renderer::Scene` is a `PickIndexScene`, so a vector backend or a build with no renderer at all hit-tests the same way a GPU one does. `with_picking()` turns indexing on; `new()` leaves it off, because filling the index costs CPU per draw call whether or not anything is queried.
-- `PickId::Skip` — don't record into the hitmap. Items beneath remain hittable through this primitive. Default for decorative chrome (gridlines, axis ticks, background fills).
-- `PickId::Block` — record with id 0. Occludes whatever is beneath in the hitmap but is itself reported as "no hit". Use for opaque panels that should block picks without being interactive.
-- `PickId::Id(n)` — record with the given id. `n` is a 24-bit caller-managed value (typically a row / item index). Ids above `0xFF_FFFF` are truncated; `Id(0)` is treated identically to `Block`.
+### The two things a primitive carries
-Encoding lives in `pick.rs` (authoritative): ids pack into the RGB channels of an `Rgba8Unorm` pick texture with alpha forced to 255, which round-trips cleanly through default SrcOver compositing without per-draw blend-mode plumbing. `decode` tests alpha before the payload: an uncovered pixel comes back alpha 0 with arbitrary colour channels, and reading those as an id reports phantom hits on empty space (low byte values decode to low ids, so the phantoms cluster on whichever item was numbered first). Partial coverage over *empty space* is fine — the rasteriser unpremultiplies, so a mark's antialiased fringe still carries its exact id.
+**A `PickId`** — the authoring layer's handle for what a call draws. Every drawing primitive on `SceneBuilder` (`fill`, `stroke`, `draw_image`, `draw_glyphs`, `draw_mesh`) takes one; `push_layer` / `pop_layer` do not.
-**Conflated ids where picked content overlaps — backend-dependent.** The compute-shader backend (`backend/vello/`) cannot disable antialiasing, so a mark's edge pixels are a coverage blend. Where that edge falls on other picked content — overlapping marks, or a mark over a `PickId::Block` fill — the blend mixes two ids into a third plausible one at full alpha, indistinguishable from a real hit. Two overlapping circles yield 28 ids that were never drawn. Keeping chrome on `PickId::Skip` (the default, and what `plot/` does) avoids it by leaving marks compositing over nothing. The sparse-strips backend (`backend/hybrid/`) computes coverage on the CPU and rasterises the pick pass with binary coverage, so it does not have this limitation at all. Documented on `crate::pick`.
+- `PickId::Skip` — no authoring id. See the indexing rule below for whether it is recorded.
+- `PickId::Block` — occlude without reporting. A point query stops here and returns nothing, so an opaque panel can hide what is under it without being interactive. Region queries are unaffected: a marquee is a spatial query, not a ray.
+- `PickId::Id(n)` — the given id, across the **full `u32` range** with nothing reserved. Occlusion is `Block`, a variant rather than a magic value; `0` was special only while ids were packed into a texture. The caller owns the namespace; nothing allocates ids.
-**Alpha-insensitive picking — every backend.** Picking ignores display alpha. A semi-transparent layer or image fully occludes picks of content beneath it, even though that content remains visible in the rasterised image. This follows from compositing ids with SrcOver, which is our encoding choice rather than any rasteriser's behaviour, so no backend escapes it. Documented on `crate::pick` and on each renderer's `pick_at`.
+**A `PickScope` stack** — the logical tree the drawing sits in, pushed and popped like a layer but with no visual effect and no clip. The stack in effect at a draw is that primitive's ancestor chain, so **the scope stack is the bubble path**. This is what makes chrome pickable: chrome has no id of its own, and carving a range out of a namespace the caller owns was never safe.
-Backend semantics:
+### The indexing rule
-- **Recording backend (`scene::recording::RecordingScene`)** stores `PickId` in each `Op` faithfully. Future SVG / PDF emitters may surface it or ignore it — both are valid.
-- **Non-rasterising backends** are free to ignore `pick_id` entirely. The trait parameter is unconditional; its effect is backend-defined.
+A primitive is recorded when:
+
+```
+pick_id != Skip OR the innermost scope's mode is Target
+```
+
+`ScopeMode::Group` is the default, so the safe behaviour is what you get by omission: a dense geom with no `pick_id` channel emits `Skip`, sits only in `Group` frames, and is **not recorded at all** — no entry, no leaf box, nothing to test. `ScopeMode::Target` is emitted only by `plot::pick`'s `part_scope` / `item_scope`, so chrome opts in structurally rather than at ~90 individual call sites. Measured on a two-plot composition: 500 marks without a `pick_id` channel contribute 0 entries; the 26 that exist are all chrome.
+
+### Layering
+
+`crate::pick` is chart-agnostic — a `PickScope` carries a `&'static str` kind plus an optional name and index, and nothing down there knows what an axis is. The vocabulary lives in `plot::pick` (`PlotPart`, the scope constructors, the typed `PlotPath` view), the same split `composition` already uses between `Slot::name` and the `Region` trait. The grammar, with only `region` and `part` always present:
+
+```
+composition → plot? → region(Slot) → [axis|legend|geom]? → part(PlotPart) → item(u32)?
+```
+
+`plot` is absent for composition-level chrome, so a hit on the figure title reports `PlotPath::plot() == None`.
+
+### Queries
+
+`PickIndex::hits_at(p)` returns every hit, topmost first, each carrying its scope chain — all-hits costs the same as topmost-only, since the tree descent is the cost and refinement is noise. `hits_in` / `hits_within` are rubber-band brushing (`hits_within` is the exact one: bounds inside a rect implies geometry inside it); `hits_in_path` is a lasso, centre-based because for an arbitrary polygon bbox containment implies nothing.
+
+A renderer exposes **one** pick method, `pick_index()`. Its part in hit testing is owning the scene that recorded the index, so every query lives on `PickIndex` rather than being forwarded through two more layers of the same names.
+
+### What it costs
+
+Filling the index during a draw, and building the R-tree lazily on the first query after a frame. Measured at 100k marks, 900×560: **+7 ms** when marks share a marker path under a per-mark transform (what `PointGeom` does, so interning collapses the geometry to one copy), **+19 ms** when every mark's path is distinct; then **5 ms** once for the tree, and **~2.7 µs** per warm query. A frame nobody queries never builds a tree.
+
+### Known limits
+
+- **Dashed strokes are hittable along their gaps.** A hit target follows the path, not the dash pattern.
+- **Glyph runs pick as layout boxes, not ink.** `skrifa` is optional and `pick` is unconditional, so real outlines are unreachable from a core module; the box is synthesized from `font_size`. Leading and side bearings are hittable, which is what a text target should be — but a glyph-backed marker shape is correspondingly looser than its outline.
+- **Stroke ends and joins are round** whatever the cap and join say. Sub-pixel to a few pixels, and on the generous side.
+- **There is no canvas.** A mark drawn partly outside the frame is hittable at coordinates outside it: the index answers about geometry, not about a framebuffer.
+
+### Backend semantics
+
+- **`RecordingScene`** stores both `PickId` and the scope ops faithfully. `draw_ops()` skips the scope bookkeeping, for a test asserting what got *drawn*; `scope_at(i)` reads the stack in effect at an op.
+- **Rasterising backends ignore `pick_id`.** The index sits above them, so a rasteriser has nothing to do with it.
+- **SVG surfaces both** — `data-pick-id` on primitives and `` for scopes, behind the one `SvgConfig::pick_ids` flag. PDF accepts and ignores.
## Core types — wrapping kurbo + peniko
@@ -80,6 +118,7 @@ Folders (each with its own CLAUDE.md):
- `backend/` — `Renderer` trait, error type, and backend implementations.
- `layout/` — grid layout solver. Recursive grids, fr / auto tracks, `respect()`, `Measure` protocol.
- `composition/` — patchwork-style plot composition. 13-col × 16-row anatomical grid; chrome alignment across nested compositions via `Extent::TrackOf`.
+- `pick/` — hit testing: `PickId`, `PickScope`, and the CPU spatial index behind them. Depends on nothing above `scene/`, which is what lets any backend — or none — answer a query.
- `primitives/` — compound 2D primitives: path constructors (rect / circle / wedge / polyline / polygon / arc), composable vertex transforms (clip / offset / round corners), arc-length sampling, ribbon tessellation.
- `plot/` — high-level plot API: `Plot`, `PlotComposition` orchestrator, key-based diff for identity-preserving animation. Geoms in `plot/geom/`; axis / legend rendering in `plot/chrome/`. Scales and values themselves live in [`crate::scales`] (see below).
- `scales/` — leaf module: `Value`, `DataColumn`, `Scale`, scale types, transforms, break / tick algorithms. Backend-agnostic and plot-agnostic; nothing inside imports from `src/plot/`, `src/scene/`, etc. Intended to be lifted into its own crate once the API settles. Hephaestus's own `Scale` bundle, `ScaleRegistry` and the ggplot-style constructors live in `plot/scale/` (which also re-exports `crate::scales::*`, so `hephaestus::plot::scale::*` reaches both); `plot/value.rs` is a pure re-export shim over `crate::scales::value`.
@@ -98,7 +137,6 @@ Single-file modules (no CLAUDE.md, one-line descriptions here):
- `linetype.rs` — the named `solid` / `dashed` / `dotted` / `dashdot` constructors plus `draw_linetype_with_markers`, the arc-length walk that stamps marker shapes along a polyline. At the crate root rather than under `plot/geom/` because rich-text block borders express their strokes as linetypes too, and `text/` must not depend on `plot/`; `plot::geom::linetype` re-exports it. The `LinetypeStep` enum itself lives in `style_vocab.rs` — it's shared vocabulary like `Color`, so `scales` can carry a column of dash patterns without depending on the renderer that walks them.
- `mesh.rs` — `Mesh`: flat 2D triangle list with per-vertex colour. Used by `primitives::ribbon` and consumed by `SceneBuilder::draw_mesh`.
- `path.rs` — `Path` (kurbo `BezPath` wrapper) and `FillRule` (intersection enum).
-- `pick.rs` — `PickId` and the authoritative encoding into `Rgba8Unorm` RGB.
- `png.rs` — aliases for the PNG entry points in `image/` (`png` feature).
- `shape.rs` — `Shape` / `ShapeRegistry` / `ShapeStyle`: named glyphs / paths for scatterplot markers and line endpoint terminators.
- `stroke.rs` — re-exports kurbo `Stroke`, `Cap`, `Join`. Stroke alignment and variable-width strokes are not in scope.
diff --git a/src/backend/CLAUDE.md b/src/backend/CLAUDE.md
index 19c2710..cbf384a 100644
--- a/src/backend/CLAUDE.md
+++ b/src/backend/CLAUDE.md
@@ -10,17 +10,17 @@ A `Renderer` owns backend resources (GPU device, pipelines, readback buffer) and
- **`Renderer`** trait — two methods: `scene(&mut self) -> &mut Self::Scene` (issue draws against this) and `render_to_buffer(width, height, background, out)` (rasterise into `out`, which must be exactly `width * height * 4` bytes RGBA8 with straight, un-premultiplied alpha).
- **`Renderer::Scene`** associated type — the backend's concrete `SceneBuilder` implementation.
-- **`WgpuRenderer`** trait — optional extension, available whenever a rasterising backend is. Carries two associated constants the host reads instead of assuming: `REQUIRED_TARGET_USAGE` (`STORAGE_BINDING` for the compute-shader backend, `RENDER_ATTACHMENT` for sparse strips) and `TARGET_IS_PREMULTIPLIED` (`false` / `true` respectively — `render_to_buffer` normalises to straight alpha on both paths, `render_to_texture` does not). Adds `render_to_texture(view, width, height, background)` for hosts that want to skip the CPU readback and present the result through their own wgpu surface. The view must be `Rgba8Unorm` with `STORAGE_BINDING` usage (the compute-shader backend writes through a storage binding, so a render-attachment-only swap chain texture cannot be the direct target; the sparse-strips backend wants `RENDER_ATTACHMENT` instead, so this contract has to become backend-supplied before it can implement the trait), plus whatever the host's own consumption needs — `TEXTURE_BINDING` to blit onto a surface, `COPY_SRC` to copy back. Hosts manage their own intermediate-storage-texture → swap-chain blit, and must premultiply during that blit if they present translucent content (the view holds straight alpha, same as `render_to_buffer`). `src/window/` is the in-crate host built on this trait. Picking still works: the pick scene continues to rasterise into the backend-owned pick target and read back to CPU.
+- **`WgpuRenderer`** trait — optional extension, available whenever a rasterising backend is. Carries two associated constants the host reads instead of assuming: `REQUIRED_TARGET_USAGE` (`STORAGE_BINDING` for the compute-shader backend, `RENDER_ATTACHMENT` for sparse strips) and `TARGET_IS_PREMULTIPLIED` (`false` / `true` respectively — `render_to_buffer` normalises to straight alpha on both paths, `render_to_texture` does not). Adds `render_to_texture(view, width, height, background)` for hosts that want to skip the CPU readback and present the result through their own wgpu surface. The view must be `Rgba8Unorm` with `STORAGE_BINDING` usage (the compute-shader backend writes through a storage binding, so a render-attachment-only swap chain texture cannot be the direct target; the sparse-strips backend wants `RENDER_ATTACHMENT` instead, so this contract has to become backend-supplied before it can implement the trait), plus whatever the host's own consumption needs — `TEXTURE_BINDING` to blit onto a surface, `COPY_SRC` to copy back. Hosts manage their own intermediate-storage-texture → swap-chain blit, and must premultiply during that blit if they present translucent content (the view holds straight alpha, same as `render_to_buffer`). `src/window/` is the in-crate host built on this trait. Picking is unaffected by which path a host takes: the hit index is built as the scene is authored, before either.
- **`BackendError`** — `BufferSize`, `NoAdapter`, `DeviceRequest`, `Readback`, `SceneTooLarge`, `Other`. Backends should prefer a typed variant over `Other` when possible.
## Conventions
- **Straight alpha on output.** Every `Renderer` writes RGBA8 with un-premultiplied alpha, so a buffer drops straight into a PNG or an `image::RgbaImage` with no conversion. A new backend whose rasteriser is natively premultiplied (most are) unpremultiplies on the way out. `tests/alpha_format.rs` pins the convention.
- **No `Box`.** The associated `Scene` type makes the trait awkward as a trait object (GAT-ish). For runtime backend selection use an enum (`AnyRenderer { Vello(VelloRenderer), Blend2d(...) }`). Dynamic dispatch on the scene side is fine: `&mut dyn SceneBuilder` is object-safe.
-- **Device sharing for windowing.** GPU backends that implement `WgpuRenderer` expose a `with_device(&wgpu::Device, &wgpu::Queue)` (+ `with_device_and_picking`) constructor so the host can hand in the device backing its presentation surface. Each backend's `new()` continues to spin up its own headless device — that path stays available for file export and tests. The crate re-exports `wgpu` at `hephaestus::wgpu` so callers don't need a separate dependency at a matching version.
+- **Device sharing for windowing.** GPU backends that implement `WgpuRenderer` expose a `with_device(&wgpu::Device, &wgpu::Queue)` (+ `with_device_and_picking`, which enables the scene's hit index) constructor so the host can hand in the device backing its presentation surface. Each backend's `new()` continues to spin up its own headless device — that path stays available for file export and tests. The crate re-exports `wgpu` at `hephaestus::wgpu` so callers don't need a separate dependency at a matching version.
- **One backend per subfolder.** Each backend lives in `src/backend//` with at minimum `mod.rs`. A *rasterising* backend implements `SceneBuilder` and `Renderer`; a *vector* backend implements `SceneBuilder` alone and exposes its own emit entry points, because `Renderer`'s contract is to fill a buffer of RGBA8 and there are no pixels to fill. `backend/svg/` and `backend/pdf/` are the worked examples, and they differ in what they aim at rather than in shape: editable output against fixed output.
- **`mesh.rs` is shared by every backend that needs it, including the ones with no GPU.** Mesh decomposition works purely in this crate's own types and emits plain `SceneBuilder::fill` calls, so `backend/svg/` reuses it verbatim; its cfg gate names `svg` alongside the rasterisers for that reason. `convert.rs` does not, because it maps onto *peniko's* enums. **`backend/pdf/` is the one backend absent from that gate**: PDF has a native Gouraud triangle shading, so it emits the mesh as one object rather than decomposing it into fills. Everything in `mesh.rs` is a workaround for the absence of such a primitive, and a backend that has one should not pay for it.
-- **`convert.rs` and `mesh.rs` sit beside the backends, not inside them.** Both rasterising backends consume peniko, so the enum mapping is identical and lives once in `backend/convert.rs`. Mesh decomposition works purely in this crate's own types and emits plain `SceneBuilder::fill` calls, so it lives once in `backend/mesh.rs` — and because each backend's own `fill` already handles its pick pass, sharing it costs nothing. A backend whose native types are *not* peniko's should add its own `convert.rs` in its own folder rather than bending the shared one.
+- **`convert.rs` and `mesh.rs` sit beside the backends, not inside them.** Both rasterising backends consume peniko, so the enum mapping is identical and lives once in `backend/convert.rs`. Mesh decomposition works purely in this crate's own types and emits plain `SceneBuilder::fill` calls, so it lives once in `backend/mesh.rs` — and because it emits ordinary `fill` calls, the hit index sees a mesh's triangles the same way any backend does. A backend whose native types are *not* peniko's should add its own `convert.rs` in its own folder rather than bending the shared one.
- **`backend/convert.rs` is where the intersection rule is enforced.** Our restricted enums (`FillRule`, `BlendMode`, `Compose`, `Mix`, `Sampling`) map into the wider native enums here. When peniko exposes `Mix::Clip` and we don't, the conversion table is the only place that knows that.
- **Feature-gated.** Each backend is gated by a cargo feature of the same name (`vello`, `svg`, `pdf`, future `blend2d`). `vello` and `png` are default-on; `jpeg` / `tiff` / `webp` gate the other writers in `src/image/`. `blend2d` is a stub feature (no code behind it yet) so dependent crates can write `features = ["blend2d"]` once available.
@@ -37,7 +37,7 @@ A `Renderer` owns backend resources (GPU device, pipelines, readback buffer) and
- `scene/` — the `SceneBuilder` trait every backend implements.
- `backend/vello/` — the compute-shader backend. See its own `CLAUDE.md` for the wgpu / vello / pollster quirks.
-- `backend/hybrid/` — the sparse-strips backend: coverage on the CPU, so it can rasterise a pick pass with binary coverage and carries no draw-count ceiling. See its own `CLAUDE.md`.
+- `backend/hybrid/` — the sparse-strips backend: coverage on the CPU, and no draw-count ceiling. See its own `CLAUDE.md`.
- `backend/svg/` — the vector backend: emits markup, implements `SceneBuilder` only, and needs no GPU. See its own `CLAUDE.md`.
- `backend/pdf/` — the fixed vector backend: emits a PDF file with every glyph embedded, so it looks the same on a machine with none of the fonts. Implements `SceneBuilder` only, needs no GPU, and is the one backend that does not use `backend/mesh.rs`. See its own `CLAUDE.md`.
- `backend/href.rs` — the link-destination allow-list `svg` and `pdf` share.
diff --git a/src/backend/hybrid/CLAUDE.md b/src/backend/hybrid/CLAUDE.md
index 8202044..531656e 100644
--- a/src/backend/hybrid/CLAUDE.md
+++ b/src/backend/hybrid/CLAUDE.md
@@ -6,10 +6,7 @@ Vello Hybrid backend: records draws against a `RecordingScene`, replays them int
`HybridScene` is a recorder, not a rasteriser — it delegates every `SceneBuilder` method to `crate::scene::recording::RecordingScene`. `HybridRenderer` owns the wgpu device and queue, and per frame replays the recording into one or two `vello_hybrid::Scene`s, rasterises them into `Target`s, and reads them back.
-`Writer` is the replay sink: a `SceneBuilder` that writes into a `vello_hybrid::Scene`. One instance per pass, chosen by `Pass`:
-
-- **`Pass::Display`** — the caller's brushes, blend modes, layer alpha, and antialiasing.
-- **`Pass::Pick`** — solid encoded ids, blend and layer alpha dropped, hairline strokes widened, and `set_aliasing_threshold(Some(PICK_ALIASING_THRESHOLD))`.
+`Writer` is the replay sink: a `SceneBuilder` that writes into a `vello_hybrid::Scene`, with the caller's brushes, blend modes, layer alpha and antialiasing. One instance per frame — there is no second pass, because hit testing is a CPU index built as the scene is *drawn* rather than a second thing to rasterise.
## Why the scene is recorded rather than written straight through
@@ -17,35 +14,16 @@ Vello Hybrid backend: records draws against a `RecordingScene`, replays them int
Two things fall out of it, both load-bearing:
-- **The pick pass is a second replay, not a second recording.** The compute-shader backend maintains two `vello::Scene`s and encodes every draw twice; here one recording feeds both passes.
+- **The recording feeds one replay.** It used to feed two — a display pass and an id-buffer pass — which is why the machinery for replaying it twice exists at all. Picking no longer needs it; the recording survives for the reason below it.
- **A resize replays rather than losing the frame.** `tests/hybrid.rs` pins rendering the same scene at two sizes in a row.
The cost is one extra owned copy of the geometry per frame (`Op` clones paths and brushes). Worth measuring before it is optimised away — the obvious next step is replaying directly out of the plot layer rather than through an op list.
## Quirks worth remembering
-- **Aliased picking is the point of this backend.** Coverage is computed CPU-side, so the pick pass can paint with binary coverage: a pixel belongs to exactly one primitive. On two overlapping circles the compute-shader backend yields 28 ids that were never drawn; this one yields two. `tests/hybrid.rs::overlapping_picked_marks_never_blend_into_a_third_id` is that case. Note the geometry has to be *antialiased* to show the difference — axis-aligned rects on integer pixel boundaries have no fringe and both backends agree.
-- **`MIN_PICK_STROKE_WIDTH` is load-bearing here, not a nicety.** With binary coverage a stroke thinner than the threshold covers no pixel at all and vanishes from the hitmap entirely, rather than merely fading.
-- **The display and pick passes must not share a command buffer.** One
- `vello_hybrid::Renderer` serves both, and rasterising a scene writes that
- frame's coverage, paints and glyph atlas into renderer-owned textures *while
- the pass is being recorded*. Recording both into one encoder therefore lets
- the pick pass's uploads land before the GPU has run the display pass, and the
- display comes back reading the pick pass's binary coverage — aliased,
- glyph-less, and with the wrong paints. Every path submits the display pass
- before recording the pick one; `submit_pick_blocking` is the shared helper,
- and the deferred `submit_pick` was always separate. Two submits per frame is
- the price. `tests/hybrid.rs::picking_does_not_change_the_{buffered,textured}_display`
- pin the invariant, and fail loudly if the submits are ever merged.
-- **The pick scene gets no background fill.** An uncovered pick pixel must stay at alpha 0, which is what `pick::decode` reads as a miss.
-- **`RENDER_ATTACHMENT`, not `STORAGE_BINDING`.** Rasterisation goes through a render pipeline, so the target is an ordinary colour attachment — which is exactly what a swap-chain texture is, so `window` and `canvas` skip the intermediate texture and its per-frame blit entirely on this backend.
-- **The target format is settable, and the pick target follows it.** `set_target_format` exists so a host presenting straight into its swap chain can name that surface's format; `render_to_buffer` ignores it and always uses `Rgba8Unorm`, since that is the byte order it hands out. One `vello_hybrid::Renderer` targets one format, so the pick target takes the display's — meaning a `Bgra8Unorm` surface needs `read_hitmap` to swap red and blue before `pick::decode` sees an id.
-- **Output is premultiplied; `unpremultiply` converts on the way out.** Every `Renderer` hands out straight alpha. `tests/hybrid.rs::output_is_straight_alpha` fails loudly if the conversion goes missing.
-- **The background is a draw, not a parameter.** `render` takes no base colour, so `replay` fills the frame rect first. It has to stay the first draw.
-- **A resize rebuilds the renderer, the scenes, and the image atlas.** `RenderTargetConfig` and `Scene` both fix their dimensions at construction, so `Sized` holds all three together and is replaced wholesale. Uploaded images are invalidated with it.
- **Image opacity cannot ride on the sampler.** `vello_common`'s paint encoder does `unimplemented!("Applying opacity to image commands")` for any `sampler.alpha != 1.0`. `draw_image`'s alpha becomes a `push_opacity_layer` instead. `tests/hybrid.rs::a_translucent_image_fades_instead_of_panicking` pins it.
- **Images must be atlas handles.** The paint encoder matches only `ImageSource::OpaqueId` and panics on `ImageSource::Pixmap`, so every image is uploaded before replay can reference it. `ImageSource::from_peniko_image_data` does the format narrowing and premultiply; we take the pixmap back out of it and upload that.
-- **Bitmap color glyphs bypass the rasteriser's own strike path**, and that is not an optimisation — `glyph_bitmap.rs` exists because the upstream path is unusable here twice over. It reaches the GPU only through the glyph atlas, and the atlas takes no rotation or skew; the fallback for anything else is a `Pixmap` paint, which is the panic in the bullet above. And the atlas path paints the strike's *own colors*, so the pick pass reads an emoji back as hundreds of ids that were never drawn — measured, on one 48 px emoji. Resolved as an image instead, a strike costs one atlas upload, survives any transform, and picks as the caller's id.
+- **Bitmap color glyphs bypass the rasteriser's own strike path**, and that is not an optimisation — `glyph_bitmap.rs` exists because the upstream path is unusable here twice over. It reaches the GPU only through the glyph atlas, and the atlas takes no rotation or skew; the fallback for anything else is a `Pixmap` paint, which is the panic in the bullet above. Resolved as an image instead, a strike costs one atlas upload and survives any transform. It also stays one pick target rather than becoming one per coloured region — `tests/hybrid.rs::a_bitmap_color_glyph_picks_as_one_id` pins that.
- **Masks are unreachable, deliberately.** `Scene::push_layer` panics on a mask layer, and our `push_layer` has no mask channel, so `None` is always passed.
- **Scene dimensions are `u16`.** `MAX_DIMENSION` is the ceiling; `dimension()` reports anything past it as a `BackendError`.
- **Blend coverage is complete.** All 16 `Mix` and 14 `Compose` variants are mapped upstream, a superset of what `backend/convert.rs` exposes, so no conversion entries are missing.
@@ -61,17 +39,20 @@ scatter that is the whole story. Measured with
|---|---|---|
| building the paths (both backends pay) | 2.3 ms | 11.4 ms |
| recording, before any rasterisation | 4.2 ms | 24.6 ms |
-| display pass | 18.7 ms | 84.8 ms |
-| display + pick | 31.3 ms | 145.0 ms |
-| display + pick, pick pass skipped | 18.7 ms | 85.6 ms |
-| compute-shader backend, display | 6.3 ms | 27.4 ms |
-| compute-shader backend, display + pick | 9.1 ms | 39.4 ms |
+| display pass | 18.7 ms | 83.3 ms |
+| display + hit index | — | 102.4 ms |
+| compute-shader backend, display | 6.3 ms | 26.9 ms |
+| compute-shader backend, display + hit index | — | 46.0 ms |
Three things to read off it:
-- **The pick pass costs what the display pass costs**, because it is a second
- strip generation over the same geometry. `set_refresh_pick(false)` recovers
- all of it, and `WindowConfig::pick_interval` is how a host throttles it.
+- **Picking is no longer this backend's problem.** It used to be a second
+ strip generation over the same geometry and cost about what the display pass
+ did — 145 ms against 85 ms at 100k. It is now a CPU index built while the
+ scene is drawn, so both backends pay the same ~19 ms and neither rasterises
+ anything twice. The rows above are the pathological case for it (every mark
+ a distinct path); a shared marker path interns to ~7 ms. See the picking
+ model in `src/CLAUDE.md`.
- **Recording is a real but minor tax** — about 13 ms of the 100k frame once the
shared path-building is subtracted. Writing straight into the rasteriser's
scene would recover it, at the cost of needing the frame size before the
@@ -81,7 +62,10 @@ Three things to read off it:
already auto-detected (`Level::try_detect`), and `vello_hybrid` exposes no
threading feature — only `vello_cpu` does, via rayon. So this backend is
roughly 3x slower on dense scatter and picking it is a correctness and
- footprint decision, not a speed one.
+ footprint decision, not a speed one. That trade used to be partly repaid by
+ its aliased pick pass being the correct one; the CPU index makes both
+ backends correct, so what remains is footprint — measured at less than half
+ the wasm bundle.
## The one remaining capacity limit
@@ -98,9 +82,9 @@ The exception is the alpha texture, which holds per-pixel coverage for antialias
## Two renderers, one scene layer
-`mod.rs` holds everything that needs no GPU API — `HybridScene`, `Writer`, `Pass`, the image collection and the alpha conversion — and the renderers sit beside it:
+`mod.rs` holds everything that needs no GPU API — `HybridScene`, `Writer`, the image collection and the alpha conversion — and the renderers sit beside it:
-- **`wgpu_renderer.rs`** (`vello-hybrid`) — `HybridRenderer`: renders to a wgpu texture, implements `Renderer` and `WgpuRenderer`, and reads the pick target back through a mapped buffer.
+- **`wgpu_renderer.rs`** (`vello-hybrid`) — `HybridRenderer`: renders to a wgpu texture and implements `Renderer` and `WgpuRenderer`. Its `Scene` is a `PickIndexScene`, so `with_picking` is a flag on the scene rather than a second target to allocate.
- **`webgl.rs`** (`webgl`, `wasm32` only) — `HybridWebGlRenderer`: renders to a canvas's WebGL2 default framebuffer through precompiled GLSL, with no wgpu in the build.
Keeping the scene layer GPU-free is what makes the second one possible: a WebGL2 build has no wgpu types to name anywhere.
@@ -108,14 +92,14 @@ Keeping the scene layer GPU-free is what makes the second one possible: a WebGL2
The WebGL renderer differs in three ways worth knowing:
- **No offscreen target.** Upstream draws to the default framebuffer and nothing else — the field that would redirect it is private — so there is no intermediate texture and no blit. The canvas *is* the target.
-- **Picking draws the id buffer to the canvas and reads it straight back**, then overdraws it with the display. Both happen in one JS task and a canvas is not composited until the task yields, so the id frame is never seen. It does mean the pick pass has to go first, and that `readPixels` is synchronous.
-- **`readPixels` reads bottom-up**, so the hitmap rows are reversed on the way in. It also implements no `Renderer`: that trait rasterises into a caller's buffer, which here would mean drawing to a visible canvas and reading it back — not what the name promises. Use the wgpu renderer for file output.
+- **Picking costs no GPU work at all.** It used to draw the id buffer to the canvas, `readPixels` it synchronously, and overdraw it with the display inside one JS task — which worked only because a canvas is not composited until the task yields. The CPU index removes the whole arrangement, including the ordering constraint it imposed.
+- **It implements no `Renderer`**: that trait rasterises into a caller's buffer, which here would mean drawing to a visible canvas and reading it back — not what the name promises. Use the wgpu renderer for file output.
## Files
- `mod.rs` — `HybridScene`, `Writer`, `Pass`, and the shared helpers.
- `glyph_bitmap.rs` — bitmap color glyphs: which of a run's glyphs a strike serves, the strike decoded as an image, and where it sits. Placement is Skia's arithmetic, the same the compute-shader backend and `backend/pdf/` carry; `tests/hybrid.rs::a_bitmap_color_glyph_lands_where_the_other_backend_puts_it` holds the two to the same pixels.
-- `wgpu_renderer.rs` — `Target`, `SizeBound`, `PendingPick`, `HybridRenderer`.
+- `wgpu_renderer.rs` — `Target`, `SizeBound`, `HybridRenderer`.
- `webgl.rs` — `HybridWebGlRenderer`.
Enum mapping lives in `backend/convert.rs` and mesh decomposition in `backend/mesh.rs`, both shared with the other rasterising backend.
@@ -123,5 +107,6 @@ Enum mapping lives in `backend/convert.rs` and mesh decomposition in `backend/me
## Cross-references
- `backend/` — the `Renderer` trait and the `WgpuRenderer` target contract this backend does not yet satisfy.
-- `backend/vello/` — the compute-shader backend, and the picking limitations this one lifts.
+- `backend/vello/` — the compute-shader backend.
+- `pick/` — the hit index both backends' scenes are wrapped in.
- `scene/recording.rs` — `RecordingScene` and `replay`, the mechanism the whole module is built on.
diff --git a/src/backend/hybrid/mod.rs b/src/backend/hybrid/mod.rs
index ca5cf74..1dd7f0e 100644
--- a/src/backend/hybrid/mod.rs
+++ b/src/backend/hybrid/mod.rs
@@ -30,7 +30,7 @@ use crate::brush::{Brush, Image, Sampling};
use crate::geometry::Affine;
use crate::mesh::Mesh;
use crate::path::{FillRule, Path};
-use crate::pick::{self, PickId};
+use crate::pick::{PickId, PickScope};
use crate::scene::recording::RecordingScene;
use crate::scene::{GlyphRun, SceneBuilder};
use crate::stroke::Stroke;
@@ -46,21 +46,6 @@ pub use webgl::HybridWebGlRenderer;
#[cfg(feature = "vello-hybrid")]
pub use wgpu_renderer::HybridRenderer;
-/// Coverage a pick pixel must exceed to be painted at all.
-///
-/// The midpoint: a pixel belongs to whichever mark covers most of it. Any
-/// value disables antialiasing; the choice only decides which side of a
-/// half-covered pixel wins.
-const PICK_ALIASING_THRESHOLD: u8 = 128;
-
-/// Minimum stroke width (in pixels) the pick pass uses, so hairline strokes
-/// remain hittable even when the visual stroke is sub-pixel.
-///
-/// Binary coverage makes this load-bearing rather than a nicety: a stroke
-/// thinner than the threshold covers no pixel past
-/// [`PICK_ALIASING_THRESHOLD`] and would vanish from the hitmap entirely.
-const MIN_PICK_STROKE_WIDTH: f64 = 2.0;
-
/// Largest scene dimension the rasterizer accepts, in pixels.
///
/// `vello_hybrid::Scene` sizes itself in `u16`.
@@ -153,19 +138,18 @@ impl SceneBuilder for HybridScene {
fn pop_layer(&mut self) {
self.ops.pop_layer();
}
-}
-// ---------- replay ----------
+ fn push_pick_scope(&mut self, scope: &PickScope) {
+ self.ops.push_pick_scope(scope);
+ }
-/// Which of the two scenes a replay is filling.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Pass {
- /// The visible frame: the caller's brushes, blend modes and antialiasing.
- Display,
- /// The id buffer: solid encoded ids, normalized blending, binary coverage.
- Pick,
+ fn pop_pick_scope(&mut self) {
+ self.ops.pop_pick_scope();
+ }
}
+// ---------- replay ----------
+
/// Key identifying an image's pixels, so one upload serves every draw of it.
///
/// Peniko blobs carry a process-local id, which is exactly the identity an
@@ -175,34 +159,26 @@ fn image_key(image: &Image) -> u64 {
}
/// Replays recorded draws into a `vello_hybrid::Scene`.
-///
-/// One writer per pass. The pick pass differs in three ways: solid ids
-/// replace brushes, blending and layer alpha are normalized so ids cannot
-/// fade toward the no-hit sentinel, and hairline strokes are widened.
struct Writer<'a> {
scene: &'a mut Scene,
resources: &'a mut Resources,
- pass: Pass,
/// Atlas handle per image, filled in before replay — uploading needs the
/// device, which a [`SceneBuilder`] has no access to.
images: &'a HashMap,
}
impl Writer<'_> {
- /// Paint for a draw, or `None` when this pass should skip the draw.
- fn paint(&self, brush: &Brush, pick_id: PickId) -> Option {
- match self.pass {
- Pass::Display => match brush {
- Brush::Solid(color) => Some((*color).into()),
- Brush::Gradient(gradient) => Some(gradient.clone().into()),
- Brush::Image(image) => self.image_paint(
- &image.image,
- image.sampler.quality,
- image.sampler.x_extend,
- image.sampler.y_extend,
- ),
- },
- Pass::Pick => pick::raw_id(pick_id).map(|id| pick::id_to_color(id).into()),
+ /// Paint for a draw, or `None` when the draw should be skipped.
+ fn paint(&self, brush: &Brush) -> Option {
+ match brush {
+ Brush::Solid(color) => Some((*color).into()),
+ Brush::Gradient(gradient) => Some(gradient.clone().into()),
+ Brush::Image(image) => self.image_paint(
+ &image.image,
+ image.sampler.quality,
+ image.sampler.x_extend,
+ image.sampler.y_extend,
+ ),
}
}
@@ -255,9 +231,9 @@ impl SceneBuilder for Writer<'_> {
brush: &Brush,
brush_transform: Option,
path: &Path,
- pick_id: PickId,
+ _pick_id: PickId,
) {
- let Some(paint) = self.paint(brush, pick_id) else {
+ let Some(paint) = self.paint(brush) else {
return;
};
self.set_placement(transform, brush_transform);
@@ -273,17 +249,13 @@ impl SceneBuilder for Writer<'_> {
brush: &Brush,
brush_transform: Option,
path: &Path,
- pick_id: PickId,
+ _pick_id: PickId,
) {
- let Some(paint) = self.paint(brush, pick_id) else {
+ let Some(paint) = self.paint(brush) else {
return;
};
- let mut stroke = stroke.clone();
- if self.pass == Pass::Pick && stroke.width < MIN_PICK_STROKE_WIDTH {
- stroke.width = MIN_PICK_STROKE_WIDTH;
- }
self.set_placement(transform, brush_transform);
- self.scene.set_stroke(stroke);
+ self.scene.set_stroke(stroke.clone());
self.scene.set_paint(paint);
self.scene.stroke_path(path);
}
@@ -294,24 +266,19 @@ impl SceneBuilder for Writer<'_> {
transform: Affine,
sampling: Sampling,
alpha: f32,
- pick_id: PickId,
+ _pick_id: PickId,
) {
let bounds = crate::geometry::Rect::new(0.0, 0.0, image.width.into(), image.height.into());
- let paint = match self.pass {
- Pass::Display => self.image_paint(
- image,
- convert::sampling_to_quality(sampling),
- peniko::Extend::Pad,
- peniko::Extend::Pad,
- ),
- Pass::Pick => pick::raw_id(pick_id).map(|id| pick::id_to_color(id).into()),
- };
- let Some(paint) = paint else {
+ let Some(paint) = self.image_paint(
+ image,
+ convert::sampling_to_quality(sampling),
+ peniko::Extend::Pad,
+ peniko::Extend::Pad,
+ ) else {
return;
};
- // Image opacity has to be a layer rather than a sampler field; the
- // pick pass ignores it, since a faded id is a wrong id.
- let layered = self.pass == Pass::Display && alpha < 1.0;
+ // Image opacity has to be a layer rather than a sampler field.
+ let layered = alpha < 1.0;
if layered {
self.scene.push_opacity_layer(alpha);
}
@@ -324,11 +291,11 @@ impl SceneBuilder for Writer<'_> {
}
}
- fn draw_glyphs(&mut self, run: &GlyphRun<'_>, pick_id: PickId) {
- let Some(paint) = self.paint(run.brush, pick_id) else {
+ fn draw_glyphs(&mut self, run: &GlyphRun<'_>, _pick_id: PickId) {
+ let Some(paint) = self.paint(run.brush) else {
return;
};
- let layered = self.pass == Pass::Display && run.brush_alpha < 1.0;
+ let layered = run.brush_alpha < 1.0;
if layered {
self.scene.push_opacity_layer(run.brush_alpha);
}
@@ -352,15 +319,12 @@ impl SceneBuilder for Writer<'_> {
self.scene.reset_paint_transform();
self.scene.set_paint(paint);
- let stroked = match (self.pass, run.style) {
- (Pass::Display, Some(stroke)) => {
+ let stroked = match run.style {
+ Some(stroke) => {
self.scene.set_stroke(stroke.clone());
true
}
- // The pick pass fills glyph outlines whatever the display
- // style: an outlined glyph should still be hittable in its
- // interior.
- _ => false,
+ None => false,
};
let glyphs = outlines
@@ -395,7 +359,7 @@ impl SceneBuilder for Writer<'_> {
run.transform * strike.transform,
Sampling::Bilinear,
1.0,
- pick_id,
+ PickId::Skip,
);
}
@@ -410,20 +374,13 @@ impl SceneBuilder for Writer<'_> {
fn push_layer(&mut self, blend: BlendMode, alpha: f32, transform: Affine, clip: &Path) {
self.scene.set_transform(transform);
- match self.pass {
- Pass::Display => self.scene.push_layer(
- Some(clip),
- Some(convert::blend_mode(blend)),
- Some(alpha),
- None,
- None,
- ),
- // Mirror the clip so subsequent draws are clipped identically,
- // but drop the blend mode and alpha: either would distort the
- // encoded ids, and a translucent layer would fade them toward
- // the no-hit sentinel.
- Pass::Pick => self.scene.push_layer(Some(clip), None, None, None, None),
- }
+ self.scene.push_layer(
+ Some(clip),
+ Some(convert::blend_mode(blend)),
+ Some(alpha),
+ None,
+ None,
+ );
}
fn pop_layer(&mut self) {
diff --git a/src/backend/hybrid/webgl.rs b/src/backend/hybrid/webgl.rs
index bfa33d7..d995ca9 100644
--- a/src/backend/hybrid/webgl.rs
+++ b/src/backend/hybrid/webgl.rs
@@ -12,10 +12,9 @@
//!
//! - **Presentation is one step.** No intermediate texture, no blit, no swap
//! chain. The canvas *is* the target.
-//! - **Picking draws the id buffer to the canvas and reads it straight back**,
-//! then draws the display over the top. Both happen inside one JS task, and a
-//! canvas is not composited until the task yields, so the id frame is never
-//! seen. It does mean the pick pass must come first.
+//! - **Picking costs no GPU work at all.** The scene records a CPU-side hit
+//! index as it is drawn, so there is nothing to rasterise, read back, or
+//! order against the display.
//! - **There is no `Renderer` impl.** That trait rasterises into a caller's
//! byte buffer, which here would mean drawing to a visible canvas and reading
//! it back — a different contract than the name promises. Use the wgpu
@@ -26,31 +25,25 @@ use std::sync::Arc;
use vello_common::paint::ImageSource;
use vello_hybrid::{Pixmap, RenderSize, Resources, Scene, WebGlRenderer, WebGlTextureBindings};
-use web_sys::{HtmlCanvasElement, WebGl2RenderingContext};
+use web_sys::HtmlCanvasElement;
-use super::{
- dimension, image_key, recorded_images, HybridScene, Pass, Writer, PICK_ALIASING_THRESHOLD,
-};
+use super::{dimension, image_key, recorded_images, HybridScene, Writer};
use crate::backend::BackendError;
use crate::color::Color;
use crate::geometry::Affine;
-use crate::pick;
+use crate::pick::PickIndexScene;
/// Hephaestus WebGL2 renderer: owns the canvas's GL context, the recorded
/// scene, and the sparse-strip scenes it replays into.
pub struct HybridWebGlRenderer {
renderer: WebGlRenderer,
resources: Resources,
- scene: HybridScene,
+ scene: PickIndexScene,
display: Scene,
- pick: Option,
/// Atlas handle per image, keyed as [`image_key`].
images: HashMap,
width: u32,
height: u32,
- hitmap: Option>,
- hitmap_dims: Option<(u32, u32)>,
- refresh_pick: bool,
}
impl HybridWebGlRenderer {
@@ -70,20 +63,16 @@ impl HybridWebGlRenderer {
Ok(Self {
renderer,
resources,
- scene: HybridScene::new(),
+ scene: PickIndexScene::new(HybridScene::new(), picking),
display: Scene::new(w, h),
- pick: picking.then(|| Scene::new(w, h)),
images: HashMap::new(),
width,
height,
- hitmap: None,
- hitmap_dims: None,
- refresh_pick: true,
})
}
/// The scene to draw into.
- pub fn scene(&mut self) -> &mut HybridScene {
+ pub fn scene(&mut self) -> &mut PickIndexScene {
&mut self.scene
}
@@ -97,71 +86,38 @@ impl HybridWebGlRenderer {
}
let (w, h) = (dimension(width)?, dimension(height)?);
self.display.reset_and_resize(w, h);
- if let Some(pick) = self.pick.as_mut() {
- pick.reset_and_resize(w, h);
- }
self.images.clear();
self.width = width;
self.height = height;
Ok(())
}
- /// Control whether the coming frame refreshes the hitmap.
- ///
- /// Costs the same as it does on the wgpu path — a second strip generation
- /// over the same geometry, plus a synchronous `readPixels` — so a host
- /// redrawing faster than it queries should throttle it.
- pub fn set_refresh_pick(&mut self, refresh: bool) {
- self.refresh_pick = refresh;
- }
-
- /// Whether the coming frame will refresh the hitmap.
- pub fn refreshes_pick(&self) -> bool {
- self.pick.is_some() && self.refresh_pick
- }
-
- /// Id recorded at the given pixel of the last refreshed hitmap.
- pub fn pick_at(&self, x: u32, y: u32) -> Option {
- let (w, h) = self.hitmap_dims?;
- if x >= w || y >= h {
- return None;
- }
- pick::decode(self.hitmap.as_ref()?[(y * w + x) as usize])
- }
-
- /// Current drawing-buffer size in device pixels.
+ /// The canvas drawing-buffer size the renderer is currently built for.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
- /// Raw pick pixels of the last refreshed hitmap, for bulk queries.
- pub fn hitmap(&self) -> Option<&[u32]> {
- self.hitmap.as_deref()
+ /// The hit index the scene built while it was drawn, or `None` when this
+ /// renderer was not built with picking.
+ ///
+ /// The only pick method here, deliberately: a renderer's part in hit
+ /// testing is owning the scene that recorded the index, so every query
+ /// lives on [`PickIndex`](crate::pick::PickIndex) rather than being
+ /// forwarded through two more layers of the same names.
+ pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> {
+ self.scene.indexes().then(|| self.scene.index())
}
/// Draw the recorded scene onto the canvas.
- ///
- /// When picking is on and due, the id buffer is drawn and read back first,
- /// then overdrawn by the display — invisible, because the canvas is not
- /// composited until this task yields.
pub fn present(&mut self, background: Color) -> Result<(), BackendError> {
self.upload_images();
- let refresh = self.refreshes_pick();
- self.replay(background, refresh);
+ self.replay(background);
let size = RenderSize {
width: self.width,
height: self.height,
};
let bindings = WebGlTextureBindings::new();
-
- if refresh {
- let pick = self.pick.as_ref().expect("pick scene present");
- self.renderer
- .render(pick, &mut self.resources, &size, &bindings)
- .map_err(|e| BackendError::Other(format!("hybrid webgl pick render: {e}")))?;
- self.read_hitmap();
- }
self.renderer
.render(&self.display, &mut self.resources, &size, &bindings)
.map_err(|e| BackendError::Other(format!("hybrid webgl render: {e}")))
@@ -169,7 +125,7 @@ impl HybridWebGlRenderer {
/// Upload every image the recording needs that is not already resident.
fn upload_images(&mut self) {
- for image in recorded_images(&self.scene.ops) {
+ for image in recorded_images(&self.scene.inner().ops) {
let key = image_key(&image);
if self.images.contains_key(&key) {
continue;
@@ -188,8 +144,8 @@ impl HybridWebGlRenderer {
}
}
- /// Replay the recording into the display scene, and the pick scene when due.
- fn replay(&mut self, background: Color, refresh_pick: bool) {
+ /// Replay the recording into the display scene.
+ fn replay(&mut self, background: Color) {
let frame = crate::geometry::Rect::new(0.0, 0.0, self.width.into(), self.height.into());
self.display.reset();
@@ -201,71 +157,8 @@ impl HybridWebGlRenderer {
let mut writer = Writer {
scene: &mut self.display,
resources: &mut self.resources,
- pass: Pass::Display,
images: &self.images,
};
- self.scene.ops.replay(&mut writer);
-
- if !refresh_pick {
- return;
- }
- let pick = self.pick.as_mut().expect("pick scene present");
- pick.reset();
- // Binary coverage: one primitive owns each pick pixel, so an edge
- // reports a real id rather than a blend of the two either side.
- pick.set_aliasing_threshold(Some(PICK_ALIASING_THRESHOLD));
- let mut writer = Writer {
- scene: pick,
- resources: &mut self.resources,
- pass: Pass::Pick,
- images: &self.images,
- };
- self.scene.ops.replay(&mut writer);
+ self.scene.inner().ops.replay(&mut writer);
}
-
- /// Read the just-drawn id buffer out of the default framebuffer.
- fn read_hitmap(&mut self) {
- let (w, h) = (self.width as usize, self.height as usize);
- let mut raw = vec![0u8; w * h * 4];
- let gl = self.renderer.gl_context();
- // Errors here leave the hitmap as it was rather than corrupting it: a
- // stale answer beats a wrong one.
- if gl
- .read_pixels_with_opt_u8_array(
- 0,
- 0,
- self.width as i32,
- self.height as i32,
- WebGl2RenderingContext::RGBA,
- WebGl2RenderingContext::UNSIGNED_BYTE,
- Some(&mut raw),
- )
- .is_err()
- {
- return;
- }
-
- let hitmap = self.hitmap.get_or_insert_with(Vec::new);
- hitmap.clear();
- hitmap.resize(w * h, 0);
- // GL reads bottom-up while the hitmap is indexed top-down, so the rows
- // go back in reverse.
- for y in 0..h {
- let src = (h - 1 - y) * w * 4;
- let dst: &mut [u8] = bytemuck_cast(&mut hitmap[y * w..(y + 1) * w]);
- dst.copy_from_slice(&raw[src..src + w * 4]);
- }
- self.hitmap_dims = Some((self.width, self.height));
- }
-}
-
-/// View a `u32` row as the bytes behind it.
-///
-/// Hand-rolled rather than pulling `bytemuck` in: this build exists to be
-/// small, and one cast does not justify a dependency.
-fn bytemuck_cast(row: &mut [u32]) -> &mut [u8] {
- // Safety: `u32` has no invalid bit patterns and no padding, so any byte
- // sequence of the same length is a valid `[u32]` and vice versa. The
- // lifetime and length are both derived from the input.
- unsafe { core::slice::from_raw_parts_mut(row.as_mut_ptr().cast::(), row.len() * 4) }
}
diff --git a/src/backend/hybrid/wgpu_renderer.rs b/src/backend/hybrid/wgpu_renderer.rs
index 9a0a74e..a07c0dd 100644
--- a/src/backend/hybrid/wgpu_renderer.rs
+++ b/src/backend/hybrid/wgpu_renderer.rs
@@ -11,14 +11,11 @@ use vello_hybrid::{
RenderSize, RenderTargetConfig, Renderer as HRenderer, Resources, Scene, TextureBindings,
};
-use super::{
- dimension, image_key, recorded_images, unpremultiply, HybridScene, Pass, Writer,
- PICK_ALIASING_THRESHOLD,
-};
+use super::{dimension, image_key, recorded_images, unpremultiply, HybridScene, Writer};
use crate::backend::{BackendError, Renderer, WgpuRenderer};
use crate::color::Color;
use crate::geometry::Affine;
-use crate::pick;
+use crate::pick::PickIndexScene;
// ---------- Renderer ----------
@@ -32,7 +29,6 @@ struct Target {
height: u32,
/// Bytes per row in the readback buffer (padded to wgpu's alignment).
padded_bytes_per_row: u32,
- format: wgpu::TextureFormat,
}
impl Target {
@@ -73,7 +69,6 @@ impl Target {
width,
height,
padded_bytes_per_row,
- format,
}
}
}
@@ -87,61 +82,40 @@ struct SizeBound {
renderer: HRenderer,
resources: Resources,
display: Scene,
- pick: Option,
images: HashMap,
width: u32,
height: u32,
format: wgpu::TextureFormat,
}
-/// A pick readback in flight: a slot the `map_async` callback fills, and the
-/// dimensions it covers.
-///
-/// A slot rather than a future, so completion can be *checked* instead of
-/// awaited. Awaiting would mean holding a borrow of the renderer across a
-/// suspension point, which a browser host — where the only caller is a
-/// callback that may re-enter — cannot do safely.
-struct PendingPick {
- slot: std::sync::Arc>>>,
- width: u32,
- height: u32,
-}
-
/// Hephaestus Hybrid renderer: owns the wgpu device and queue, the recorded
/// scene, and the per-size rasterisation state.
///
-/// When constructed via [`Self::with_picking`], every render also replays the
-/// recording into a pick scene rasterised with binary coverage, reads it back,
-/// and caches it as the hitmap behind [`Self::pick_at`].
+/// Hit testing is a property of the scene, not of this renderer: the scene is
+/// a [`crate::pick::PickIndexScene`], and
+/// [`Self::with_picking`] is what turns its indexing on.
pub struct HybridRenderer {
device: wgpu::Device,
queue: wgpu::Queue,
- scene: HybridScene,
- picking: bool,
+ scene: PickIndexScene,
sized: Option,
target: Option,
- pick_target: Option,
- /// Decoded pick pixels of the most recent render, one `u32` per pixel.
- hitmap: Option>,
- hitmap_dims: Option<(u32, u32)>,
- pick_pending: Option,
/// Format `render_to_texture` writes. A host presenting straight into its
/// swap chain sets this to the surface's format.
target_format: wgpu::TextureFormat,
- /// Whether the coming render refreshes the hitmap. See
- /// [`HybridRenderer::set_refresh_pick`].
- refresh_pick: bool,
}
impl HybridRenderer {
- /// Build a renderer with no picking machinery. File-export workloads
- /// should use this form; nothing in the pick path is allocated.
+ /// Build a renderer that does not hit-test. File-export workloads
+ /// should use this form; the scene indexes nothing.
pub fn new() -> Result {
pollster::block_on(Self::new_async(false))
}
- /// Build a renderer with picking enabled. Each render additionally
- /// rasterises the pick scene with binary coverage and reads it back.
+ /// Build a renderer whose scene records a hit index as it is drawn,
+ /// making [`Self::pick_at`] and the other queries answerable.
+ ///
+ /// Indexing costs CPU per draw call, so it is off by default.
pub fn with_picking() -> Result {
pollster::block_on(Self::new_async(true))
}
@@ -155,7 +129,8 @@ impl HybridRenderer {
Ok(Self::build(device.clone(), queue.clone(), false))
}
- /// Like [`Self::with_device`] but enables picking.
+ /// Like [`Self::with_device`] but with hit indexing enabled — see
+ /// [`Self::with_picking`].
pub fn with_device_and_picking(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -198,55 +173,22 @@ impl HybridRenderer {
Self {
device,
queue,
- scene: HybridScene::new(),
- picking,
+ scene: PickIndexScene::new(HybridScene::new(), picking),
sized: None,
target: None,
- pick_target: None,
- hitmap: None,
- hitmap_dims: None,
- pick_pending: None,
target_format: wgpu::TextureFormat::Rgba8Unorm,
- refresh_pick: true,
}
}
- /// Id recorded at the given pixel, or `None` for a miss.
- ///
- /// Returns `None` when picking is disabled, nothing has been rendered
- /// yet, the coordinates fall outside the last render, or nothing
- /// pickable covered the pixel. Binary coverage means the answer is the
- /// id of exactly one primitive — never a blend of two.
- pub fn pick_at(&self, x: u32, y: u32) -> Option {
- let (w, h) = self.hitmap_dims?;
- if x >= w || y >= h {
- return None;
- }
- let hitmap = self.hitmap.as_ref()?;
- pick::decode(hitmap[(y * w + x) as usize])
- }
-
- /// Control whether the coming render refreshes the hitmap.
- ///
- /// The pick pass costs about what the display pass does — it is a second
- /// strip generation over the same geometry, on the CPU — so a host that is
- /// resizing, animating, or otherwise redrawing faster than it queries can
- /// leave the hitmap alone and pay for it only when an answer is wanted.
- /// Measured at 100k marks: 88 ms a frame without it, 150 ms with.
+ /// The hit index the scene built while it was drawn, or `None` when this
+ /// renderer was not built with picking.
///
- /// While it is off, [`Self::pick_at`] keeps answering from the last render
- /// that refreshed — so the ids stay readable but describe an older frame.
- /// Set it back to `true` (the default) and the next render brings the
- /// hitmap up to date.
- ///
- /// No effect when picking was not enabled at construction.
- pub fn set_refresh_pick(&mut self, refresh: bool) {
- self.refresh_pick = refresh;
- }
-
- /// Whether the coming render will refresh the hitmap.
- pub fn refreshes_pick(&self) -> bool {
- self.picking && self.refresh_pick
+ /// The only pick method here, deliberately: a renderer's part in hit
+ /// testing is owning the scene that recorded the index, so every query
+ /// lives on [`PickIndex`](crate::pick::PickIndex) rather than being
+ /// forwarded through two more layers of the same names.
+ pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> {
+ self.scene.indexes().then(|| self.scene.index())
}
/// Set the texture format [`WgpuRenderer::render_to_texture`] writes.
@@ -263,14 +205,6 @@ impl HybridRenderer {
self.target_format = format;
}
- /// Raw pick pixels of the most recent render, for bulk queries.
- ///
- /// Row-major, `width * height` entries. Interpret each with
- /// [`pick::decode`].
- pub fn hitmap(&self) -> Option<&[u32]> {
- self.hitmap.as_deref()
- }
-
/// Rebuild the size-bound state when the requested frame size differs
/// from what it was built for.
fn ensure_sized(
@@ -299,7 +233,6 @@ impl HybridRenderer {
renderer,
resources,
display: Scene::new(w16, h16),
- pick: self.picking.then(|| Scene::new(w16, h16)),
images: HashMap::new(),
width,
height,
@@ -314,7 +247,7 @@ impl HybridRenderer {
/// Upload every image the recording needs, reusing atlas handles already
/// held for this size.
fn upload_images(&mut self, encoder: &mut wgpu::CommandEncoder) -> Result<(), BackendError> {
- let images = recorded_images(&self.scene.ops);
+ let images = recorded_images(&self.scene.inner().ops);
if images.is_empty() {
return Ok(());
}
@@ -347,9 +280,8 @@ impl HybridRenderer {
Ok(())
}
- /// Replay the recording into the display scene, and into the pick scene
- /// when picking is on.
- fn replay(&mut self, background: Color, width: u32, height: u32, refresh_pick: bool) {
+ /// Replay the recording into the display scene.
+ fn replay(&mut self, background: Color, width: u32, height: u32) {
let sized = self.sized.as_mut().expect("sized state ensured");
let frame = crate::geometry::Rect::new(0.0, 0.0, width.into(), height.into());
@@ -364,67 +296,23 @@ impl HybridRenderer {
let mut writer = Writer {
scene: &mut sized.display,
resources: &mut sized.resources,
- pass: Pass::Display,
images: &sized.images,
};
- self.scene.ops.replay(&mut writer);
-
- if !refresh_pick {
- return;
- }
- if let Some(pick) = sized.pick.as_mut() {
- pick.reset();
- // The one line the whole backend exists for: a pick pixel is
- // painted by exactly one primitive, so an edge reports a real id
- // instead of a blend of the two ids either side of it.
- pick.set_aliasing_threshold(Some(PICK_ALIASING_THRESHOLD));
- // No background: an uncovered pick pixel must stay at alpha 0,
- // which is what `pick::decode` reads as "no hit".
- let mut writer = Writer {
- scene: pick,
- resources: &mut sized.resources,
- pass: Pass::Pick,
- images: &sized.images,
- };
- self.scene.ops.replay(&mut writer);
- }
+ self.scene.inner().ops.replay(&mut writer);
}
}
impl HybridRenderer {
- /// Allocate the pick target when the requested size differs from the
- /// cached one.
- fn ensure_pick_target(&mut self, width: u32, height: u32) {
- // The pick pass goes through the same renderer as the display, and a
- // renderer targets one format — so the pick target has to match it.
- // `read_hitmap` puts the channels back in order.
- let format = self
- .sized
- .as_ref()
- .map_or(wgpu::TextureFormat::Rgba8Unorm, |s| s.format);
- if self
- .pick_target
- .as_ref()
- .is_none_or(|t| t.width != width || t.height != height || t.format != format)
- {
- self.pick_target = Some(Target::new(&self.device, width, height, format));
- }
- }
-
- /// Rasterise one of the two scenes into `view`.
+ /// Rasterise the display scene into `view`.
fn rasterise(
&mut self,
encoder: &mut wgpu::CommandEncoder,
- pass: Pass,
view: &wgpu::TextureView,
width: u32,
height: u32,
) -> Result<(), BackendError> {
let sized = self.sized.as_mut().expect("sized state ensured");
- let scene = match pass {
- Pass::Display => &sized.display,
- Pass::Pick => sized.pick.as_ref().expect("pick scene present"),
- };
+ let scene = &sized.display;
sized
.renderer
.render(
@@ -437,212 +325,7 @@ impl HybridRenderer {
view,
&TextureBindings::new(),
)
- .map_err(|e| match pass {
- Pass::Display => BackendError::Other(format!("hybrid render: {e}")),
- Pass::Pick => BackendError::Other(format!("hybrid pick render: {e}")),
- })
- }
-
- /// Drain the pick target into the CPU-side hitmap.
- ///
- /// Assumes the copy has been submitted and the buffer mapped.
- fn read_hitmap(&mut self, width: u32, height: u32) {
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
- let row_bytes = (width as usize) * 4;
- let row_px = width as usize;
- let hitmap = self.hitmap.get_or_insert_with(Vec::new);
- hitmap.clear();
- hitmap.resize(row_px * height as usize, 0);
- // The pick target carries the display format, because one renderer
- // targets one format. `pick::decode` reads an id out of a
- // little-endian RGBA word, so a BGRA target needs its red and blue
- // channels put back before that means anything.
- let swizzle = matches!(
- pick_target.format,
- wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
- );
- {
- let data = pick_target.readback.slice(..).get_mapped_range();
- let padded = pick_target.padded_bytes_per_row as usize;
- for y in 0..height as usize {
- let dst: &mut [u8] =
- bytemuck::cast_slice_mut(&mut hitmap[y * row_px..(y + 1) * row_px]);
- dst.copy_from_slice(&data[y * padded..y * padded + row_bytes]);
- if swizzle {
- for px in dst.chunks_exact_mut(4) {
- px.swap(0, 2);
- }
- }
- }
- }
- pick_target.readback.unmap();
- self.hitmap_dims = Some((width, height));
- }
-
- /// Settle any deferred pick readback before a blocking path reuses the
- /// buffer.
- ///
- /// `map_async` on a buffer with a map already outstanding is a validation
- /// error, so a renderer that has been driven through
- /// [`Self::render_to_texture_deferring_pick`] and is then rendered
- /// blocking has to land the old readback first. Blocking is allowed on
- /// these paths, so this waits.
- fn settle_pending_pick(&mut self) -> Result<(), BackendError> {
- if self.pick_pending.is_some() {
- let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
- self.try_finish_pick()?;
- }
- Ok(())
- }
-
- /// Rasterise the pick scene, read it back, and refresh the hitmap.
- ///
- /// Uses an encoder of its own and submits it separately from the display
- /// pass. Both passes go through one renderer, whose per-frame coverage,
- /// paint and glyph uploads are written while a pass is being *recorded* —
- /// so sharing a command buffer would let this pass's uploads overwrite the
- /// display pass's before the GPU consumed them.
- fn submit_pick_blocking(&mut self, width: u32, height: u32) -> Result<(), BackendError> {
- let mut encoder = self
- .device
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("hephaestus.hybrid.pick"),
- });
- let pick_view = self
- .pick_target
- .as_ref()
- .expect("pick target ensured")
- .view
- .clone();
- self.rasterise(&mut encoder, Pass::Pick, &pick_view, width, height)?;
- {
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
- copy_to_readback(&mut encoder, pick_target, width, height);
- }
- self.queue.submit(std::iter::once(encoder.finish()));
-
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
- let (tx, rx) = futures_intrusive::channel::shared::oneshot_channel();
- pick_target
- .readback
- .slice(..)
- .map_async(wgpu::MapMode::Read, move |res| {
- let _ = tx.send(res);
- });
- let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
- await_map(pollster::block_on(rx.receive()))?;
- self.read_hitmap(width, height);
- Ok(())
- }
-
- /// Rasterise the pick scene and submit its readback without waiting.
- ///
- /// Pair with [`Self::try_finish_pick`]. Assumes the scene has already been
- /// replayed for this frame.
- fn submit_pick(&mut self, width: u32, height: u32) -> Result<(), BackendError> {
- self.ensure_pick_target(width, height);
- let mut encoder = self
- .device
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("hephaestus.hybrid.pick"),
- });
- let pick_view = self
- .pick_target
- .as_ref()
- .expect("pick target ensured")
- .view
- .clone();
- self.rasterise(&mut encoder, Pass::Pick, &pick_view, width, height)?;
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
- copy_to_readback(&mut encoder, pick_target, width, height);
- self.queue.submit(std::iter::once(encoder.finish()));
-
- let slot = std::sync::Arc::new(std::sync::Mutex::new(None));
- let sink = std::sync::Arc::clone(&slot);
- pick_target
- .readback
- .slice(..)
- .map_async(wgpu::MapMode::Read, move |res| {
- if let Ok(mut guard) = sink.lock() {
- *guard = Some(res);
- }
- });
- self.pick_pending = Some(PendingPick {
- slot,
- width,
- height,
- });
- Ok(())
- }
-
- /// Drain a readback submitted by [`Self::submit_pick`] into the hitmap,
- /// if it has landed.
- ///
- /// Returns whether the hitmap was refreshed: `false` means nothing was in
- /// flight, or the GPU has not finished. Never blocks, so a host that
- /// cannot park a thread calls this and accepts that the hitmap may lag
- /// the drawn frame.
- ///
- /// Only meaningful after [`Self::render_to_texture_deferring_pick`]; the
- /// blocking render paths drain their own readback before returning.
- pub fn try_finish_pick(&mut self) -> Result {
- let Some(pending) = self.pick_pending.as_ref() else {
- return Ok(false);
- };
- let landed = pending
- .slot
- .lock()
- .map_err(|_| BackendError::Readback("pick readback slot poisoned".into()))?
- .take();
- let Some(result) = landed else {
- return Ok(false);
- };
- let PendingPick { width, height, .. } =
- self.pick_pending.take().expect("checked just above");
- result.map_err(|e| BackendError::Readback(e.to_string()))?;
- self.read_hitmap(width, height);
- Ok(true)
- }
-
- /// Rasterise into `view` and submit the pick pass without waiting on it.
- ///
- /// The non-blocking counterpart to
- /// [`WgpuRenderer::render_to_texture`](crate::WgpuRenderer::render_to_texture),
- /// whose pick readback parks the calling thread until the GPU is done —
- /// which a browser's main thread cannot do. Pair with
- /// [`Self::try_finish_pick`]: until that drains, [`Self::pick_at`] keeps
- /// answering from the last frame that landed.
- pub fn render_to_texture_deferring_pick(
- &mut self,
- view: &wgpu::TextureView,
- width: u32,
- height: u32,
- background: Color,
- ) -> Result<(), BackendError> {
- let mut encoder = self
- .device
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("hephaestus.hybrid.render_to_texture"),
- });
- let format = self.target_format;
- self.prepare(width, height, background, format, &mut encoder)?;
- self.rasterise(&mut encoder, Pass::Display, view, width, height)?;
- self.queue.submit(std::iter::once(encoder.finish()));
-
- if self.refreshes_pick() {
- // Drain first: that unmaps the readback buffer, and `map_async`
- // on a still-mapped buffer is a validation error. Draining also
- // has to happen before `ensure_pick_target`, which may reallocate
- // the target the in-flight readback is reading from.
- self.try_finish_pick()?;
- // Still in flight — skip this frame rather than queue a second
- // map on the same buffer. The hitmap lags until it lands, which
- // `pick_at` already documents.
- if self.pick_pending.is_none() {
- self.submit_pick(width, height)?;
- }
- }
- Ok(())
+ .map_err(|e| BackendError::Other(format!("hybrid render: {e}")))
}
/// Shared front half of both render entry points: validate the size,
@@ -662,13 +345,13 @@ impl HybridRenderer {
}
self.ensure_sized(width, height, format)?;
self.upload_images(encoder)?;
- self.replay(background, width, height, self.refresh_pick);
+ self.replay(background, width, height);
Ok(())
}
}
impl Renderer for HybridRenderer {
- type Scene = HybridScene;
+ type Scene = PickIndexScene;
fn scene(&mut self) -> &mut Self::Scene {
&mut self.scene
@@ -713,28 +396,13 @@ impl Renderer for HybridRenderer {
wgpu::TextureFormat::Rgba8Unorm,
));
}
- let picking = self.refreshes_pick();
- if picking {
- self.settle_pending_pick()?;
- self.ensure_pick_target(width, height);
- }
-
let display_view = self.target.as_ref().expect("target ensured").view.clone();
- self.rasterise(&mut encoder, Pass::Display, &display_view, width, height)?;
+ self.rasterise(&mut encoder, &display_view, width, height)?;
{
let target = self.target.as_ref().expect("target ensured");
copy_to_readback(&mut encoder, target, width, height);
}
- // Submit before the pick pass is recorded, not after. Rasterising a
- // scene writes this frame's coverage, paints and glyphs into
- // renderer-owned textures, and both passes share one renderer — so
- // recording them into a single command buffer would let the pick
- // pass's uploads land before the GPU ran the display pass, and the
- // display would come out reading the pick pass's binary coverage.
self.queue.submit(std::iter::once(encoder.finish()));
- if picking {
- self.submit_pick_blocking(width, height)?;
- }
let target = self.target.as_ref().expect("target ensured");
let display_slice = target.readback.slice(..);
@@ -781,16 +449,8 @@ impl WgpuRenderer for HybridRenderer {
});
let format = self.target_format;
self.prepare(width, height, background, format, &mut encoder)?;
- self.rasterise(&mut encoder, Pass::Display, view, width, height)?;
- // Submitted before the pick pass is recorded — see
- // `submit_pick_blocking` for why they cannot share a command buffer.
+ self.rasterise(&mut encoder, view, width, height)?;
self.queue.submit(std::iter::once(encoder.finish()));
-
- if self.refreshes_pick() {
- self.settle_pending_pick()?;
- self.ensure_pick_target(width, height);
- self.submit_pick_blocking(width, height)?;
- }
Ok(())
}
}
diff --git a/src/backend/svg/CLAUDE.md b/src/backend/svg/CLAUDE.md
index e32016f..99621bf 100644
--- a/src/backend/svg/CLAUDE.md
+++ b/src/backend/svg/CLAUDE.md
@@ -103,7 +103,23 @@ Two things `document/images.rs` gets wrong that this does not, and which are wor
Off by default — file export is the common case and the attributes are pure weight there. When on, `PickId::Id(n)` becomes `data-pick-id="n"`, `Block` becomes `"0"`, and **`Skip` becomes `pointer-events="none"`**. That last row is what makes the feature correct rather than decorative: it reproduces "items beneath remain hittable through this primitive" under `elementFromPoint`, without which a `Skip` gridline over a mark swallows the hit.
-Ids are emitted unmasked. `pick.rs` documents 24-bit truncation, but that is an artifact of packing into a texture's RGB channels, and reproducing a texture encoding's limits in a text format would be cargo-culting.
+Pick **scopes** ride the same flag, as `` with
+`data-pick-name` and `data-pick-index` when the scope carries them. They are
+the same feature from a consumer's side, and they are the larger part of what
+makes the output *editable*: a designer opening the file selects "the bottom
+axis" or "this tick label" as a group, rather than a flat soup of paths. A
+real plot emits `region → axis → part → item` nesting straight out of
+`plot/chrome/`.
+
+**The two group stacks must stay tagged apart.** `push_layer` and
+`push_pick_scope` both emit ``, but they are independent stacks — a scope
+can open inside a layer and close outside it. `SvgScene` tracks
+`Vec` rather than a depth counter, and a pop whose kind does not
+match the innermost group is refused and noted as `SvgWarning::UnbalancedScopes`
+instead of emitting `` against the wrong element. Sharing one counter
+produces malformed XML, which
+`tests/svg.rs::interleaved_layers_and_scopes_do_not_produce_malformed_xml`
+would catch.
## Files
diff --git a/src/backend/svg/mod.rs b/src/backend/svg/mod.rs
index 7e0a6cf..ed644f5 100644
--- a/src/backend/svg/mod.rs
+++ b/src/backend/svg/mod.rs
@@ -32,7 +32,7 @@ use crate::color::Color;
use crate::geometry::{Affine, Size};
use crate::mesh::Mesh;
use crate::path::{FillRule, Path};
-use crate::pick::PickId;
+use crate::pick::{PickId, PickScope};
use crate::scene::{GlyphRun, SceneBuilder};
use defs::{DefKind, Defs};
@@ -66,6 +66,10 @@ pub enum SvgWarning {
/// More layers were pushed than popped; the difference was closed
/// at write time.
UnbalancedLayers,
+ /// A pick scope was popped where a layer was open, or popped with
+ /// nothing open. The group was left alone rather than closing an
+ /// element it did not open.
+ UnbalancedScopes,
/// A glyph run arrived with no source text and no outline path to
/// fall back on.
TextWithoutSource,
@@ -247,7 +251,13 @@ pub struct SvgScene {
body: String,
defs: Defs,
warnings: Warnings,
- depth: usize,
+ /// Open `` elements, innermost last.
+ ///
+ /// Tagged rather than a plain count because layers and pick scopes both
+ /// emit groups and their stacks are independent — a scope opened inside
+ /// a layer need not close inside it. Popping the wrong kind would emit
+ /// `` against the wrong element and produce malformed XML.
+ groups: Vec,
pending: Option,
/// Runs accumulating toward one `` element.
block: text::TextBlock,
@@ -281,7 +291,7 @@ impl SvgScene {
body: String::new(),
defs: Defs::default(),
warnings: Warnings::default(),
- depth: 0,
+ groups: Vec::new(),
pending: None,
block: text::TextBlock::default(),
fonts: fonts::FontRegistry::default(),
@@ -368,6 +378,20 @@ impl SvgScene {
}
/// Append the picking attributes, when the config asks for them.
+ /// Close the innermost ``, provided it is the kind being closed.
+ ///
+ /// A mismatch means the two stacks were interleaved rather than nested;
+ /// emitting `` anyway would close the wrong element, so the pop is
+ /// dropped and noted instead.
+ fn close_group(&mut self, kind: GroupKind, warning: SvgWarning) {
+ if self.groups.last() != Some(&kind) {
+ self.warnings.note(warning);
+ return;
+ }
+ self.groups.pop();
+ self.body.push_str("");
+ }
+
fn write_pick(&mut self, pick: PickId) {
let on = self.config.pick_ids;
write_pick_to(&mut self.body, pick, on);
@@ -512,7 +536,7 @@ impl SvgScene {
}
// A scene may leave layers open; closing them keeps the
// document well-formed, which matters more than the warning.
- for _ in 0..self.depth {
+ for _ in 0..self.groups.len() {
out.push_str("");
}
out.push_str("");
@@ -529,7 +553,7 @@ impl SceneBuilder for SvgScene {
self.defs.clear();
self.warnings.0.clear();
self.root_font = text::RootFont::default();
- self.depth = 0;
+ self.groups.clear();
self.pending = None;
}
@@ -783,19 +807,53 @@ impl SceneBuilder for SvgScene {
self.body.push_str(" style=\"isolation:isolate\"");
}
self.body.push('>');
- self.depth += 1;
+ self.groups.push(GroupKind::Layer);
}
fn pop_layer(&mut self) {
self.flush_block();
self.flush_pending();
- if self.depth == 0 {
- self.warnings.note(SvgWarning::UnbalancedLayers);
+ self.close_group(GroupKind::Layer, SvgWarning::UnbalancedLayers);
+ }
+
+ fn push_pick_scope(&mut self, scope: &PickScope) {
+ if !self.config.pick_ids {
return;
}
- self.depth -= 1;
- self.body.push_str("");
+ self.flush_block();
+ self.flush_pending();
+ self.body.push_str("');
+ self.groups.push(GroupKind::Scope);
}
+
+ fn pop_pick_scope(&mut self) {
+ if !self.config.pick_ids {
+ return;
+ }
+ self.flush_block();
+ self.flush_pending();
+ self.close_group(GroupKind::Scope, SvgWarning::UnbalancedScopes);
+ }
+}
+
+/// What an open `` was opened for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum GroupKind {
+ Layer,
+ Scope,
}
/// CSS `mix-blend-mode` keyword for a mix function.
diff --git a/src/backend/vello/CLAUDE.md b/src/backend/vello/CLAUDE.md
index c8a756b..19acd9f 100644
--- a/src/backend/vello/CLAUDE.md
+++ b/src/backend/vello/CLAUDE.md
@@ -4,12 +4,12 @@ Vello backend: implements `SceneBuilder` against a `vello::Scene` and `Renderer`
## What this module does
-`VelloScene` (in `mod.rs`) wraps a `vello::Scene` and translates our restricted enums to peniko's wider set via `../convert.rs`. `VelloRenderer` owns the wgpu device, queue, and the cached `HeadlessTarget` (storage texture + readback buffer) needed to render headlessly. When picking is enabled (`VelloRenderer::with_picking()`), every draw call is also recorded into a parallel pick `vello::Scene`, rasterised into a second target, and read back to power `pick_at(x, y) -> Option`.
+`VelloScene` (in `mod.rs`) wraps a `vello::Scene` and translates our restricted enums to peniko's wider set via `../convert.rs`. `VelloRenderer` owns the wgpu device, queue, and the cached `HeadlessTarget` (storage texture + readback buffer) needed to render headlessly. Its `Renderer::Scene` is a `PickIndexScene`, so `with_picking()` turns on a CPU hit index built as the scene is drawn rather than allocating anything in the rasteriser. `VelloScene` itself ignores `pick_id`.
Two output paths share one scene:
-- **`Renderer::render_to_buffer`** — the headless path. Renders into the backend-owned display `HeadlessTarget`, copies it back to CPU, and writes into the caller's RGBA8 slab. Display + pick texture-→-buffer copies share a single submit.
-- **`WgpuRenderer::render_to_texture`** — the windowing path. Renders straight into a host-owned `wgpu::TextureView` (must be `Rgba8Unorm` storage; the host blits to its swap chain). The display `HeadlessTarget` is never allocated on this path. Picking, when enabled, still rasterises and reads back the pick scene through the backend-owned pick target, so `pick_at` keeps working without forcing the display through CPU.
+- **`Renderer::render_to_buffer`** — the headless path. Renders into the backend-owned display `HeadlessTarget`, copies it back to CPU, and writes into the caller's RGBA8 slab.
+- **`WgpuRenderer::render_to_texture`** — the windowing path. Renders straight into a host-owned `wgpu::TextureView` (must be `Rgba8Unorm` storage; the host blits to its swap chain). The display `HeadlessTarget` is never allocated on this path. Picking is unaffected by which path is taken, since the index is built before either.
`VelloRenderer::new()` / `with_picking()` spin up a private wgpu device. `with_device(&Device, &Queue)` / `with_device_and_picking(&Device, &Queue)` share an existing host device so the rendered texture is on the same device as the host's surface.
@@ -21,12 +21,9 @@ Two output paths share one scene:
- **Vello unpremultiplies on output.** Its fine shader writes straight alpha into the target texture, and our readback is a plain row-by-row memcpy, so both output paths hand out un-premultiplied RGBA8. This is vello's behavior, not ours — re-check it on every vello bump; `tests/alpha_format.rs` fails loudly if it flips.
- **Readback honours wgpu's 256-byte row alignment** (`COPY_BYTES_PER_ROW_ALIGNMENT`). The readback buffer has padded rows; the copy-out strips padding into the caller's tight RGBA8 buffer.
- **GPU drain pattern after `queue.submit`** — `device.poll(PollType::wait_indefinitely())` then await `map_async` via a `futures_intrusive` oneshot. Non-obvious; preserve this sequence.
-- **Pick scene composition normalises blend.** Inside `push_layer` the pick scene uses `NORMAL` blend with `alpha = 1.0` so encoded ids don't fade toward the no-hit sentinel through alpha attenuation. Display scene keeps the caller's `BlendMode` and `alpha`.
-- **Pick scene AA: `AaConfig::Area`** with `base_color` transparent — same AA the display scene uses; matches `AaSupport::area_only()` at renderer init.
-- **One submit per render.** Display and pick texture-→-buffer copies share a single command buffer / submit / poll round-trip; don't fan them out.
-- **Draw budget is a hard cap, not a soft one.** Vello sizes its `bin_data` buffer to a fixed `1 << 18` words and stores the scene's draw-info stream at its front; `RenderConfig::new` subtracts the stream length from that size, so an over-long stream underflows and panics before any GPU work is queued (and a panic raised inside winit's macOS draw callback aborts rather than unwinds). `MAX_DRAW_INFO_WORDS` mirrors the buffer size and both render entry points reject an over-budget scene — display *and* pick — with `BackendError::SceneTooLarge`. Solid brushes cost one word per fill / stroke, so ~262k flat-coloured objects; gradients and images cost more. Re-check the constant against `vello_encoding::BufferSizes::new` on every vello bump.
+- **Draw budget is a hard cap, not a soft one.** Vello sizes its `bin_data` buffer to a fixed `1 << 18` words and stores the scene's draw-info stream at its front; `RenderConfig::new` subtracts the stream length from that size, so an over-long stream underflows and panics before any GPU work is queued (and a panic raised inside winit's macOS draw callback aborts rather than unwinds). `MAX_DRAW_INFO_WORDS` mirrors the buffer size and both render entry points reject an over-budget scene with `BackendError::SceneTooLarge`. Solid brushes cost one word per fill / stroke, so ~262k flat-coloured objects; gradients and images cost more. Re-check the constant against `vello_encoding::BufferSizes::new` on every vello bump.
- **Bump-buffer exhaustion fails silently and blanks the frame.** Flatten, path-count and coarse bump-allocate from fixed buffers — `lines` / `tile` / `seg_counts` / `segments` at `1 << 21` each, `ptcl` at `1 << 23`. When one runs out the stage sets a bit in `bump.failed`, `path_tiling_setup` writes `ptcl[0] = ~0u`, `coarse` returns early and `fine` skips every tile, leaving the target texture untouched — an all-zero image, which presents as a black window rather than partial output. `Renderer::render_to_texture` reads the bump counters back only under vello's `debug_layers` feature, so nothing surfaces this by default; to diagnose it, enable `debug_layers` + `bump_estimate` on the vello dep and call the deprecated `render_to_texture_async`, which returns the `BumpAllocators`. `seg_counts` (line-segment × tile intersections) binds first for dense mark geoms — measured ~20 per 7px circle and ~42 per 28px circle, so a 14px-diameter scatter blanks around 76k marks. It tracks mark count × mark perimeter in device pixels, so DPI moves it; overlap lands in `ptcl` instead, which has far more headroom.
-- **Minimum pick stroke width.** Hairline strokes (< `MIN_PICK_STROKE_WIDTH = 2.0` px) are widened in the pick scene so sub-pixel strokes remain hittable even when visually invisible.
+- **This backend's picking used to be the wrong one.** It cannot disable antialiasing, so an id buffer's edge pixels were a coverage blend of the two marks either side — two overlapping circles yielded 28 ids that were never drawn. The failure went away along with the pick pass itself: the index tests geometry, so every id it reports is one that was drawn.
## Dependency version quirks
@@ -40,5 +37,5 @@ Linebender / wgpu move fast and broke surface between recent versions. Notes for
## Files
-- `mod.rs` — `VelloScene`, `VelloRenderer`, `HeadlessTarget`, the pick scene rasterisation path.
+- `mod.rs` — `VelloScene`, `VelloRenderer`, `HeadlessTarget`.
- `../convert.rs` — the enum-mapping layer: `FillRule`, `BlendMode`, `Compose`, `Mix`, `Sampling` → peniko's native types.
diff --git a/src/backend/vello/mod.rs b/src/backend/vello/mod.rs
index af46c31..92278cf 100644
--- a/src/backend/vello/mod.rs
+++ b/src/backend/vello/mod.rs
@@ -6,7 +6,6 @@ use crate::backend::mesh;
use std::num::NonZeroUsize;
-use crate::geometry::Shape as _;
use vello::{AaConfig, AaSupport, RenderParams, Renderer as VRenderer, RendererOptions, Scene};
use crate::backend::{BackendError, Renderer, WgpuRenderer};
@@ -17,14 +16,10 @@ use crate::geometry::Affine;
use crate::mesh::Mesh;
use crate::path::{FillRule, Path};
-use crate::pick::{self, PickId};
+use crate::pick::{PickId, PickIndexScene};
use crate::scene::{GlyphRun, SceneBuilder};
use crate::stroke::Stroke;
-/// Minimum stroke width (in pixels) the pick pass uses, so hairline strokes
-/// remain hittable even when the visual stroke is sub-pixel.
-const MIN_PICK_STROKE_WIDTH: f64 = 2.0;
-
/// Largest number of draw-info words vello can rasterise in one pass.
///
/// Vello sizes its `bin_data` GPU buffer to a fixed `1 << 18` words and stores
@@ -40,32 +35,19 @@ pub const MAX_DRAW_INFO_WORDS: u32 = 1 << 18;
/// A `SceneBuilder` that writes into a `vello::Scene`.
///
-/// When picking is enabled (constructed via `with_picking`), every
-/// drawing call is also recorded into a parallel "pick" scene with its brush
-/// replaced by a solid colour encoding the call's [`PickId`]. The renderer
-/// rasterises both scenes; the pick scene is read back to a CPU u32 buffer
-/// that powers hit tests.
+/// Ignores `pick_id`: hit testing is a CPU-side index built by
+/// [`PickIndexScene`], which wraps this, so a rasteriser has nothing to do
+/// with it. The parameter stays on the trait because the vector backends do
+/// surface it — SVG emits `data-pick-id`.
pub struct VelloScene {
inner: Scene,
- pick: Option,
}
impl VelloScene {
- /// Build a scene with no picking machinery — file-export workloads should
- /// use this form (zero overhead).
+ /// Build an empty scene.
pub fn new() -> Self {
Self {
inner: Scene::new(),
- pick: None,
- }
- }
-
- /// Build a scene that records into both the display scene and a parallel
- /// pick scene. Used internally by [`VelloRenderer::with_picking`].
- pub(crate) fn with_picking() -> Self {
- Self {
- inner: Scene::new(),
- pick: Some(Scene::new()),
}
}
@@ -74,25 +56,16 @@ impl VelloScene {
&self.inner
}
- /// Borrow the parallel pick scene, if picking is enabled.
- pub(crate) fn raw_pick(&self) -> Option<&Scene> {
- self.pick.as_ref()
- }
-
- /// Draw-info words the encoded display scene occupies, to be compared
- /// against [`MAX_DRAW_INFO_WORDS`].
+ /// Draw-info words the encoded scene occupies, to be compared against
+ /// [`MAX_DRAW_INFO_WORDS`].
pub fn draw_info_words(&self) -> u32 {
draw_info_words(&self.inner)
}
- /// True when both the display scene and the pick scene fit the backend's
- /// draw budget, so a render will not be rejected.
+ /// True when the scene fits the backend's draw budget, so a render will
+ /// not be rejected.
pub fn fits_draw_budget(&self) -> bool {
check_draw_budget(&self.inner).is_ok()
- && self
- .pick
- .as_ref()
- .is_none_or(|p| check_draw_budget(p).is_ok())
}
}
@@ -124,13 +97,8 @@ impl Default for VelloScene {
}
impl SceneBuilder for VelloScene {
- /// Clears both the display scene and, when picking is enabled, the
- /// parallel pick scene.
fn clear(&mut self) {
self.inner.reset();
- if let Some(p) = &mut self.pick {
- p.reset();
- }
}
fn fill(
@@ -140,17 +108,11 @@ impl SceneBuilder for VelloScene {
brush: &Brush,
brush_transform: Option,
path: &Path,
- pick_id: PickId,
+ _pick_id: PickId,
) {
let fill_rule = convert::fill_rule(rule);
self.inner
.fill(fill_rule, transform, brush, brush_transform, path);
- if let Some(pick) = &mut self.pick {
- if let Some(id) = pick::raw_id(pick_id) {
- let pick_brush = Brush::Solid(pick::id_to_color(id));
- pick.fill(fill_rule, transform, &pick_brush, None, path);
- }
- }
}
fn stroke(
@@ -160,20 +122,10 @@ impl SceneBuilder for VelloScene {
brush: &Brush,
brush_transform: Option,
path: &Path,
- pick_id: PickId,
+ _pick_id: PickId,
) {
self.inner
.stroke(stroke, transform, brush, brush_transform, path);
- if let Some(pick) = &mut self.pick {
- if let Some(id) = pick::raw_id(pick_id) {
- let pick_brush = Brush::Solid(pick::id_to_color(id));
- let mut pick_stroke = stroke.clone();
- if pick_stroke.width < MIN_PICK_STROKE_WIDTH {
- pick_stroke.width = MIN_PICK_STROKE_WIDTH;
- }
- pick.stroke(&pick_stroke, transform, &pick_brush, None, path);
- }
- }
}
fn draw_image(
@@ -182,7 +134,7 @@ impl SceneBuilder for VelloScene {
transform: Affine,
sampling: Sampling,
alpha: f32,
- pick_id: PickId,
+ _pick_id: PickId,
) {
let sampler = peniko::ImageSampler {
x_extend: peniko::Extend::Pad,
@@ -195,18 +147,9 @@ impl SceneBuilder for VelloScene {
sampler,
};
self.inner.draw_image(&brush, transform);
- if let Some(pick) = &mut self.pick {
- if let Some(id) = pick::raw_id(pick_id) {
- let pick_brush = Brush::Solid(pick::id_to_color(id));
- let bounds =
- crate::geometry::Rect::new(0.0, 0.0, image.width as f64, image.height as f64)
- .to_path(0.1);
- pick.fill(peniko::Fill::NonZero, transform, &pick_brush, None, &bounds);
- }
- }
}
- fn draw_glyphs(&mut self, run: &GlyphRun<'_>, pick_id: PickId) {
+ fn draw_glyphs(&mut self, run: &GlyphRun<'_>, _pick_id: PickId) {
let style: peniko::StyleRef<'_> = match run.style {
Some(stroke) => peniko::StyleRef::from(stroke),
None => peniko::StyleRef::from(peniko::Fill::NonZero),
@@ -228,38 +171,11 @@ impl SceneBuilder for VelloScene {
y: g.y,
}),
);
-
- if let Some(pick) = &mut self.pick {
- if let Some(id) = pick::raw_id(pick_id) {
- let pick_brush = Brush::Solid(pick::id_to_color(id));
- let pick_style: peniko::StyleRef<'_> = match run.style {
- Some(stroke) => peniko::StyleRef::from(stroke),
- None => peniko::StyleRef::from(peniko::Fill::NonZero),
- };
- let pick_builder = pick
- .draw_glyphs(run.font.data())
- .font_size(run.font_size)
- .transform(run.transform)
- .glyph_transform(run.glyph_transform)
- .brush(&pick_brush)
- .brush_alpha(1.0)
- .hint(run.hint);
- pick_builder.draw(
- pick_style,
- run.glyphs.iter().map(|g| vello::Glyph {
- id: g.id,
- x: g.x,
- y: g.y,
- }),
- );
- }
- }
}
fn draw_mesh(&mut self, mesh: &Mesh, transform: Affine, pick_id: PickId) {
// Neither vello nor peniko has an indexed-mesh primitive, so the mesh
- // becomes fills. Routing them back through `self.fill` is what gives
- // the pick scene its copy of each triangle.
+ // becomes fills.
mesh::decompose(mesh, transform, pick_id, self);
}
@@ -271,26 +187,10 @@ impl SceneBuilder for VelloScene {
transform,
clip,
);
- if let Some(pick) = &mut self.pick {
- // Mirror the layer's clip/transform so subsequent draws are clipped
- // identically in the pick buffer, but normalize the blend so it
- // doesn't distort id colors. Alpha = 1 prevents translucent layers
- // from fading ids into the no-hit sentinel.
- pick.push_layer(
- peniko::Fill::NonZero,
- convert::blend_mode(BlendMode::NORMAL),
- 1.0,
- transform,
- clip,
- );
- }
}
fn pop_layer(&mut self) {
self.inner.pop_layer();
- if let Some(pick) = &mut self.pick {
- pick.pop_layer();
- }
}
}
@@ -352,52 +252,28 @@ impl HeadlessTarget {
/// Hephaestus Vello renderer: owns wgpu device/queue, the vello::Renderer, the
/// scene being built, and per-size headless targets.
///
-/// When constructed via [`Self::with_picking`], the renderer also rasterises a
-/// parallel "pick" scene to a second target, reads it back after each render,
-/// A pick readback in flight: a slot the `map_async` callback fills, and the
-/// dimensions it covers.
-///
-/// A slot rather than a future, so completion can be *checked* instead of
-/// awaited. Awaiting would mean holding a borrow of the renderer across a
-/// suspension point, which a browser host — where the only caller is a
-/// callback that may re-enter — cannot do safely.
-struct PendingPick {
- slot: std::sync::Arc>>>,
- width: u32,
- height: u32,
-}
-
-/// and caches the result in a CPU-side hitmap that powers [`Self::pick_at`].
+/// Hit testing is a property of the scene, not of this renderer: the scene is
+/// a [`crate::pick::PickIndexScene`], and
+/// [`Self::with_picking`] is what turns its indexing on.
pub struct VelloRenderer {
device: wgpu::Device,
queue: wgpu::Queue,
renderer: VRenderer,
- scene: VelloScene,
+ scene: PickIndexScene,
target: Option,
- pick_target: Option,
- /// Tightly-packed RGBA8 bytes of the most-recent pick render, viewable as
- /// `&[u32]` via bytemuck. `None` until the first picking-enabled render.
- hitmap: Option>,
- hitmap_dims: Option<(u32, u32)>,
- /// A pick readback that has been submitted but not yet drained, with the
- /// dimensions it was submitted at. `Some` only between `submit_pick` and
- /// `finish_pick`, which is the window a browser has to await across.
- pick_pending: Option,
- /// Whether the coming render refreshes the hitmap. See
- /// [`VelloRenderer::set_refresh_pick`].
- refresh_pick: bool,
}
impl VelloRenderer {
- /// Build a renderer with no picking machinery. File-export workloads
- /// should use this form; nothing in the pick path is allocated.
+ /// Build a renderer that does not hit-test. File-export workloads
+ /// should use this form; the scene indexes nothing.
pub fn new() -> Result {
pollster::block_on(Self::new_async(false))
}
- /// Build a renderer with picking enabled. Each call to
- /// [`Self::render_to_buffer`] additionally rasterises the pick scene and
- /// reads it back into an internal hitmap.
+ /// Build a renderer whose scene records a hit index as it is drawn,
+ /// making [`Self::pick_at`] and the other queries answerable.
+ ///
+ /// Indexing costs CPU per draw call, so it is off by default.
pub fn with_picking() -> Result {
pollster::block_on(Self::new_async(true))
}
@@ -413,10 +289,8 @@ impl VelloRenderer {
Self::build(device.clone(), queue.clone(), false)
}
- /// Like [`Self::with_device`] but enables picking. The pick scene is
- /// rasterised into a backend-owned headless target and read back to
- /// CPU on every render, regardless of whether the display render goes
- /// to a buffer or directly to a texture.
+ /// Like [`Self::with_device`] but with hit indexing enabled — see
+ /// [`Self::with_picking`].
pub fn with_device_and_picking(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -461,7 +335,7 @@ impl VelloRenderer {
}
/// Shared post-device construction: build the vello renderer and the
- /// (optionally picking) scene against an already-owned device/queue.
+ /// scene against an already-owned device/queue.
fn build(
device: wgpu::Device,
queue: wgpu::Queue,
@@ -478,23 +352,12 @@ impl VelloRenderer {
)
.map_err(|e| BackendError::Other(format!("vello renderer init: {e}")))?;
- let scene = if picking {
- VelloScene::with_picking()
- } else {
- VelloScene::new()
- };
-
Ok(Self {
device,
queue,
renderer,
- scene,
+ scene: PickIndexScene::new(VelloScene::new(), picking),
target: None,
- pick_target: None,
- hitmap: None,
- hitmap_dims: None,
- pick_pending: None,
- refresh_pick: true,
})
}
@@ -512,285 +375,25 @@ impl VelloRenderer {
}
}
- /// Re-allocate the pick headless target when picking is enabled and
- /// the dimensions don't match the cached ones. No-op when picking is
- /// disabled.
- fn ensure_pick_target(&mut self, width: u32, height: u32) {
- if self.scene.raw_pick().is_none() {
- return;
- }
- let need_new = match &self.pick_target {
- None => true,
- Some(t) => t.width != width || t.height != height,
- };
- if need_new {
- self.pick_target = Some(HeadlessTarget::new(&self.device, width, height));
- }
- }
-
/// Reject a scene vello cannot configure, before any GPU work is queued.
fn check_scene_budget(&self) -> Result<(), BackendError> {
- check_draw_budget(self.scene.raw())?;
- if let Some(pick) = self.scene.raw_pick() {
- check_draw_budget(pick)?;
- }
- Ok(())
- }
-
- /// Rasterise the pick scene into the cached pick target, copy it back
- /// to CPU, and refresh the hitmap. Assumes [`Self::ensure_pick_target`]
- /// has already been called and picking is enabled.
- ///
- /// Blocks until the readback lands. [`Self::submit_pick`] and
- /// [`Self::finish_pick`] are the same work either side of the wait, for a
- /// host that cannot park a thread.
- fn render_pick_and_readback(&mut self, width: u32, height: u32) -> Result<(), BackendError> {
- self.submit_pick(width, height)?;
- // A waiting poll returns only once the map callback has run, so the
- // slot is filled by the time this is reached.
- let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
- if !self.try_finish_pick()? {
- return Err(BackendError::Readback(
- "pick readback did not complete after a blocking device poll".into(),
- ));
- }
- Ok(())
- }
-
- /// Rasterise the pick scene and submit its readback, without waiting.
- ///
- /// Pair with [`Self::finish_pick`]. Assumes [`Self::ensure_pick_target`]
- /// has already been called and picking is enabled.
- fn submit_pick(&mut self, width: u32, height: u32) -> Result<(), BackendError> {
- let pick_scene = self.scene.raw_pick().expect("pick scene present");
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
-
- // AaConfig::Area is the only mode vello offers that our AaSupport
- // opted into, and vello has no way to turn antialiasing off — so the
- // pick scene is antialiased whether or not that suits it, and edge
- // pixels blend.
- //
- // The transparent base is what makes that survivable. Vello
- // unpremultiplies on output, so a mark's fringe over *nothing*
- // divides back out to its exact id with coverage left in alpha. An
- // opaque base would instead blend every fringe toward black and hand
- // back a plausible but wrong id at full alpha. Measured on one mark
- // tagged 200: transparent base leaves 140 stray pixels, all at alpha
- // 0 and rejected by `pick::decode`; an opaque base leaves 228, all at
- // alpha 255 and undetectable.
- //
- // What neither base fixes: a fringe over *other picked content*
- // blends two real ids and lands at full alpha. See the conflation
- // note on `crate::pick`.
- self.renderer
- .render_to_texture(
- &self.device,
- &self.queue,
- pick_scene,
- &pick_target.view,
- &RenderParams {
- base_color: Color::new([0.0, 0.0, 0.0, 0.0]),
- width,
- height,
- antialiasing_method: AaConfig::Area,
- },
- )
- .map_err(|e| BackendError::Other(format!("vello pick render: {e}")))?;
-
- let mut encoder = self
- .device
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("hephaestus.vello.pick_readback"),
- });
- encoder.copy_texture_to_buffer(
- wgpu::TexelCopyTextureInfo {
- texture: &pick_target.texture,
- mip_level: 0,
- origin: wgpu::Origin3d::ZERO,
- aspect: wgpu::TextureAspect::All,
- },
- wgpu::TexelCopyBufferInfo {
- buffer: &pick_target.readback,
- layout: wgpu::TexelCopyBufferLayout {
- offset: 0,
- bytes_per_row: Some(pick_target.padded_bytes_per_row),
- rows_per_image: Some(height),
- },
- },
- wgpu::Extent3d {
- width,
- height,
- depth_or_array_layers: 1,
- },
- );
- self.queue.submit(std::iter::once(encoder.finish()));
-
- let slot = std::sync::Arc::new(std::sync::Mutex::new(None));
- let sink = std::sync::Arc::clone(&slot);
- pick_target
- .readback
- .slice(..)
- .map_async(wgpu::MapMode::Read, move |res| {
- if let Ok(mut guard) = sink.lock() {
- *guard = Some(res);
- }
- });
- self.pick_pending = Some(PendingPick {
- slot,
- width,
- height,
- });
- Ok(())
- }
-
- /// Drain a readback submitted by [`Self::submit_pick`] into the hitmap,
- /// if it has landed.
- ///
- /// Returns whether the hitmap was refreshed: `false` means nothing was in
- /// flight, or the GPU has not finished. Never blocks, so a host that
- /// cannot park a thread calls this and accepts that the hitmap may lag
- /// the drawn frame.
- ///
- /// Only meaningful after [`Self::render_to_texture_deferring_pick`]; the
- /// blocking render paths drain their own readback before returning.
- pub fn try_finish_pick(&mut self) -> Result {
- let Some(pending) = self.pick_pending.as_ref() else {
- return Ok(false);
- };
- let landed = pending
- .slot
- .lock()
- .map_err(|_| BackendError::Readback("pick readback slot poisoned".into()))?
- .take();
- let Some(result) = landed else {
- return Ok(false);
- };
-
- let PendingPick { width, height, .. } =
- self.pick_pending.take().expect("checked just above");
- result.map_err(|e| BackendError::Readback(e.to_string()))?;
-
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
- let pick_slice = pick_target.readback.slice(..);
-
- let row_bytes = (width as usize) * 4;
- let row_px = width as usize;
- let total_px = (width as usize) * (height as usize);
- let hitmap = self.hitmap.get_or_insert_with(Vec::new);
- if hitmap.len() != total_px {
- hitmap.resize(total_px, 0);
- }
- {
- let data = pick_slice.get_mapped_range();
- let padded = pick_target.padded_bytes_per_row as usize;
- for y in 0..height as usize {
- let src = &data[y * padded..y * padded + row_bytes];
- let dst: &mut [u8] =
- bytemuck::cast_slice_mut(&mut hitmap[y * row_px..y * row_px + row_px]);
- dst.copy_from_slice(src);
- }
- }
- pick_target.readback.unmap();
- self.hitmap_dims = Some((width, height));
- Ok(true)
- }
-
- /// Rasterise into `view` and submit the pick pass without waiting on it.
- ///
- /// The non-blocking counterpart to
- /// [`WgpuRenderer::render_to_texture`](crate::WgpuRenderer::render_to_texture),
- /// whose pick readback parks the calling thread until the GPU is done —
- /// which a browser's main thread cannot do. Pair with
- /// [`Self::try_finish_pick`]: until that drains, [`Self::pick_at`] keeps
- /// answering from the previous frame's hitmap.
- ///
- /// Identical to the trait method when picking is disabled.
- pub fn render_to_texture_deferring_pick(
- &mut self,
- view: &wgpu::TextureView,
- width: u32,
- height: u32,
- background: Color,
- ) -> Result<(), BackendError> {
- self.check_scene_budget()?;
- self.renderer
- .render_to_texture(
- &self.device,
- &self.queue,
- self.scene.raw(),
- view,
- &RenderParams {
- base_color: background,
- width,
- height,
- antialiasing_method: AaConfig::Area,
- },
- )
- .map_err(|e| BackendError::Other(format!("vello render: {e}")))?;
-
- if self.refreshes_pick() {
- // Drain first: that unmaps the readback buffer, and `map_async`
- // on a still-mapped buffer is a validation error. Draining also
- // has to happen before `ensure_pick_target`, which may reallocate
- // the target the in-flight readback is reading from.
- self.try_finish_pick()?;
- // Still in flight — skip this frame rather than queue a second
- // map on the same buffer. The hitmap lags until it lands, which
- // `pick_at` already documents.
- if self.pick_pending.is_none() {
- self.ensure_pick_target(width, height);
- self.submit_pick(width, height)?;
- }
- }
- Ok(())
+ check_draw_budget(self.scene.inner().raw())
}
- /// Control whether the coming render refreshes the hitmap.
+ /// The hit index the scene built while it was drawn, or `None` when this
+ /// renderer was not built with picking.
///
- /// The pick pass here is a second GPU rasterisation plus a readback, so it
- /// costs less than it does on a CPU-coverage backend but is not free. A
- /// host redrawing faster than it queries — mid-resize, say — can leave the
- /// hitmap alone for a few frames.
- ///
- /// While it is off, [`Self::pick_at`] keeps answering from the last render
- /// that refreshed. Set it back to `true` (the default) and the next render
- /// brings the hitmap up to date. No effect when picking was not enabled at
- /// construction.
- pub fn set_refresh_pick(&mut self, refresh: bool) {
- self.refresh_pick = refresh;
- }
-
- /// Whether the coming render will refresh the hitmap.
- pub fn refreshes_pick(&self) -> bool {
- self.refresh_pick && self.scene.raw_pick().is_some()
- }
-
- /// Look up the id at pixel `(x, y)` in the most-recent pick render.
- /// Returns `None` if picking is disabled, no render has been performed
- /// yet, the coordinates are out of range, or the pixel is the "no hit"
- /// sentinel (uncovered or [`PickId::Block`]).
- ///
- /// Note: picking does not respect display alpha; see the [`crate::pick`]
- /// module docs for the alpha-insensitive picking limitation.
- pub fn pick_at(&self, x: u32, y: u32) -> Option {
- let (w, h) = self.hitmap_dims?;
- if x >= w || y >= h {
- return None;
- }
- let map = self.hitmap.as_deref()?;
- pick::decode(map[(y * w + x) as usize])
- }
-
- /// Borrow the full hitmap as a flat `&[u32]` of `width * height` pixels
- /// laid out row-major. Useful for bulk queries (marquee selection etc.).
- /// Returns `None` if picking is disabled or no render has been performed.
- pub fn hitmap(&self) -> Option<&[u32]> {
- self.hitmap.as_deref()
+ /// The only pick method here, deliberately: a renderer's part in hit
+ /// testing is owning the scene that recorded the index, so every query
+ /// lives on [`PickIndex`](crate::pick::PickIndex) rather than being
+ /// forwarded through two more layers of the same names.
+ pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> {
+ self.scene.indexes().then(|| self.scene.index())
}
}
impl Renderer for VelloRenderer {
- type Scene = VelloScene;
+ type Scene = PickIndexScene;
fn scene(&mut self) -> &mut Self::Scene {
&mut self.scene
@@ -813,14 +416,13 @@ impl Renderer for VelloRenderer {
self.check_scene_budget()?;
self.ensure_display_target(width, height);
- self.ensure_pick_target(width, height);
let target = self.target.as_ref().unwrap();
self.renderer
.render_to_texture(
&self.device,
&self.queue,
- self.scene.raw(),
+ self.scene.inner().raw(),
&target.view,
&RenderParams {
base_color: background,
@@ -831,32 +433,7 @@ impl Renderer for VelloRenderer {
)
.map_err(|e| BackendError::Other(format!("vello render: {e}")))?;
- // If picking is enabled, render the parallel pick scene over a
- // transparent base. See `render_pick_and_readback` for why the base
- // must stay transparent.
- let picking = self.refreshes_pick();
- if picking {
- let pick_scene = self.scene.raw_pick().unwrap();
- let pick_target = self.pick_target.as_ref().expect("pick target ensured");
- // Same AA and base-colour contract as `render_pick_and_readback`.
- self.renderer
- .render_to_texture(
- &self.device,
- &self.queue,
- pick_scene,
- &pick_target.view,
- &RenderParams {
- base_color: Color::new([0.0, 0.0, 0.0, 0.0]),
- width,
- height,
- antialiasing_method: AaConfig::Area,
- },
- )
- .map_err(|e| BackendError::Other(format!("vello pick render: {e}")))?;
- }
-
- // Encode both texture→buffer copies into one command buffer so they
- // share a single submit + map round-trip.
+ // Copy the rendered texture back to CPU.
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
@@ -883,30 +460,6 @@ impl Renderer for VelloRenderer {
depth_or_array_layers: 1,
},
);
- if picking {
- let pick_target = self.pick_target.as_ref().unwrap();
- encoder.copy_texture_to_buffer(
- wgpu::TexelCopyTextureInfo {
- texture: &pick_target.texture,
- mip_level: 0,
- origin: wgpu::Origin3d::ZERO,
- aspect: wgpu::TextureAspect::All,
- },
- wgpu::TexelCopyBufferInfo {
- buffer: &pick_target.readback,
- layout: wgpu::TexelCopyBufferLayout {
- offset: 0,
- bytes_per_row: Some(pick_target.padded_bytes_per_row),
- rows_per_image: Some(height),
- },
- },
- wgpu::Extent3d {
- width,
- height,
- depth_or_array_layers: 1,
- },
- );
- }
self.queue.submit(std::iter::once(encoder.finish()));
let display_slice = target.readback.slice(..);
@@ -915,18 +468,6 @@ impl Renderer for VelloRenderer {
let _ = display_tx.send(res);
});
- let pick_rx = if picking {
- let pick_target = self.pick_target.as_ref().unwrap();
- let pick_slice = pick_target.readback.slice(..);
- let (pick_tx, pick_rx) = futures_intrusive::channel::shared::oneshot_channel();
- pick_slice.map_async(wgpu::MapMode::Read, move |res| {
- let _ = pick_tx.send(res);
- });
- Some(pick_rx)
- } else {
- None
- };
-
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
match pollster::block_on(display_rx.receive()) {
@@ -934,18 +475,6 @@ impl Renderer for VelloRenderer {
Some(Err(e)) => return Err(BackendError::Readback(e.to_string())),
None => return Err(BackendError::Readback("map_async sender dropped".into())),
}
- if let Some(rx) = pick_rx.as_ref() {
- match pollster::block_on(rx.receive()) {
- Some(Ok(())) => {}
- Some(Err(e)) => return Err(BackendError::Readback(e.to_string())),
- None => {
- return Err(BackendError::Readback(
- "map_async pick sender dropped".into(),
- ))
- }
- }
- }
-
let row_bytes = (width as usize) * 4;
{
let data = display_slice.get_mapped_range();
@@ -958,29 +487,6 @@ impl Renderer for VelloRenderer {
}
target.readback.unmap();
- if picking {
- let pick_target = self.pick_target.as_ref().unwrap();
- let row_px = width as usize;
- let total_px = (width as usize) * (height as usize);
- let hitmap = self.hitmap.get_or_insert_with(Vec::new);
- if hitmap.len() != total_px {
- hitmap.resize(total_px, 0);
- }
- let pick_slice = pick_target.readback.slice(..);
- {
- let data = pick_slice.get_mapped_range();
- let padded = pick_target.padded_bytes_per_row as usize;
- for y in 0..height as usize {
- let src = &data[y * padded..y * padded + row_bytes];
- let dst: &mut [u8] =
- bytemuck::cast_slice_mut(&mut hitmap[y * row_px..y * row_px + row_px]);
- dst.copy_from_slice(src);
- }
- }
- pick_target.readback.unmap();
- self.hitmap_dims = Some((width, height));
- }
-
Ok(())
}
}
@@ -1002,7 +508,7 @@ impl WgpuRenderer for VelloRenderer {
.render_to_texture(
&self.device,
&self.queue,
- self.scene.raw(),
+ self.scene.inner().raw(),
view,
&RenderParams {
base_color: background,
@@ -1012,14 +518,6 @@ impl WgpuRenderer for VelloRenderer {
},
)
.map_err(|e| BackendError::Other(format!("vello render: {e}")))?;
-
- // Picking still goes through the backend-owned pick target +
- // CPU readback. Display has no readback to wait on, so the pick
- // submit / poll happens after the display submit returns.
- if self.refreshes_pick() {
- self.ensure_pick_target(width, height);
- self.render_pick_and_readback(width, height)?;
- }
Ok(())
}
}
diff --git a/src/composition/anatomy.rs b/src/composition/anatomy.rs
index a4dc8b0..1d0987f 100644
--- a/src/composition/anatomy.rs
+++ b/src/composition/anatomy.rs
@@ -143,6 +143,66 @@ impl Slot {
}
}
+ /// The slot a [`Slot::name`] identifier came from, or `None` when the
+ /// name addresses a `place_at` region rather than an anatomical slot.
+ pub fn from_name(name: &str) -> Option {
+ Some(match name {
+ "panel" => Slot::Panel,
+ "background" => Slot::Background,
+
+ "axis_top" => Slot::AxisTop,
+ "axis_top_title" => Slot::AxisTopTitle,
+ "strip_top" => Slot::StripTop,
+ "legend_top" => Slot::LegendTop,
+
+ "axis_bottom" => Slot::AxisBottom,
+ "axis_bottom_title" => Slot::AxisBottomTitle,
+ "strip_bottom" => Slot::StripBottom,
+ "legend_bottom" => Slot::LegendBottom,
+
+ "axis_left" => Slot::AxisLeft,
+ "axis_left_title" => Slot::AxisLeftTitle,
+ "strip_left" => Slot::StripLeft,
+ "legend_left" => Slot::LegendLeft,
+
+ "axis_right" => Slot::AxisRight,
+ "axis_right_title" => Slot::AxisRightTitle,
+ "strip_right" => Slot::StripRight,
+ "legend_right" => Slot::LegendRight,
+
+ "title" => Slot::Title,
+ "subtitle" => Slot::Subtitle,
+ "caption" => Slot::Caption,
+
+ _ => return None,
+ })
+ }
+
+ /// Every anatomical slot, in declaration order.
+ pub const ALL: [Slot; 21] = [
+ Slot::Panel,
+ Slot::Background,
+ Slot::AxisTop,
+ Slot::AxisTopTitle,
+ Slot::StripTop,
+ Slot::LegendTop,
+ Slot::AxisBottom,
+ Slot::AxisBottomTitle,
+ Slot::StripBottom,
+ Slot::LegendBottom,
+ Slot::AxisLeft,
+ Slot::AxisLeftTitle,
+ Slot::StripLeft,
+ Slot::LegendLeft,
+ Slot::AxisRight,
+ Slot::AxisRightTitle,
+ Slot::StripRight,
+ Slot::LegendRight,
+ Slot::Title,
+ Slot::Subtitle,
+ Slot::Caption,
+ ];
+
/// (row, col, row_span, col_span), 1-indexed within the per-patch
/// 13×16 anatomy.
pub const fn placement(self) -> (u16, u16, u16, u16) {
@@ -198,29 +258,7 @@ impl Slot {
mod tests {
use super::*;
- const ALL_SLOTS: &[Slot] = &[
- Slot::Panel,
- Slot::Background,
- Slot::AxisTop,
- Slot::AxisTopTitle,
- Slot::StripTop,
- Slot::LegendTop,
- Slot::AxisBottom,
- Slot::AxisBottomTitle,
- Slot::StripBottom,
- Slot::LegendBottom,
- Slot::AxisLeft,
- Slot::AxisLeftTitle,
- Slot::StripLeft,
- Slot::LegendLeft,
- Slot::AxisRight,
- Slot::AxisRightTitle,
- Slot::StripRight,
- Slot::LegendRight,
- Slot::Title,
- Slot::Subtitle,
- Slot::Caption,
- ];
+ const ALL_SLOTS: &[Slot] = &Slot::ALL;
#[test]
fn names_are_unique() {
@@ -253,6 +291,25 @@ mod tests {
}
}
+ #[test]
+ fn every_slot_round_trips_through_its_name() {
+ for &slot in ALL_SLOTS {
+ assert_eq!(
+ Slot::from_name(slot.name()),
+ Some(slot),
+ "{} did not round-trip",
+ slot.name()
+ );
+ }
+ }
+
+ #[test]
+ fn a_place_at_region_name_is_not_a_slot() {
+ assert_eq!(Slot::from_name("inset"), None);
+ assert_eq!(Slot::from_name(""), None);
+ assert_eq!(Slot::from_name("Panel"), None);
+ }
+
#[test]
fn background_excludes_margin_tracks() {
// Background's row range is [2, 15] inclusive, col range [2, 12].
diff --git a/src/pick.rs b/src/pick.rs
deleted file mode 100644
index 26d1901..0000000
--- a/src/pick.rs
+++ /dev/null
@@ -1,210 +0,0 @@
-//! Hit-testing primitives.
-//!
-//! Picking is opt-in at scene/renderer construction. When enabled, every
-//! drawing call carries a [`PickId`] that tells the backend whether (and with
-//! what id) the call should appear in a parallel "hitmap" buffer. After
-//! rendering, the hitmap is read back to CPU once and indexed directly to
-//! answer "which item is at pixel (x, y)?" — no per-event GPU round-trip.
-//!
-//! The id space is 24-bit (1..=0xFF_FFFF, ~16M items), with `0` reserved as the
-//! "no hit" sentinel, alongside a fully transparent pixel — nothing was drawn
-//! there, so its colour channels are not an id. Callers manage their own id assignment (typically a row
-//! index or item index). The encoding packs the id into the RGB channels of an
-//! `Rgba8Unorm` texture with alpha forced to 255, which round-trips cleanly
-//! through default SrcOver compositing without any per-draw blend-mode plumbing.
-//!
-//! # Limitation: blended ids where picked content meets picked content
-//!
-//! This one depends on the backend. A rasteriser that cannot disable
-//! antialiasing produces a pick pass whose edge pixels are a coverage blend
-//! of what is above and below them. Over empty space that is harmless: the
-//! rasteriser unpremultiplies, so the fringe divides back out to the mark's
-//! exact id. Where a mark's edge falls on **other picked content**, the blend
-//! mixes two ids and the result is a third, entirely plausible id at full
-//! alpha, which [`decode`] cannot tell from a real hit.
-//!
-//! Two arrangements trigger it: overlapping marks (a boundary between ids
-//! 100 and 200 reports values across that range) and a mark drawn over a
-//! [`PickId::Block`] fill (the fringe ramps from 0 up to the mark's id,
-//! producing low ids that alias onto low-numbered rows).
-//!
-//! Keeping decorative chrome on [`PickId::Skip`] — the default, and what the
-//! plot layer does for panel backgrounds and gridlines — keeps marks
-//! compositing over nothing and avoids the conflation entirely. The affected
-//! band is one pixel wide at each boundary.
-//!
-//! A backend that computes coverage on the CPU can paint the pick pass with
-//! binary coverage instead, which rules the whole failure out: a pixel is
-//! covered by exactly one primitive, so every id read back is an id that was
-//! drawn. The `backend::hybrid` backend does this.
-//!
-//! # Limitation: alpha-insensitive picking
-//!
-//! Picking ignores display alpha. A semi-transparent layer or image fully
-//! occludes picks of content beneath it, even though the same content remains
-//! visible in the rasterised image. This keeps the encoded id intact under
-//! SrcOver and avoids decoding ambiguity, at the cost of a known mismatch
-//! between visual appearance and hit behaviour for translucent overlays.
-
-use crate::color::Color;
-
-/// Per-draw-call hitmap directive.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
-pub enum PickId {
- /// Don't record into the hitmap. Items beneath remain hittable through
- /// this primitive. Sensible default for decorative chrome (gridlines,
- /// axis ticks, etc.).
- #[default]
- Skip,
- /// Record with id 0 — occludes whatever is beneath in the hitmap, but is
- /// itself reported as "no hit". Useful for opaque panels/backgrounds
- /// that should block picks without being interactive themselves.
- Block,
- /// Record with the given id. id 0 is reserved internally for "no hit"
- /// and `Id(0)` is treated identically to `Block`. Ids above `0xFF_FFFF`
- /// are truncated to 24 bits — the high byte is discarded.
- Id(u32),
-}
-
-/// Encode a 24-bit id into the [`Color`] that will be written to the pick
-/// texture. Bytes land in the `Rgba8Unorm` target as
-/// `(R = id & 0xFF, G = (id>>8) & 0xFF, B = (id>>16) & 0xFF, A = 255)`, so a
-/// `u32` lifted off the little-endian readback buffer equals
-/// `(0xFF << 24) | (id & 0x00FF_FFFF)`.
-pub fn id_to_color(id: u32) -> Color {
- let r = (id & 0xFF) as f32 / 255.0;
- let g = ((id >> 8) & 0xFF) as f32 / 255.0;
- let b = ((id >> 16) & 0xFF) as f32 / 255.0;
- Color::new([r, g, b, 1.0])
-}
-
-/// Decode a u32 pixel sampled from the hitmap into the originating id, or
-/// `None` for a miss.
-///
-/// A pixel misses when nothing was drawn over it (alpha `0`) or when what was
-/// drawn carries the no-hit sentinel (`id == 0`). The alpha test is what makes
-/// the RGB payload meaningful: every recorded id composites at alpha `255`
-/// (see [`id_to_color`]), so an alpha of `0` means the RGB channels hold
-/// whatever the rasteriser left behind rather than an id, and reading them as
-/// one reports hits on empty space.
-///
-/// Public because a caller doing bulk queries over
-/// [`VelloRenderer::hitmap`](crate::backend::vello::VelloRenderer::hitmap)
-/// reads raw pixels and needs this to interpret them.
-pub fn decode(px: u32) -> Option {
- if px >> 24 == 0 {
- return None;
- }
- let id = px & 0x00FF_FFFF;
- (id != 0).then_some(id)
-}
-
-/// Resolve a [`PickId`] to the raw id that should land in the hitmap, or
-/// `None` if the call should not be recorded at all.
-pub fn raw_id(pick: PickId) -> Option {
- match pick {
- PickId::Skip => None,
- PickId::Block => Some(0),
- PickId::Id(n) => Some(n),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- /// The u32 a little-endian `Rgba8Unorm` readback yields for an
- /// encoded pick colour: red in the low byte, alpha in the high one.
- fn readback_u32(c: Color) -> u32 {
- let px = c.to_rgba8();
- ((px.a as u32) << 24) | ((px.b as u32) << 16) | ((px.g as u32) << 8) | px.r as u32
- }
-
- /// Ids that bracket each byte boundary of the 24-bit space.
- const BOUNDARIES: [u32; 10] = [
- 1, 0x7F, 0xFF, 0x100, 0x101, 0xFFFF, 0x1_0000, 0x1_0001, 0xFE_FFFF, 0xFF_FFFF,
- ];
-
- #[test]
- fn boundary_ids_round_trip_through_the_encoded_pixel() {
- for id in BOUNDARIES {
- let got = decode(readback_u32(id_to_color(id)));
- assert_eq!(got, Some(id), "id {id:#x} did not round-trip");
- }
- }
-
- #[test]
- fn every_sampled_id_across_the_24_bit_space_round_trips() {
- // Prime stride so the sweep hits every byte value in each of the
- // three channels rather than aliasing to a fixed pattern.
- let mut id = 1u32;
- while id <= 0xFF_FFFF {
- assert_eq!(decode(readback_u32(id_to_color(id))), Some(id));
- id += 7919;
- }
- }
-
- #[test]
- fn encoded_pixel_is_opaque_with_the_id_in_the_low_24_bits() {
- for id in BOUNDARIES {
- assert_eq!(
- readback_u32(id_to_color(id)),
- (0xFF << 24) | (id & 0x00FF_FFFF),
- "id {id:#x} encoded to an unexpected pixel"
- );
- }
- }
-
- #[test]
- fn id_zero_encodes_the_same_pixel_as_block() {
- let block = id_to_color(raw_id(PickId::Block).unwrap());
- let id_zero = id_to_color(raw_id(PickId::Id(0)).unwrap());
- assert_eq!(raw_id(PickId::Id(0)), raw_id(PickId::Block));
- assert_eq!(readback_u32(id_zero), readback_u32(block));
- // Opaque black — the no-hit sentinel, which still occludes.
- assert_eq!(readback_u32(block), 0xFF00_0000);
- assert_eq!(decode(readback_u32(block)), None);
- }
-
- #[test]
- fn ids_past_24_bits_lose_their_high_byte() {
- assert_eq!(
- readback_u32(id_to_color(0x0100_0001)),
- readback_u32(id_to_color(1))
- );
- assert_eq!(decode(readback_u32(id_to_color(0x0100_0001))), Some(1));
- assert_eq!(decode(readback_u32(id_to_color(u32::MAX))), Some(0xFF_FFFF));
- // A caller id whose payload is entirely in the high byte collides
- // with the no-hit sentinel and becomes unhittable.
- assert_eq!(decode(readback_u32(id_to_color(0x0100_0000))), None);
- }
-
- #[test]
- fn a_fully_transparent_pixel_misses_whatever_its_rgb_holds() {
- // Nothing composited here, so the RGB channels are residue, not an id.
- assert_eq!(decode(0x0000_1234), None);
- assert_eq!(decode(0x0000_0000), None);
- }
-
- #[test]
- fn any_coverage_at_all_makes_the_id_payload_count() {
- assert_eq!(decode(0xFF00_1234), Some(0x1234));
- assert_eq!(decode(0xFF00_0000), None);
- // Partial coverage still carries an exact id: the rasteriser
- // unpremultiplies, so a fringe pixel reports the mark it belongs to.
- assert_eq!(decode(0x0100_1234), Some(0x1234));
- }
-
- #[test]
- fn raw_id_omits_skip_and_maps_block_to_zero() {
- assert_eq!(raw_id(PickId::Skip), None);
- assert_eq!(raw_id(PickId::Block), Some(0));
- assert_eq!(raw_id(PickId::Id(7)), Some(7));
- }
-
- #[test]
- fn default_pick_id_stays_out_of_the_hitmap() {
- assert_eq!(PickId::default(), PickId::Skip);
- assert_eq!(raw_id(PickId::default()), None);
- }
-}
diff --git a/src/pick/CLAUDE.md b/src/pick/CLAUDE.md
new file mode 100644
index 0000000..3f8662e
--- /dev/null
+++ b/src/pick/CLAUDE.md
@@ -0,0 +1,94 @@
+# src/pick/CLAUDE.md
+
+Hit testing: what a drawing records about itself, and how a point, rectangle
+or lasso is answered against it.
+
+## What this module does
+
+A scene wrapped in [`PickIndexScene`] forwards every call to the scene beneath
+it unchanged and records each primitive's geometry on the way past. The result
+is a [`PickIndex`] — entries in draw order, an R-tree over their bounding
+boxes, and the clip and scope stacks each entry was drawn under. Queries run
+entirely on the CPU: no second rasterisation, no readback, and no way for the
+answer to describe a different frame from the one on screen.
+
+Nothing here knows what a chart is. A [`PickScope`] carries a `&'static str`
+kind plus an optional name and index; the vocabulary that gives those meaning
+lives in `plot::pick`. That split is deliberate and matches the one
+`composition` already uses between `Slot::name` and the `Region` trait — see
+the layering rule in `src/CLAUDE.md`.
+
+## Files
+
+- `mod.rs` — `PickId`, `raw_id`, and the module docs stating the known limits.
+- `scene.rs` — `PickIndexScene`: the owning decorator that *is* each
+ renderer's `Renderer::Scene`.
+- `index.rs` — `PickIndex`, `Entry`, `Hit`. Recording and query orchestration.
+- `rtree.rs` — the packed Hilbert R-tree.
+- `hilbert.rs` — the Hilbert d-index the tree sorts on.
+- `clip.rs` — the clip stack, and the axis-aligned-rect recogniser both it and
+ `index.rs` use.
+- `geom.rs` — per-primitive hit geometry: the shared arena, interning,
+ flattening, chunking, and the exact tests.
+- `scope.rs` — `PickScope`, `ScopeMode`, the hash-consed `ScopeTree`, and the
+ `PickPath` view over it.
+
+## Things worth knowing before changing this
+
+- **Geometry is stored in the primitive's own frame, and the query point is
+ inverse-transformed.** That is the whole reason a hundred thousand scatter
+ markers cost one stored path: the plot layer draws them all from one
+ `ShapeRegistry` entry, varying only the transform. Storing geometry in
+ device space would defeat interning entirely.
+
+- **Paths live in one flat `Vec` arena, not a `Vec`.** kurbo
+ implements `Shape` for `&[PathEl]`, so slices are hit-tested directly. A
+ low-level caller placing every mark in absolute coordinates shares nothing,
+ and a `BezPath` per mark would be a hundred thousand allocations.
+
+- **A stored path's bounds are cached with it.** A tight box around a cubic
+ means solving for the curve's extrema; computing it per *mark* rather than
+ per distinct *shape* was worth ~6 ms at 100k marks.
+
+- **The intern map uses a pass-through hasher.** Its key is already a content
+ hash, so the default SipHash would be a second hash over a good one.
+
+- **Chunking at 64 points is what keeps a long primitive from degenerating.**
+ A ten-thousand-point line is one primitive with a panel-sized bounding box;
+ unchunked, every hover inside the panel would walk the whole polyline.
+ Fills are *not* chunked — winding needs the whole ring.
+
+- **Clips only subtract, which is why they are nearly free.** A clip's bounds
+ are intersected into every entry's box at insert, so a primitive clipped
+ away entirely is never recorded. The exact test runs only for a candidate
+ that already passed its own geometry test, and only once per distinct clip
+ per query — the memo is what stops a panel clip being evaluated a hundred
+ thousand times. `is_rect` is a fast path, not a limitation: an arbitrary
+ Bézier clip is tested exactly.
+
+- **The tree is built lazily and invalidated on insert.** A window redrawing
+ faster than it is queried never builds one. Below `LINEAR_SCAN_MAX` no
+ levels are built at all and the query scans — which is also the shape a
+ future off-thread build would degrade to while the tree is in flight.
+
+- **`RefCell` guards the lazy tree and the query scratch buffers**, because
+ queries take `&self`. Recording takes `&mut self`, so it uses `get_mut` and
+ pays no borrow check. `PickIndex` is `Send` but not `Sync`; there is a test
+ asserting the former, because it is what any off-thread build would need.
+
+- **Entry order is draw order**, so an entry's index *is* its z-order and
+ nothing is sorted at insert. A query sorts its candidates descending.
+
+- **Hits coalesce on `(pick_id, scope)`.** A fill-plus-stroke mark, a chunked
+ stroke and a chunked mesh are several entries a caller should see as one
+ thing; the first kept is topmost and fixes the order, and the rest only
+ widen the reported bounds.
+
+## Cross-references
+
+- `src/CLAUDE.md` — the authoritative picking model: the indexing rule, the
+ scope grammar, what it costs, and the known limits.
+- `src/plot/pick.rs` — the chart vocabulary: `PlotPart`, the scope
+ constructors, and the typed `PlotPath` view a consumer reads a hit through.
+- `src/backend/svg/CLAUDE.md` — the one backend that surfaces both `PickId`
+ and scopes, as `data-pick-id` and ``.
diff --git a/src/pick/clip.rs b/src/pick/clip.rs
new file mode 100644
index 0000000..371db78
--- /dev/null
+++ b/src/pick/clip.rs
@@ -0,0 +1,405 @@
+//! The clip stack, and the memoized test that asks whether a clip lets a
+//! point through.
+//!
+//! Clips only ever *subtract*, which is what makes them cheap here. A clip's
+//! bounding box is intersected into every entry's bounds as the entry is
+//! recorded, so most of the work happens once at insert rather than per
+//! query, and a primitive clipped away entirely is never indexed at all. The
+//! exact test runs only for a candidate that has already passed its own
+//! geometry test, and only once per distinct clip per query.
+
+use crate::geometry::{Affine, Point, Rect, Shape};
+
+use crate::path::Path;
+
+/// Handle into a [`ClipStack`]'s arena. [`ClipId::NONE`] means unclipped.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) struct ClipId(u32);
+
+impl ClipId {
+ /// No clip applies.
+ pub(crate) const NONE: ClipId = ClipId(u32::MAX);
+
+ pub(crate) fn is_none(self) -> bool {
+ self == ClipId::NONE
+ }
+}
+
+#[derive(Debug)]
+struct ClipNode {
+ /// `transform * clip`, baked the way the vector backends bake theirs, so
+ /// the test needs no transform of its own.
+ path: Path,
+ /// This clip's bounds intersected with every ancestor's — the
+ /// conservative region anything under it can occupy.
+ bounds: Rect,
+ parent: ClipId,
+ /// The baked path is an axis-aligned rectangle, so `bounds` is already
+ /// the exact answer and the winding test can be skipped.
+ is_rect: bool,
+}
+
+/// The stack of clips in effect, plus the arena the entries point into.
+///
+/// Nodes are never removed: an entry recorded under a clip holds its
+/// [`ClipId`] for the life of the frame, and [`Self::clear`] drops the whole
+/// arena at once.
+#[derive(Debug, Default)]
+pub(crate) struct ClipStack {
+ nodes: Vec,
+ stack: Vec,
+}
+
+impl ClipStack {
+ /// The clip in effect for a primitive recorded right now.
+ pub(crate) fn current(&self) -> ClipId {
+ self.stack.last().copied().unwrap_or(ClipId::NONE)
+ }
+
+ /// Conservative bounds of the clip in effect — the region a primitive
+ /// recorded now can occupy. Unbounded when nothing is clipping.
+ pub(crate) fn current_bounds(&self) -> Option {
+ let id = self.current();
+ (!id.is_none()).then(|| self.nodes[id.0 as usize].bounds)
+ }
+
+ /// Enter a clip. An empty path pushes the enclosing clip again, matching
+ /// the vector backends, which emit no `clipPath` for one.
+ pub(crate) fn push(&mut self, transform: Affine, clip: &Path) {
+ let parent = self.current();
+ if clip.elements().is_empty() {
+ self.stack.push(parent);
+ return;
+ }
+ let baked = if transform == Affine::IDENTITY {
+ clip.clone()
+ } else {
+ transform * clip.clone()
+ };
+ let own = baked.bounding_box();
+ let bounds = match self.parent_bounds(parent) {
+ Some(p) => p.intersect(own),
+ None => own,
+ };
+ let is_rect = as_axis_rect(&baked).is_some();
+ self.nodes.push(ClipNode {
+ path: baked,
+ bounds,
+ parent,
+ is_rect,
+ });
+ let id = ClipId(self.nodes.len() as u32 - 1);
+ self.stack.push(id);
+ }
+
+ /// Leave the innermost clip. Unbalanced pops are ignored — the index has
+ /// no warning channel, and a malformed scene should not panic a hover.
+ pub(crate) fn pop(&mut self) {
+ self.stack.pop();
+ }
+
+ /// Forget every clip. Called at the frame boundary.
+ pub(crate) fn clear(&mut self) {
+ self.nodes.clear();
+ self.stack.clear();
+ }
+
+ fn parent_bounds(&self, parent: ClipId) -> Option {
+ (!parent.is_none()).then(|| self.nodes[parent.0 as usize].bounds)
+ }
+
+ /// Whether `p` survives `clip` and every clip enclosing it.
+ ///
+ /// `memo` carries verdicts within one query: a clip shared by thousands
+ /// of candidates — the panel clip over a dense scatter — is evaluated
+ /// once. The caller clears it per query.
+ pub(crate) fn allows(&self, clip: ClipId, p: Point, memo: &mut Vec<(ClipId, bool)>) -> bool {
+ let mut id = clip;
+ // Ancestors visited on the way up, so each gets its own memo entry
+ // rather than only the node we were asked about.
+ let mark = memo.len();
+ while !id.is_none() {
+ if let Some(&(_, verdict)) = memo.iter().find(|&&(k, _)| k == id) {
+ if !verdict {
+ Self::memo_all(memo, mark, false);
+ }
+ return verdict;
+ }
+ let node = &self.nodes[id.0 as usize];
+ let inside = node.bounds.contains(p) && (node.is_rect || node.path.contains(p));
+ if !inside {
+ memo.push((id, false));
+ Self::memo_all(memo, mark, false);
+ return false;
+ }
+ memo.push((id, true));
+ id = node.parent;
+ }
+ true
+ }
+
+ /// Overwrite verdicts recorded during this walk. A rejection anywhere up
+ /// the chain rejects every descendant we passed through.
+ fn memo_all(memo: &mut [(ClipId, bool)], from: usize, verdict: bool) {
+ for slot in &mut memo[from..] {
+ slot.1 = verdict;
+ }
+ }
+}
+
+/// The rectangle a path describes, if it describes one exactly.
+///
+/// Recognises the `MoveTo` + three or four `LineTo` (+ optional `ClosePath`)
+/// shape that rect constructors emit, with all edges axis-aligned. Bar charts
+/// build a fresh path per row, so catching this keeps them from interning
+/// hundreds of thousands of near-identical paths.
+pub(crate) fn as_axis_rect(path: &Path) -> Option {
+ use crate::geometry::PathEl;
+ // A fixed buffer, not a `Vec`: this runs on every fill, and one heap
+ // allocation per mark is a real cost at scatter densities.
+ let mut pts = [Point::ZERO; 5];
+ let mut n = 0usize;
+ for el in path.elements() {
+ match el {
+ PathEl::MoveTo(p) => {
+ if n != 0 {
+ return None; // more than one subpath
+ }
+ pts[0] = *p;
+ n = 1;
+ }
+ PathEl::LineTo(p) => {
+ if n == 0 || n >= 5 {
+ return None;
+ }
+ pts[n] = *p;
+ n += 1;
+ }
+ PathEl::ClosePath => {}
+ _ => return None, // any curve disqualifies it
+ }
+ }
+ // A closed rect is 4 corners, or 5 with the first repeated.
+ if n == 5 {
+ if !near(pts[4], pts[0]) {
+ return None;
+ }
+ n = 4;
+ }
+ if n != 4 {
+ return None;
+ }
+ let pts = &pts[..4];
+ // An axis-aligned rect uses exactly two x values and two y values, and
+ // its four corners are the four distinct pairings of them.
+ let (mut xs, mut ys): (Vec, Vec) = (
+ pts.iter().map(|p| p.x).collect(),
+ pts.iter().map(|p| p.y).collect(),
+ );
+ xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+ ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+ let (x0, x1) = (xs[0], xs[3]);
+ let (y0, y1) = (ys[0], ys[3]);
+ // Two of each value, so the middle pair must match the outer ones.
+ if (xs[1] - x0).abs() > EPS || (xs[2] - x1).abs() > EPS {
+ return None;
+ }
+ if (ys[1] - y0).abs() > EPS || (ys[2] - y1).abs() > EPS {
+ return None;
+ }
+ // Each corner distinct: a degenerate or zigzag quad repeats one.
+ for i in 0..4 {
+ for j in i + 1..4 {
+ if near(pts[i], pts[j]) {
+ return None;
+ }
+ }
+ }
+ Some(Rect::new(x0, y0, x1, y1))
+}
+
+const EPS: f64 = 1e-9;
+
+fn near(a: Point, b: Point) -> bool {
+ (a.x - b.x).abs() <= EPS && (a.y - b.y).abs() <= EPS
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::primitives;
+
+ fn rect_path(r: Rect) -> Path {
+ primitives::rect(r)
+ }
+
+ #[test]
+ fn a_rect_path_is_recognised_and_a_curved_one_is_not() {
+ let r = Rect::new(10.0, 20.0, 110.0, 220.0);
+ let got = as_axis_rect(&rect_path(r)).expect("rect not recognised");
+ assert!((got.x0 - r.x0).abs() < 1e-9 && (got.y0 - r.y0).abs() < 1e-9);
+ assert!((got.x1 - r.x1).abs() < 1e-9 && (got.y1 - r.y1).abs() < 1e-9);
+
+ // A rounded rect has curves, so it is not the fast path.
+ assert!(as_axis_rect(&primitives::rounded_rect(r, 8.0)).is_none());
+ assert!(as_axis_rect(&primitives::circle(Point::new(0.0, 0.0), 5.0)).is_none());
+ }
+
+ #[test]
+ fn a_rotated_or_degenerate_quad_is_not_an_axis_rect() {
+ // Diamond: four corners, no axis-aligned edge.
+ let mut d = Path::new();
+ d.move_to((10.0, 0.0));
+ d.line_to((20.0, 10.0));
+ d.line_to((10.0, 20.0));
+ d.line_to((0.0, 10.0));
+ d.close_path();
+ assert!(as_axis_rect(&d).is_none());
+
+ // Zero-width: two corners coincide with two others.
+ let flat = rect_path(Rect::new(5.0, 5.0, 5.0, 20.0));
+ assert!(as_axis_rect(&flat).is_none());
+
+ // Two subpaths is not one rect.
+ let mut two = rect_path(Rect::new(0.0, 0.0, 1.0, 1.0));
+ two.move_to((5.0, 5.0));
+ two.line_to((6.0, 6.0));
+ assert!(as_axis_rect(&two).is_none());
+ }
+
+ #[test]
+ fn a_clip_shrinks_to_the_intersection_of_its_ancestors() {
+ let mut cs = ClipStack::default();
+ assert!(cs.current().is_none());
+ assert_eq!(cs.current_bounds(), None);
+
+ cs.push(
+ Affine::IDENTITY,
+ &rect_path(Rect::new(0.0, 0.0, 100.0, 100.0)),
+ );
+ assert_eq!(cs.current_bounds(), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
+
+ cs.push(
+ Affine::IDENTITY,
+ &rect_path(Rect::new(50.0, 50.0, 200.0, 200.0)),
+ );
+ // Cumulative, not just the innermost.
+ assert_eq!(
+ cs.current_bounds(),
+ Some(Rect::new(50.0, 50.0, 100.0, 100.0))
+ );
+
+ cs.pop();
+ assert_eq!(cs.current_bounds(), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
+ cs.pop();
+ assert!(cs.current().is_none());
+ }
+
+ #[test]
+ fn the_push_transform_is_baked_into_the_clip() {
+ let mut cs = ClipStack::default();
+ cs.push(
+ Affine::translate((1000.0, 0.0)),
+ &rect_path(Rect::new(0.0, 0.0, 10.0, 10.0)),
+ );
+ let b = cs.current_bounds().expect("clipped");
+ assert!((b.x0 - 1000.0).abs() < 1e-9, "got {b:?}");
+ assert!((b.x1 - 1010.0).abs() < 1e-9, "got {b:?}");
+
+ let mut memo = Vec::new();
+ assert!(cs.allows(cs.current(), Point::new(1005.0, 5.0), &mut memo));
+ memo.clear();
+ assert!(!cs.allows(cs.current(), Point::new(5.0, 5.0), &mut memo));
+ }
+
+ #[test]
+ fn an_arbitrary_curved_clip_is_tested_exactly() {
+ let mut cs = ClipStack::default();
+ // A circle of radius 50 at (50, 50): the rect corner is outside it.
+ cs.push(
+ Affine::IDENTITY,
+ &primitives::circle(Point::new(50.0, 50.0), 50.0),
+ );
+ let id = cs.current();
+ let mut memo = Vec::new();
+
+ assert!(cs.allows(id, Point::new(50.0, 50.0), &mut memo));
+ memo.clear();
+ // Inside the bounding box, outside the circle — only an exact test
+ // rejects this, which is the whole point.
+ assert!(!cs.allows(id, Point::new(2.0, 2.0), &mut memo));
+ }
+
+ #[test]
+ fn an_empty_clip_path_pushes_the_enclosing_clip_unchanged() {
+ let mut cs = ClipStack::default();
+ cs.push(
+ Affine::IDENTITY,
+ &rect_path(Rect::new(0.0, 0.0, 10.0, 10.0)),
+ );
+ let outer = cs.current();
+ cs.push(Affine::IDENTITY, &Path::new());
+ assert_eq!(cs.current(), outer);
+ assert_eq!(cs.current_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
+ cs.pop();
+ assert_eq!(cs.current(), outer);
+ }
+
+ #[test]
+ fn a_rejection_anywhere_up_the_chain_rejects_the_leaf() {
+ let mut cs = ClipStack::default();
+ cs.push(
+ Affine::IDENTITY,
+ &rect_path(Rect::new(0.0, 0.0, 20.0, 20.0)),
+ );
+ cs.push(
+ Affine::IDENTITY,
+ &rect_path(Rect::new(0.0, 0.0, 100.0, 100.0)),
+ );
+ let inner = cs.current();
+ let mut memo = Vec::new();
+
+ // Inside the inner clip but outside the outer one.
+ assert!(!cs.allows(inner, Point::new(50.0, 50.0), &mut memo));
+ // Both the leaf and its ancestor are memoized as rejecting, so a
+ // second candidate under the same clip costs a lookup, not a test.
+ assert!(memo.iter().all(|&(_, v)| !v), "memo = {memo:?}");
+ assert!(!cs.allows(inner, Point::new(50.0, 50.0), &mut memo));
+ }
+
+ #[test]
+ fn the_memo_answers_repeated_candidates_under_one_clip() {
+ let mut cs = ClipStack::default();
+ cs.push(
+ Affine::IDENTITY,
+ &primitives::rounded_rect(Rect::new(0.0, 0.0, 100.0, 100.0), 20.0),
+ );
+ let id = cs.current();
+ let mut memo = Vec::new();
+ for _ in 0..1000 {
+ assert!(cs.allows(id, Point::new(50.0, 50.0), &mut memo));
+ }
+ // One clip touched, so one entry however many candidates asked.
+ assert_eq!(memo.len(), 1);
+ }
+
+ #[test]
+ fn an_unbalanced_pop_does_not_panic() {
+ let mut cs = ClipStack::default();
+ cs.pop();
+ cs.pop();
+ assert!(cs.current().is_none());
+ // And the stack still works afterwards.
+ cs.push(Affine::IDENTITY, &rect_path(Rect::new(0.0, 0.0, 1.0, 1.0)));
+ assert!(!cs.current().is_none());
+ }
+
+ #[test]
+ fn clear_drops_the_arena_and_the_stack() {
+ let mut cs = ClipStack::default();
+ cs.push(Affine::IDENTITY, &rect_path(Rect::new(0.0, 0.0, 1.0, 1.0)));
+ cs.clear();
+ assert!(cs.current().is_none());
+ assert_eq!(cs.current_bounds(), None);
+ }
+}
diff --git a/src/pick/geom.rs b/src/pick/geom.rs
new file mode 100644
index 0000000..e076c4a
--- /dev/null
+++ b/src/pick/geom.rs
@@ -0,0 +1,706 @@
+//! Per-primitive hit geometry: what a recorded primitive stores, and the
+//! exact test that decides whether a point is inside it.
+//!
+//! Geometry is kept in the primitive's **own** coordinate frame and shared
+//! between primitives that draw the same shape; the query point is pushed
+//! through the primitive's inverse transform instead. That is what makes a
+//! hundred thousand scatter markers cost one stored path — the plot layer
+//! draws them all from one `ShapeRegistry` entry, varying only the
+//! transform.
+
+use std::collections::HashMap;
+use std::hash::{BuildHasherDefault, Hasher};
+
+use crate::geometry::{flatten, Affine, PathEl, Point, Rect, Shape};
+
+use crate::mesh::Mesh;
+use crate::path::{FillRule, Path};
+
+/// Points per stroke chunk, and triangles per mesh chunk.
+///
+/// Chunking is what keeps a long primitive from degenerating into a linear
+/// scan: a ten-thousand-point line is one primitive with a panel-sized
+/// bounding box, so without it every hover inside the panel would walk the
+/// whole polyline. Split, it becomes many entries with tight boxes that the
+/// tree prunes to one or two.
+const CHUNK: usize = 64;
+
+/// Paths longer than this are not interned. Hashing them would cost more
+/// than the copy saves, and they do not repeat the way markers do.
+const INTERN_MAX_ELEMENTS: usize = 64;
+
+/// Half-width floor, in device pixels, for stroke hit testing.
+///
+/// A hairline is one pixel of ink and impossible to hit exactly, so it gets
+/// a pick target a pixel wide either side. The intent the old pick pass met
+/// by widening the stroke it rasterised, without distorting anything drawn.
+const MIN_HIT_HALF_WIDTH_PX: f64 = 1.0;
+
+/// Miter joins can push a stroke past `half_width` from the centreline; this
+/// bounds how far a chunk's box is grown to allow for it.
+const MITER_BBOX_LIMIT: f64 = 4.0;
+
+/// A stored path: where its elements live, and its bounds.
+///
+/// The bounds are cached because computing them is not cheap — a tight box
+/// around a cubic means solving for the curve's extrema — and every mark
+/// sharing a marker shape would otherwise recompute the same answer.
+#[derive(Debug, Clone, Copy)]
+struct PathSlot {
+ start: u32,
+ len: u32,
+ bbox: Rect,
+}
+
+/// Handle into the shared path arena.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct ShapeId(u32);
+
+/// Handle into the shared flattened-polyline arena.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct PolyId(u32);
+
+/// What a recorded primitive is, for the purpose of testing a point against
+/// it. Every variant is tested in the primitive's local frame.
+#[derive(Debug, Clone, Copy)]
+pub(crate) enum Geom {
+ /// The entry's local bounds are the exact answer: an axis-aligned
+ /// rectangular fill, an image quad, a glyph run's layout box.
+ Box,
+ Fill {
+ shape: ShapeId,
+ even_odd: bool,
+ },
+ Stroke {
+ poly: PolyId,
+ first: u32,
+ count: u32,
+ half_width: f64,
+ },
+ Tris {
+ first: u32,
+ count: u32,
+ },
+}
+
+/// Shared storage for everything the exact tests read.
+///
+/// Paths live in one flat arena of elements rather than as a `Vec`:
+/// a scene that draws a hundred thousand *distinct* paths — a low-level
+/// caller placing each mark in absolute coordinates rather than reusing one
+/// marker under a transform — would otherwise pay a hundred thousand
+/// allocations. Slices of the arena are hit-tested directly, since kurbo
+/// implements `Shape` for `&[PathEl]`.
+#[derive(Debug, Default)]
+pub(crate) struct GeomStore {
+ path_els: Vec,
+ /// One slot per [`ShapeId`], indexing `path_els`.
+ path_ranges: Vec,
+ /// Content hash → shapes sharing it. Collisions are resolved by
+ /// comparing the elements outright, and the key is already a hash, so
+ /// hashing it again would be pure cost — hence [`PassThrough`].
+ intern: HashMap, BuildHasherDefault>,
+ /// Flattened polylines, one entry per `(shape, tolerance)` pair.
+ polys: Vec>,
+ poly_cache: HashMap<(u32, i32), u32>,
+ /// Triangles in their mesh's own frame.
+ tris: Vec<[Point; 3]>,
+}
+
+impl GeomStore {
+ /// Drop everything a frame accumulated, keeping the allocations.
+ pub(crate) fn clear(&mut self) {
+ self.path_els.clear();
+ self.path_ranges.clear();
+ self.intern.clear();
+ self.polys.clear();
+ self.poly_cache.clear();
+ self.tris.clear();
+ }
+
+ /// Store `path`, reusing an identical one already held this frame.
+ pub(crate) fn intern_path(&mut self, path: &Path) -> ShapeId {
+ let els = path.elements();
+ // Long paths are stored without hashing: the hash would cost more
+ // than the lookup saves, and they do not repeat the way markers do.
+ if els.len() <= INTERN_MAX_ELEMENTS {
+ let key = hash_path(els);
+ if let Some(bucket) = self.intern.get(&key) {
+ for &id in bucket {
+ let slot = self.path_ranges[id as usize];
+ if &self.path_els[slot.start as usize..(slot.start + slot.len) as usize] == els
+ {
+ return ShapeId(id);
+ }
+ }
+ }
+ let id = self.push_els(els);
+ self.intern.entry(key).or_default().push(id.0);
+ return id;
+ }
+ self.push_els(els)
+ }
+
+ fn push_els(&mut self, els: &[PathEl]) -> ShapeId {
+ let start = self.path_els.len() as u32;
+ self.path_els.extend_from_slice(els);
+ self.path_ranges.push(PathSlot {
+ start,
+ len: els.len() as u32,
+ bbox: els.bounding_box(),
+ });
+ ShapeId(self.path_ranges.len() as u32 - 1)
+ }
+
+ /// Borrow a stored path's elements.
+ pub(crate) fn path(&self, id: ShapeId) -> &[PathEl] {
+ let slot = self.path_ranges[id.0 as usize];
+ &self.path_els[slot.start as usize..(slot.start + slot.len) as usize]
+ }
+
+ /// A stored path's bounds, computed once when it was stored.
+ pub(crate) fn path_bounds(&self, id: ShapeId) -> Rect {
+ self.path_ranges[id.0 as usize].bbox
+ }
+
+ /// Flatten a stored path at `tolerance`, reusing an earlier flattening
+ /// at the same tolerance bucket. Returns the polyline plus the ranges
+ /// that must not be joined across — one per subpath.
+ pub(crate) fn flatten_path(
+ &mut self,
+ id: ShapeId,
+ tolerance: f64,
+ ) -> (PolyId, Vec<(u32, u32)>) {
+ let bucket = tolerance_bucket(tolerance);
+ let key = (id.0, bucket);
+ if let Some(&existing) = self.poly_cache.get(&key) {
+ let runs = subpath_runs(&self.polys[existing as usize]);
+ return (PolyId(existing), runs);
+ }
+ let mut pts: Vec = Vec::new();
+ let slot = self.path_ranges[id.0 as usize];
+ let els: Vec =
+ self.path_els[slot.start as usize..(slot.start + slot.len) as usize].to_vec();
+ // `f64::NAN` marks a subpath break, so one flat buffer can hold a
+ // path with holes without a parallel index.
+ flatten(els.iter().copied(), tolerance.max(1e-6), |el| {
+ match el {
+ PathEl::MoveTo(p) => {
+ if !pts.is_empty() {
+ pts.push(BREAK);
+ }
+ pts.push(p);
+ }
+ PathEl::LineTo(p) => pts.push(p),
+ PathEl::ClosePath => {
+ // Close the ring so the last edge is testable.
+ if let Some(ring_start) = last_subpath_start(&pts) {
+ pts.push(ring_start);
+ }
+ }
+ _ => {}
+ }
+ });
+ self.polys.push(pts);
+ let poly = self.polys.len() as u32 - 1;
+ self.poly_cache.insert(key, poly);
+ let runs = subpath_runs(&self.polys[poly as usize]);
+ (PolyId(poly), runs)
+ }
+
+ /// Borrow a stored polyline.
+ pub(crate) fn poly(&self, id: PolyId) -> &[Point] {
+ &self.polys[id.0 as usize]
+ }
+
+ /// Append a mesh's triangles, returning the range they occupy.
+ pub(crate) fn push_triangles(&mut self, mesh: &Mesh) -> (u32, u32) {
+ let first = self.tris.len() as u32;
+ for tri in mesh.indices.chunks_exact(3) {
+ let (a, b, c) = (
+ mesh.vertices[tri[0] as usize],
+ mesh.vertices[tri[1] as usize],
+ mesh.vertices[tri[2] as usize],
+ );
+ self.tris.push([a, b, c]);
+ }
+ (first, self.tris.len() as u32 - first)
+ }
+
+ /// Borrow a range of triangles.
+ pub(crate) fn triangles(&self, first: u32, count: u32) -> &[[Point; 3]] {
+ &self.tris[first as usize..(first + count) as usize]
+ }
+
+ /// Whether `p`, already in the primitive's local frame, is inside it.
+ ///
+ /// `local` is the entry's own bounds and has already been tested by the
+ /// caller, so this is only the part the bounds cannot answer.
+ pub(crate) fn contains(&self, geom: &Geom, local: Rect, p: Point) -> bool {
+ match *geom {
+ Geom::Box => true,
+ Geom::Fill { shape, even_odd } => {
+ let w = self.path(shape).winding(p);
+ if even_odd {
+ w % 2 != 0
+ } else {
+ w != 0
+ }
+ }
+ Geom::Stroke {
+ poly,
+ first,
+ count,
+ half_width,
+ } => {
+ let pts = &self.poly(poly)[first as usize..(first + count) as usize];
+ let limit = half_width * half_width;
+ pts.windows(2).any(|w| {
+ !is_break(w[0]) && !is_break(w[1]) && dist_sq_to_segment(p, w[0], w[1]) <= limit
+ })
+ }
+ Geom::Tris { first, count } => {
+ let _ = local;
+ self.triangles(first, count)
+ .iter()
+ .any(|t| point_in_triangle(p, t[0], t[1], t[2]))
+ }
+ }
+ }
+}
+
+/// A hasher for keys that are already hashes.
+///
+/// The intern table is keyed by a content hash of a path's elements, so the
+/// default SipHash would be a second hash over a good one. Only ever fed a
+/// single `u64`; anything else would defeat it, so `write` says so.
+#[derive(Default)]
+pub(crate) struct PassThrough(u64);
+
+impl Hasher for PassThrough {
+ fn finish(&self) -> u64 {
+ self.0
+ }
+
+ fn write(&mut self, _bytes: &[u8]) {
+ unreachable!("PassThrough only accepts a single u64 key");
+ }
+
+ fn write_u64(&mut self, n: u64) {
+ self.0 = n;
+ }
+}
+
+/// Sentinel separating subpaths inside a flattened polyline.
+const BREAK: Point = Point {
+ x: f64::NAN,
+ y: f64::NAN,
+};
+
+fn is_break(p: Point) -> bool {
+ p.x.is_nan()
+}
+
+fn last_subpath_start(pts: &[Point]) -> Option {
+ pts.iter()
+ .rposition(|p| is_break(*p))
+ .map_or_else(|| pts.first().copied(), |i| pts.get(i + 1).copied())
+}
+
+/// Contiguous `[start, end)` ranges of a polyline that contain no break.
+fn subpath_runs(pts: &[Point]) -> Vec<(u32, u32)> {
+ let mut runs = Vec::new();
+ let mut start = 0usize;
+ for (i, p) in pts.iter().enumerate() {
+ if is_break(*p) {
+ if i > start {
+ runs.push((start as u32, i as u32));
+ }
+ start = i + 1;
+ }
+ }
+ if pts.len() > start {
+ runs.push((start as u32, pts.len() as u32));
+ }
+ runs
+}
+
+/// Split `[start, end)` into overlapping chunks of at most [`CHUNK`] points.
+///
+/// Chunks share an endpoint so the segment spanning a boundary is tested by
+/// exactly one of them rather than falling between the two.
+pub(crate) fn chunk_run(start: u32, end: u32) -> Vec<(u32, u32)> {
+ let mut out = Vec::new();
+ if end <= start + 1 {
+ return out;
+ }
+ let mut a = start;
+ while a + 1 < end {
+ let b = (a + CHUNK as u32).min(end);
+ out.push((a, b - a));
+ if b >= end {
+ break;
+ }
+ a = b - 1;
+ }
+ out
+}
+
+/// Split a mesh's triangle range into chunks of at most [`CHUNK`].
+pub(crate) fn chunk_triangles(first: u32, count: u32) -> Vec<(u32, u32)> {
+ (0..count)
+ .step_by(CHUNK)
+ .map(|off| (first + off, CHUNK.min((count - off) as usize) as u32))
+ .collect()
+}
+
+/// The pick half-width for a stroke of `width` under `transform`.
+///
+/// Widths are local, the floor is in device pixels, so the floor is divided
+/// back through the transform's mean scale to land in the same frame.
+pub(crate) fn hit_half_width(width: f64, transform: Affine) -> f64 {
+ let scale = mean_scale(transform);
+ let floor = if scale > 0.0 {
+ MIN_HIT_HALF_WIDTH_PX / scale
+ } else {
+ MIN_HIT_HALF_WIDTH_PX
+ };
+ (width * 0.5).max(floor)
+}
+
+/// How much a chunk's bounds must grow to contain the stroke around it.
+pub(crate) fn stroke_outset(half_width: f64, miter_limit: f64) -> f64 {
+ half_width * miter_limit.clamp(1.0, MITER_BBOX_LIMIT)
+}
+
+/// Geometric mean of the transform's axis scales — the factor a length in
+/// local units is multiplied by on its way to device space.
+pub(crate) fn mean_scale(t: Affine) -> f64 {
+ t.determinant().abs().sqrt()
+}
+
+/// Bounds of a run of points, ignoring subpath breaks.
+pub(crate) fn points_bounds(pts: &[Point]) -> Option {
+ let mut acc: Option = None;
+ for p in pts.iter().filter(|p| !is_break(**p)) {
+ let r = Rect::new(p.x, p.y, p.x, p.y);
+ acc = Some(match acc {
+ Some(a) => a.union(r),
+ None => r,
+ });
+ }
+ acc
+}
+
+/// Bounds of a run of triangles.
+pub(crate) fn triangles_bounds(tris: &[[Point; 3]]) -> Option {
+ let mut acc: Option = None;
+ for t in tris {
+ for p in t {
+ let r = Rect::new(p.x, p.y, p.x, p.y);
+ acc = Some(match acc {
+ Some(a) => a.union(r),
+ None => r,
+ });
+ }
+ }
+ acc
+}
+
+/// Whether a fill rule wants even-odd winding.
+pub(crate) fn is_even_odd(rule: FillRule) -> bool {
+ matches!(rule, FillRule::EvenOdd)
+}
+
+/// Bucket a tolerance so nearby values share one flattening.
+fn tolerance_bucket(tolerance: f64) -> i32 {
+ if !tolerance.is_finite() || tolerance <= 0.0 {
+ return i32::MIN;
+ }
+ (tolerance.log2() * 4.0).round() as i32
+}
+
+fn hash_path(els: &[PathEl]) -> u64 {
+ // FNV-1a over the element bit patterns. Only a bucket key — equality is
+ // still checked outright — so speed matters more than distribution.
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut feed = |bits: u64| {
+ h ^= bits;
+ h = h.wrapping_mul(0x1000_0000_01b3);
+ };
+ for el in els {
+ let (tag, pts): (u64, &[Point]) = match el {
+ PathEl::MoveTo(p) => (1, std::slice::from_ref(p)),
+ PathEl::LineTo(p) => (2, std::slice::from_ref(p)),
+ PathEl::QuadTo(a, _) => (3, std::slice::from_ref(a)),
+ PathEl::CurveTo(a, _, _) => (4, std::slice::from_ref(a)),
+ PathEl::ClosePath => (5, &[]),
+ };
+ feed(tag);
+ for p in pts {
+ feed(p.x.to_bits());
+ feed(p.y.to_bits());
+ }
+ }
+ feed(els.len() as u64);
+ h
+}
+
+fn dist_sq_to_segment(p: Point, a: Point, b: Point) -> f64 {
+ let (dx, dy) = (b.x - a.x, b.y - a.y);
+ let len_sq = dx * dx + dy * dy;
+ if len_sq <= f64::EPSILON {
+ let (ex, ey) = (p.x - a.x, p.y - a.y);
+ return ex * ex + ey * ey;
+ }
+ let t = (((p.x - a.x) * dx + (p.y - a.y) * dy) / len_sq).clamp(0.0, 1.0);
+ let (cx, cy) = (a.x + t * dx, a.y + t * dy);
+ let (ex, ey) = (p.x - cx, p.y - cy);
+ ex * ex + ey * ey
+}
+
+fn point_in_triangle(p: Point, a: Point, b: Point, c: Point) -> bool {
+ let d1 = cross(p, a, b);
+ let d2 = cross(p, b, c);
+ let d3 = cross(p, c, a);
+ let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
+ let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
+ !(neg && pos)
+}
+
+fn cross(p: Point, a: Point, b: Point) -> f64 {
+ (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::primitives;
+
+ fn store() -> GeomStore {
+ GeomStore::default()
+ }
+
+ #[test]
+ fn identical_paths_share_one_stored_shape() {
+ let mut s = store();
+ let a = primitives::circle(Point::new(0.0, 0.0), 3.0);
+ let b = primitives::circle(Point::new(0.0, 0.0), 3.0);
+ let c = primitives::circle(Point::new(0.0, 0.0), 4.0);
+
+ let ia = s.intern_path(&a);
+ let ib = s.intern_path(&b);
+ let ic = s.intern_path(&c);
+ assert_eq!(ia, ib, "identical paths must intern to one shape");
+ assert_ne!(ia, ic);
+ assert_eq!(s.path_ranges.len(), 2);
+
+ // Repeating the same shape ten thousand times stores it once — the
+ // scatter-marker case the whole scheme exists for.
+ for _ in 0..10_000 {
+ assert_eq!(s.intern_path(&a), ia);
+ }
+ assert_eq!(s.path_ranges.len(), 2);
+ }
+
+ #[test]
+ fn a_long_path_is_stored_without_interning() {
+ let mut s = store();
+ let mut long = Path::new();
+ long.move_to((0.0, 0.0));
+ for i in 1..(INTERN_MAX_ELEMENTS + 10) {
+ long.line_to((i as f64, 0.0));
+ }
+ let first = s.intern_path(&long);
+ let second = s.intern_path(&long);
+ // Two copies: hashing a path this size costs more than it saves.
+ assert_ne!(first, second);
+ }
+
+ #[test]
+ fn clear_drops_everything_a_frame_accumulated() {
+ let mut s = store();
+ let p = primitives::circle(Point::new(0.0, 0.0), 3.0);
+ let id = s.intern_path(&p);
+ s.flatten_path(id, 0.25);
+ s.clear();
+ assert!(s.polys.is_empty());
+ assert!(s.path_els.is_empty());
+ assert!(s.path_ranges.is_empty());
+ // Interning starts over, which costs a handful of hashes: the
+ // distinct *shapes* in a frame are few even when the marks are many.
+ assert_eq!(s.intern_path(&p), ShapeId(0));
+ }
+
+ #[test]
+ fn flattening_closes_rings_so_the_last_edge_is_testable() {
+ let mut s = store();
+ let sq = primitives::rect(Rect::new(0.0, 0.0, 10.0, 10.0));
+ let id = s.intern_path(&sq);
+ let (poly, runs) = s.flatten_path(id, 0.1);
+ assert_eq!(runs.len(), 1, "one subpath");
+ let pts = s.poly(poly);
+ assert!(
+ pts.len() >= 5,
+ "closed square needs its first point repeated"
+ );
+ let (first, last) = (pts[0], *pts.last().unwrap());
+ assert!(
+ (first.x - last.x).abs() < 1e-9 && (first.y - last.y).abs() < 1e-9,
+ "ring not closed: {first:?} vs {last:?}"
+ );
+ }
+
+ #[test]
+ fn a_path_with_a_hole_flattens_into_two_runs() {
+ let mut s = store();
+ let mut annulus = primitives::circle(Point::new(0.0, 0.0), 10.0);
+ annulus.extend(primitives::circle(Point::new(0.0, 0.0), 5.0).iter());
+ let id = s.intern_path(&annulus);
+ let (poly, runs) = s.flatten_path(id, 0.1);
+ assert_eq!(runs.len(), 2, "outer ring and hole are separate runs");
+ // Runs never span the break sentinel.
+ for &(a, b) in &runs {
+ for p in &s.poly(poly)[a as usize..b as usize] {
+ assert!(!is_break(*p));
+ }
+ }
+ }
+
+ #[test]
+ fn chunks_share_an_endpoint_so_no_segment_falls_between_them() {
+ // One short run is a single chunk.
+ assert_eq!(chunk_run(0, 5), vec![(0, 5)]);
+ // A single point has no segment at all.
+ assert!(chunk_run(0, 1).is_empty());
+ assert!(chunk_run(7, 7).is_empty());
+
+ // Every segment of a long run is covered exactly once.
+ let n = 300u32;
+ let chunks = chunk_run(0, n);
+ assert!(chunks.len() > 1);
+ let mut covered = vec![0u32; (n - 1) as usize];
+ for &(first, count) in &chunks {
+ assert!(count as usize <= CHUNK);
+ for seg in first..first + count - 1 {
+ covered[seg as usize] += 1;
+ }
+ }
+ assert!(
+ covered.iter().all(|&c| c == 1),
+ "segments covered {:?} times",
+ covered.iter().collect::>()
+ );
+ }
+
+ #[test]
+ fn triangle_chunks_partition_the_range() {
+ let chunks = chunk_triangles(10, 150);
+ assert_eq!(chunks.iter().map(|&(_, c)| c).sum::(), 150);
+ assert_eq!(chunks[0].0, 10);
+ for &(_, c) in &chunks {
+ assert!(c as usize <= CHUNK);
+ }
+ assert!(chunk_triangles(0, 0).is_empty());
+ }
+
+ #[test]
+ fn a_hairline_still_gets_a_pixel_of_pick_target() {
+ // Under an identity transform the floor is in device units already.
+ assert_eq!(hit_half_width(0.1, Affine::IDENTITY), MIN_HIT_HALF_WIDTH_PX);
+ // A wide stroke keeps its own half-width.
+ assert_eq!(hit_half_width(10.0, Affine::IDENTITY), 5.0);
+ // Under a 10x scale, one device pixel is a tenth of a local unit.
+ let scaled = hit_half_width(0.01, Affine::scale(10.0));
+ assert!((scaled - 0.1).abs() < 1e-12, "got {scaled}");
+ // A singular transform must not divide by zero.
+ assert!(hit_half_width(0.0, Affine::scale(0.0)).is_finite());
+ }
+
+ #[test]
+ fn fill_winding_honours_the_rule_over_a_hole() {
+ let mut s = store();
+ let mut annulus = primitives::circle(Point::new(0.0, 0.0), 10.0);
+ annulus.extend(primitives::circle(Point::new(0.0, 0.0), 5.0).iter());
+ let shape = s.intern_path(&annulus);
+ let local = s.path_bounds(shape);
+
+ let in_ring = Point::new(7.5, 0.0);
+ let in_hole = Point::new(0.0, 0.0);
+
+ let even_odd = Geom::Fill {
+ shape,
+ even_odd: true,
+ };
+ assert!(s.contains(&even_odd, local, in_ring));
+ assert!(
+ !s.contains(&even_odd, local, in_hole),
+ "even-odd must see the hole"
+ );
+
+ // Both rings wind the same way here, so nonzero fills the hole in.
+ let nonzero = Geom::Fill {
+ shape,
+ even_odd: false,
+ };
+ assert!(s.contains(&nonzero, local, in_ring));
+ assert!(s.contains(&nonzero, local, in_hole));
+ }
+
+ #[test]
+ fn a_stroke_is_hit_within_its_half_width_and_missed_outside_it() {
+ let mut s = store();
+ let mut line = Path::new();
+ line.move_to((0.0, 0.0));
+ line.line_to((100.0, 0.0));
+ let shape = s.intern_path(&line);
+ let (poly, runs) = s.flatten_path(shape, 0.1);
+ let (first, count) = (runs[0].0, runs[0].1 - runs[0].0);
+ let geom = Geom::Stroke {
+ poly,
+ first,
+ count,
+ half_width: 4.0,
+ };
+ let local = Rect::new(0.0, -4.0, 100.0, 4.0);
+
+ assert!(s.contains(&geom, local, Point::new(50.0, 0.0)));
+ assert!(s.contains(&geom, local, Point::new(50.0, 3.99)));
+ assert!(!s.contains(&geom, local, Point::new(50.0, 4.01)));
+ // Past the end cap, distance is to the endpoint, not the infinite line.
+ assert!(s.contains(&geom, local, Point::new(102.0, 0.0)));
+ assert!(!s.contains(&geom, local, Point::new(105.0, 0.0)));
+ }
+
+ #[test]
+ fn a_mesh_is_hit_inside_a_triangle_and_missed_between_them() {
+ use crate::color::rgb8;
+ let mut s = store();
+ let mesh = Mesh::new(
+ vec![
+ Point::new(0.0, 0.0),
+ Point::new(10.0, 0.0),
+ Point::new(0.0, 10.0),
+ ],
+ vec![rgb8(0, 0, 0); 3],
+ vec![0, 1, 2],
+ );
+ let (first, count) = s.push_triangles(&mesh);
+ assert_eq!(count, 1);
+ let geom = Geom::Tris { first, count };
+ let local = Rect::new(0.0, 0.0, 10.0, 10.0);
+
+ assert!(s.contains(&geom, local, Point::new(1.0, 1.0)));
+ // Inside the bounding box, outside the triangle.
+ assert!(!s.contains(&geom, local, Point::new(9.0, 9.0)));
+ }
+
+ #[test]
+ fn bounds_helpers_skip_breaks_and_report_none_when_empty() {
+ assert_eq!(points_bounds(&[]), None);
+ assert_eq!(points_bounds(&[BREAK]), None);
+ let b = points_bounds(&[Point::new(1.0, 2.0), BREAK, Point::new(5.0, 0.0)]).unwrap();
+ assert_eq!(b, Rect::new(1.0, 0.0, 5.0, 2.0));
+ assert_eq!(triangles_bounds(&[]), None);
+ }
+}
diff --git a/src/pick/hilbert.rs b/src/pick/hilbert.rs
new file mode 100644
index 0000000..4913077
--- /dev/null
+++ b/src/pick/hilbert.rs
@@ -0,0 +1,116 @@
+//! The Hilbert d-index, used to order leaves before an R-tree is packed.
+//!
+//! A Hilbert curve visits every cell of a 2^order × 2^order grid once, and
+//! cells close together on the curve are close together in the plane. Sorting
+//! leaves by the curve position of their centre is what makes consecutive
+//! runs of leaves — which is what a packed node is — occupy a compact region,
+//! and so what makes a query prune well.
+
+/// Bits per axis. Inputs are quantised to `0..=65535` before hashing.
+pub(crate) const ORDER_BITS: u32 = 16;
+
+/// Position along the Hilbert curve of grid cell `(x, y)`.
+///
+/// Both coordinates are treated as 16-bit; anything above `0xFFFF` is
+/// clamped. The result fits in 32 bits, which is why the tree can sort on a
+/// plain `u32` key.
+pub(crate) fn hilbert_d(x: u32, y: u32) -> u32 {
+ let mut x = x.min(0xFFFF);
+ let mut y = y.min(0xFFFF);
+ let mut d: u32 = 0;
+ let mut s: u32 = 1 << (ORDER_BITS - 1);
+ while s > 0 {
+ let rx = u32::from(x & s > 0);
+ let ry = u32::from(y & s > 0);
+ d += s * s * ((3 * rx) ^ ry);
+ // Rotate the quadrant so the curve stays continuous across it.
+ if ry == 0 {
+ if rx == 1 {
+ x = s.wrapping_sub(1).wrapping_sub(x);
+ y = s.wrapping_sub(1).wrapping_sub(y);
+ }
+ std::mem::swap(&mut x, &mut y);
+ }
+ s /= 2;
+ }
+ d
+}
+
+/// Quantise `v` from `[min, min + span]` onto the `0..=65535` grid the curve
+/// is defined over. A non-positive or non-finite span collapses to `0`, which
+/// is what a degenerate axis should do: every leaf lands in the same column
+/// and the other axis does the ordering.
+pub(crate) fn quantise(v: f64, min: f64, span: f64) -> u32 {
+ if !span.is_finite() || span <= 0.0 {
+ return 0;
+ }
+ let t = ((v - min) / span * 65535.0).round();
+ if t.is_nan() {
+ 0
+ } else {
+ t.clamp(0.0, 65535.0) as u32
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::HashSet;
+
+ /// The defining property: over a full grid the curve is a bijection.
+ #[test]
+ fn the_curve_visits_every_cell_of_a_small_grid_exactly_once() {
+ // 5 bits' worth of the 16-bit curve, sampled on its own stride.
+ let step = 1u32 << (ORDER_BITS - 5);
+ let mut seen = HashSet::new();
+ for gx in 0..32u32 {
+ for gy in 0..32u32 {
+ assert!(
+ seen.insert(hilbert_d(gx * step, gy * step)),
+ "duplicate index at ({gx}, {gy})"
+ );
+ }
+ }
+ assert_eq!(seen.len(), 32 * 32);
+ }
+
+ /// Locality is the whole reason to use the curve: consecutive positions
+ /// on it are adjacent cells, never a jump across the grid.
+ #[test]
+ fn consecutive_curve_positions_are_adjacent_cells() {
+ let step = 1u32 << (ORDER_BITS - 4);
+ let mut cells: Vec<((u32, u32), u32)> = Vec::new();
+ for gx in 0..16u32 {
+ for gy in 0..16u32 {
+ cells.push(((gx, gy), hilbert_d(gx * step, gy * step)));
+ }
+ }
+ cells.sort_by_key(|&(_, d)| d);
+ for w in cells.windows(2) {
+ let ((ax, ay), _) = w[0];
+ let ((bx, by), _) = w[1];
+ let dist = ax.abs_diff(bx) + ay.abs_diff(by);
+ assert_eq!(dist, 1, "({ax},{ay}) -> ({bx},{by}) is not a step");
+ }
+ }
+
+ #[test]
+ fn quantise_spans_the_grid_and_survives_degenerate_input() {
+ assert_eq!(quantise(0.0, 0.0, 10.0), 0);
+ assert_eq!(quantise(10.0, 0.0, 10.0), 65535);
+ assert_eq!(quantise(5.0, 0.0, 10.0), 32768);
+ // Out of range clamps rather than wrapping.
+ assert_eq!(quantise(-1.0, 0.0, 10.0), 0);
+ assert_eq!(quantise(11.0, 0.0, 10.0), 65535);
+ // Degenerate spans collapse instead of dividing by zero.
+ assert_eq!(quantise(5.0, 5.0, 0.0), 0);
+ assert_eq!(quantise(5.0, 0.0, f64::NAN), 0);
+ assert_eq!(quantise(f64::NAN, 0.0, 10.0), 0);
+ }
+
+ #[test]
+ fn coordinates_past_the_grid_are_clamped_not_wrapped() {
+ assert_eq!(hilbert_d(0x1_0000, 0), hilbert_d(0xFFFF, 0));
+ assert_eq!(hilbert_d(0, u32::MAX), hilbert_d(0, 0xFFFF));
+ }
+}
diff --git a/src/pick/index.rs b/src/pick/index.rs
new file mode 100644
index 0000000..3a0534e
--- /dev/null
+++ b/src/pick/index.rs
@@ -0,0 +1,683 @@
+//! The pick index: what a scene recorded, and the queries that read it.
+//!
+//! Entries are held in draw order, so an entry's position **is** its z
+//! order and nothing has to be sorted at insert. The R-tree over them is
+//! built lazily on the first query after a frame, which is what lets a
+//! window redrawing faster than it is queried never build one at all.
+
+use std::cell::RefCell;
+
+use crate::geometry::{Affine, Point, Rect, Shape};
+
+use crate::brush::Image;
+use crate::mesh::Mesh;
+use crate::path::{FillRule, Path};
+use crate::pick::clip::{as_axis_rect, ClipId, ClipStack};
+use crate::pick::geom::{self, Geom, GeomStore};
+use crate::pick::rtree::{to_bbox, Bbox, HilbertRtree};
+use crate::pick::scope::{PickPath, PickScope, ScopeNode, ScopeTree};
+use crate::pick::PickId;
+use crate::scene::GlyphRun;
+use crate::stroke::Stroke;
+
+/// Em multiples used to synthesize a glyph run's box.
+///
+/// A run arrives as positioned glyph ids with no metrics attached, and the
+/// font that could supply them is not reachable from a module that compiles
+/// with no features at all. These cover a CJK or emoji face rather than
+/// hugging a Latin one, so the box is a generous **layout** box: leading and
+/// side bearings are hittable, which is what a text hit target should be.
+const GLYPH_ASCENT_EM: f64 = 1.0;
+const GLYPH_DESCENT_EM: f64 = 0.3;
+/// Advance allowance for the last glyph when the run carries no source text.
+const GLYPH_TRAILING_EM: f64 = 0.6;
+
+/// One recorded primitive.
+#[derive(Debug)]
+struct Entry {
+ /// Device → local. The exact test runs in the primitive's own frame, so
+ /// shared geometry needs no per-primitive copy.
+ inv: Affine,
+ /// Bounds in that local frame.
+ local: Rect,
+ geom: Geom,
+ pick_id: PickId,
+ clip: ClipId,
+ scope: ScopeNode,
+}
+
+/// A primitive found under a query point or region.
+#[derive(Debug, Clone, Copy)]
+#[non_exhaustive]
+pub struct Hit<'a> {
+ /// The authoring id. [`PickId::Skip`] for chrome, which is a target by
+ /// virtue of its scope rather than by carrying an id.
+ pub pick_id: PickId,
+ /// The scope chain this primitive was drawn inside.
+ pub path: PickPath<'a>,
+ /// Draw order within the frame; higher is nearer the front.
+ pub order: u32,
+ /// Device-space bounds of the primitive — for anchoring a tooltip.
+ pub bounds: Rect,
+}
+
+impl Hit<'_> {
+ /// The authoring id, if this hit carries one. `None` for chrome, which
+ /// is a target by virtue of its scope, and for an occluder.
+ pub fn id(&self) -> Option {
+ match self.pick_id {
+ PickId::Id(n) => Some(n),
+ _ => None,
+ }
+ }
+}
+
+/// Everything a scene recorded for hit testing.
+///
+/// Built by [`PickIndexScene`](crate::pick::PickIndexScene) as a scene is
+/// drawn, then queried by point, rectangle or lasso.
+#[derive(Debug, Default)]
+pub struct PickIndex {
+ entries: Vec,
+ /// Device-space bounds, parallel to `entries`, in tree storage form.
+ leaves: Vec,
+ store: GeomStore,
+ clips: ClipStack,
+ scopes: ScopeTree,
+ tree: RefCell