From 44195e4ad445fe80a0aaa627a891755c4658b6fc Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 2 Sep 2026 12:54:59 +0200 Subject: [PATCH 1/3] Scaffold for R-tree --- src/composition/anatomy.rs | 103 ++++- src/pick/clip.rs | 399 +++++++++++++++++ src/pick/geom.rs | 646 ++++++++++++++++++++++++++++ src/pick/hilbert.rs | 116 +++++ src/pick/index.rs | 668 +++++++++++++++++++++++++++++ src/{pick.rs => pick/mod.rs} | 12 + src/pick/rtree.rs | 375 ++++++++++++++++ src/pick/scene.rs | 196 +++++++++ src/pick/scope.rs | 330 ++++++++++++++ src/plot/chrome/axis.rs | 76 +++- src/plot/chrome/legend/colorbar.rs | 126 ++++-- src/plot/chrome/linear_axis.rs | 105 ++++- src/plot/chrome/polar.rs | 34 +- src/plot/composition.rs | 36 +- src/plot/plot.rs | 24 ++ src/scene/mod.rs | 22 +- tests/pick_index.rs | 540 +++++++++++++++++++++++ 17 files changed, 3694 insertions(+), 114 deletions(-) create mode 100644 src/pick/clip.rs create mode 100644 src/pick/geom.rs create mode 100644 src/pick/hilbert.rs create mode 100644 src/pick/index.rs rename src/{pick.rs => pick/mod.rs} (97%) create mode 100644 src/pick/rtree.rs create mode 100644 src/pick/scene.rs create mode 100644 src/pick/scope.rs create mode 100644 tests/pick_index.rs 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/clip.rs b/src/pick/clip.rs new file mode 100644 index 0000000..7cbd555 --- /dev/null +++ b/src/pick/clip.rs @@ -0,0 +1,399 @@ +//! 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; + let mut pts: Vec = Vec::with_capacity(5); + for el in path.elements() { + match el { + PathEl::MoveTo(p) => { + if !pts.is_empty() { + return None; // more than one subpath + } + pts.push(*p); + } + PathEl::LineTo(p) => { + if pts.is_empty() || pts.len() > 4 { + return None; + } + pts.push(*p); + } + PathEl::ClosePath => {} + _ => return None, // any curve disqualifies it + } + } + // A closed rect is 4 corners, or 5 with the first repeated. + if pts.len() == 5 { + if !near(pts[4], pts[0]) { + return None; + } + pts.pop(); + } + if pts.len() != 4 { + return None; + } + // 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..6c7d43e --- /dev/null +++ b/src/pick/geom.rs @@ -0,0 +1,646 @@ +//! 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 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; + +/// Interning is a frame-to-frame cache; past this many entries it is reset +/// wholesale rather than grown without bound. +const INTERN_MAX_SHAPES: usize = 4096; + +/// 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 hitmap expressed +/// 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; + +/// 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. +#[derive(Debug, Default)] +pub(crate) struct GeomStore { + paths: Vec, + /// Content hash → shapes sharing it. Collisions are resolved by + /// comparing the paths outright. + intern: HashMap>, + /// 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. The intern table survives — the + /// same marker shapes recur every frame — unless it has grown past + /// [`INTERN_MAX_SHAPES`]. + pub(crate) fn clear(&mut self) { + self.polys.clear(); + self.poly_cache.clear(); + self.tris.clear(); + if self.paths.len() > INTERN_MAX_SHAPES { + self.paths.clear(); + self.intern.clear(); + } + } + + /// Store `path`, reusing an identical one already held. + pub(crate) fn intern_path(&mut self, path: &Path) -> ShapeId { + if path.elements().len() > INTERN_MAX_ELEMENTS { + self.paths.push(path.clone()); + return ShapeId(self.paths.len() as u32 - 1); + } + let key = hash_path(path); + if let Some(bucket) = self.intern.get(&key) { + for &id in bucket { + if &self.paths[id as usize] == path { + return ShapeId(id); + } + } + } + self.paths.push(path.clone()); + let id = self.paths.len() as u32 - 1; + self.intern.entry(key).or_default().push(id); + ShapeId(id) + } + + /// Borrow a stored path. + pub(crate) fn path(&self, id: ShapeId) -> &Path { + &self.paths[id.0 as usize] + } + + /// 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(); + // `f64::NAN` marks a subpath break, so one flat buffer can hold a + // path with holes without a parallel index. + flatten( + self.paths[id.0 as usize].iter(), + 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(start) = last_subpath_start(&pts) { + pts.push(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])) + } + } + } +} + +/// 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(path: &Path) -> 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 path.elements() { + 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(path.elements().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.paths.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.paths.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_keeps_the_shape_cache_but_drops_per_frame_geometry() { + 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(), "flattenings are per frame"); + // Shapes recur every frame, so the table survives. + assert_eq!(s.intern_path(&p), id); + } + + #[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(shape).bounding_box(); + + 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..04bcb3f --- /dev/null +++ b/src/pick/index.rs @@ -0,0 +1,668 @@ +//! 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. + pub fn id(&self) -> Option { + match self.pick_id { + PickId::Id(n) if n != 0 => 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>, + scratch: RefCell>, + clip_memo: RefCell>, +} + +impl PickIndex { + /// An empty index. + pub fn new() -> Self { + Self::default() + } + + /// Number of indexed primitives. Not a count of distinct ids: one mark + /// can be a fill plus a stroke, and a long line is many chunks. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// True when nothing has been recorded. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Drop everything recorded. The frame boundary. + pub fn clear(&mut self) { + self.entries.clear(); + self.leaves.clear(); + self.store.clear(); + self.clips.clear(); + self.scopes.clear(); + *self.tree.borrow_mut() = None; + self.clip_memo.borrow_mut().clear(); + } + + // ── Recording ─────────────────────────────────────────────────────── + + /// Whether a primitive with this id should be indexed at all. + /// + /// An id makes it a target; so does sitting directly inside a + /// [`ScopeMode::Target`](crate::pick::ScopeMode::Target) scope, which is + /// how chrome participates without every chrome call site growing a + /// `PickId` argument. + fn wants(&self, pick_id: PickId) -> bool { + pick_id != PickId::Skip || self.scopes.current_is_target() + } + + pub(crate) fn push_scope(&mut self, scope: &PickScope) { + self.scopes.push(scope); + } + + pub(crate) fn pop_scope(&mut self) { + self.scopes.pop(); + } + + pub(crate) fn push_clip(&mut self, transform: Affine, clip: &Path) { + self.clips.push(transform, clip); + } + + pub(crate) fn pop_clip(&mut self) { + self.clips.pop(); + } + + pub(crate) fn record_fill( + &mut self, + rule: FillRule, + transform: Affine, + path: &Path, + pick_id: PickId, + ) { + if !self.wants(pick_id) || path.elements().is_empty() { + return; + } + // An axis-aligned rectangle is its own bounds, so it needs no stored + // geometry at all — the bar-chart case, where a fresh path per row + // would otherwise defeat interning. + if let Some(r) = as_axis_rect(path) { + self.push_entry(transform, r, Geom::Box, pick_id); + return; + } + let shape = self.store.intern_path(path); + let local = self.store.path(shape).bounding_box(); + let even_odd = geom::is_even_odd(rule); + self.push_entry(transform, local, Geom::Fill { shape, even_odd }, pick_id); + } + + pub(crate) fn record_stroke( + &mut self, + stroke: &Stroke, + transform: Affine, + path: &Path, + pick_id: PickId, + ) { + if !self.wants(pick_id) || path.elements().is_empty() { + return; + } + let scale = geom::mean_scale(transform); + let tolerance = if scale > 0.0 { 0.25 / scale } else { 0.25 }; + let shape = self.store.intern_path(path); + let (poly, runs) = self.store.flatten_path(shape, tolerance); + let half_width = geom::hit_half_width(stroke.width, transform); + let outset = geom::stroke_outset(half_width, stroke.miter_limit); + for (start, end) in runs { + for (first, count) in geom::chunk_run(start, end) { + let pts = &self.store.poly(poly)[first as usize..(first + count) as usize]; + let Some(bounds) = geom::points_bounds(pts) else { + continue; + }; + self.push_entry( + transform, + bounds.inflate(outset, outset), + Geom::Stroke { + poly, + first, + count, + half_width, + }, + pick_id, + ); + } + } + } + + pub(crate) fn record_image(&mut self, image: &Image, transform: Affine, pick_id: PickId) { + if !self.wants(pick_id) || image.width == 0 || image.height == 0 { + return; + } + let local = Rect::new(0.0, 0.0, f64::from(image.width), f64::from(image.height)); + self.push_entry(transform, local, Geom::Box, pick_id); + } + + pub(crate) fn record_glyphs(&mut self, run: &GlyphRun<'_>, pick_id: PickId) { + if !self.wants(pick_id) || run.glyphs.is_empty() { + return; + } + let Some(local) = glyph_run_box(run) else { + return; + }; + self.push_entry(run.transform, local, Geom::Box, pick_id); + } + + pub(crate) fn record_mesh(&mut self, mesh: &Mesh, transform: Affine, pick_id: PickId) { + if !self.wants(pick_id) || mesh.indices.is_empty() { + return; + } + let (first, count) = self.store.push_triangles(mesh); + for (chunk_first, chunk_count) in geom::chunk_triangles(first, count) { + let tris = self.store.triangles(chunk_first, chunk_count); + let Some(bounds) = geom::triangles_bounds(tris) else { + continue; + }; + self.push_entry( + transform, + bounds, + Geom::Tris { + first: chunk_first, + count: chunk_count, + }, + pick_id, + ); + } + } + + /// Record one entry, dropping it if it is degenerate or clipped away. + fn push_entry(&mut self, transform: Affine, local: Rect, geom: Geom, pick_id: PickId) { + // A singular transform paints nothing and has no inverse to test with. + let Some(inv) = invert(transform) else { + return; + }; + let mut device = transform.transform_rect_bbox(local); + if let Some(clip) = self.clips.current_bounds() { + // Entirely outside its clip: not drawn, so not hittable. Tested + // before intersecting, because `Rect::intersect` clamps a + // disjoint result to zero area rather than leaving it inverted, + // and a legitimately zero-area primitive must survive. + if !device.overlaps(clip) { + return; + } + device = device.intersect(clip); + } + if !device.x0.is_finite() + || !device.y0.is_finite() + || !device.x1.is_finite() + || !device.y1.is_finite() + { + return; + } + self.leaves.push(to_bbox(device)); + self.entries.push(Entry { + inv, + local, + geom, + pick_id, + clip: self.clips.current(), + scope: self.scopes.current(), + }); + *self.tree.borrow_mut() = None; + } + + // ── Queries ───────────────────────────────────────────────────────── + + /// Every hit at `p`, topmost first, stopping at the first + /// [`PickId::Block`]. + /// + /// `p` is in the scene's own coordinate space — device pixels, the space + /// a `PlotComposition` was rendered at. + pub fn hits_at(&self, p: Point) -> Vec> { + let mut out = Vec::new(); + self.hits_at_into(p, &mut out); + out + } + + /// [`Self::hits_at`] into a caller-owned buffer, for a hover loop that + /// runs on every pointer event. + pub fn hits_at_into<'a>(&'a self, p: Point, out: &mut Vec>) { + self.collect( + out, + true, + |tree, scratch| tree.query_point(p, scratch), + |e, memo| self.hit_by_point(e, p, memo), + ); + } + + /// The topmost authoring id at `p`, or `None` over empty space or an + /// occluder. `hits_at(p)` filtered to the first hit carrying an id. + pub fn pick_at(&self, p: Point) -> Option { + self.hits_at(p).iter().find_map(Hit::id) + } + + /// Every hit whose bounds intersect `rect`, topmost first. + /// + /// Bounds-level: a mark whose box clips the rect but whose geometry + /// misses it is included. See [`Self::hits_within`] for the exact one. + pub fn hits_in(&self, rect: Rect) -> Vec> { + let mut out = Vec::new(); + self.hits_in_into(rect, &mut out); + out + } + + /// [`Self::hits_in`] into a caller-owned buffer. + pub fn hits_in_into<'a>(&'a self, rect: Rect, out: &mut Vec>) { + self.collect( + out, + false, + |tree, scratch| tree.query_rect(rect, scratch), + |_, _| true, + ); + } + + /// Every hit whose bounds lie entirely inside `rect`, topmost first. + /// + /// Exact: bounds inside the rect implies the geometry is, so this is the + /// query a selection marquee wants. + pub fn hits_within(&self, rect: Rect) -> Vec> { + let mut out = Vec::new(); + self.hits_within_into(rect, &mut out); + out + } + + /// [`Self::hits_within`] into a caller-owned buffer. + pub fn hits_within_into<'a>(&'a self, rect: Rect, out: &mut Vec>) { + self.collect( + out, + false, + |tree, scratch| tree.query_rect(rect, scratch), + |_, _| true, + ); + out.retain(|h| { + h.bounds.x0 >= rect.x0 + && h.bounds.y0 >= rect.y0 + && h.bounds.x1 <= rect.x1 + && h.bounds.y1 <= rect.y1 + }); + } + + /// Lasso selection: every hit whose bounds-centre lies inside `path`. + /// + /// Centre-based rather than enclosure-based, and deliberately so. For a + /// rectangle, bounds-inside-rect implies geometry-inside-rect, which is + /// what lets [`Self::hits_within`] promise exactness; for an arbitrary + /// polygon it does not, because a concave lasso can exclude part of a + /// box whose corners all fall inside it. Centre-in-polygon is the + /// conventional lasso semantic and is predictable for the small marks a + /// lasso is used on. + pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { + let mut out = Vec::new(); + self.hits_in_path_into(path, rule, &mut out); + out + } + + /// [`Self::hits_in_path`] into a caller-owned buffer. + pub fn hits_in_path_into<'a>(&'a self, path: &Path, rule: FillRule, out: &mut Vec>) { + let bbox = path.bounding_box(); + self.collect( + out, + false, + |tree, scratch| tree.query_rect(bbox, scratch), + |_, _| true, + ); + let even_odd = geom::is_even_odd(rule); + out.retain(|h| { + let c = h.bounds.center(); + let w = path.winding(c); + if even_odd { + w % 2 != 0 + } else { + w != 0 + } + }); + } + + /// Shared query body. + /// + /// `descend` picks candidates off the tree; `keep` decides whether a + /// candidate really is hit, and owns the clip test because only the + /// point query has a point to test a clip with. `occlude` stops the walk + /// at the first [`PickId::Block`] — right for a point, which is a ray, + /// and wrong for a region, which is not. + fn collect<'a>( + &'a self, + out: &mut Vec>, + occlude: bool, + descend: impl Fn(&HilbertRtree, &mut Vec), + keep: impl Fn(&Entry, &mut Vec<(ClipId, bool)>) -> bool, + ) { + out.clear(); + if self.entries.is_empty() { + return; + } + self.ensure_tree(); + let tree = self.tree.borrow(); + let tree = tree.as_ref().expect("built above"); + + let mut scratch = self.scratch.borrow_mut(); + descend(tree, &mut scratch); + // Entry order is draw order, so descending is topmost first. + scratch.sort_unstable_by(|a, b| b.cmp(a)); + + let mut memo = self.clip_memo.borrow_mut(); + memo.clear(); + + let mut last: Option<(PickId, ScopeNode)> = None; + for &i in scratch.iter() { + let e = &self.entries[i as usize]; + if !keep(e, &mut memo) { + continue; + } + if occlude && e.pick_id == PickId::Block { + return; + } + // A fill-plus-stroke mark, a chunked stroke and a chunked mesh + // all produce several entries a caller should see as one hit. + // The first one kept is topmost, so it fixes the order; the rest + // only widen the reported bounds. + let key = (e.pick_id, e.scope); + let bounds = rect_of(&self.leaves[i as usize]); + if last == Some(key) { + if let Some(prev) = out.last_mut() { + prev.bounds = prev.bounds.union(bounds); + } + continue; + } + last = Some(key); + out.push(Hit { + pick_id: e.pick_id, + path: PickPath::new(&self.scopes, e.scope), + order: i, + bounds, + }); + } + } + + fn ensure_tree(&self) { + let mut slot = self.tree.borrow_mut(); + if slot.is_none() { + *slot = Some(HilbertRtree::pack(&self.leaves)); + } + } + + /// The exact test for a point: the primitive's own geometry, then every + /// clip enclosing it. + fn hit_by_point(&self, e: &Entry, p: Point, memo: &mut Vec<(ClipId, bool)>) -> bool { + let local = e.inv * p; + if !e.local.contains(local) || !self.store.contains(&e.geom, e.local, local) { + return false; + } + e.clip.is_none() || self.clips.allows(e.clip, p, memo) + } +} + +/// The layout box of a glyph run, in the run's own frame. +fn glyph_run_box(run: &GlyphRun<'_>) -> Option { + let first = run.glyphs.first()?; + let (mut x0, mut x1) = (f64::from(first.x), f64::from(first.x)); + let (mut y0, mut y1) = (f64::from(first.y), f64::from(first.y)); + for g in run.glyphs { + x0 = x0.min(f64::from(g.x)); + x1 = x1.max(f64::from(g.x)); + y0 = y0.min(f64::from(g.y)); + y1 = y1.max(f64::from(g.y)); + } + let size = f64::from(run.font_size); + // The last glyph's own advance is not in its origin, so add one. + match run.source.as_ref() { + Some(src) => x1 = x1.max(x0 + f64::from(src.advance)), + None => x1 += size * GLYPH_TRAILING_EM, + } + // A skew leans the glyphs out of their origins' box. + if let Some(gt) = run.glyph_transform { + let skew = gt.as_coeffs()[2].abs() * size; + x0 -= skew; + x1 += skew; + } + let r = Rect::new( + x0, + y0 - size * GLYPH_ASCENT_EM, + x1, + y1 + size * GLYPH_DESCENT_EM, + ); + r.is_finite().then_some(r) +} + +fn invert(t: Affine) -> Option { + let det = t.determinant(); + (det.abs() > 1e-12 && det.is_finite()).then(|| t.inverse()) +} + +fn rect_of(b: &Bbox) -> Rect { + Rect::new(b[0] as f64, b[1] as f64, b[2] as f64, b[3] as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::primitives; + + impl PickIndex { + /// What `hits_at` must agree with: every entry, back to front, with + /// no tree and no coalescing. Deliberately the dumbest correct + /// implementation. + fn naive_ids_at(&self, p: Point) -> Vec { + let mut memo = Vec::new(); + let mut out = Vec::new(); + for (i, e) in self.entries.iter().enumerate().rev() { + memo.clear(); + if !self.hit_by_point(e, p, &mut memo) { + continue; + } + if e.pick_id == PickId::Block { + break; + } + if let PickId::Id(n) = e.pick_id { + if out.last() != Some(&(i as u32)) { + out.push(n); + } + } + } + out + } + } + + /// A scatter of small circles plus a few stroked lines and rects, with + /// enough marks to force real tree levels. + fn populated() -> PickIndex { + let mut ix = PickIndex::new(); + 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 marker = primitives::circle(Point::new(0.0, 0.0), 4.0); + for i in 0..2000u32 { + let x = unit() * 400.0; + let y = unit() * 300.0; + ix.record_fill( + FillRule::NonZero, + Affine::translate((x, y)), + &marker, + PickId::Id(i + 1), + ); + } + for i in 0..50u32 { + let y = unit() * 300.0; + let mut line = Path::new(); + line.move_to((0.0, y)); + line.line_to((400.0, y + 10.0)); + ix.record_stroke( + &Stroke::new(3.0), + Affine::IDENTITY, + &line, + PickId::Id(10_000 + i), + ); + } + ix + } + + #[test] + fn hits_agree_with_a_naive_scan_over_every_entry() { + let ix = populated(); + assert!( + ix.len() > 2000, + "the fixture must exercise real tree levels" + ); + + let mut s = 0xDEAD_BEEF_CAFE_1234u64; + let mut unit = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s >> 11) as f64 / (1u64 << 53) as f64 + }; + for _ in 0..3000 { + let p = Point::new(unit() * 420.0 - 10.0, unit() * 320.0 - 10.0); + let got: Vec = ix.hits_at(p).iter().filter_map(Hit::id).collect(); + let want = ix.naive_ids_at(p); + assert_eq!(got, want, "disagreement at {p:?}"); + } + } + + #[test] + fn the_tree_is_built_lazily_and_invalidated_by_recording() { + let mut ix = PickIndex::new(); + ix.record_fill( + FillRule::NonZero, + Affine::IDENTITY, + &primitives::rect(Rect::new(0.0, 0.0, 10.0, 10.0)), + PickId::Id(1), + ); + // Recording alone builds nothing: a frame nobody queries costs no + // tree, which is what replaces the old pick-interval throttle. + assert!(ix.tree.borrow().is_none()); + + assert_eq!(ix.pick_at(Point::new(5.0, 5.0)), Some(1)); + assert!(ix.tree.borrow().is_some()); + + // A further draw invalidates it rather than leaving a stale answer. + ix.record_fill( + FillRule::NonZero, + Affine::IDENTITY, + &primitives::rect(Rect::new(0.0, 0.0, 10.0, 10.0)), + PickId::Id(2), + ); + assert!(ix.tree.borrow().is_none()); + assert_eq!(ix.pick_at(Point::new(5.0, 5.0)), Some(2)); + } + + #[test] + fn a_glyph_run_box_covers_the_line_and_is_finite() { + use crate::scene::Glyph; + let glyphs = [ + Glyph { + id: 1, + x: 0.0, + y: 0.0, + }, + Glyph { + id: 2, + x: 20.0, + y: 0.0, + }, + ]; + let font = crate::scene::Font::new(crate::brush::Blob::from(vec![0u8; 4]), 0); + let brush = crate::brush::Brush::Solid(crate::color::rgb8(0, 0, 0)); + let run = GlyphRun { + font: &font, + font_size: 10.0, + transform: Affine::IDENTITY, + glyph_transform: None, + brush: &brush, + brush_alpha: 1.0, + hint: false, + glyphs: &glyphs, + style: None, + source: None, + }; + let b = glyph_run_box(&run).expect("a box"); + // Ascent above the baseline, descent below it. + assert!(b.y0 < 0.0 && b.y1 > 0.0, "{b:?}"); + // Wide enough for the last glyph's own advance. + assert!(b.x1 > 20.0, "{b:?}"); + assert!(b.x0 <= 0.0, "{b:?}"); + } +} diff --git a/src/pick.rs b/src/pick/mod.rs similarity index 97% rename from src/pick.rs rename to src/pick/mod.rs index 26d1901..bbccd40 100644 --- a/src/pick.rs +++ b/src/pick/mod.rs @@ -46,6 +46,18 @@ //! SrcOver and avoids decoding ambiguity, at the cost of a known mismatch //! between visual appearance and hit behaviour for translucent overlays. +mod clip; +mod geom; +mod hilbert; +mod index; +mod rtree; +mod scene; +mod scope; + +pub use index::{Hit, PickIndex}; +pub use scene::PickIndexScene; +pub use scope::{PickPath, PickScope, ScopeMode}; + use crate::color::Color; /// Per-draw-call hitmap directive. diff --git a/src/pick/rtree.rs b/src/pick/rtree.rs new file mode 100644 index 0000000..6e36f7a --- /dev/null +++ b/src/pick/rtree.rs @@ -0,0 +1,375 @@ +//! A packed Hilbert R-tree over axis-aligned boxes. +//! +//! Static and bulk-built: leaves are sorted onto a Hilbert curve, then parent +//! levels are packed [`NODE_SIZE`] at a time until one node is left. There is +//! no insert or remove — a scene is indexed wholesale and thrown away, which +//! is what lets the layout be three flat vectors with no per-node allocation. +//! +//! Boxes are `f32` and rounded **outward** from the `f64` originals, so the +//! tree over-reports rather than under-reports: a false positive is caught by +//! the caller's exact test, while a false negative would be a missed hit with +//! nothing to catch it. + +use crate::geometry::{Point, Rect}; + +/// Children per internal node. +const NODE_SIZE: usize = 16; + +/// Below this many leaves, no parent levels are built and a query scans them +/// linearly. Packing a handful of boxes costs more than testing them, and +/// chrome-only scenes live here. +const LINEAR_SCAN_MAX: usize = 256; + +/// An axis-aligned box in the tree's own storage form. +pub(crate) type Bbox = [f32; 4]; + +/// Widen a `f64` rect to the `f32` box the tree stores, rounding outward. +pub(crate) fn to_bbox(r: Rect) -> Bbox { + [ + round_down(r.x0), + round_down(r.y0), + round_up(r.x1), + round_up(r.y1), + ] +} + +fn round_down(v: f64) -> f32 { + let f = v as f32; + if (f as f64) <= v { + f + } else { + f32::from_bits(if f.is_sign_negative() { + f.to_bits() + 1 + } else { + f.to_bits().wrapping_sub(1) + }) + } +} + +fn round_up(v: f64) -> f32 { + let f = v as f32; + if (f as f64) >= v { + f + } else { + f32::from_bits(if f.is_sign_negative() { + f.to_bits().wrapping_sub(1) + } else { + f.to_bits() + 1 + }) + } +} + +#[inline] +fn contains_point(b: &Bbox, x: f32, y: f32) -> bool { + x >= b[0] && x <= b[2] && y >= b[1] && y <= b[3] +} + +#[inline] +fn intersects(b: &Bbox, q: &Bbox) -> bool { + b[0] <= q[2] && b[2] >= q[0] && b[1] <= q[3] && b[3] >= q[1] +} + +#[inline] +fn union_into(acc: &mut Bbox, b: &Bbox) { + acc[0] = acc[0].min(b[0]); + acc[1] = acc[1].min(b[1]); + acc[2] = acc[2].max(b[2]); + acc[3] = acc[3].max(b[3]); +} + +const EMPTY: Bbox = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + +/// A bulk-built R-tree returning leaf payload indices. +#[derive(Debug, Default)] +pub(crate) struct HilbertRtree { + /// Leaf boxes in Hilbert order, then each parent level, root last. + boxes: Vec, + /// For a leaf, the caller's index. For an internal node, the offset of + /// its first child in the level below. + refs: Vec, + /// Exclusive end offset of each level in `boxes`, leaves first. + level_bounds: Vec, + /// Leaf count, i.e. where the leaf level ends. + num_items: usize, +} + +impl HilbertRtree { + /// Build a tree over `leaves`, whose positions become the payload + /// indices the queries report. + pub(crate) fn pack(leaves: &[Bbox]) -> Self { + let n = leaves.len(); + if n == 0 { + return Self::default(); + } + if n <= LINEAR_SCAN_MAX { + return Self { + boxes: leaves.to_vec(), + refs: (0..n as u32).collect(), + level_bounds: vec![n as u32], + num_items: n, + }; + } + + let mut extent = EMPTY; + for b in leaves { + union_into(&mut extent, b); + } + let (min_x, min_y) = (extent[0] as f64, extent[1] as f64); + let span_x = extent[2] as f64 - min_x; + let span_y = extent[3] as f64 - min_y; + + let mut order: Vec<(u32, u32)> = leaves + .iter() + .enumerate() + .map(|(i, b)| { + let cx = (b[0] as f64 + b[2] as f64) * 0.5; + let cy = (b[1] as f64 + b[3] as f64) * 0.5; + let key = super::hilbert::hilbert_d( + super::hilbert::quantise(cx, min_x, span_x), + super::hilbert::quantise(cy, min_y, span_y), + ); + (key, i as u32) + }) + .collect(); + order.sort_unstable(); + + // Leaves, then one entry per node of each parent level. + let mut level_bounds = vec![n as u32]; + let mut count = n; + while count > 1 { + count = count.div_ceil(NODE_SIZE); + level_bounds.push(level_bounds.last().unwrap() + count as u32); + } + let total = *level_bounds.last().unwrap() as usize; + + let mut boxes = vec![EMPTY; total]; + let mut refs = vec![0u32; total]; + for (slot, &(_, src)) in order.iter().enumerate() { + boxes[slot] = leaves[src as usize]; + refs[slot] = src; + } + + let mut read = 0usize; + for &bound in &level_bounds[..level_bounds.len() - 1] { + let end = bound as usize; + let mut write = end; + while read < end { + let first = read; + let mut acc = EMPTY; + for _ in 0..NODE_SIZE { + if read >= end { + break; + } + union_into(&mut acc, &boxes[read]); + read += 1; + } + boxes[write] = acc; + refs[write] = first as u32; + write += 1; + } + } + + Self { + boxes, + refs, + level_bounds, + num_items: n, + } + } + + /// Number of indexed leaves. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.num_items + } + + /// Payload indices of every leaf whose box contains `p`, pushed onto + /// `out` (which is cleared first). Order is unspecified. + pub(crate) fn query_point(&self, p: Point, out: &mut Vec) { + let (x, y) = (p.x as f32, p.y as f32); + self.query(out, |b| contains_point(b, x, y)); + } + + /// Payload indices of every leaf whose box intersects `rect`. + pub(crate) fn query_rect(&self, rect: Rect, out: &mut Vec) { + let q = to_bbox(rect); + self.query(out, |b| intersects(b, &q)); + } + + /// Shared descent. `hit` is monotone in the box-containment order — + /// it must be true for a parent whenever it is true for a child, which + /// both point and rect predicates are. + fn query(&self, out: &mut Vec, hit: impl Fn(&Bbox) -> bool) { + out.clear(); + if self.num_items == 0 { + return; + } + // No parent levels: the leaves are the whole tree. + if self.level_bounds.len() == 1 { + for i in 0..self.num_items { + if hit(&self.boxes[i]) { + out.push(self.refs[i]); + } + } + return; + } + + let root = self.boxes.len() - 1; + // Depth is log16(n); 64 frames covers far more than u32 can index. + let mut stack: Vec<(usize, usize)> = Vec::with_capacity(64); + stack.push((root, self.level_bounds.len() - 1)); + while let Some((node, level)) = stack.pop() { + if !hit(&self.boxes[node]) { + continue; + } + if level == 0 { + out.push(self.refs[node]); + continue; + } + let child_start = self.refs[node] as usize; + let child_end = (child_start + NODE_SIZE).min(self.level_bounds[level - 1] as usize); + for c in child_start..child_end { + stack.push((c, level - 1)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Deterministic pseudo-random boxes, so a failure reproduces. + fn boxes(n: usize, seed: u64) -> Vec { + let mut s = seed; + let mut unit = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s >> 11) as f64 / (1u64 << 53) as f64 + }; + (0..n) + .map(|_| { + let x = unit() * 900.0; + let y = unit() * 560.0; + let w = unit() * 20.0; + let h = unit() * 20.0; + to_bbox(Rect::new(x, y, x + w, y + h)) + }) + .collect() + } + + fn brute_point(bs: &[Bbox], p: Point) -> Vec { + let (x, y) = (p.x as f32, p.y as f32); + (0..bs.len() as u32) + .filter(|&i| contains_point(&bs[i as usize], x, y)) + .collect() + } + + fn brute_rect(bs: &[Bbox], r: Rect) -> Vec { + let q = to_bbox(r); + (0..bs.len() as u32) + .filter(|&i| intersects(&bs[i as usize], &q)) + .collect() + } + + fn sorted(mut v: Vec) -> Vec { + v.sort_unstable(); + v + } + + /// The oracle: whatever the tree returns, a linear scan must agree. + /// Sizes straddle the linear-scan cutoff and the node-width boundaries, + /// which is where a packing or level-bound error would hide. + #[test] + fn point_queries_agree_with_a_linear_scan_at_every_size() { + for &n in &[0, 1, 2, 15, 16, 17, 255, 256, 257, 1000, 4096, 4097] { + let bs = boxes(n, 0x2545_f491_4f6c_dd1d); + let tree = HilbertRtree::pack(&bs); + assert_eq!(tree.len(), n); + let mut out = Vec::new(); + for q in boxes(200, 0x9E37_79B9_7F4A_7C15) { + let p = Point::new(q[0] as f64, q[1] as f64); + tree.query_point(p, &mut out); + assert_eq!( + sorted(out.clone()), + sorted(brute_point(&bs, p)), + "n = {n}, p = {p:?}" + ); + } + } + } + + #[test] + fn rect_queries_agree_with_a_linear_scan_at_every_size() { + for &n in &[0, 1, 17, 257, 1000, 4097] { + let bs = boxes(n, 0x1234_5678_9ABC_DEF0); + let tree = HilbertRtree::pack(&bs); + let mut out = Vec::new(); + for q in boxes(60, 0xDEAD_BEEF_CAFE_1234) { + let r = Rect::new( + q[0] as f64, + q[1] as f64, + q[2] as f64 + 40.0, + q[3] as f64 + 40.0, + ); + tree.query_rect(r, &mut out); + assert_eq!( + sorted(out.clone()), + sorted(brute_rect(&bs, r)), + "n = {n}, r = {r:?}" + ); + } + } + } + + #[test] + fn degenerate_geometry_still_answers_correctly() { + // Every box identical — the Hilbert keys all collide. + let same = vec![to_bbox(Rect::new(10.0, 10.0, 20.0, 20.0)); 500]; + let tree = HilbertRtree::pack(&same); + let mut out = Vec::new(); + tree.query_point(Point::new(15.0, 15.0), &mut out); + assert_eq!(out.len(), 500); + tree.query_point(Point::new(0.0, 0.0), &mut out); + assert!(out.is_empty()); + + // Zero-area boxes, and an extent with no width at all. + let column: Vec = (0..400) + .map(|i| to_bbox(Rect::new(5.0, i as f64, 5.0, i as f64))) + .collect(); + let tree = HilbertRtree::pack(&column); + tree.query_point(Point::new(5.0, 42.0), &mut out); + assert_eq!( + sorted(out.clone()), + sorted(brute_point(&column, Point::new(5.0, 42.0))) + ); + } + + #[test] + fn an_empty_tree_reports_nothing() { + let tree = HilbertRtree::pack(&[]); + let mut out = vec![7, 8, 9]; + tree.query_point(Point::new(0.0, 0.0), &mut out); + assert!(out.is_empty(), "query must clear the output buffer"); + assert_eq!(tree.len(), 0); + } + + #[test] + fn stored_boxes_never_shrink_below_the_source_rect() { + // Outward rounding is what makes a false negative impossible. + for q in boxes(200, 0x51E7_A11E_D00D) { + let r = Rect::new( + q[0] as f64 + 0.123_456_789, + q[1] as f64 + 0.987_654_321, + q[2] as f64 + 0.111_111_111, + q[3] as f64 + 0.222_222_222, + ); + let b = to_bbox(r); + assert!(b[0] as f64 <= r.x0, "x0 grew: {} > {}", b[0], r.x0); + assert!(b[1] as f64 <= r.y0, "y0 grew: {} > {}", b[1], r.y0); + assert!(b[2] as f64 >= r.x1, "x1 shrank: {} < {}", b[2], r.x1); + assert!(b[3] as f64 >= r.y1, "y1 shrank: {} < {}", b[3], r.y1); + } + } +} diff --git a/src/pick/scene.rs b/src/pick/scene.rs new file mode 100644 index 0000000..8053690 --- /dev/null +++ b/src/pick/scene.rs @@ -0,0 +1,196 @@ +//! [`PickIndexScene`] — the scene wrapper that builds a [`PickIndex`] as a +//! drawing goes past. +//! +//! It is a [`SceneBuilder`] wrapping a [`SceneBuilder`], and each renderer's +//! `Renderer::Scene` is one of these, so picking is something a scene has +//! rather than something a backend implements. That is what lets a vector +//! backend, or a build with no renderer at all, hit-test the same way a GPU +//! one does. + +use crate::geometry::Affine; + +use crate::blend::BlendMode; +use crate::brush::{Brush, Image, Sampling}; +use crate::geometry::{Point, Rect}; +use crate::mesh::Mesh; +use crate::path::{FillRule, Path}; +use crate::pick::{Hit, PickId, PickIndex, PickScope}; +use crate::scene::{GlyphRun, SceneBuilder}; +use crate::stroke::Stroke; + +/// A scene that records a hit index alongside whatever it draws. +/// +/// Every call is forwarded to the wrapped scene unchanged, so the picture is +/// identical whether or not indexing is on. When `enabled` is false the +/// wrapper costs one predictable branch per draw call and records nothing. +#[derive(Debug)] +pub struct PickIndexScene { + inner: S, + index: PickIndex, + enabled: bool, +} + +impl PickIndexScene { + /// Wrap `inner`. `enabled` decides whether anything is indexed — + /// filling the index is not free, so a host that never queries should + /// pass `false`. + pub fn new(inner: S, enabled: bool) -> Self { + Self { + inner, + index: PickIndex::new(), + enabled, + } + } + + /// The index built by the most recent drawing. + pub fn index(&self) -> &PickIndex { + &self.index + } + + /// Whether draws are being indexed. + pub fn indexes(&self) -> bool { + self.enabled + } + + /// Turn indexing on or off. Takes effect from the next [`Self::clear`]; + /// the index keeps answering from what it already holds until then. + pub fn set_indexing(&mut self, enabled: bool) { + self.enabled = enabled; + } + + /// Borrow the wrapped scene. + pub fn inner(&self) -> &S { + &self.inner + } + + /// Borrow the wrapped scene mutably. + pub fn inner_mut(&mut self) -> &mut S { + &mut self.inner + } + + /// Unwrap, discarding the index. + pub fn into_inner(self) -> S { + self.inner + } + + /// Every hit at `p`, topmost first. See [`PickIndex::hits_at`]. + pub fn hits_at(&self, p: Point) -> Vec> { + self.index.hits_at(p) + } + + /// The topmost authoring id at `p`. See [`PickIndex::pick_at`]. + pub fn pick_at(&self, p: Point) -> Option { + self.index.pick_at(p) + } + + /// Every hit whose bounds intersect `rect`. See [`PickIndex::hits_in`]. + pub fn hits_in(&self, rect: Rect) -> Vec> { + self.index.hits_in(rect) + } + + /// Every hit entirely inside `rect`. See [`PickIndex::hits_within`]. + pub fn hits_within(&self, rect: Rect) -> Vec> { + self.index.hits_within(rect) + } + + /// Lasso selection. See [`PickIndex::hits_in_path`]. + pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { + self.index.hits_in_path(path, rule) + } +} + +impl SceneBuilder for PickIndexScene { + fn clear(&mut self) { + self.inner.clear(); + self.index.clear(); + } + + fn fill( + &mut self, + rule: FillRule, + transform: Affine, + brush: &Brush, + brush_transform: Option, + path: &Path, + pick_id: PickId, + ) { + if self.enabled { + self.index.record_fill(rule, transform, path, pick_id); + } + self.inner + .fill(rule, transform, brush, brush_transform, path, pick_id); + } + + fn stroke( + &mut self, + stroke: &Stroke, + transform: Affine, + brush: &Brush, + brush_transform: Option, + path: &Path, + pick_id: PickId, + ) { + if self.enabled { + self.index.record_stroke(stroke, transform, path, pick_id); + } + self.inner + .stroke(stroke, transform, brush, brush_transform, path, pick_id); + } + + fn draw_image( + &mut self, + image: &Image, + transform: Affine, + sampling: Sampling, + alpha: f32, + pick_id: PickId, + ) { + if self.enabled { + self.index.record_image(image, transform, pick_id); + } + self.inner + .draw_image(image, transform, sampling, alpha, pick_id); + } + + fn draw_glyphs(&mut self, run: &GlyphRun<'_>, pick_id: PickId) { + if self.enabled { + self.index.record_glyphs(run, pick_id); + } + self.inner.draw_glyphs(run, pick_id); + } + + fn draw_mesh(&mut self, mesh: &Mesh, transform: Affine, pick_id: PickId) { + if self.enabled { + self.index.record_mesh(mesh, transform, pick_id); + } + self.inner.draw_mesh(mesh, transform, pick_id); + } + + fn push_layer(&mut self, blend: BlendMode, alpha: f32, transform: Affine, clip: &Path) { + if self.enabled { + self.index.push_clip(transform, clip); + } + self.inner.push_layer(blend, alpha, transform, clip); + } + + fn pop_layer(&mut self) { + if self.enabled { + self.index.pop_clip(); + } + self.inner.pop_layer(); + } + + fn push_pick_scope(&mut self, scope: &PickScope) { + if self.enabled { + self.index.push_scope(scope); + } + self.inner.push_pick_scope(scope); + } + + fn pop_pick_scope(&mut self) { + if self.enabled { + self.index.pop_scope(); + } + self.inner.pop_pick_scope(); + } +} diff --git a/src/pick/scope.rs b/src/pick/scope.rs new file mode 100644 index 0000000..414a3bc --- /dev/null +++ b/src/pick/scope.rs @@ -0,0 +1,330 @@ +//! Pick scopes: the logical tree a drawing sits in, recorded alongside the +//! geometry so a hit can say *what* it hit and not merely *which id*. +//! +//! A scope is pushed and popped like a layer, but has no visual effect and +//! imposes no clip. The stack in effect when a primitive is drawn becomes +//! that primitive's ancestor chain, so the stack *is* the bubble path. +//! +//! Nothing here knows what a chart is. A scope carries a `&'static str` +//! kind and two optional fields, and the vocabulary that gives them meaning +//! lives in [`crate::plot::pick`] — the same split the composition module +//! already uses between `Slot::name` and the `Region` trait. + +use std::collections::HashMap; +use std::sync::Arc; + +/// Whether primitives drawn directly inside a scope are pick targets in +/// their own right. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ScopeMode { + /// A grouping frame. A primitive carrying [`PickId::Skip`] stays + /// unindexed, exactly as it was before scopes existed. What structural + /// frames and geoms use. + /// + /// [`PickId::Skip`]: crate::pick::PickId::Skip + #[default] + Group, + /// The scope *is* the target. A primitive drawn directly inside it is + /// indexed whatever its [`PickId`], and reported against this path. + /// What chrome uses, since chrome has no id of its own. + /// + /// [`PickId`]: crate::pick::PickId + Target, +} + +/// One node of the logical tree. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PickScope { + kind: &'static str, + name: Option>, + index: Option, + mode: ScopeMode, +} + +impl PickScope { + /// A grouping frame — see [`ScopeMode::Group`]. + pub fn group(kind: &'static str) -> Self { + Self { + kind, + name: None, + index: None, + mode: ScopeMode::Group, + } + } + + /// A frame that is itself a pick target — see [`ScopeMode::Target`]. + pub fn target(kind: &'static str) -> Self { + Self { + kind, + name: None, + index: None, + mode: ScopeMode::Target, + } + } + + /// Attach a name — a patch id, a scale name, a region name. + pub fn with_name(mut self, name: impl Into>) -> Self { + self.name = Some(name.into()); + self + } + + /// Attach an ordinal — a break index, a legend row, a channel. + pub fn with_index(mut self, index: u32) -> Self { + self.index = Some(index); + self + } + + /// What kind of node this is. Interpreted by the authoring layer. + pub fn kind(&self) -> &'static str { + self.kind + } + + /// The attached name, if any. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// The attached ordinal, if any. + pub fn index(&self) -> Option { + self.index + } + + /// Whether primitives drawn directly here are targets themselves. + pub fn mode(&self) -> ScopeMode { + self.mode + } +} + +/// Index of a node in a [`ScopeTree`]. [`ScopeNode::ROOT`] is the empty path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct ScopeNode(u32); + +impl ScopeNode { + /// The empty path — nothing pushed. + pub(crate) const ROOT: ScopeNode = ScopeNode(u32::MAX); + + pub(crate) fn is_root(self) -> bool { + self == ScopeNode::ROOT + } +} + +/// Hash-consed tree of scope paths. +/// +/// Every indexed primitive stores one [`ScopeNode`], four bytes, rather than +/// its own copy of the chain. A frame draws hundreds of distinct paths and +/// can draw hundreds of thousands of primitives, so sharing is the +/// difference between the scope stack costing nothing and costing more than +/// the geometry. +#[derive(Debug, Default)] +pub(crate) struct ScopeTree { + nodes: Vec<(ScopeNode, PickScope)>, + intern: HashMap<(ScopeNode, PickScope), u32>, + stack: Vec, +} + +impl ScopeTree { + /// The path in effect for a primitive recorded right now. + pub(crate) fn current(&self) -> ScopeNode { + self.stack.last().copied().unwrap_or(ScopeNode::ROOT) + } + + /// Whether the innermost scope makes its primitives targets. + pub(crate) fn current_is_target(&self) -> bool { + let node = self.current(); + !node.is_root() && self.nodes[node.0 as usize].1.mode() == ScopeMode::Target + } + + /// Enter `scope`, reusing the node if this exact path has been walked + /// before — which it will have been, since the five draw phases each + /// re-establish the same `composition → plot` prefix. + pub(crate) fn push(&mut self, scope: &PickScope) { + let parent = self.current(); + let key = (parent, scope.clone()); + let node = match self.intern.get(&key) { + Some(&existing) => ScopeNode(existing), + None => { + self.nodes.push((parent, scope.clone())); + let id = self.nodes.len() as u32 - 1; + self.intern.insert(key, id); + ScopeNode(id) + } + }; + self.stack.push(node); + } + + /// Leave the innermost scope. Unbalanced pops are ignored, for the same + /// reason unbalanced clip pops are: a malformed scene must not panic a + /// hover. + pub(crate) fn pop(&mut self) { + self.stack.pop(); + } + + /// Forget everything. Called at the frame boundary. + pub(crate) fn clear(&mut self) { + self.nodes.clear(); + self.intern.clear(); + self.stack.clear(); + } + + /// Number of distinct paths interned. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.nodes.len() + } + + fn parent_of(&self, node: ScopeNode) -> ScopeNode { + self.nodes[node.0 as usize].0 + } + + fn scope_of(&self, node: ScopeNode) -> &PickScope { + &self.nodes[node.0 as usize].1 + } +} + +/// The ancestor chain of a hit. +#[derive(Debug, Clone, Copy)] +pub struct PickPath<'a> { + tree: &'a ScopeTree, + node: ScopeNode, +} + +impl<'a> PickPath<'a> { + pub(crate) fn new(tree: &'a ScopeTree, node: ScopeNode) -> Self { + Self { tree, node } + } + + /// Whether the hit sits in no scope at all. + pub fn is_empty(&self) -> bool { + self.node.is_root() + } + + /// Depth of the chain. + pub fn len(&self) -> usize { + self.bubble().count() + } + + /// The chain innermost first — the order an event bubbles outward. + pub fn bubble(&self) -> impl Iterator + '_ { + let mut node = self.node; + std::iter::from_fn(move || { + if node.is_root() { + return None; + } + let scope = self.tree.scope_of(node); + node = self.tree.parent_of(node); + Some(scope) + }) + } + + /// The chain outermost first — the order it was captured in. + /// + /// Allocates, unlike [`Self::bubble`]: the chain is stored child-to-parent + /// and has to be reversed. Depth is a handful of frames. + pub fn frames(&self) -> Vec<&'a PickScope> { + let mut v: Vec<&PickScope> = self.bubble().collect(); + v.reverse(); + v + } + + /// The innermost frame of the given kind, searching outward. + pub fn find(&self, kind: &str) -> Option<&'a PickScope> { + self.bubble().find(|s| s.kind() == kind) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn walking_the_same_path_twice_reuses_one_node() { + let mut t = ScopeTree::default(); + let comp = PickScope::group("composition").with_name("root"); + let plot = PickScope::group("plot").with_name("a").with_index(0); + + t.push(&comp); + t.push(&plot); + let first = t.current(); + t.pop(); + t.pop(); + + // The five draw phases each re-establish this prefix. + t.push(&comp); + t.push(&plot); + assert_eq!(t.current(), first); + assert_eq!(t.len(), 2, "re-walking must not add nodes"); + } + + #[test] + fn scopes_differing_in_any_field_are_distinct_nodes() { + let mut t = ScopeTree::default(); + t.push(&PickScope::group("plot").with_name("a").with_index(0)); + t.pop(); + t.push(&PickScope::group("plot").with_name("a").with_index(1)); + t.pop(); + t.push(&PickScope::group("plot").with_name("b").with_index(0)); + t.pop(); + t.push(&PickScope::target("plot").with_name("a").with_index(0)); + t.pop(); + assert_eq!(t.len(), 4); + } + + #[test] + fn a_path_reads_outward_and_inward() { + let mut t = ScopeTree::default(); + t.push(&PickScope::group("composition")); + t.push(&PickScope::group("plot").with_name("a")); + t.push(&PickScope::target("part").with_name("axis_tick_label")); + t.push(&PickScope::target("item").with_index(3)); + let node = t.current(); + + let path = PickPath::new(&t, node); + assert_eq!(path.len(), 4); + assert!(!path.is_empty()); + + let outward: Vec<&str> = path.frames().iter().map(|s| s.kind()).collect(); + assert_eq!(outward, vec!["composition", "plot", "part", "item"]); + + let inward: Vec<&str> = path.bubble().map(|s| s.kind()).collect(); + assert_eq!(inward, vec!["item", "part", "plot", "composition"]); + + assert_eq!(path.find("plot").and_then(|s| s.name()), Some("a")); + assert_eq!(path.find("item").and_then(|s| s.index()), Some(3)); + assert!(path.find("legend").is_none()); + } + + #[test] + fn the_empty_path_has_no_frames() { + let t = ScopeTree::default(); + let path = PickPath::new(&t, ScopeNode::ROOT); + assert!(path.is_empty()); + assert_eq!(path.len(), 0); + assert!(path.frames().is_empty()); + assert!(path.find("plot").is_none()); + } + + #[test] + fn target_mode_is_read_from_the_innermost_frame_only() { + let mut t = ScopeTree::default(); + assert!(!t.current_is_target(), "nothing pushed is not a target"); + t.push(&PickScope::group("plot")); + assert!(!t.current_is_target()); + t.push(&PickScope::target("part")); + assert!(t.current_is_target()); + // A group nested inside a target is not itself a target. + t.push(&PickScope::group("inner")); + assert!(!t.current_is_target()); + t.pop(); + assert!(t.current_is_target()); + } + + #[test] + fn an_unbalanced_pop_does_not_panic() { + let mut t = ScopeTree::default(); + t.pop(); + t.pop(); + assert!(t.current().is_root()); + t.push(&PickScope::group("plot")); + assert!(!t.current().is_root()); + } +} diff --git a/src/plot/chrome/axis.rs b/src/plot/chrome/axis.rs index 551ea2d..046b27e 100644 --- a/src/plot/chrome/axis.rs +++ b/src/plot/chrome/axis.rs @@ -16,7 +16,9 @@ use crate::geometry::{Point, Rect}; use crate::layout::{Measure, WidthHint}; -use crate::plot::chrome::linear_axis::{draw_linear_axis_at, AxisChromeStyle}; +use crate::plot::chrome::linear_axis::{ + axis_minor_ticks, axis_ticks, draw_linear_axis_at, AxisChromeStyle, +}; use crate::plot::scale::Scale; use crate::plot::theme::Theme; use crate::scales::breaks::DEFAULT_BREAK_COUNT; @@ -57,6 +59,11 @@ pub struct Axis { scale_name: Option, placement: AxisPlacement, title: Option, + /// Handle minted when the axis is attached to a plot. `None` while + /// the axis is still free-standing — the caller holds the [`AxisId`] + /// that [`Plot::add_axis`](crate::plot::Plot::add_axis) returns, and + /// storing it here is what lets the draw walk report it back. + id: Option, } /// Where an axis sits relative to its plot. @@ -93,6 +100,7 @@ impl Axis { scale_name: Some(scale_name.into()), placement, title: None, + id: None, } } @@ -105,6 +113,7 @@ impl Axis { scale_name: None, placement, title: Some(title.into()), + id: None, } } @@ -124,6 +133,17 @@ impl Axis { } /// Where the axis sits relative to its plot. + /// Handle this axis was attached under, or `None` if it has not been + /// attached to a plot. + pub fn id(&self) -> Option { + self.id + } + + /// Record the handle minted at attach time. + pub(crate) fn set_id(&mut self, id: AxisId) { + self.id = Some(id); + } + pub fn placement(&self) -> AxisPlacement { self.placement } @@ -328,24 +348,8 @@ pub fn draw( ), }; - let majors: Vec<(f64, String)> = breaks - .iter() - .filter(|v| !matches!(v, Value::Null)) - .filter_map(|v| { - scale - .map_break(v) - .as_number() - .map(|f| (f, scale.format(v, &theme.locale))) - }) - .filter(|(f, _)| f.is_finite()) - .collect(); - let minors: Vec = scale - .minor_breaks(DEFAULT_BREAK_COUNT) - .into_iter() - .filter(|v| !matches!(v, Value::Null)) - .filter_map(|v| scale.map_break(&v).as_number()) - .filter(|f| f.is_finite()) - .collect(); + let majors = axis_ticks(&breaks, scale, &theme.locale); + let minors = axis_minor_ticks(scale, DEFAULT_BREAK_COUNT); // Resolve the (channel, side) axis from the theme. Channel // is determined by which axis side this is — Bottom/Top @@ -398,6 +402,40 @@ mod tests { use crate::scales::value::Value; use crate::scene::recording::{Op, RecordingScene}; + #[test] + fn attaching_an_axis_records_the_handle_it_was_given() { + use crate::composition::{beside, Patch as CompPatch}; + let comp = beside(CompPatch::new("a"), CompPatch::new("b")); + let mut plot = crate::plot::Plot::new(&comp, "a"); + + // Free-standing: no handle yet. + let axis = Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom)); + assert_eq!(axis.id(), None); + + let first = plot.add_axis(axis); + let second = plot.add_axis(Axis::rail("y", AxisPlacement::Cartesian(AxisSide::Left))); + + assert_eq!(plot.axes()[0].id(), Some(first)); + assert_eq!(plot.axes()[1].id(), Some(second)); + assert_ne!(first, second); + } + + #[test] + fn handles_stay_unique_across_a_clear() { + use crate::composition::{beside, Patch as CompPatch}; + let comp = beside(CompPatch::new("a"), CompPatch::new("b")); + let mut plot = crate::plot::Plot::new(&comp, "a"); + + let first = plot.add_axis(Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom))); + plot.clear_axes(); + let after = plot.add_axis(Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom))); + + // `clear_axes` deliberately does not reset the counter, so a handle + // the caller still holds never silently addresses a different axis. + assert_ne!(first, after); + assert_eq!(plot.axes()[0].id(), Some(after)); + } + fn dpi_96() -> f64 { 96.0 } diff --git a/src/plot/chrome/legend/colorbar.rs b/src/plot/chrome/legend/colorbar.rs index 26f90ec..0195719 100644 --- a/src/plot/chrome/legend/colorbar.rs +++ b/src/plot/chrome/legend/colorbar.rs @@ -15,6 +15,7 @@ use crate::geometry::Shape as _; use crate::geometry::{Affine, Point, Rect}; use crate::path::{FillRule, Path}; use crate::pick::PickId; +use crate::plot::chrome::linear_axis::AxisTick; use crate::plot::chrome::text::ChromeRun; use crate::plot::scale::ScaleRegistry; use crate::scales::breaks::DEFAULT_BREAK_COUNT; @@ -491,23 +492,39 @@ pub(super) fn render_colorbar_body( /// is in [`BinSpacing::Equal`] mode so the tick rail's labels still /// report the underlying break values but their positions line up /// with the equal-width bin / colour blocks. -fn colorbar_majors_remap_equal(majors: &[(f64, String)]) -> Vec<(f64, String)> { +fn colorbar_majors_remap_equal(majors: &[AxisTick]) -> Vec { let n = majors.len(); if n <= 1 { - return majors.to_vec(); + return majors + .iter() + .map(|t| AxisTick { + break_index: t.break_index, + frac: t.frac, + label: t.label.clone(), + }) + .collect(); } majors .iter() .enumerate() - .map(|(i, (_, label))| (i as f64 / (n - 1) as f64, label.clone())) + .map(|(i, t)| AxisTick { + // Position is remapped; identity is not. + break_index: t.break_index, + frac: i as f64 / (n - 1) as f64, + label: t.label.clone(), + }) .collect() } /// Drop the first and / or last element from a majors slice when the /// caller has marked the corresponding outer bin as open. Operates on -/// the per-break `(frac, label)` pairs `draw_linear_axis_at` -/// consumes — the swatches / gradient blocks themselves are unaffected. -fn open_end_trim(majors: &[(f64, String)], open_lower: bool, open_upper: bool) -> &[(f64, String)] { +/// the per-break [`AxisTick`]s `draw_linear_axis_at` consumes — the +/// swatches / gradient blocks themselves are unaffected. +/// +/// Trimming shifts positions but not identities: each surviving tick keeps +/// the `break_index` it arrived with, so an open-ended colorbar still +/// reports the break a tick actually came from. +fn open_end_trim(majors: &[AxisTick], open_lower: bool, open_upper: bool) -> &[AxisTick] { let start = if open_lower && !majors.is_empty() { 1 } else { @@ -535,7 +552,7 @@ fn open_end_trim(majors: &[(f64, String)], open_lower: bool, open_upper: bool) - fn colorbar_majors( domain: &crate::plot::scale::Scale, locale: &crate::scales::Locale, -) -> Vec<(f64, String)> { +) -> Vec { let (min, max) = match domain.input_range() { Some(crate::scales::input::InputRange::Continuous { min, max }) => (*min, *max), _ => return Vec::new(), @@ -544,17 +561,22 @@ fn colorbar_majors( if !span.is_finite() || span.abs() < f64::EPSILON { return Vec::new(); } + // `enumerate` before the filters — see the note in `chrome::axis::draw`. domain .breaks(DEFAULT_BREAK_COUNT) .iter() - .filter(|v| !matches!(v, Value::Null)) - .filter_map(|v| { + .enumerate() + .filter(|(_, v)| !matches!(v, Value::Null)) + .filter_map(|(break_index, v)| { let n = v.as_number().or_else(|| v.as_temporal_f64())?; if !n.is_finite() { return None; } - let frac = (n - min) / span; - Some((frac, domain.format(v, locale))) + Some(AxisTick { + break_index, + frac: (n - min) / span, + label: domain.format(v, locale), + }) }) .collect() } @@ -764,13 +786,23 @@ mod tests { assert!(bin_midpoints(&[5.0]).is_empty()); assert!(bin_midpoints(&[5.0, 5.0]).is_empty()); } - fn sample_majors() -> Vec<(f64, String)> { + /// A tick whose `break_index` is deliberately *not* its position, so a + /// test that confuses the two fails. + fn tick(break_index: usize, frac: f64, label: &str) -> AxisTick { + AxisTick { + break_index, + frac, + label: label.to_string(), + } + } + + fn sample_majors() -> Vec { vec![ - (0.0, "0".into()), - (0.25, "1".into()), - (0.5, "2".into()), - (0.75, "3".into()), - (1.0, "4".into()), + tick(10, 0.0, "0"), + tick(11, 0.25, "1"), + tick(12, 0.5, "2"), + tick(13, 0.75, "3"), + tick(14, 1.0, "4"), ] } @@ -779,8 +811,8 @@ mod tests { let m = sample_majors(); let trimmed = open_end_trim(&m, true, false); assert_eq!(trimmed.len(), 4); - assert_eq!(trimmed[0].1, "1"); - assert_eq!(trimmed[3].1, "4"); + assert_eq!(trimmed[0].label, "1"); + assert_eq!(trimmed[3].label, "4"); } #[test] @@ -788,8 +820,8 @@ mod tests { let m = sample_majors(); let trimmed = open_end_trim(&m, false, true); assert_eq!(trimmed.len(), 4); - assert_eq!(trimmed[0].1, "0"); - assert_eq!(trimmed[3].1, "3"); + assert_eq!(trimmed[0].label, "0"); + assert_eq!(trimmed[3].label, "3"); } #[test] @@ -797,8 +829,8 @@ mod tests { let m = sample_majors(); let trimmed = open_end_trim(&m, true, true); assert_eq!(trimmed.len(), 3); - assert_eq!(trimmed[0].1, "1"); - assert_eq!(trimmed[2].1, "3"); + assert_eq!(trimmed[0].label, "1"); + assert_eq!(trimmed[2].label, "3"); } #[test] @@ -808,13 +840,26 @@ mod tests { assert_eq!(trimmed.len(), 5); } + #[test] + fn trimming_shifts_positions_but_not_break_indices() { + // The whole point of carrying `break_index`: after a trim, a tick's + // position in the drawn set no longer matches its position in the + // scale's break list, and the identity that survives is the latter. + let m = sample_majors(); + let trimmed = open_end_trim(&m, true, true); + assert_eq!( + trimmed.iter().map(|t| t.break_index).collect::>(), + vec![11, 12, 13] + ); + } + #[test] fn open_trim_handles_short_slices() { // Single element + open_lower yields empty. - let one = vec![(0.5_f64, "mid".to_string())]; + let one = vec![tick(0, 0.5, "mid")]; assert!(open_end_trim(&one, true, false).is_empty()); // Empty slice in is empty slice out. - let empty: Vec<(f64, String)> = vec![]; + let empty: Vec = vec![]; assert!(open_end_trim(&empty, true, true).is_empty()); } @@ -822,32 +867,39 @@ mod tests { fn equal_remap_spaces_majors_uniformly() { // Pathological proportional split with five breaks. let m = vec![ - (0.0, "0".into()), - (0.01, "1".into()), - (0.05, "5".into()), - (0.5, "50".into()), - (1.0, "100".into()), + tick(3, 0.0, "0"), + tick(4, 0.01, "1"), + tick(5, 0.05, "5"), + tick(6, 0.5, "50"), + tick(7, 1.0, "100"), ]; let remapped = colorbar_majors_remap_equal(&m); assert_eq!(remapped.len(), 5); // Labels preserved in order. - assert_eq!(remapped[0].1, "0"); - assert_eq!(remapped[4].1, "100"); + assert_eq!(remapped[0].label, "0"); + assert_eq!(remapped[4].label, "100"); + // Remapping moves ticks; it does not renumber them. + assert_eq!( + remapped.iter().map(|t| t.break_index).collect::>(), + vec![3, 4, 5, 6, 7] + ); // Fractions are i / (n - 1) = i / 4. - for (i, (frac, _)) in remapped.iter().enumerate() { + for (i, t) in remapped.iter().enumerate() { let expected = i as f64 / 4.0; assert!( - (frac - expected).abs() < 1e-12, - "remap[{i}] = {frac}, expected {expected}" + (t.frac - expected).abs() < 1e-12, + "remap[{i}] = {}, expected {expected}", + t.frac ); } } #[test] fn equal_remap_short_slice_is_passthrough() { - let single = vec![(0.42_f64, "lonely".to_string())]; + let single = vec![tick(9, 0.42, "lonely")]; let remapped = colorbar_majors_remap_equal(&single); assert_eq!(remapped.len(), 1); - assert!((remapped[0].0 - 0.42).abs() < 1e-12); + assert!((remapped[0].frac - 0.42).abs() < 1e-12); + assert_eq!(remapped[0].break_index, 9); } } diff --git a/src/plot/chrome/linear_axis.rs b/src/plot/chrome/linear_axis.rs index acbaaa6..1adf518 100644 --- a/src/plot/chrome/linear_axis.rs +++ b/src/plot/chrome/linear_axis.rs @@ -240,14 +240,72 @@ impl AxisChromeStyle { /// with a grid line drawn by the surrounding chrome — the axis line /// is intrinsic to "this is an axis", and cartesian + polar radius /// axes share that semantics. +/// +/// `minors` carries `(break_index, frac)` per minor tick, indexed against +/// the scale's minor-break list the same way [`AxisTick::break_index`] is. +/// One major tick on an axis rail. +/// +/// `break_index` addresses the scale's own break list, **not** the position +/// of this tick within the drawn set. Nulls and breaks the scale cannot map +/// are dropped on the way here, and an open-ended colorbar trims its first +/// or last tick, so the two differ whenever a scale emits a break that does +/// not survive. Carrying the true index is what lets a consumer recover the +/// domain value as `scale.breaks(..)[break_index]`. +pub(crate) struct AxisTick { + pub break_index: usize, + /// Position along the rail, `0..=1`. + pub frac: f64, + pub label: String, +} + +/// The major ticks a scale contributes to a rail: every break it can map to +/// a finite position, paired with its formatted label. +/// +/// The enumeration runs **before** the filters, so a break the scale drops — +/// a [`Value::Null`], or one it cannot map — leaves a gap in the reported +/// indices rather than renumbering everything after it. +pub(crate) fn axis_ticks( + breaks: &[crate::scales::value::Value], + scale: &crate::plot::scale::Scale, + locale: &crate::scales::Locale, +) -> Vec { + breaks + .iter() + .enumerate() + .filter(|(_, v)| !matches!(v, crate::scales::value::Value::Null)) + .filter_map(|(break_index, v)| { + scale.map_break(v).as_number().map(|frac| AxisTick { + break_index, + frac, + label: scale.format(v, locale), + }) + }) + .filter(|t| t.frac.is_finite()) + .collect() +} + +/// The minor ticks a scale contributes, as `(break_index, frac)`. Indexed +/// against the scale's minor-break list exactly as [`axis_ticks`] is against +/// its major one. +pub(crate) fn axis_minor_ticks(scale: &crate::plot::scale::Scale, n: usize) -> Vec<(usize, f64)> { + scale + .minor_breaks(n) + .into_iter() + .enumerate() + .filter(|(_, v)| !matches!(v, crate::scales::value::Value::Null)) + .filter_map(|(i, v)| scale.map_break(&v).as_number().map(|f| (i, f))) + .filter(|(_, f)| f.is_finite()) + .collect() +} + #[allow(clippy::too_many_arguments)] pub(crate) fn draw_linear_axis_at( scene: &mut dyn SceneBuilder, start: Point, end: Point, tick_direction: (f64, f64), - majors: &[(f64, String)], - minors: &[f64], + majors: &[AxisTick], + minors: &[(usize, f64)], style: &AxisChromeStyle, dpi: f64, ) { @@ -260,7 +318,7 @@ pub(crate) fn draw_linear_axis_at( // Minor ticks first so a major drawn at the same frac wins. if let Some(brush) = &style.minor_brush { - for &frac in minors { + for &(_break_index, frac) in minors { if !frac.is_finite() || !(0.0..=1.0).contains(&frac) { continue; } @@ -274,11 +332,12 @@ pub(crate) fn draw_linear_axis_at( } // Major ticks + labels. - for (frac, label) in majors { - if !frac.is_finite() || !(0.0..=1.0).contains(frac) { + for tick in majors { + let (frac, label) = (tick.frac, &tick.label); + if !frac.is_finite() || !(0.0..=1.0).contains(&frac) { continue; } - let pos = lerp(start, end, *frac); + let pos = lerp(start, end, frac); let tick_end = Point::new( pos.x + style.tick_length_px * tx, pos.y + style.tick_length_px * ty, @@ -432,6 +491,40 @@ mod tests { use crate::color::rgb; use crate::scene::recording::{Op, RecordingScene}; + #[test] + fn a_dropped_break_leaves_a_gap_rather_than_shifting_later_ticks() { + use crate::plot::scale; + use crate::scales::value::Value; + use crate::scales::Locale; + + // Breaks 1 and 3 are unmappable on a continuous scale, so the rail + // draws three ticks out of five. + let sc = scale::continuous(0.0..=1.0).with_breaks(vec![ + Value::Number(0.0), + Value::String("nope".into()), + Value::Number(0.5), + Value::Null, + Value::Number(1.0), + ]); + let breaks = sc.breaks(5); + let ticks = axis_ticks(&breaks, &sc, &Locale::default()); + + assert_eq!(ticks.len(), 3); + // The survivors report where they sit in `breaks`, not where they + // sit in the drawn set. Numbering the survivors would give 0, 1, 2. + assert_eq!( + ticks.iter().map(|t| t.break_index).collect::>(), + vec![0, 2, 4] + ); + // And each index really does address the break it came from. + for t in &ticks { + assert_eq!( + sc.format(&breaks[t.break_index], &Locale::default()), + t.label + ); + } + } + const DPI: f64 = 96.0; /// A label with a break opportunity next to a same-shape control /// that has none — the pair isolates wrap-width effects from diff --git a/src/plot/chrome/polar.rs b/src/plot/chrome/polar.rs index 1c4b4e5..5f626e7 100644 --- a/src/plot/chrome/polar.rs +++ b/src/plot/chrome/polar.rs @@ -14,7 +14,8 @@ use crate::geometry::{Affine, Point, Rect, Vec2}; use crate::layout::{Measure, WidthHint}; use crate::pick::PickId; use crate::plot::chrome::linear_axis::{ - draw_axis_label, draw_linear_axis_at, AxisChromeStyle, AxisLabelAt, + axis_minor_ticks, axis_ticks, draw_axis_label, draw_linear_axis_at, AxisChromeStyle, + AxisLabelAt, }; use crate::plot::chrome::text::ChromeRun; use crate::plot::projection::PolarProjection; @@ -51,25 +52,8 @@ pub fn draw_radius_axis( if g.r_outer <= 0.0 { return; } - let majors: Vec<(f64, String)> = scale - .breaks(DEFAULT_BREAK_COUNT) - .iter() - .filter(|v| !matches!(v, Value::Null)) - .filter_map(|v| { - scale - .map_break(v) - .as_number() - .map(|f| (f, scale.format(v, &theme.locale))) - }) - .filter(|(f, _)| f.is_finite()) - .collect(); - let minors: Vec = scale - .minor_breaks(DEFAULT_BREAK_COUNT) - .into_iter() - .filter(|v| !matches!(v, Value::Null)) - .filter_map(|v| scale.map_break(&v).as_number()) - .filter(|f| f.is_finite()) - .collect(); + let majors = axis_ticks(&scale.breaks(DEFAULT_BREAK_COUNT), scale, &theme.locale); + let minors = axis_minor_ticks(scale, DEFAULT_BREAK_COUNT); let (ux, uy) = polar.unit_position(theta_frac); let start = Point::new(g.cx + g.r_inner * ux, g.cy - g.r_inner * uy); @@ -103,12 +87,10 @@ pub fn draw_radius_axis( // calculation matches what the rail itself drew. let label_style = style.text_style.clone(); let (max_label_w, max_label_h) = - majors - .iter() - .fold((0.0_f64, 0.0_f64), |(mw, mh), (_, label)| { - let run = ChromeRun::shape(label, &label_style, dpi, style.rich.as_ref()); - (mw.max(run.width()), mh.max(run.line_box_height())) - }); + majors.iter().fold((0.0_f64, 0.0_f64), |(mw, mh), tick| { + let run = ChromeRun::shape(&tick.label, &label_style, dpi, style.rich.as_ref()); + (mw.max(run.width()), mh.max(run.line_box_height())) + }); // Projecting the label's bbox onto the tick direction picks // whichever axis the label is offset along. Equivalent to the // cartesian title placement past the longest label. diff --git a/src/plot/composition.rs b/src/plot/composition.rs index f830240..11e6d3e 100644 --- a/src/plot/composition.rs +++ b/src/plot/composition.rs @@ -1155,13 +1155,15 @@ impl PlotComposition { /// plot list — multiple plots per patch are supported; later /// plots draw on top of earlier ones. Flips the layout's dirty /// flag. - pub fn attach_plot(&mut self, plot: Plot) { + pub fn attach_plot(&mut self, mut plot: Plot) { let id = plot.patch_id().to_string(); self.plot_dirty.insert(id.clone(), true); if !self.plots.contains_key(&id) { self.plot_order.push(id.clone()); } - self.plots.entry(id).or_default().push(plot); + let list = self.plots.entry(id).or_default(); + plot.set_index_in_patch(list.len() as u32); + list.push(plot); self.layout_dirty = true; } @@ -1487,6 +1489,36 @@ mod tests { assert_eq!(view.template.placements.len(), 2); } + #[test] + fn attach_numbers_plots_within_their_patch() { + let mut view = PlotComposition::new(&comp_two()); + view.attach_plot(Plot::new(&comp_two(), "a")); + view.attach_plot(Plot::new(&comp_two(), "a")); + view.attach_plot(Plot::new(&comp_two(), "b")); + + let a = view.plots_in("a"); + assert_eq!(a.len(), 2); + assert_eq!(a[0].index_in_patch(), 0); + assert_eq!(a[1].index_in_patch(), 1); + // Numbering is per patch, not global. + assert_eq!(view.plots_in("b")[0].index_in_patch(), 0); + } + + #[test] + fn detaching_a_patch_leaves_other_patches_numbered() { + let mut view = PlotComposition::new(&comp_two()); + view.attach_plot(Plot::new(&comp_two(), "a")); + view.attach_plot(Plot::new(&comp_two(), "b")); + view.attach_plot(Plot::new(&comp_two(), "b")); + + // `detach_plot` removes a whole patch's list, so no surviving plot's + // index shifts — which is what makes `(patch_id, index)` a stable key. + view.detach_plot("a"); + let b = view.plots_in("b"); + assert_eq!(b[0].index_in_patch(), 0); + assert_eq!(b[1].index_in_patch(), 1); + } + #[test] fn add_scale_flips_layout_dirty() { let mut view = PlotComposition::new(&comp_two()); diff --git a/src/plot/plot.rs b/src/plot/plot.rs index 690a701..067822e 100644 --- a/src/plot/plot.rs +++ b/src/plot/plot.rs @@ -147,6 +147,13 @@ impl Plot { /// [`ScaleRegistry`] (owned by the orchestrator in the canonical flow). pub struct Plot { patch_id: Arc, + /// Position of this plot within its patch's attach list. Assigned by + /// [`PlotComposition::attach_plot`](crate::plot::PlotComposition::attach_plot); + /// `0` for a plot driven directly, without the orchestrator. Together + /// with `patch_id` this is the pair + /// [`PlotComposition::update_plot_at`](crate::plot::PlotComposition::update_plot_at) + /// addresses plots by, and the pair a pick hit reports. + index_in_patch: u32, bindings: HashMap, geoms: Vec<(GeomId, Box)>, next_geom_id: u32, @@ -283,6 +290,7 @@ impl Plot { } Ok(Self { patch_id: Arc::from(patch_id), + index_in_patch: 0, bindings: HashMap::new(), geoms: Vec::new(), next_geom_id: 0, @@ -407,6 +415,20 @@ impl Plot { &self.patch_id } + /// Position of this plot within its patch's attach list. + /// + /// `(patch_id, index_in_patch)` is the pair that identifies a plot + /// uniquely: a patch can hold several overlaid plots. + pub fn index_in_patch(&self) -> u32 { + self.index_in_patch + } + + /// Set the attach-list position. Called by the orchestrator as the plot + /// is pushed onto its patch's list. + pub(crate) fn set_index_in_patch(&mut self, index: u32) { + self.index_in_patch = index; + } + /// Borrow the coordinate projection. Defaults to /// [`Projection::Cartesian`](crate::plot::projection::Projection); /// override via [`Self::projection`]. @@ -1163,6 +1185,8 @@ impl Plot { } let id = crate::plot::chrome::axis::AxisId::new(self.next_axis_id); self.next_axis_id += 1; + let mut axis = axis; + axis.set_id(id); self.axes.push(axis); Ok(id) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 544d822..ba35426 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9,7 +9,7 @@ use crate::brush::{Brush, Image, Sampling}; use crate::geometry::Affine; use crate::mesh::Mesh; use crate::path::{FillRule, Path}; -use crate::pick::PickId; +use crate::pick::{PickId, PickScope}; use crate::style_vocab::FontSpec; pub mod recording; @@ -86,6 +86,26 @@ pub trait SceneBuilder { /// Pop the most recently pushed layer. fn pop_layer(&mut self); + + /// Push a pick scope. Every primitive issued until the matching + /// [`Self::pop_pick_scope`] inherits it as an ancestor, so the stack at + /// the time of a draw is that primitive's full ancestor chain. + /// + /// Orthogonal to [`Self::push_layer`]: a scope has no visual effect and + /// imposes no clip, and the two stacks need not nest with one another. + /// The only contract is that pushes and pops balance. + /// + /// Unlike every other method on this trait, ignoring this one still + /// produces a correct picture — the intersection-of-backends rule is + /// about visual capabilities, and a scope has none. So it defaults to a + /// no-op and a backend that does not hit-test writes nothing. + fn push_pick_scope(&mut self, scope: &PickScope) { + let _ = scope; + } + + /// Pop the most recently pushed pick scope. Defaults to a no-op, for the + /// reason given on [`Self::push_pick_scope`]. + fn pop_pick_scope(&mut self) {} } // ---------- glyph types ---------- diff --git a/tests/pick_index.rs b/tests/pick_index.rs new file mode 100644 index 0000000..d6045be --- /dev/null +++ b/tests/pick_index.rs @@ -0,0 +1,540 @@ +//! The pick index, end to end, against a scene with no renderer behind it. +//! +//! Needs no cargo features at all: hit testing is something a scene does, so +//! it is testable without a GPU, a rasteriser or a codec. The suite is the +//! successor to the GPU hitmap's round-trip tests, and its oracle is a naive +//! scan over the recorded primitives rather than a rendered image. + +use hephaestus::geometry::{Affine, Point, Rect}; +use hephaestus::path::FillRule; +use hephaestus::pick::{PickId, PickIndexScene, PickScope}; +use hephaestus::scene::recording::RecordingScene; +use hephaestus::{primitives, Brush, SceneBuilder}; + +fn rgb() -> Brush { + Brush::Solid(hephaestus::color::rgb8(10, 20, 30)) +} + +fn scene() -> PickIndexScene { + PickIndexScene::new(RecordingScene::new(), true) +} + +/// Fill an axis-aligned rect tagged with `id`. +fn fill_rect(s: &mut PickIndexScene, r: Rect, id: PickId) { + s.fill( + FillRule::NonZero, + Affine::IDENTITY, + &rgb(), + None, + &primitives::rect(r), + id, + ); +} + +// ── Ported from the deleted GPU round-trip suite ──────────────────────── + +#[test] +fn an_index_nothing_was_drawn_into_reports_nothing() { + let s = scene(); + assert!(s.index().is_empty()); + assert_eq!(s.pick_at(Point::new(5.0, 5.0)), None); + assert!(s.hits_at(Point::new(5.0, 5.0)).is_empty()); +} + +#[test] +fn ids_are_reported_at_known_positions() { + let mut s = scene(); + fill_rect(&mut s, Rect::new(10.0, 10.0, 60.0, 60.0), PickId::Id(7)); + fill_rect(&mut s, Rect::new(100.0, 10.0, 150.0, 60.0), PickId::Id(42)); + fill_rect( + &mut s, + Rect::new(10.0, 100.0, 60.0, 150.0), + PickId::Id(9000), + ); + + assert_eq!(s.pick_at(Point::new(35.0, 35.0)), Some(7)); + assert_eq!(s.pick_at(Point::new(125.0, 35.0)), Some(42)); + assert_eq!(s.pick_at(Point::new(35.0, 125.0)), Some(9000)); + // The gaps between them are misses. + assert_eq!(s.pick_at(Point::new(80.0, 35.0)), None); + assert_eq!(s.pick_at(Point::new(200.0, 200.0)), None); +} + +#[test] +fn block_occludes_what_is_under_it() { + let mut s = scene(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 100.0), PickId::Id(7)); + fill_rect(&mut s, Rect::new(40.0, 40.0, 60.0, 60.0), PickId::Block); + + assert_eq!(s.pick_at(Point::new(10.0, 10.0)), Some(7)); + assert_eq!( + s.pick_at(Point::new(50.0, 50.0)), + None, + "Block reports nothing and hides what is beneath" + ); + // `Block` is an occluder, not a target: it truncates the walk and is + // itself absent from the result, so a caller sees empty space. + assert!(s.hits_at(Point::new(50.0, 50.0)).is_empty()); + // The mark beneath really is a candidate — it is the Block that hides it. + assert_eq!(s.hits_at(Point::new(10.0, 10.0)).len(), 1); +} + +#[test] +fn skip_is_absent_rather_than_transparent() { + let mut s = scene(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 100.0), PickId::Id(7)); + fill_rect(&mut s, Rect::new(40.0, 40.0, 60.0, 60.0), PickId::Skip); + + assert_eq!(s.pick_at(Point::new(50.0, 50.0)), Some(7)); + // Stronger than the hitmap could assert: the skipped fill was never + // recorded, so it cannot occlude, blend or cost anything at query time. + assert_eq!(s.index().len(), 1); +} + +#[test] +fn ids_past_24_bits_survive_intact() { + // The hitmap packed ids into RGB and truncated the high byte. With no + // encoding there is nothing to truncate. + let mut s = scene(); + fill_rect( + &mut s, + Rect::new(0.0, 0.0, 10.0, 10.0), + PickId::Id(0x0100_0001), + ); + assert_eq!(s.pick_at(Point::new(5.0, 5.0)), Some(0x0100_0001)); + + let mut s = scene(); + fill_rect( + &mut s, + Rect::new(0.0, 0.0, 10.0, 10.0), + PickId::Id(u32::MAX), + ); + assert_eq!(s.pick_at(Point::new(5.0, 5.0)), Some(u32::MAX)); +} + +#[test] +fn a_point_beyond_every_primitive_misses() { + // The index has no canvas bounds — it answers about geometry, not about + // a framebuffer — so this is "outside everything", not "off-canvas". + let mut s = scene(); + fill_rect(&mut s, Rect::new(10.0, 10.0, 20.0, 20.0), PickId::Id(1)); + assert_eq!(s.pick_at(Point::new(-500.0, -500.0)), None); + assert_eq!(s.pick_at(Point::new(1e6, 1e6)), None); +} + +#[test] +fn a_mesh_is_picked_inside_its_triangle() { + use hephaestus::color::rgb8; + use hephaestus::mesh::Mesh; + let mut s = scene(); + let mesh = Mesh::new( + vec![ + Point::new(0.0, 0.0), + Point::new(100.0, 0.0), + Point::new(0.0, 100.0), + ], + vec![rgb8(1, 2, 3); 3], + vec![0, 1, 2], + ); + s.draw_mesh(&mesh, Affine::IDENTITY, PickId::Id(42)); + + assert_eq!(s.pick_at(Point::new(10.0, 10.0)), Some(42)); + // Inside the bounding box, outside the triangle. + assert_eq!(s.pick_at(Point::new(90.0, 90.0)), None); +} + +// ── Z order, coalescing, transforms ───────────────────────────────────── + +#[test] +fn overlapping_marks_report_topmost_first_and_all_hits_on_request() { + let mut s = scene(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 100.0), PickId::Id(1)); + fill_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), PickId::Id(2)); + + let hits = s.hits_at(Point::new(75.0, 75.0)); + assert_eq!(hits.len(), 2, "both are under the point"); + assert_eq!(hits[0].id(), Some(2), "later draw is on top"); + assert_eq!(hits[1].id(), Some(1)); + assert!(hits[0].order > hits[1].order); + assert_eq!(s.pick_at(Point::new(75.0, 75.0)), Some(2)); +} + +#[test] +fn a_filled_and_stroked_mark_is_one_hit() { + let mut s = scene(); + let circle = primitives::circle(Point::new(50.0, 50.0), 20.0); + s.fill( + FillRule::NonZero, + Affine::IDENTITY, + &rgb(), + None, + &circle, + PickId::Id(5), + ); + s.stroke( + &hephaestus::stroke::Stroke::new(3.0), + Affine::IDENTITY, + &rgb(), + None, + &circle, + PickId::Id(5), + ); + // Two primitives were recorded... + assert!(s.index().len() >= 2); + // ...but a caller hovering the mark sees one thing. + let hits = s.hits_at(Point::new(50.0, 50.0)); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id(), Some(5)); +} + +#[test] +fn a_rotated_mark_is_hit_in_its_own_frame() { + let mut s = scene(); + // A tall thin rect, rotated a quarter turn about its centre. + let r = Rect::new(-40.0, -5.0, 40.0, 5.0); + let xf = Affine::translate((100.0, 100.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2); + s.fill( + FillRule::NonZero, + xf, + &rgb(), + None, + &primitives::rect(r), + PickId::Id(3), + ); + + // Along the rotated long axis (vertical in device space). + assert_eq!(s.pick_at(Point::new(100.0, 130.0)), Some(3)); + // Along the unrotated long axis, which is now the short one. + assert_eq!(s.pick_at(Point::new(130.0, 100.0)), None); +} + +#[test] +fn a_singular_transform_records_nothing() { + let mut s = scene(); + s.fill( + FillRule::NonZero, + Affine::scale(0.0), + &rgb(), + None, + &primitives::rect(Rect::new(0.0, 0.0, 10.0, 10.0)), + PickId::Id(1), + ); + assert!(s.index().is_empty()); + assert_eq!(s.pick_at(Point::new(0.0, 0.0)), None); +} + +// ── Clipping ──────────────────────────────────────────────────────────── + +#[test] +fn a_rounded_clip_rejects_the_cut_corner_and_accepts_it_unclipped() { + let panel = Rect::new(0.0, 0.0, 100.0, 100.0); + let corner = Point::new(1.0, 1.0); + + let mut clipped = scene(); + clipped.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &primitives::rounded_rect(panel, 30.0), + ); + fill_rect(&mut clipped, panel, PickId::Id(1)); + clipped.pop_layer(); + assert_eq!( + clipped.pick_at(corner), + None, + "the rounding cuts this corner away" + ); + assert_eq!(clipped.pick_at(Point::new(50.0, 50.0)), Some(1)); + + let mut plain = scene(); + fill_rect(&mut plain, panel, PickId::Id(1)); + assert_eq!(plain.pick_at(corner), Some(1)); +} + +#[test] +fn a_nested_clip_rejects_what_either_level_rejects() { + let mut s = scene(); + s.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &primitives::rect(Rect::new(0.0, 0.0, 100.0, 100.0)), + ); + s.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &primitives::rect(Rect::new(50.0, 0.0, 200.0, 100.0)), + ); + fill_rect(&mut s, Rect::new(0.0, 0.0, 200.0, 200.0), PickId::Id(1)); + s.pop_layer(); + s.pop_layer(); + + assert_eq!(s.pick_at(Point::new(75.0, 50.0)), Some(1), "inside both"); + assert_eq!(s.pick_at(Point::new(25.0, 50.0)), None, "outside the inner"); + assert_eq!( + s.pick_at(Point::new(150.0, 50.0)), + None, + "outside the outer" + ); +} + +#[test] +fn a_primitive_entirely_outside_its_clip_is_never_recorded() { + let mut s = scene(); + s.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &primitives::rect(Rect::new(0.0, 0.0, 10.0, 10.0)), + ); + fill_rect(&mut s, Rect::new(500.0, 500.0, 600.0, 600.0), PickId::Id(1)); + s.pop_layer(); + assert!(s.index().is_empty()); +} + +#[test] +fn an_unbalanced_pop_layer_does_not_panic() { + let mut s = scene(); + s.pop_layer(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 10.0, 10.0), PickId::Id(1)); + assert_eq!(s.pick_at(Point::new(5.0, 5.0)), Some(1)); +} + +// ── Scopes ────────────────────────────────────────────────────────────── + +#[test] +fn a_hit_carries_the_scope_chain_it_was_drawn_inside() { + let mut s = scene(); + s.push_pick_scope(&PickScope::group("plot").with_name("a").with_index(0)); + s.push_pick_scope(&PickScope::group("region").with_name("panel")); + fill_rect(&mut s, Rect::new(0.0, 0.0, 10.0, 10.0), PickId::Id(1)); + s.pop_pick_scope(); + s.pop_pick_scope(); + + let hits = s.hits_at(Point::new(5.0, 5.0)); + assert_eq!(hits.len(), 1); + let kinds: Vec<&str> = hits[0].path.frames().iter().map(|f| f.kind()).collect(); + assert_eq!(kinds, vec!["plot", "region"]); + assert_eq!(hits[0].path.find("plot").and_then(|f| f.name()), Some("a")); +} + +#[test] +fn a_target_scope_makes_an_unidentified_primitive_pickable() { + let mut s = scene(); + // Chrome draws with `Skip` and has no id of its own. + s.push_pick_scope(&PickScope::target("part").with_name("axis_tick_label")); + fill_rect(&mut s, Rect::new(0.0, 0.0, 10.0, 10.0), PickId::Skip); + s.pop_pick_scope(); + + let hits = s.hits_at(Point::new(5.0, 5.0)); + assert_eq!(hits.len(), 1, "a Target scope indexes what it contains"); + assert_eq!(hits[0].pick_id, PickId::Skip); + assert_eq!(hits[0].id(), None, "chrome carries no authoring id"); + assert_eq!( + hits[0].path.find("part").and_then(|f| f.name()), + Some("axis_tick_label") + ); +} + +#[test] +fn a_group_scope_leaves_an_unidentified_primitive_alone() { + let mut s = scene(); + // The default, and what a dense geom with no `pick_id` channel gets. + s.push_pick_scope(&PickScope::group("geom").with_index(0)); + for i in 0..1000 { + fill_rect( + &mut s, + Rect::new(i as f64, 0.0, i as f64 + 1.0, 1.0), + PickId::Skip, + ); + } + s.pop_pick_scope(); + assert!( + s.index().is_empty(), + "Group must preserve the pre-scope behaviour of Skip" + ); +} + +// ── Brushing and lasso ────────────────────────────────────────────────── + +fn marks() -> PickIndexScene { + let mut s = scene(); + // A 5x5 grid of 10x10 marks on a 40px pitch, ids 1..=25. + let mut id = 1u32; + for row in 0..5 { + for col in 0..5 { + let (x, y) = (col as f64 * 40.0, row as f64 * 40.0); + fill_rect(&mut s, Rect::new(x, y, x + 10.0, y + 10.0), PickId::Id(id)); + id += 1; + } + } + s +} + +#[test] +fn a_marquee_selects_exactly_what_it_encloses() { + let s = marks(); + // Covers the marks at cols 0-1, rows 0-1 => ids 1, 2, 6, 7. + let rect = Rect::new(-5.0, -5.0, 55.0, 55.0); + let mut got: Vec = s.hits_within(rect).iter().filter_map(|h| h.id()).collect(); + got.sort_unstable(); + assert_eq!(got, vec![1, 2, 6, 7]); +} + +#[test] +fn hits_in_is_a_superset_of_hits_within() { + let s = marks(); + // An edge cutting through the marks at col 1. + let rect = Rect::new(-5.0, -5.0, 45.0, 55.0); + let within: Vec = s.hits_within(rect).iter().filter_map(|h| h.id()).collect(); + let inside: Vec = s.hits_in(rect).iter().filter_map(|h| h.id()).collect(); + + assert!(within.iter().all(|id| inside.contains(id))); + assert!( + inside.len() > within.len(), + "a straddling mark is in `hits_in` only" + ); +} + +#[test] +fn a_marquee_selects_through_an_occluder() { + let mut s = marks(); + fill_rect( + &mut s, + Rect::new(-100.0, -100.0, 500.0, 500.0), + PickId::Block, + ); + // A region query is not a ray, so `Block` does not truncate it. + let rect = Rect::new(-5.0, -5.0, 55.0, 55.0); + let got: Vec = s.hits_within(rect).iter().filter_map(|h| h.id()).collect(); + assert_eq!(got.len(), 4); +} + +#[test] +fn a_clipped_away_mark_is_not_brushable() { + let mut s = scene(); + s.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &primitives::rect(Rect::new(0.0, 0.0, 20.0, 20.0)), + ); + fill_rect(&mut s, Rect::new(100.0, 100.0, 110.0, 110.0), PickId::Id(1)); + s.pop_layer(); + assert!(s.hits_in(Rect::new(0.0, 0.0, 500.0, 500.0)).is_empty()); +} + +#[test] +fn a_concave_lasso_excludes_marks_sitting_in_its_notch() { + let s = marks(); + // A C shape opening to the right: the notch spans the middle rows over + // the right-hand columns. Its bounding box covers the whole grid, which + // is exactly why a bbox test would get this wrong. + let mut c = hephaestus::path::Path::new(); + c.move_to((-10.0, -10.0)); + c.line_to((200.0, -10.0)); + c.line_to((200.0, 30.0)); + c.line_to((30.0, 30.0)); + c.line_to((30.0, 130.0)); + c.line_to((200.0, 130.0)); + c.line_to((200.0, 200.0)); + c.line_to((-10.0, 200.0)); + c.close_path(); + + let got: Vec = s + .hits_in_path(&c, FillRule::NonZero) + .iter() + .filter_map(|h| h.id()) + .collect(); + + // Top row is inside the upper arm; ids 1..=5. + assert!(got.contains(&1) && got.contains(&5)); + // The notch swallows the right-hand columns of the middle rows. + assert!(!got.contains(&8), "id 8 sits in the notch"); + assert!(!got.contains(&15), "id 15 sits in the notch"); + // The left column runs down the spine and survives. + assert!(got.contains(&6) && got.contains(&11)); +} + +#[test] +fn an_even_odd_lasso_excludes_marks_in_its_hole() { + let s = marks(); + let mut ring = primitives::rect(Rect::new(-10.0, -10.0, 200.0, 200.0)); + ring.extend(primitives::rect(Rect::new(30.0, 30.0, 130.0, 130.0)).iter()); + + let got: Vec = s + .hits_in_path(&ring, FillRule::EvenOdd) + .iter() + .filter_map(|h| h.id()) + .collect(); + // id 1 is at (0,0), outside the hole; id 13 is at (80,80), inside it. + assert!(got.contains(&1)); + assert!(!got.contains(&13), "the hole is not selected"); +} + +// ── The decorator is transparent ──────────────────────────────────────── + +/// Draw the same thing into a bare recording and a wrapped one; the two +/// recordings must be identical. This is the cheapest possible guarantee +/// that indexing never changes what gets drawn. +#[test] +fn wrapping_a_scene_does_not_change_what_it_records() { + fn draw(s: &mut dyn SceneBuilder) { + s.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &primitives::rect(Rect::new(0.0, 0.0, 100.0, 100.0)), + ); + s.fill( + FillRule::EvenOdd, + Affine::translate((5.0, 5.0)), + &Brush::Solid(hephaestus::color::rgb8(1, 2, 3)), + None, + &primitives::circle(Point::new(10.0, 10.0), 4.0), + PickId::Id(11), + ); + s.stroke( + &hephaestus::stroke::Stroke::new(2.0), + Affine::IDENTITY, + &Brush::Solid(hephaestus::color::rgb8(4, 5, 6)), + None, + &primitives::rect(Rect::new(1.0, 1.0, 9.0, 9.0)), + PickId::Skip, + ); + s.pop_layer(); + } + + let mut bare = RecordingScene::new(); + draw(&mut bare); + + for enabled in [true, false] { + let mut wrapped = PickIndexScene::new(RecordingScene::new(), enabled); + draw(&mut wrapped); + assert_eq!( + wrapped.inner(), + &bare, + "enabled = {enabled}: the recording must be identical" + ); + } +} + +#[test] +fn disabling_indexing_draws_the_same_and_records_nothing() { + let mut off = PickIndexScene::new(RecordingScene::new(), false); + fill_rect(&mut off, Rect::new(0.0, 0.0, 10.0, 10.0), PickId::Id(1)); + assert!(off.index().is_empty()); + assert_eq!(off.pick_at(Point::new(5.0, 5.0)), None); + assert!(!off.inner().ops.is_empty(), "but it still drew"); +} + +#[test] +fn clearing_the_scene_clears_the_index() { + let mut s = scene(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 10.0, 10.0), PickId::Id(1)); + assert_eq!(s.index().len(), 1); + s.clear(); + assert!(s.index().is_empty()); + assert_eq!(s.pick_at(Point::new(5.0, 5.0)), None); +} From ec1834e6ba7d0928d5a4aca7068faf31fe120f55 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 2 Sep 2026 14:17:17 +0200 Subject: [PATCH 2/3] Rip out old hitmap approach --- Cargo.toml | 4 - crates/hephaestus-wasm/js/hephaestus.d.ts | 4 +- crates/hephaestus-wasm/js/hephaestus.js | 10 +- crates/hephaestus-wasm/src/lib.rs | 14 +- examples/backend_perf.rs | 95 +++- examples/window.rs | 19 +- src/backend/hybrid/mod.rs | 139 ++--- src/backend/hybrid/webgl.rs | 172 ++----- src/backend/hybrid/wgpu_renderer.rs | 429 +++------------- src/backend/vello/mod.rs | 591 +++------------------- src/pick/clip.rs | 22 +- src/pick/geom.rs | 182 ++++--- src/pick/index.rs | 18 +- src/pick/mod.rs | 224 ++------ src/plot/chrome/panel.rs | 31 +- src/plot/composition.rs | 91 +++- src/plot/geom/ellipse.rs | 17 +- src/plot/geom/mod.rs | 2 +- src/plot/geom/point.rs | 2 +- src/plot/geom/resolve.rs | 10 +- src/plot/geom/state.rs | 10 +- src/plot/mod.rs | 2 + src/plot/pick.rs | 367 ++++++++++++++ src/plot/plot.rs | 79 ++- src/scene/mod.rs | 7 +- src/scene/recording.rs | 48 +- src/window/app.rs | 19 +- src/window/canvas.rs | 38 +- src/window/mod.rs | 69 ++- src/window/renderer.rs | 52 +- src/window/webgl_host.rs | 31 +- tests/hybrid.rs | 231 +++------ tests/mesh.rs | 4 +- tests/picking.rs | 211 -------- 34 files changed, 1306 insertions(+), 1938 deletions(-) create mode 100644 src/plot/pick.rs delete mode 100644 tests/picking.rs diff --git a/Cargo.toml b/Cargo.toml index 06e1178..ad9c80e 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/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..a8aea74 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,26 @@ 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::path::{FillRule, Path}; +use crate::pick::{Hit, 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 +64,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 +87,62 @@ 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; + /// The canvas drawing-buffer size the renderer is currently built for. + pub fn size(&self) -> (u32, u32) { + (self.width, self.height) } - /// Whether the coming frame will refresh the hitmap. - pub fn refreshes_pick(&self) -> bool { - self.pick.is_some() && self.refresh_pick + /// Every hit at `p`, topmost first. + pub fn hits_at(&self, p: crate::geometry::Point) -> Vec> { + self.scene.hits_at(p) } - /// 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]) + /// The topmost authoring id at `p`. + pub fn pick_at(&self, p: crate::geometry::Point) -> Option { + self.scene.pick_at(p) } - /// Current drawing-buffer size in device pixels. - pub fn size(&self) -> (u32, u32) { - (self.width, self.height) + /// Every hit whose bounds intersect `rect` — rubber-band brushing. + pub fn hits_in(&self, rect: crate::geometry::Rect) -> Vec> { + self.scene.hits_in(rect) } - /// Raw pick pixels of the last refreshed hitmap, for bulk queries. - pub fn hitmap(&self) -> Option<&[u32]> { - self.hitmap.as_deref() + /// Every hit entirely inside `rect` — a selection marquee. + pub fn hits_within(&self, rect: crate::geometry::Rect) -> Vec> { + self.scene.hits_within(rect) + } + + /// Lasso selection. + pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { + self.scene.hits_in_path(path, rule) + } + + /// The hit index the scene built. `None` when picking is off. + pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> { + self.scene.indexes().then(|| self.scene.index()) + } + + /// Whether this renderer's scene records a hit index. + pub fn picks(&self) -> bool { + self.scene.indexes() } /// 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 +150,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 +169,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 +182,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..9f3b713 100644 --- a/src/backend/hybrid/wgpu_renderer.rs +++ b/src/backend/hybrid/wgpu_renderer.rs @@ -11,14 +11,12 @@ 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::path::{FillRule, Path}; +use crate::pick::{Hit, PickIndexScene}; // ---------- Renderer ---------- @@ -32,7 +30,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 +70,6 @@ impl Target { width, height, padded_bytes_per_row, - format, } } } @@ -87,61 +83,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 [`PickIndexScene`](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 +130,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 +174,48 @@ 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]) + /// Every hit at `p`, topmost first. Answers from the index the scene + /// built while it was drawn — no second rasterisation, no readback. + pub fn hits_at(&self, p: crate::geometry::Point) -> Vec> { + self.scene.hits_at(p) } - /// 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. - /// - /// 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; + /// The topmost authoring id at `p`, or `None` over empty space, an + /// occluder, or when this renderer was not built with picking. + pub fn pick_at(&self, p: crate::geometry::Point) -> Option { + self.scene.pick_at(p) } - /// Whether the coming render will refresh the hitmap. - pub fn refreshes_pick(&self) -> bool { - self.picking && self.refresh_pick + /// Every hit whose bounds intersect `rect` — rubber-band brushing. + pub fn hits_in(&self, rect: crate::geometry::Rect) -> Vec> { + self.scene.hits_in(rect) + } + + /// Every hit entirely inside `rect` — a selection marquee. + pub fn hits_within(&self, rect: crate::geometry::Rect) -> Vec> { + self.scene.hits_within(rect) + } + + /// Lasso selection. + pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { + self.scene.hits_in_path(path, rule) + } + + /// The hit index the scene built. `None` when picking is off. + pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> { + self.scene.indexes().then(|| self.scene.index()) + } + + /// Whether this renderer's scene records a hit index. + pub fn picks(&self) -> bool { + self.scene.indexes() } /// Set the texture format [`WgpuRenderer::render_to_texture`] writes. @@ -263,14 +232,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 +260,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 +274,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 +307,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 +323,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 +352,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 +372,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 +423,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 +476,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/vello/mod.rs b/src/backend/vello/mod.rs index af46c31..ee4babb 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::{Hit, 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 [`PickIndexScene`](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,52 @@ 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(()) + check_draw_budget(self.scene.inner().raw()) } - /// 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(()) + /// Every hit at `p`, topmost first. Answers from the index the scene + /// built while it was drawn, so it needs no GPU round-trip and no + /// readback — see [`PickIndex::hits_at`](crate::pick::PickIndex::hits_at). + pub fn hits_at(&self, p: crate::geometry::Point) -> Vec> { + self.scene.hits_at(p) } - /// 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) + /// The topmost authoring id at `p`, or `None` over empty space, an + /// occluder, or when this renderer was not built with picking. + pub fn pick_at(&self, p: crate::geometry::Point) -> Option { + self.scene.pick_at(p) } - /// 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(()) + /// Every hit whose bounds intersect `rect` — rubber-band brushing. + pub fn hits_in(&self, rect: crate::geometry::Rect) -> Vec> { + self.scene.hits_in(rect) } - /// Control whether the coming render refreshes the hitmap. - /// - /// 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; + /// Every hit entirely inside `rect` — a selection marquee. + pub fn hits_within(&self, rect: crate::geometry::Rect) -> Vec> { + self.scene.hits_within(rect) } - /// Whether the coming render will refresh the hitmap. - pub fn refreshes_pick(&self) -> bool { - self.refresh_pick && self.scene.raw_pick().is_some() + /// Lasso selection. + pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { + self.scene.hits_in_path(path, rule) } - /// 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]) + /// The hit index the scene built. `None` when picking is off. + pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> { + self.scene.indexes().then(|| self.scene.index()) } - /// 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() + /// Whether this renderer's scene records a hit index. + pub fn picks(&self) -> bool { + self.scene.indexes() } } impl Renderer for VelloRenderer { - type Scene = VelloScene; + type Scene = PickIndexScene; fn scene(&mut self) -> &mut Self::Scene { &mut self.scene @@ -813,14 +443,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 +460,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 +487,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 +495,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 +502,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 +514,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 +535,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 +545,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/pick/clip.rs b/src/pick/clip.rs index 7cbd555..371db78 100644 --- a/src/pick/clip.rs +++ b/src/pick/clip.rs @@ -155,35 +155,41 @@ impl ClipStack { /// hundreds of thousands of near-identical paths. pub(crate) fn as_axis_rect(path: &Path) -> Option { use crate::geometry::PathEl; - let mut pts: Vec = Vec::with_capacity(5); + // 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 !pts.is_empty() { + if n != 0 { return None; // more than one subpath } - pts.push(*p); + pts[0] = *p; + n = 1; } PathEl::LineTo(p) => { - if pts.is_empty() || pts.len() > 4 { + if n == 0 || n >= 5 { return None; } - pts.push(*p); + 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 pts.len() == 5 { + if n == 5 { if !near(pts[4], pts[0]) { return None; } - pts.pop(); + n = 4; } - if pts.len() != 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) = ( diff --git a/src/pick/geom.rs b/src/pick/geom.rs index 6c7d43e..e076c4a 100644 --- a/src/pick/geom.rs +++ b/src/pick/geom.rs @@ -9,6 +9,7 @@ //! transform. use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; use crate::geometry::{flatten, Affine, PathEl, Point, Rect, Shape}; @@ -28,14 +29,10 @@ const CHUNK: usize = 64; /// than the copy saves, and they do not repeat the way markers do. const INTERN_MAX_ELEMENTS: usize = 64; -/// Interning is a frame-to-frame cache; past this many entries it is reset -/// wholesale rather than grown without bound. -const INTERN_MAX_SHAPES: usize = 4096; - /// 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 hitmap expressed +/// 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; @@ -43,6 +40,18 @@ const MIN_HIT_HALF_WIDTH_PX: f64 = 1.0; /// 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); @@ -75,12 +84,22 @@ pub(crate) enum Geom { } /// 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 { - paths: Vec, + path_els: Vec, + /// One slot per [`ShapeId`], indexing `path_els`. + path_ranges: Vec, /// Content hash → shapes sharing it. Collisions are resolved by - /// comparing the paths outright. - intern: HashMap>, + /// 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>, @@ -89,42 +108,59 @@ pub(crate) struct GeomStore { } impl GeomStore { - /// Drop everything a frame accumulated. The intern table survives — the - /// same marker shapes recur every frame — unless it has grown past - /// [`INTERN_MAX_SHAPES`]. + /// 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(); - if self.paths.len() > INTERN_MAX_SHAPES { - self.paths.clear(); - self.intern.clear(); - } } - /// Store `path`, reusing an identical one already held. + /// Store `path`, reusing an identical one already held this frame. pub(crate) fn intern_path(&mut self, path: &Path) -> ShapeId { - if path.elements().len() > INTERN_MAX_ELEMENTS { - self.paths.push(path.clone()); - return ShapeId(self.paths.len() as u32 - 1); - } - let key = hash_path(path); - if let Some(bucket) = self.intern.get(&key) { - for &id in bucket { - if &self.paths[id as usize] == path { - return ShapeId(id); + 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.paths.push(path.clone()); - let id = self.paths.len() as u32 - 1; - self.intern.entry(key).or_default().push(id); - ShapeId(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] } - /// Borrow a stored path. - pub(crate) fn path(&self, id: ShapeId) -> &Path { - &self.paths[id.0 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 @@ -142,30 +178,29 @@ impl GeomStore { 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( - self.paths[id.0 as usize].iter(), - tolerance.max(1e-6), - |el| { - match el { - PathEl::MoveTo(p) => { - if !pts.is_empty() { - pts.push(BREAK); - } - pts.push(p); + flatten(els.iter().copied(), tolerance.max(1e-6), |el| { + match el { + PathEl::MoveTo(p) => { + if !pts.is_empty() { + pts.push(BREAK); } - PathEl::LineTo(p) => pts.push(p), - PathEl::ClosePath => { - // Close the ring so the last edge is testable. - if let Some(start) = last_subpath_start(&pts) { - pts.push(start); - } + 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); @@ -234,6 +269,28 @@ impl GeomStore { } } +/// 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, @@ -363,7 +420,7 @@ fn tolerance_bucket(tolerance: f64) -> i32 { (tolerance.log2() * 4.0).round() as i32 } -fn hash_path(path: &Path) -> u64 { +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; @@ -371,7 +428,7 @@ fn hash_path(path: &Path) -> u64 { h ^= bits; h = h.wrapping_mul(0x1000_0000_01b3); }; - for el in path.elements() { + 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)), @@ -385,7 +442,7 @@ fn hash_path(path: &Path) -> u64 { feed(p.y.to_bits()); } } - feed(path.elements().len() as u64); + feed(els.len() as u64); h } @@ -436,14 +493,14 @@ mod tests { let ic = s.intern_path(&c); assert_eq!(ia, ib, "identical paths must intern to one shape"); assert_ne!(ia, ic); - assert_eq!(s.paths.len(), 2); + 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.paths.len(), 2); + assert_eq!(s.path_ranges.len(), 2); } #[test] @@ -461,15 +518,18 @@ mod tests { } #[test] - fn clear_keeps_the_shape_cache_but_drops_per_frame_geometry() { + 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(), "flattenings are per frame"); - // Shapes recur every frame, so the table survives. - assert_eq!(s.intern_path(&p), id); + 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] @@ -563,7 +623,7 @@ mod tests { 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(shape).bounding_box(); + let local = s.path_bounds(shape); let in_ring = Point::new(7.5, 0.0); let in_hole = Point::new(0.0, 0.0); diff --git a/src/pick/index.rs b/src/pick/index.rs index 04bcb3f..7ad3926 100644 --- a/src/pick/index.rs +++ b/src/pick/index.rs @@ -162,7 +162,7 @@ impl PickIndex { return; } let shape = self.store.intern_path(path); - let local = self.store.path(shape).bounding_box(); + let local = self.store.path_bounds(shape); let even_odd = geom::is_even_odd(rule); self.push_entry(transform, local, Geom::Fill { shape, even_odd }, pick_id); } @@ -268,6 +268,10 @@ impl PickIndex { { return; } + // `get_mut` rather than `borrow_mut`: recording takes `&mut self`, so + // the invalidation needs no runtime borrow check, and this runs once + // per primitive. + *self.tree.get_mut() = None; self.leaves.push(to_bbox(device)); self.entries.push(Entry { inv, @@ -277,7 +281,6 @@ impl PickIndex { clip: self.clips.current(), scope: self.scopes.current(), }); - *self.tree.borrow_mut() = None; } // ── Queries ───────────────────────────────────────────────────────── @@ -542,6 +545,17 @@ mod tests { } } + /// The index has to be movable to a worker thread for the tree build to + /// be offloadable at all. It is deliberately not `Sync`: the lazy tree + /// and the query scratch buffers live behind `RefCell`. + #[test] + fn the_index_is_send() { + fn assert_send() {} + assert_send::(); + assert_send::(); + assert_send::>(); + } + /// A scatter of small circles plus a few stroked lines and rects, with /// enough marks to force real tree levels. fn populated() -> PickIndex { diff --git a/src/pick/mod.rs b/src/pick/mod.rs index bbccd40..fe8491c 100644 --- a/src/pick/mod.rs +++ b/src/pick/mod.rs @@ -1,50 +1,36 @@ //! 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. +//! Every drawing call on a [`SceneBuilder`](crate::scene::SceneBuilder) +//! carries a [`PickId`], the authoring layer's own handle for whatever the +//! call draws. A scene wrapped in [`PickIndexScene`] records each call's +//! geometry into a [`PickIndex`] as it goes past, and the index answers point, +//! rectangle and lasso queries afterwards — on the CPU, with no second +//! rasterisation and nothing read back from a GPU. //! -//! 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. +//! Ids are caller-managed and span the full `u32` range; `0` is reserved as +//! the no-hit sentinel, which is what [`PickId::Block`] encodes. //! -//! # Limitation: blended ids where picked content meets picked content +//! # Beyond ids: scopes //! -//! 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. +//! An id is a leaf. [`PickScope`] records the *tree* a drawing sits in — +//! pushed and popped like a layer, but with no visual effect — so a hit can +//! report the axis, panel and plot it belongs to and not merely a number. +//! That 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. +//! The vocabulary the plot layer pushes lives in [`crate::plot::pick`]. //! -//! 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). +//! # Known limits //! -//! 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. +//! - **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.** Leading and side bearings +//! are hittable, which is what a text target should be; a glyph-backed +//! marker shape is correspondingly looser than its outline. +//! - **Stroke ends and joins are round** whatever the cap and join say. The +//! error is 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. mod clip; mod geom; @@ -58,61 +44,30 @@ pub use index::{Hit, PickIndex}; pub use scene::PickIndexScene; pub use scope::{PickPath, PickScope, ScopeMode}; -use crate::color::Color; - -/// Per-draw-call hitmap directive. +/// The authoring layer's handle for whatever a draw call draws. #[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.). + /// Carry no authoring id. + /// + /// The primitive is not indexed on its own, and whatever is beneath it + /// stays hittable through it. The default, and what all decorative chrome + /// passes — chrome becomes pickable by sitting inside a + /// [`ScopeMode::Target`] scope, not by taking an id. #[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. + /// Occlude without reporting. + /// + /// A point query stops here and reports nothing, so an opaque panel can + /// hide what is under it without being interactive itself. Region queries + /// are unaffected: a marquee is a spatial query, not a ray. 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. + /// Carry the given id. `Id(0)` is treated identically to [`Self::Block`], + /// `0` being the no-hit sentinel; every other `u32` is reported as given. 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. +/// Resolve a [`PickId`] to the raw id it reports, or `None` if the call +/// should not be indexed at all. pub fn raw_id(pick: PickId) -> Option { match pick { PickId::Skip => None, @@ -125,97 +80,26 @@ pub fn raw_id(pick: PickId) -> Option { 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)); + // `Id(0)` and `Block` are the same request: occlude, report nothing. + assert_eq!(raw_id(PickId::Id(0)), raw_id(PickId::Block)); + } + + #[test] + fn ids_span_the_whole_u32_range() { + // Nothing packs an id into colour channels any more, so there is no + // width to truncate to. + assert_eq!(raw_id(PickId::Id(0x0100_0001)), Some(0x0100_0001)); + assert_eq!(raw_id(PickId::Id(u32::MAX)), Some(u32::MAX)); + assert_eq!(raw_id(PickId::Id(0x0100_0000)), Some(0x0100_0000)); } #[test] - fn default_pick_id_stays_out_of_the_hitmap() { + fn default_pick_id_carries_no_id() { assert_eq!(PickId::default(), PickId::Skip); assert_eq!(raw_id(PickId::default()), None); } diff --git a/src/plot/chrome/panel.rs b/src/plot/chrome/panel.rs index f772cbe..4124651 100644 --- a/src/plot/chrome/panel.rs +++ b/src/plot/chrome/panel.rs @@ -22,6 +22,7 @@ use crate::geometry::{Affine, Point, Rect}; use crate::path::{FillRule, Path}; use crate::pick::PickId; use crate::plot::chrome::linear_axis::{stroke_from_line_element, stroke_from_rect_border}; +use crate::plot::pick::{item_scope, part_scope, part_scope_for_channel, PlotPart}; use crate::plot::projection::{PolarEdgeStyle, PolarProjection, Projection}; use crate::plot::scale::Scale; use crate::plot::theme::{LineElement, RectElement, Theme}; @@ -81,7 +82,9 @@ pub(crate) fn draw_panel_chrome( _ => FillRule::NonZero, }; if let Some(bg) = theme.panel_background.as_set() { + scene.push_pick_scope(&part_scope(PlotPart::PanelBackground)); fill_rect_element(scene, bg, &theme.palette, &outline_path, bg_fill_rule); + scene.pop_pick_scope(); } // Grid lines, per channel. Wrapped in a `push_layer` clip so @@ -126,6 +129,7 @@ pub(crate) fn draw_panel_chrome( draw_grid_lines( scene, scale, + 0, |frac| channel_grid_path(projection, panel, 0, frac), major_0, minor_0, @@ -137,6 +141,7 @@ pub(crate) fn draw_panel_chrome( draw_grid_lines( scene, scale, + 1, |frac| channel_grid_path(projection, panel, 1, frac), major_1, minor_1, @@ -149,7 +154,9 @@ pub(crate) fn draw_panel_chrome( // Panel outline. if let Some(border) = theme.panel_border.as_set() { + scene.push_pick_scope(&part_scope(PlotPart::PanelOutline)); stroke_rect_element_border(scene, border, &theme.palette, &outline_path, dpi); + scene.pop_pick_scope(); } } @@ -208,9 +215,15 @@ fn stroke_rect_element_border( /// are optional theme elements — `None` (Blank or unresolved) /// suppresses that level entirely. #[allow(clippy::too_many_arguments)] +/// Stroke one channel's grid lines. +/// +/// `channel` is the `PerChannel` coordinate the theme files these elements +/// under, and is what the part frame reports, so a hit on a gridline names +/// the theme slot that styled it. fn draw_grid_lines( scene: &mut dyn SceneBuilder, scale: &Scale, + channel: u8, mut path_at: F, major: Option<&LineElement>, minor: Option<&LineElement>, @@ -233,7 +246,14 @@ fn draw_grid_lines( let major_resolved = major.map(|el| (stroke_from_line_element(el, dpi), resolve_color(el))); if let Some((stroke, brush)) = &minor_resolved { - for v in scale.minor_breaks(DEFAULT_BREAK_COUNT) { + scene.push_pick_scope(&part_scope_for_channel(PlotPart::GridMinor, channel)); + // `enumerate` before the guards, so the ordinal addresses the + // scale's own break list — see the note in `chrome::axis::draw`. + for (break_index, v) in scale + .minor_breaks(DEFAULT_BREAK_COUNT) + .into_iter() + .enumerate() + { if matches!(v, Value::Null) { continue; } @@ -242,12 +262,16 @@ fn draw_grid_lines( _ => continue, }; if let Some(path) = path_at(frac) { + scene.push_pick_scope(&item_scope(break_index as u32)); scene.stroke(stroke, Affine::IDENTITY, brush, None, &path, PickId::Skip); + scene.pop_pick_scope(); } } + scene.pop_pick_scope(); } if let Some((stroke, brush)) = &major_resolved { - for v in scale.breaks(DEFAULT_BREAK_COUNT) { + scene.push_pick_scope(&part_scope_for_channel(PlotPart::GridMajor, channel)); + for (break_index, v) in scale.breaks(DEFAULT_BREAK_COUNT).into_iter().enumerate() { if matches!(v, Value::Null) { continue; } @@ -256,9 +280,12 @@ fn draw_grid_lines( _ => continue, }; if let Some(path) = path_at(frac) { + scene.push_pick_scope(&item_scope(break_index as u32)); scene.stroke(stroke, Affine::IDENTITY, brush, None, &path, PickId::Skip); + scene.pop_pick_scope(); } } + scene.pop_pick_scope(); } } diff --git a/src/plot/composition.rs b/src/plot/composition.rs index 11e6d3e..4f26ea4 100644 --- a/src/plot/composition.rs +++ b/src/plot/composition.rs @@ -1293,6 +1293,14 @@ impl PlotComposition { .map(|plot| self.effective_theme_for(plot)) .collect(); + // Phases 1-4 all sit under the root composition. A plot's drawing is + // not contiguous — each phase walks every plot before the next + // starts — but the scope stack is a logical path, not a bracket + // around a run of ops, so each phase simply re-establishes + // `composition → plot`. The scope tree interns paths, so the + // repeated prefixes cost nothing. + scene.push_pick_scope(&crate::plot::pick::composition_scope(&self.root_id)); + for (plot, effective) in self.plots_in_order().zip(&plot_themes) { plot.draw_patch_background_into(scene, layout, effective, dpi); } @@ -1336,6 +1344,7 @@ impl PlotComposition { for (plot, effective) in self.plots_in_order().zip(&plot_themes) { plot.draw_chrome_into(scene, layout, &self.scales, dpi, effective); } + scene.pop_pick_scope(); // Phase 5: composition-level chrome. Drawn last so a shared // title / legend paints over the canonical chrome band it @@ -1347,6 +1356,9 @@ impl PlotComposition { .iter() .filter_map(|id| self.chrome.get(id).map(|c| (id, c))) { + // Composition chrome has no owning plot, so its path is one + // frame shorter and `PlotPath::plot` reports `None` for it. + scene.push_pick_scope(&crate::plot::pick::composition_scope(comp_id)); chrome.draw_into( comp_id, scene, @@ -1357,6 +1369,7 @@ impl PlotComposition { dpi, &self.theme, ); + scene.pop_pick_scope(); } // Clear dirty bits after a successful render. @@ -1472,7 +1485,7 @@ fn matches_expected(expected: super::geom::ExpectedOutput, found: &'static str) #[cfg(test)] mod tests { use super::*; - use crate::composition::{beside, Patch as CompPatch}; + use crate::composition::{beside, Patch as CompPatch, Slot}; use crate::plot::geom::PointGeom; use crate::plot::scale; @@ -1713,6 +1726,82 @@ mod tests { ); } + #[test] + fn every_drawn_op_sits_in_a_balanced_scope() { + use crate::scene::recording::{Op, RecordingScene}; + let mut view = view_two_plots(); + let mut scene = RecordingScene::default(); + view.render(&mut scene, Size::new(600.0, 400.0), 96.0); + + // Balance: the stack returns to empty, and never goes negative. + let mut depth = 0i32; + for op in &scene.ops { + match op { + Op::PushPickScope { .. } => depth += 1, + Op::PopPickScope => depth -= 1, + _ => {} + } + assert!(depth >= 0, "pick scopes went negative"); + } + assert_eq!(depth, 0, "pick scopes left unbalanced"); + } + + #[test] + fn a_gridline_reports_its_plot_region_part_and_break() { + use crate::plot::pick::{PlotPart, PlotPath}; + use crate::scene::recording::{Op, RecordingScene}; + let mut view = view_two_plots(); + view.insert_scale("x", scale::continuous(0.0..=1.0)); + view.update_plot("a", |p| { + p.set_binding("x", "x"); + }); + let mut scene = RecordingScene::default(); + view.render(&mut scene, Size::new(600.0, 400.0), 96.0); + + // Find a stroke drawn under the major-grid part. + let found = scene.ops.iter().enumerate().find_map(|(i, op)| { + if !matches!(op, Op::Stroke { .. }) { + return None; + } + let frames = scene.scope_at(i); + frames + .iter() + .any(|f| f.name() == Some(PlotPart::GridMajor.name())) + .then_some((i, frames)) + }); + let (_, frames) = found.expect("a major gridline should be drawn"); + + let kinds: Vec<&str> = frames.iter().map(|f| f.kind()).collect(); + assert_eq!( + kinds, + vec!["composition", "plot", "region", "part", "item"], + "unexpected grammar: {kinds:?}" + ); + // The names line up with the anatomy and the theme's own addressing. + assert_eq!(frames[1].name(), Some("a")); + assert_eq!(frames[1].index(), Some(0)); + assert_eq!(frames[2].name(), Some(Slot::Panel.name())); + assert_eq!(frames[3].index(), Some(0), "channel 0 gridlines"); + assert!(frames[4].index().is_some(), "a break ordinal"); + + // And the typed view decodes the same path. + let mut tree = crate::pick::PickIndexScene::new(RecordingScene::new(), true); + view.render(&mut tree, Size::new(600.0, 400.0), 96.0); + let hits = tree + .index() + .hits_in(crate::geometry::Rect::new(0.0, 0.0, 600.0, 400.0)); + let p = hits + .iter() + .map(|h| PlotPath::new(h.path)) + .find(|p| p.part() == Some(PlotPart::GridMajor)) + .expect("the index should hold a major gridline"); + assert_eq!(p.plot(), Some(("a", 0))); + assert_eq!(p.region(), Some(Slot::Panel)); + assert_eq!(p.part_channel(), Some(0)); + assert!(p.item().is_some()); + assert_eq!(p.composition(), Some(super::ROOT_COMPOSITION_ID)); + } + #[test] fn composition_title_is_drawn() { let mut bare = view_two_plots(); diff --git a/src/plot/geom/ellipse.rs b/src/plot/geom/ellipse.rs index 2ec3b00..14e9a8e 100644 --- a/src/plot/geom/ellipse.rs +++ b/src/plot/geom/ellipse.rs @@ -755,8 +755,9 @@ mod tests { } #[test] - #[should_panic(expected = "must be a non-negative integer")] - fn pick_id_above_24_bit_panics_at_build() { + fn pick_id_past_24_bits_is_accepted() { + // Ids were capped at 24 bits only because they were packed into a + // texture's colour channels. The index carries them as numbers. EllipseGeom::builder() .set("x", vec![0.0_f64]) .set("y", vec![0.0_f64]) @@ -766,6 +767,18 @@ mod tests { .build(); } + #[test] + #[should_panic(expected = "must be a non-negative integer")] + fn a_negative_pick_id_panics_at_build() { + EllipseGeom::builder() + .set("x", vec![0.0_f64]) + .set("y", vec![0.0_f64]) + .set("x2", vec![1.0_f64]) + .set("y2", vec![1.0_f64]) + .set("pick_id", -1_i64) + .build(); + } + #[test] fn angle_zero_produces_identity_xform() { // Regression guard: explicit angle=0 produces the same Affine diff --git a/src/plot/geom/mod.rs b/src/plot/geom/mod.rs index fcbcfb8..aa9c49f 100644 --- a/src/plot/geom/mod.rs +++ b/src/plot/geom/mod.rs @@ -381,7 +381,7 @@ impl<'a> ScaleResolver for DirectScaleResolver<'a> { /// resolved value is the 24-bit id reported by /// [`pick_at`](crate::backend::vello::VelloRenderer::pick_at). Unset /// channel → `PickId::Skip` for the whole geom (no participation in -/// the hitmap); resolved value `0` → `PickId::Block` (occlude without +/// no authoring id); resolved value `0` → `PickId::Block` (occlude without /// reporting); otherwise → `PickId::Id(value)`. The context does not /// allocate or track ids — the user owns the namespace. pub struct GeomContext<'a> { diff --git a/src/plot/geom/point.rs b/src/plot/geom/point.rs index d3f1e66..9755c00 100644 --- a/src/plot/geom/point.rs +++ b/src/plot/geom/point.rs @@ -1401,7 +1401,7 @@ mod tests { PointGeom::builder() .set("x", vec![0.5_f64]) .set("y", vec![0.5_f64]) - .set("pick_id", Raw(0x100_0000_i64)) + .set("pick_id", Raw(-1_i64)) .build(); } diff --git a/src/plot/geom/resolve.rs b/src/plot/geom/resolve.rs index 77729bd..3729a6b 100644 --- a/src/plot/geom/resolve.rs +++ b/src/plot/geom/resolve.rs @@ -29,8 +29,12 @@ use crate::stroke::{Cap, Join, Stroke}; use super::{Channel, GeomContext}; -/// Maximum valid pick id — the 24-bit `PickId` encoding budget. -pub(crate) const MAX_PICK_ID: u32 = 0xFF_FFFF; +/// Largest pick id a channel can resolve to. +/// +/// The whole `u32` range. Ids used to be packed into a texture's colour +/// channels, which capped them at 24 bits; the index carries them as numbers, +/// so nothing narrows them. +pub(crate) const MAX_PICK_ID: u32 = u32::MAX; /// A `(channel, scale)` reference pair carried through draw-time /// channel bundles. Bundling halves the field count of per-geom @@ -452,7 +456,7 @@ pub(crate) fn band_width_at(scale: Option<&Scale>, raw: &Value) -> f64 { /// Resolve a `"pick_id"` channel to a [`PickId`] for row `i`. /// /// - `channel == None` → `PickId::Skip` (picking opt-out — the channel is -/// unset, so this geom doesn't participate in the hitmap). +/// unset, so this geom carries no authoring id). /// - The raw value (Constant or `Data[i]`, run through `scale` if any) /// must be a finite non-negative integer ≤ `MAX_PICK_ID`. Otherwise /// the row reports `PickId::Skip` — same convention as `is_finite` diff --git a/src/plot/geom/state.rs b/src/plot/geom/state.rs index 9043e1e..1efad5d 100644 --- a/src/plot/geom/state.rs +++ b/src/plot/geom/state.rs @@ -265,9 +265,9 @@ pub fn validate_channel_lengths(channels: &HashMap, n: usize, g /// Validate the `"pick_id"` channel if present. Constants and Data /// columns whose values are knowable at build time are checked to be -/// finite non-negative integers ≤ `0xFF_FFFF` (the 24-bit pick budget). -/// Scale-routed values are deferred to per-row draw resolution (the -/// output depends on draw-time scale state and can't be checked here). +/// finite non-negative integers within `u32`. Scale-routed values are +/// deferred to per-row draw resolution (the output depends on draw-time +/// scale state and can't be checked here). /// /// `0` is permitted — it maps to [`PickId::Block`](crate::pick::PickId) /// per the user-controlled pick-id contract. @@ -278,9 +278,9 @@ pub fn validate_pick_id_channel(channels: &HashMap, geom_label: }; let check = |v: &Value, where_: &str| { match v.as_number() { - Some(n) if n.is_finite() && n >= 0.0 && n <= 0xFF_FFFF as f64 && n.trunc() == n => {} + Some(n) if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 && n.trunc() == n => {} Some(n) => panic!( - "{geom_label}::build: \"pick_id\" {where_} must be a non-negative integer ≤ 0xFFFFFF, got {n}" + "{geom_label}::build: \"pick_id\" {where_} must be a non-negative integer that fits u32, got {n}" ), None => panic!( "{geom_label}::build: \"pick_id\" {where_} must be numeric (Number/Date/DateTime/Time/Duration), got {v:?}" diff --git a/src/plot/mod.rs b/src/plot/mod.rs index 387cc89..433d521 100644 --- a/src/plot/mod.rs +++ b/src/plot/mod.rs @@ -26,6 +26,7 @@ pub mod chrome; pub mod composition; pub mod diff; pub mod geom; +pub mod pick; #[allow(clippy::module_inception)] pub mod plot; pub mod projection; @@ -45,6 +46,7 @@ pub use geom::{ RectGeom, RibbonBSplineGeom, RibbonGeom, ScaleResolver, SegmentGeom, WedgeGeom, }; pub use geom::{TextFitGeom, TextGeom, TextPathGeom}; +pub use pick::{PlotPart, PlotPath}; pub use plot::{AspectMode, GeomId, Plot}; pub use projection::{ChromeStrategy, CustomProjection, Projection}; pub use scale::{ diff --git a/src/plot/pick.rs b/src/plot/pick.rs new file mode 100644 index 0000000..87ff73a --- /dev/null +++ b/src/plot/pick.rs @@ -0,0 +1,367 @@ +//! The pick vocabulary the plot layer speaks. +//! +//! [`crate::pick`] is chart-agnostic on purpose: a [`PickScope`] carries a +//! `&'static str` kind and two optional fields, and nothing down there knows +//! what an axis is. This module supplies the meanings — the constructors +//! plot code pushes, and the typed view a consumer reads a hit back through. +//! It is the same split [`Slot::name`] and the `Region` trait already use +//! between the composition anatomy and the layout solver. +//! +//! # The grammar +//! +//! ```text +//! composition → plot? → region(Slot) → [axis|legend|geom]? → part → item? +//! ``` +//! +//! Only `region` and `part` are always present. The middle group frame +//! appears where there is a sub-object with its own identity — an axis, a +//! legend block, a geom. `plot` is absent for composition-level chrome, +//! because the figure title has no owning plot. + +use std::sync::Arc; + +use crate::composition::Slot; +use crate::pick::{PickPath, PickScope}; +use crate::plot::chrome::axis::AxisId; +use crate::plot::plot::GeomId; + +/// Scope kinds. Interned as `&'static str` in the scope itself; these are +/// the names the typed accessors match on. +pub mod kind { + /// A whole composition, named by its composition id. + pub const COMPOSITION: &str = "composition"; + /// One plot, named by patch id and numbered within that patch. + pub const PLOT: &str = "plot"; + /// An anatomical region, named by [`Slot::name`](crate::composition::Slot::name). + pub const REGION: &str = "region"; + /// One axis, named by its scale and numbered by its `AxisId`. + pub const AXIS: &str = "axis"; + /// One legend block, named by its domain scale. + pub const LEGEND: &str = "legend"; + /// One geom, numbered by its `GeomId`. + pub const GEOM: &str = "geom"; + /// A distinguishable piece of chrome — see [`PlotPart`]. + pub const PART: &str = "part"; + /// An ordinal within a part: a break index, a legend row, a key. + pub const ITEM: &str = "item"; +} + +/// A distinguishable piece of chrome inside an anatomical region. +/// +/// Finer-grained than [`Slot`], deliberately: a slot is a layout concept and +/// owns a rect, while a part is a drawing concept with no rect of its own. +/// Keeping them separate is what stops [`Slot::placement`] — which is total +/// over the anatomy — from having to answer for tick marks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum PlotPart { + // Panel and background. + PlotBackground, + PanelBackground, + GridMajor, + GridMinor, + Graticule, + PanelOutline, + // Axis. + AxisLine, + AxisTick, + AxisMinorTick, + AxisTickLabel, + AxisTitle, + // Strip. + StripBackground, + StripLabel, + // Legend. + LegendBackground, + LegendTitle, + LegendKeyFrame, + LegendKey, + LegendLabel, + ColorbarBar, + ColorbarTick, + // Free text. + Title, + Subtitle, + Caption, +} + +impl PlotPart { + /// Stable snake_case identifier. Same contract as [`Slot::name`]: it is + /// the wire form, so it may be matched on and must not drift. + pub const fn name(self) -> &'static str { + match self { + PlotPart::PlotBackground => "plot_background", + PlotPart::PanelBackground => "panel_background", + PlotPart::GridMajor => "grid_major", + PlotPart::GridMinor => "grid_minor", + PlotPart::Graticule => "graticule", + PlotPart::PanelOutline => "panel_outline", + PlotPart::AxisLine => "axis_line", + PlotPart::AxisTick => "axis_tick", + PlotPart::AxisMinorTick => "axis_minor_tick", + PlotPart::AxisTickLabel => "axis_tick_label", + PlotPart::AxisTitle => "axis_title", + PlotPart::StripBackground => "strip_background", + PlotPart::StripLabel => "strip_label", + PlotPart::LegendBackground => "legend_background", + PlotPart::LegendTitle => "legend_title", + PlotPart::LegendKeyFrame => "legend_key_frame", + PlotPart::LegendKey => "legend_key", + PlotPart::LegendLabel => "legend_label", + PlotPart::ColorbarBar => "colorbar_bar", + PlotPart::ColorbarTick => "colorbar_tick", + PlotPart::Title => "title", + PlotPart::Subtitle => "subtitle", + PlotPart::Caption => "caption", + } + } + + /// The part a [`Self::name`] identifier came from. + pub fn from_name(name: &str) -> Option { + Some(match name { + "plot_background" => PlotPart::PlotBackground, + "panel_background" => PlotPart::PanelBackground, + "grid_major" => PlotPart::GridMajor, + "grid_minor" => PlotPart::GridMinor, + "graticule" => PlotPart::Graticule, + "panel_outline" => PlotPart::PanelOutline, + "axis_line" => PlotPart::AxisLine, + "axis_tick" => PlotPart::AxisTick, + "axis_minor_tick" => PlotPart::AxisMinorTick, + "axis_tick_label" => PlotPart::AxisTickLabel, + "axis_title" => PlotPart::AxisTitle, + "strip_background" => PlotPart::StripBackground, + "strip_label" => PlotPart::StripLabel, + "legend_background" => PlotPart::LegendBackground, + "legend_title" => PlotPart::LegendTitle, + "legend_key_frame" => PlotPart::LegendKeyFrame, + "legend_key" => PlotPart::LegendKey, + "legend_label" => PlotPart::LegendLabel, + "colorbar_bar" => PlotPart::ColorbarBar, + "colorbar_tick" => PlotPart::ColorbarTick, + "title" => PlotPart::Title, + "subtitle" => PlotPart::Subtitle, + "caption" => PlotPart::Caption, + _ => return None, + }) + } + + /// Every part, in declaration order. + pub const ALL: [PlotPart; 23] = [ + PlotPart::PlotBackground, + PlotPart::PanelBackground, + PlotPart::GridMajor, + PlotPart::GridMinor, + PlotPart::Graticule, + PlotPart::PanelOutline, + PlotPart::AxisLine, + PlotPart::AxisTick, + PlotPart::AxisMinorTick, + PlotPart::AxisTickLabel, + PlotPart::AxisTitle, + PlotPart::StripBackground, + PlotPart::StripLabel, + PlotPart::LegendBackground, + PlotPart::LegendTitle, + PlotPart::LegendKeyFrame, + PlotPart::LegendKey, + PlotPart::LegendLabel, + PlotPart::ColorbarBar, + PlotPart::ColorbarTick, + PlotPart::Title, + PlotPart::Subtitle, + PlotPart::Caption, + ]; +} + +// ─── Scope constructors ────────────────────────────────────────────────── +// +// The only way plot code builds a frame. Group vs Target lives here rather +// than at the call sites, so "chrome is a target, structure is not" cannot +// be got wrong one call site at a time. + +/// A whole composition, named by its composition id. +pub fn composition_scope(id: &str) -> PickScope { + PickScope::group(kind::COMPOSITION).with_name(id) +} + +/// One plot, addressed the way +/// [`PlotComposition::update_plot_at`](crate::plot::PlotComposition::update_plot_at) +/// addresses it. +pub fn plot_scope(patch_id: &Arc, index_in_patch: u32) -> PickScope { + PickScope::group(kind::PLOT) + .with_name(patch_id.clone()) + .with_index(index_in_patch) +} + +/// An anatomical region. Its name is the layout lookup key, so a hit +/// round-trips into `CompositionLayout::get` to recover the region's rect. +pub fn region_scope(slot: Slot) -> PickScope { + PickScope::group(kind::REGION).with_name(slot.name()) +} + +/// A region placed with `place_at` rather than into the fixed anatomy. +pub fn named_region_scope(name: &str) -> PickScope { + PickScope::group(kind::REGION).with_name(name) +} + +/// One geom within a plot. +pub fn geom_scope(id: GeomId) -> PickScope { + PickScope::group(kind::GEOM).with_index(id.raw()) +} + +/// One axis, carrying the scale that drives it so a consumer can re-derive a +/// break value from an [`PlotPath::item`] ordinal. +pub fn axis_scope(id: Option, scale_name: Option<&str>) -> PickScope { + let mut s = PickScope::group(kind::AXIS); + if let Some(name) = scale_name { + s = s.with_name(name); + } + if let Some(id) = id { + s = s.with_index(id.raw()); + } + s +} + +/// One legend block, numbered within its side's stack. +pub fn legend_scope(block: u32, domain_scale: &str) -> PickScope { + PickScope::group(kind::LEGEND) + .with_name(domain_scale) + .with_index(block) +} + +/// A piece of chrome. **A target**: whatever is drawn directly inside is +/// indexed even though chrome carries no [`PickId`](crate::pick::PickId). +pub fn part_scope(part: PlotPart) -> PickScope { + PickScope::target(kind::PART).with_name(part.name()) +} + +/// A piece of chrome that repeats per channel — gridlines, mostly. The index +/// is the `PerChannel` coordinate, i.e. the theme's own addressing. +pub fn part_scope_for_channel(part: PlotPart, channel: u8) -> PickScope { + PickScope::target(kind::PART) + .with_name(part.name()) + .with_index(u32::from(channel)) +} + +/// An ordinal within a part: a break index, a legend row, a key. +pub fn item_scope(index: u32) -> PickScope { + PickScope::target(kind::ITEM).with_index(index) +} + +// ─── Typed view ────────────────────────────────────────────────────────── + +/// A [`PickPath`] read through the plot layer's vocabulary. +/// +/// Every accessor searches inward-out, so the innermost frame of a kind wins +/// — which is what you want when a legend sits inside a panel. +#[derive(Debug, Clone, Copy)] +pub struct PlotPath<'a>(PickPath<'a>); + +impl<'a> PlotPath<'a> { + /// Read a raw path through the plot vocabulary. + pub fn new(path: PickPath<'a>) -> Self { + Self(path) + } + + /// The underlying untyped path. + pub fn raw(&self) -> PickPath<'a> { + self.0 + } + + /// Id of the composition the hit belongs to. + pub fn composition(&self) -> Option<&'a str> { + self.0.find(kind::COMPOSITION).and_then(|s| s.name()) + } + + /// `(patch_id, index_in_patch)` — the pair + /// [`PlotComposition::update_plot_at`](crate::plot::PlotComposition::update_plot_at) + /// takes. `None` for composition-level chrome, which has no owning plot. + pub fn plot(&self) -> Option<(&'a str, u32)> { + let s = self.0.find(kind::PLOT)?; + Some((s.name()?, s.index().unwrap_or(0))) + } + + /// The anatomical region, when the hit is in one. `None` for a + /// `place_at` region — see [`Self::region_name`]. + pub fn region(&self) -> Option { + Slot::from_name(self.region_name()?) + } + + /// The region's lookup name, anatomical or not. + pub fn region_name(&self) -> Option<&'a str> { + self.0.find(kind::REGION).and_then(|s| s.name()) + } + + /// The geom the hit came from, if it was a mark. + pub fn geom(&self) -> Option { + self.0 + .find(kind::GEOM) + .and_then(|s| s.index()) + .map(GeomId::new) + } + + /// The axis the hit belongs to, if it was axis chrome. + pub fn axis(&self) -> Option { + self.0 + .find(kind::AXIS) + .and_then(|s| s.index()) + .map(AxisId::new) + } + + /// Name of the scale driving the enclosing axis or legend. + /// + /// The key for recovering a domain value: with [`Self::item`] it gives + /// `registry.get(scale).breaks(n)[item]`. Carrying the name rather than + /// the value is deliberate — a + /// [`Value`](crate::scales::value::Value) is not hashable, and a name + /// resolves against the live scale rather than a snapshot of it. + pub fn scale(&self) -> Option<&'a str> { + self.0 + .find(kind::AXIS) + .or_else(|| self.0.find(kind::LEGEND)) + .and_then(|s| s.name()) + } + + /// Which legend block, within its side's stack. + pub fn legend_block(&self) -> Option { + self.0.find(kind::LEGEND).and_then(|s| s.index()) + } + + /// The piece of chrome that was hit. + pub fn part(&self) -> Option { + PlotPart::from_name(self.0.find(kind::PART)?.name()?) + } + + /// The channel a per-channel part belongs to — gridlines carry the + /// `PerChannel` index the theme stores them under. + pub fn part_channel(&self) -> Option { + self.0 + .find(kind::PART) + .and_then(|s| s.index()) + .and_then(|i| u8::try_from(i).ok()) + } + + /// Ordinal within the part: break index, legend row, key index. + pub fn item(&self) -> Option { + self.0.find(kind::ITEM).and_then(|s| s.index()) + } +} + +/// The `(channel, side)` coordinate the theme files a region's axis chrome +/// under — the pair +/// [`Sided::resolve`](crate::plot::theme::cascade::Sided::resolve) and +/// [`Theme::resolved_axis`](crate::plot::theme::Theme::resolved_axis) take. +/// +/// This is why there is no `ElementRef` type and no `"axis.text.x"` string +/// scheme: `region` plus `part` already *is* a theme address, and this turns +/// it into the coordinates the theme indexes by. +pub fn theme_channel_side(slot: Slot) -> Option<(u8, u8)> { + Some(match slot { + Slot::AxisBottom | Slot::AxisBottomTitle => (0, 0), + Slot::AxisTop | Slot::AxisTopTitle => (0, 1), + Slot::AxisLeft | Slot::AxisLeftTitle => (1, 0), + Slot::AxisRight | Slot::AxisRightTitle => (1, 1), + _ => return None, + }) +} diff --git a/src/plot/plot.rs b/src/plot/plot.rs index 067822e..ee05970 100644 --- a/src/plot/plot.rs +++ b/src/plot/plot.rs @@ -410,6 +410,15 @@ impl Plot { self.track_identity } + /// This plot's frame in the pick-scope tree. + /// + /// Pushed by each `draw_*_into` rather than by the orchestrator, because + /// those are public and documented as drivable by hand: a stand-alone + /// caller should still get a plot frame, just a shorter path. + fn pick_scope(&self) -> crate::pick::PickScope { + crate::plot::pick::plot_scope(&self.patch_id, self.index_in_patch) + } + /// Read accessor for the bound patch id. pub fn patch_id(&self) -> &str { &self.patch_id @@ -939,6 +948,18 @@ impl Plot { layout: &crate::composition::CompositionLayout, theme: &crate::plot::theme::Theme, dpi: f64, + ) { + scene.push_pick_scope(&self.pick_scope()); + self.draw_patch_background_into_inner(scene, layout, theme, dpi); + scene.pop_pick_scope(); + } + + fn draw_patch_background_into_inner( + &self, + scene: &mut dyn SceneBuilder, + layout: &crate::composition::CompositionLayout, + theme: &crate::plot::theme::Theme, + dpi: f64, ) { let Some(bg_slot) = theme.plot_background.as_set() else { return; @@ -965,6 +986,13 @@ impl Plot { } else { rect.to_path(0.0) }; + // Fill and border are siblings under one part: "the plot + // background" is one target, and a hover does not care which of the + // two primitives it landed on. + scene.push_pick_scope(&crate::plot::pick::region_scope(Slot::Background)); + scene.push_pick_scope(&crate::plot::pick::part_scope( + crate::plot::pick::PlotPart::PlotBackground, + )); if let Some(fill) = bg.fill { let brush = crate::brush::Brush::Solid(fill.resolve(&theme.palette)); scene.fill( @@ -997,6 +1025,8 @@ impl Plot { crate::pick::PickId::Skip, ); } + scene.pop_pick_scope(); + scene.pop_pick_scope(); } /// Paint the projection's panel chrome — background fill, grid @@ -1013,6 +1043,21 @@ impl Plot { registry: &ScaleRegistry, dpi: f64, theme: &crate::plot::theme::Theme, + ) { + scene.push_pick_scope(&self.pick_scope()); + scene.push_pick_scope(&crate::plot::pick::region_scope(Slot::Panel)); + self.draw_panel_chrome_into_inner(scene, layout, registry, dpi, theme); + scene.pop_pick_scope(); + scene.pop_pick_scope(); + } + + fn draw_panel_chrome_into_inner( + &self, + scene: &mut dyn SceneBuilder, + layout: &crate::composition::CompositionLayout, + registry: &ScaleRegistry, + dpi: f64, + theme: &crate::plot::theme::Theme, ) { let panel = match layout.get(&self.patch_id, Slot::Panel) { Some(r) => r, @@ -1061,6 +1106,21 @@ impl Plot { registry: &ScaleRegistry, dpi: f64, theme: &crate::plot::theme::Theme, + ) { + scene.push_pick_scope(&self.pick_scope()); + scene.push_pick_scope(&crate::plot::pick::region_scope(Slot::Panel)); + self.draw_geoms_into_inner(scene, layout, registry, dpi, theme); + scene.pop_pick_scope(); + scene.pop_pick_scope(); + } + + fn draw_geoms_into_inner( + &mut self, + scene: &mut dyn SceneBuilder, + layout: &crate::composition::CompositionLayout, + registry: &ScaleRegistry, + dpi: f64, + theme: &crate::plot::theme::Theme, ) { let panel = match layout.get(&self.patch_id, Slot::Panel) { Some(r) => r, @@ -1116,8 +1176,10 @@ impl Plot { path, ); } - for (_, geom) in self.geoms.iter() { + for (id, geom) in self.geoms.iter() { + scene.push_pick_scope(&crate::plot::pick::geom_scope(*id)); geom.draw(scene, &ctx); + scene.pop_pick_scope(); } if clip_path.is_some() { scene.pop_layer(); @@ -1763,6 +1825,19 @@ impl Plot { registry: &ScaleRegistry, dpi: f64, theme: &crate::plot::theme::Theme, + ) { + scene.push_pick_scope(&self.pick_scope()); + self.draw_chrome_into_inner(scene, layout, registry, dpi, theme); + scene.pop_pick_scope(); + } + + fn draw_chrome_into_inner( + &self, + scene: &mut dyn SceneBuilder, + layout: &crate::composition::CompositionLayout, + registry: &ScaleRegistry, + dpi: f64, + theme: &crate::plot::theme::Theme, ) { use crate::brush::Brush; use crate::text::TextRun; @@ -2341,7 +2416,7 @@ mod tests { "outline pass must precede the fill: {first_stroked} vs {first_filled}" ); - // The fill owns picking; the outline stays out of the hitmap. + // The fill owns picking; the outline is not indexed. assert_eq!(stroked[0].pick_id, crate::pick::PickId::Skip); assert_eq!(filled[0].pick_id, crate::pick::PickId::Id(7)); // Same glyphs, so the outline traces the visible text. diff --git a/src/scene/mod.rs b/src/scene/mod.rs index ba35426..1fdb95b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -27,9 +27,10 @@ pub trait SceneBuilder { /// Fill `path` with `brush`. `transform` applies to the path; `brush_transform` /// optionally transforms the brush coordinates (e.g. to rotate a gradient). /// - /// `pick_id` controls how (or whether) this primitive appears in the - /// hitmap when picking is enabled on the backend. Pass [`PickId::Skip`] - /// for purely decorative content. + /// `pick_id` is the authoring layer's handle for whatever this draws. + /// A scene that hit-tests records it; a rasteriser ignores it, and the + /// vector backends surface it (SVG emits `data-pick-id`). Pass + /// [`PickId::Skip`] for content that carries no id of its own. fn fill( &mut self, rule: FillRule, diff --git a/src/scene/recording.rs b/src/scene/recording.rs index 854b0bd..4bc9099 100644 --- a/src/scene/recording.rs +++ b/src/scene/recording.rs @@ -10,7 +10,7 @@ use crate::brush::{Brush, Image, Sampling}; use crate::geometry::Affine; use crate::mesh::Mesh; use crate::path::{FillRule, Path}; -use crate::pick::PickId; +use crate::pick::{PickId, PickScope}; use crate::stroke::Stroke; use crate::style_vocab::FontSpec; @@ -53,6 +53,10 @@ pub enum Op { clip: Path, }, PopLayer, + PushPickScope { + scope: PickScope, + }, + PopPickScope, } /// Owned counterpart of `GlyphRun<'_>` for storage in `Op::DrawGlyphs`. @@ -118,6 +122,36 @@ impl RecordingScene { Self::default() } + /// The ops that draw something, skipping pick-scope bookkeeping. + /// + /// What a test asserting "here is what got drawn" wants: pushing scopes + /// changes the op list without changing the picture, and an assertion + /// about the picture should not have to know that. + pub fn draw_ops(&self) -> impl Iterator { + self.ops + .iter() + .filter(|op| !matches!(op, Op::PushPickScope { .. } | Op::PopPickScope)) + } + + /// The pick-scope stack in effect at `ops[i]`, outermost first. + /// + /// The natural way to assert scoping: draw into a recording, find the op + /// you care about, and read the path it was drawn under. An unbalanced + /// pop is ignored, matching what the index does with one. + pub fn scope_at(&self, i: usize) -> Vec<&PickScope> { + let mut stack: Vec<&PickScope> = Vec::new(); + for op in self.ops.iter().take(i) { + match op { + Op::PushPickScope { scope } => stack.push(scope), + Op::PopPickScope => { + stack.pop(); + } + _ => {} + } + } + stack + } + /// Issue every recorded op against `scene`, in order. /// /// The inverse of recording. A backend whose rasteriser needs the @@ -190,6 +224,8 @@ impl RecordingScene { clip, } => scene.push_layer(*blend, *alpha, *transform, clip), Op::PopLayer => scene.pop_layer(), + Op::PushPickScope { scope } => scene.push_pick_scope(scope), + Op::PopPickScope => scene.pop_pick_scope(), } } } @@ -301,6 +337,16 @@ impl SceneBuilder for RecordingScene { fn pop_layer(&mut self) { self.ops.push(Op::PopLayer); } + + fn push_pick_scope(&mut self, scope: &PickScope) { + self.ops.push(Op::PushPickScope { + scope: scope.clone(), + }); + } + + fn pop_pick_scope(&mut self) { + self.ops.push(Op::PopPickScope); + } } #[cfg(test)] diff --git a/src/window/app.rs b/src/window/app.rs index f4e3928..07733f1 100644 --- a/src/window/app.rs +++ b/src/window/app.rs @@ -49,8 +49,6 @@ struct State { window: Arc, surface: WindowSurface, renderer: HostRenderer, - /// When the hitmap was last refreshed, for `WindowConfig::pick_interval`. - last_pick: Option, dpi: f64, } @@ -113,7 +111,6 @@ impl Driver { surface, renderer, dpi, - last_pick: None, }) } @@ -125,20 +122,6 @@ impl Driver { let (width, height) = state.surface.size(); let size = Size::new(width as f64, height as f64); - // Decide before drawing: on the sparse-strip backend the pick pass is - // skipped during the *replay*, not just at rasterisation, so this has - // to be known before any draw reaches the scene. - if let Some(interval) = self.config.pick_interval { - let now = std::time::Instant::now(); - let due = state - .last_pick - .is_none_or(|last| now.duration_since(last) >= interval); - state.renderer.set_refresh_pick(due); - if due { - state.last_pick = Some(now); - } - } - state.renderer.scene().clear(); { let mut frame = Frame { @@ -176,7 +159,7 @@ impl Driver { let mut exit = false; let redraw = Cell::new(false); let mut ctx = EventCtx { - renderer: &state.renderer, + index: state.renderer.pick_index(), redraw: &redraw, cursor: self.cursor, size: Size::new(width as f64, height as f64), diff --git a/src/window/canvas.rs b/src/window/canvas.rs index 321a1e0..15fd2b9 100644 --- a/src/window/canvas.rs +++ b/src/window/canvas.rs @@ -17,7 +17,6 @@ use crate::color::Color; use crate::geometry::{Point, Size}; use crate::window::renderer::HostRenderer; use crate::window::surface::WindowSurface; -use crate::window::PickSource as _; use crate::window::BASE_DPI; use crate::window::{Event, EventCtx, Frame, WindowApp, WindowConfig, WindowError}; @@ -115,14 +114,12 @@ impl CanvasHost { app.draw(&mut frame); } - // Deferring rather than `render_to_texture`: the trait method blocks - // on the pick readback, which a browser main thread cannot do. let renderer = &mut self.renderer; let background = self.background; self.surface.draw_frame( |view| { renderer - .render_to_texture_deferring_pick(view, width, height, background) + .render_to_texture(view, width, height, background) .map_err(WindowError::from) }, || {}, @@ -141,17 +138,13 @@ impl CanvasHost { _ => {} } - // Drain before borrowing the renderer for the context: `EventCtx` - // holds it immutably, so the app cannot drain from inside a handler. - let _ = self.renderer.try_finish_pick(); - let (width, height) = self.surface.size(); let redraw = Cell::new(false); // Nothing on a canvas host can honour an exit request — the page owns // the element's lifetime — so the flag is accepted and dropped. let mut exit = false; let mut ctx = EventCtx { - renderer: &self.renderer, + index: self.renderer.pick_index(), redraw: &redraw, cursor: self.cursor, size: Size::new(width as f64, height as f64), @@ -172,19 +165,22 @@ impl CanvasHost { self.dpi = dpi; } - /// The pick id at a device-pixel coordinate. - /// - /// Always `None` unless [`WindowConfig::picking`] was enabled. Drains a - /// landed readback first, which is why this takes `&mut self`. + /// The topmost pick id at a device-pixel coordinate. /// - /// The readback is never waited on, so the hitmap can describe a frame or - /// two behind what is on screen — invisible for hover, and the price of - /// not blocking. Coordinates are device pixels, matching [`Self::size`]. - pub fn pick_at(&mut self, x: u32, y: u32) -> Option { - // A failed drain means the readback errored, not that the pixel has - // no id; either way there is nothing to report for this query. - let _ = self.renderer.try_finish_pick(); - self.renderer.pick_at(x, y) + /// Always `None` unless [`WindowConfig::picking`] was enabled. Answers + /// from the index the scene built as it was drawn, so it describes the + /// frame on screen rather than lagging it. Coordinates are device pixels, + /// matching [`Self::size`]. + pub fn pick_at(&self, x: f64, y: f64) -> Option { + self.renderer + .pick_index()? + .pick_at(crate::geometry::Point::new(x, y)) + } + + /// The hit index for the last drawn frame, for hits carrying their scope + /// chain and for rectangle and lasso queries. + pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> { + self.renderer.pick_index() } /// Drawing surface size in device pixels. diff --git a/src/window/mod.rs b/src/window/mod.rs index 4cfa67b..6b6e6fe 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -137,7 +137,6 @@ pub struct WindowConfig { present_mode: PresentMode, #[cfg(any(feature = "vello", feature = "vello-hybrid"))] backend: Backend, - pick_interval: Option, } impl WindowConfig { @@ -153,7 +152,6 @@ impl WindowConfig { present_mode: PresentMode::default(), #[cfg(any(feature = "vello", feature = "vello-hybrid"))] backend: Backend::default(), - pick_interval: None, } } @@ -170,33 +168,17 @@ impl WindowConfig { self } - /// Enable picking, making [`EventCtx::pick_at`] report ids. + /// Enable hit testing, making [`EventCtx::pick_at`] and the other + /// queries answer. /// - /// Picking costs a full-frame GPU readback on every rendered frame, so it - /// stays off unless asked for. + /// The scene records a spatial index as it is drawn, which costs CPU per + /// draw call whether or not anything is queried, so it stays off unless + /// asked for. Nothing is read back from the GPU either way. pub fn picking(mut self, picking: bool) -> Self { self.picking = picking; self } - /// Refresh the hitmap at most this often, rather than every frame. - /// - /// The pick pass is a second rasterisation of the whole scene. On the - /// sparse-strip backend that means a second CPU strip generation and costs - /// about what the display pass does — measured at 100k marks, a frame goes - /// from 88 ms to 150 ms with it. A window that redraws faster than a person - /// can query it — during a resize drag, or while animating — is paying that - /// for hitmaps nobody reads. - /// - /// With an interval set, frames in between reuse the previous hitmap, so - /// [`EventCtx::pick_at`] stays answerable but may describe a slightly older - /// frame. A few milliseconds is invisible to a pointer; the default is - /// `None`, which refreshes every frame. - pub fn pick_interval(mut self, interval: std::time::Duration) -> Self { - self.pick_interval = Some(interval); - self - } - /// Choose which rasterising backend draws the window. #[cfg(any(feature = "vello", feature = "vello-hybrid"))] /// @@ -268,19 +250,9 @@ impl Frame<'_> { } } -/// Anything that can answer a pick query for [`EventCtx`]. -/// -/// An abstraction rather than a concrete renderer because the wgpu hosts and -/// the WebGL2 one own entirely different renderers — and a WebGL2 build has no -/// wgpu types at all to name. -pub(crate) trait PickSource { - /// Id at a device-pixel coordinate of the last refreshed hitmap. - fn pick_at(&self, x: u32, y: u32) -> Option; -} - /// What an event handler can inspect and ask for. pub struct EventCtx<'a> { - renderer: &'a dyn PickSource, + index: Option<&'a crate::pick::PickIndex>, // A flag rather than a direct call into the windowing backend: it keeps // winit out of everything but `app.rs`, and lets the canvas host share // this type. The host acts on it once the handler returns. @@ -292,12 +264,31 @@ pub struct EventCtx<'a> { } impl EventCtx<'_> { - /// The pick id at a device-pixel coordinate of the last drawn frame. + /// The topmost pick id at a device-pixel coordinate of the last drawn + /// frame. + /// + /// Always `None` unless [`WindowConfig::picking`] was enabled. Answers + /// from a CPU-side index, so calling it per pointer event is cheap and + /// never lags the frame. + pub fn pick_at(&self, x: f64, y: f64) -> Option { + self.index?.pick_at(Point::new(x, y)) + } + + /// Every hit at a device-pixel coordinate, topmost first, each carrying + /// the scope chain it was drawn inside — the path an event bubbles along. /// - /// Always `None` unless [`WindowConfig::picking`] was enabled. Reads a - /// CPU-side hitmap, so calling it per pointer event is cheap. - pub fn pick_at(&self, x: u32, y: u32) -> Option { - self.renderer.pick_at(x, y) + /// Chrome participates: a hover over an axis tick label reports a target + /// even though chrome carries no authoring id. + pub fn hits_at(&self, x: f64, y: f64) -> Vec> { + self.index + .map(|ix| ix.hits_at(Point::new(x, y))) + .unwrap_or_default() + } + + /// The hit index for the last drawn frame, for rectangle and lasso + /// queries. `None` unless [`WindowConfig::picking`] was enabled. + pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> { + self.index } /// The last known cursor position in device pixels, if it is over the diff --git a/src/window/renderer.rs b/src/window/renderer.rs index 42208e1..156e95c 100644 --- a/src/window/renderer.rs +++ b/src/window/renderer.rs @@ -130,13 +130,14 @@ impl HostRenderer { } } - /// Control whether the coming render refreshes the hitmap. - pub(crate) fn set_refresh_pick(&mut self, refresh: bool) { + /// The hit index the scene built while it was drawn, when this renderer + /// was built with picking. + pub(crate) fn pick_index(&self) -> Option<&crate::pick::PickIndex> { match self { #[cfg(feature = "vello")] - Self::Vello(r) => r.set_refresh_pick(refresh), + Self::Vello(r) => r.pick_index(), #[cfg(feature = "vello-hybrid")] - Self::Hybrid(r) => r.set_refresh_pick(refresh), + Self::Hybrid(r) => r.pick_index(), } } @@ -165,47 +166,4 @@ impl HostRenderer { Self::Hybrid(r) => r.render_to_texture(view, width, height, background), } } - - /// Rasterise into `view` and submit the pick pass without waiting on it. - /// - /// The browser host's counterpart to - /// [`Self::render_to_texture`], whose pick readback would park the main - /// thread. Pair with [`Self::try_finish_pick`]. - #[cfg(all(feature = "canvas", target_arch = "wasm32"))] - pub(crate) fn render_to_texture_deferring_pick( - &mut self, - view: &wgpu::TextureView, - width: u32, - height: u32, - background: Color, - ) -> Result<(), BackendError> { - match self { - #[cfg(feature = "vello")] - Self::Vello(r) => r.render_to_texture_deferring_pick(view, width, height, background), - #[cfg(feature = "vello-hybrid")] - Self::Hybrid(r) => r.render_to_texture_deferring_pick(view, width, height, background), - } - } - - /// Drain a deferred pick readback into the hitmap, if it has landed. - #[cfg(all(feature = "canvas", target_arch = "wasm32"))] - pub(crate) fn try_finish_pick(&mut self) -> Result { - match self { - #[cfg(feature = "vello")] - Self::Vello(r) => r.try_finish_pick(), - #[cfg(feature = "vello-hybrid")] - Self::Hybrid(r) => r.try_finish_pick(), - } - } -} - -impl crate::window::PickSource for HostRenderer { - fn pick_at(&self, x: u32, y: u32) -> Option { - match self { - #[cfg(feature = "vello")] - Self::Vello(r) => r.pick_at(x, y), - #[cfg(feature = "vello-hybrid")] - Self::Hybrid(r) => r.pick_at(x, y), - } - } } diff --git a/src/window/webgl_host.rs b/src/window/webgl_host.rs index bf72d64..bc0f087 100644 --- a/src/window/webgl_host.rs +++ b/src/window/webgl_host.rs @@ -15,7 +15,7 @@ use crate::backend::hybrid::HybridWebGlRenderer; use crate::color::Color; use crate::geometry::{Point, Size}; use crate::scene::SceneBuilder as _; -use crate::window::{Event, EventCtx, Frame, PickSource, WindowApp, WindowConfig, WindowError}; +use crate::window::{Event, EventCtx, Frame, WindowApp, WindowConfig, WindowError}; /// Presents a scene onto an existing `` through WebGL2. pub struct WebGlHost { @@ -81,7 +81,7 @@ impl WebGlHost { // here: accepted and dropped, as on the wgpu canvas host. let mut exit = false; let mut ctx = EventCtx { - renderer: &self.renderer, + index: self.renderer.pick_index(), redraw: &redraw, cursor: self.cursor, size, @@ -106,21 +106,18 @@ impl WebGlHost { Ok(()) } - /// Id at a device-pixel coordinate of the last refreshed hitmap. + /// The topmost pick id at a device-pixel coordinate. /// - /// Always `None` unless [`WindowConfig::picking`] was enabled. Reads a - /// CPU-side hitmap, so calling it per pointer event is cheap. - pub fn pick_at(&self, x: u32, y: u32) -> Option { - self.renderer.pick_at(x, y) + /// Always `None` unless [`WindowConfig::picking`] was enabled. Answers + /// from a CPU-side index, so calling it per pointer event is cheap. + pub fn pick_at(&self, x: f64, y: f64) -> Option { + self.renderer.pick_at(crate::geometry::Point::new(x, y)) } - /// Control whether the coming frame refreshes the hitmap. - /// - /// The pick pass rasterises the scene a second time and reads it back - /// synchronously, so a page redrawing faster than it queries — mid-resize, - /// or while animating — should leave it alone for a frame or two. - pub fn set_refresh_pick(&mut self, refresh: bool) { - self.renderer.set_refresh_pick(refresh); + /// The hit index for the last drawn frame, for hits carrying their scope + /// chain and for rectangle and lasso queries. + pub fn pick_index(&self) -> Option<&crate::pick::PickIndex> { + self.renderer.pick_index() } /// Drawing surface size in device pixels. @@ -139,9 +136,3 @@ impl WebGlHost { self.background = background; } } - -impl PickSource for HybridWebGlRenderer { - fn pick_at(&self, x: u32, y: u32) -> Option { - HybridWebGlRenderer::pick_at(self, x, y) - } -} diff --git a/tests/hybrid.rs b/tests/hybrid.rs index 6990f7c..8eba151 100644 --- a/tests/hybrid.rs +++ b/tests/hybrid.rs @@ -1,11 +1,12 @@ //! End-to-end tests for the Hybrid backend. //! -//! The picking cases are the point of the backend: binary coverage means an -//! edge pixel carries exactly one id, so an id read back from a boundary -//! between two marks is one of the two rather than a blend of both. +//! The picking cases check that hit testing survives every render path — +//! buffer, texture, and both device-sharing constructors — and that turning +//! it on changes no drawn pixel. use hephaestus::backend::hybrid::HybridRenderer; use hephaestus::color::rgb8; +use hephaestus::geometry::Point; use hephaestus::{Affine, Brush, FillRule, PickId, Rect, Renderer, SceneBuilder}; use kurbo::Shape; @@ -21,6 +22,26 @@ fn px(buf: &[u8], x: u32, y: u32) -> [u8; 4] { [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]] } +#[test] +fn a_scene_can_be_rendered_at_two_sizes_in_a_row() { + let mut r = HybridRenderer::new().expect("hybrid renderer init"); + fill( + r.scene(), + Rect::new(0.0, 0.0, 10.0, 10.0), + [0, 255, 0], + PickId::Skip, + ); + let mut small = vec![0u8; 40 * 40 * 4]; + r.render_to_buffer(40, 40, rgb8(0, 0, 0), &mut small) + .expect("small render"); + let mut large = vec![0u8; 120 * 90 * 4]; + r.render_to_buffer(120, 90, rgb8(0, 0, 0), &mut large) + .expect("large render"); + + assert_eq!(&small[0..4], &[0, 255, 0, 255], "fill survives resize"); + assert_eq!(&large[0..4], &[0, 255, 0, 255]); +} + /// Fill `rect` with `color`, tagged `pick`. fn fill(scene: &mut impl SceneBuilder, rect: Rect, color: [u8; 3], pick: PickId) { scene.fill( @@ -50,99 +71,6 @@ fn renders_a_solid_fill_over_the_background() { assert_eq!(px(&out, 5, 5), [255, 255, 255, 255], "background"); } -#[test] -fn pick_at_returns_none_when_picking_disabled() { - let mut r = HybridRenderer::new().expect("hybrid renderer init"); - let mut out = buf(); - fill( - r.scene(), - Rect::new(20.0, 20.0, 80.0, 80.0), - [255, 0, 0], - PickId::Id(7), - ); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) - .expect("render"); - assert_eq!(r.pick_at(50, 50), None); -} - -#[test] -fn pick_at_reports_the_id_under_the_pixel() { - let mut r = HybridRenderer::with_picking().expect("hybrid renderer init"); - let mut out = buf(); - fill( - r.scene(), - Rect::new(20.0, 20.0, 80.0, 80.0), - [255, 0, 0], - PickId::Id(42), - ); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) - .expect("render"); - - assert_eq!(r.pick_at(50, 50), Some(42), "inside the mark"); - assert_eq!(r.pick_at(5, 5), None, "empty space"); -} - -/// The case the compute-shader backend cannot pass. -/// -/// Two overlapping circles: the upper circle's antialiased edge falls on the -/// lower one, and an antialiased pick pass blends their two ids into a ramp of -/// values that are neither, all at full alpha and so indistinguishable from -/// real hits. Measured against the compute-shader backend, this exact scene -/// yields 28 ids that were never drawn. Binary coverage cannot produce one. -#[test] -fn overlapping_picked_marks_never_blend_into_a_third_id() { - let mut r = HybridRenderer::with_picking().expect("hybrid renderer init"); - let mut out = buf(); - // Far apart in value, so any blend lands nowhere near either id. - for (shape, color, id) in [ - (kurbo::Circle::new((40.0, 50.0), 28.0), [255, 0, 0], 0x20u32), - (kurbo::Circle::new((62.0, 50.0), 28.0), [0, 0, 255], 0xC0u32), - ] { - r.scene().fill( - FillRule::NonZero, - Affine::IDENTITY, - &Brush::Solid(rgb8(color[0], color[1], color[2])), - None, - &shape.to_path(0.1), - PickId::Id(id), - ); - } - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) - .expect("render"); - - let mut seen = std::collections::BTreeSet::new(); - for raw in r.hitmap().expect("picking enabled") { - if let Some(id) = hephaestus::pick::decode(*raw) { - seen.insert(id); - } - } - assert_eq!( - seen, - [0x20, 0xC0].into_iter().collect(), - "hitmap holds an id that was never drawn" - ); -} - -#[test] -fn a_scene_can_be_rendered_at_two_sizes_in_a_row() { - let mut r = HybridRenderer::new().expect("hybrid renderer init"); - fill( - r.scene(), - Rect::new(0.0, 0.0, 10.0, 10.0), - [0, 255, 0], - PickId::Skip, - ); - let mut small = vec![0u8; 40 * 40 * 4]; - r.render_to_buffer(40, 40, rgb8(0, 0, 0), &mut small) - .expect("small render"); - let mut large = vec![0u8; 120 * 90 * 4]; - r.render_to_buffer(120, 90, rgb8(0, 0, 0), &mut large) - .expect("large render"); - - assert_eq!(&small[0..4], &[0, 255, 0, 255], "fill survives resize"); - assert_eq!(&large[0..4], &[0, 255, 0, 255]); -} - // ─── Alpha convention ─────────────────────────────────────────────────────── /// `render_to_buffer` hands out straight (un-premultiplied) alpha, same as @@ -256,7 +184,11 @@ fn a_mesh_triangle_rasterises() { .expect("render"); assert_eq!(px(&out, 50, 40)[1], 200, "mesh interior"); - assert_eq!(r.pick_at(50, 40), Some(5), "mesh carries its pick id"); + assert_eq!( + r.pick_at(Point::new(50.0, 40.0)), + Some(5), + "mesh carries its pick id" + ); } // ─── Images ───────────────────────────────────────────────────────────────── @@ -296,7 +228,11 @@ fn an_image_is_uploaded_and_sampled() { assert_eq!(px(&out, 25, 25), [255, 0, 0, 255], "top-left source pixel"); assert_eq!(px(&out, 75, 25), [0, 255, 0, 255], "top-right"); assert_eq!(px(&out, 25, 75), [0, 0, 255, 255], "bottom-left"); - assert_eq!(r.pick_at(50, 50), Some(9), "image carries its pick id"); + assert_eq!( + r.pick_at(Point::new(50.0, 50.0)), + Some(9), + "image carries its pick id" + ); } /// Image opacity cannot ride on the sampler — the shared paint encoder @@ -518,8 +454,8 @@ fn picking_survives_the_texture_path() { r.render_to_texture(&view, W, H, rgb8(0, 0, 0)) .expect("texture render"); - assert_eq!(r.pick_at(50, 50), Some(77)); - assert_eq!(r.pick_at(5, 5), None); + assert_eq!(r.pick_at(Point::new(50.0, 50.0)), Some(77)); + assert_eq!(r.pick_at(Point::new(5.0, 5.0)), None); } /// An isolated wgpu device, standing in for the one a window's swap chain @@ -758,7 +694,11 @@ fn picking_does_not_change_the_buffered_display() { "enabling picking altered the display output" ); // And the hitmap is still populated, so the split did not cost the pick. - assert_eq!(r.pick_at(44, 52), Some(2), "circle should be hittable"); + assert_eq!( + r.pick_at(Point::new(44.0, 52.0)), + Some(2), + "circle should be hittable" + ); } /// Same invariant on the windowing path, which had its own copy of the bug. @@ -1153,20 +1093,24 @@ fn picking_survives_a_bgra_target() { .expect("render"); } assert_eq!( - r.pick_at(50, 50), + r.pick_at(Point::new(50.0, 50.0)), Some(id), "id came back wrong on a {format:?} target" ); - assert_eq!(r.pick_at(5, 5), None, "empty space on {format:?}"); + assert_eq!( + r.pick_at(Point::new(5.0, 5.0)), + None, + "empty space on {format:?}" + ); } } -// ─── Skipping the pick pass ───────────────────────────────────────────────── +// ─── Redrawing without querying ───────────────────────────────────────────── -/// With the pick pass off, the hitmap keeps answering from the last render -/// that refreshed it, and a later refresh brings it up to date. +/// The index is rebuilt from scratch each frame, so a redraw that changes an +/// id changes the answer — there is no stale-hitmap window to reason about. #[test] -fn skipping_the_pick_pass_holds_the_previous_hitmap() { +fn a_redraw_replaces_the_previous_index() { let mut out = buf(); let mut r = HybridRenderer::with_picking().expect("init"); let square = Rect::new(20.0, 20.0, 80.0, 80.0); @@ -1174,63 +1118,36 @@ fn skipping_the_pick_pass_holds_the_previous_hitmap() { fill(r.scene(), square, [255, 0, 0], PickId::Id(11)); r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) .expect("render"); - assert_eq!(r.pick_at(50, 50), Some(11)); + assert_eq!(r.pick_at(Point::new(50.0, 50.0)), Some(11)); - // Re-tag the same geometry, but skip the pick pass: the display follows - // the new scene while the hitmap still describes the old one. - r.set_refresh_pick(false); r.scene().clear(); fill(r.scene(), square, [0, 255, 0], PickId::Id(22)); r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) .expect("render"); - assert_eq!(px(&out, 50, 50), [0, 255, 0, 255], "display did update"); - assert_eq!( - r.pick_at(50, 50), - Some(11), - "hitmap should be the stale one" - ); - - // Turn it back on and the next render catches up. - r.set_refresh_pick(true); - r.scene().clear(); - fill(r.scene(), square, [0, 255, 0], PickId::Id(22)); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) - .expect("render"); - assert_eq!(r.pick_at(50, 50), Some(22), "hitmap should have caught up"); -} - -/// Skipping the pick pass must not change a display pixel — the whole point -/// is that it is invisible except in the hitmap. -#[test] -fn skipping_the_pick_pass_does_not_change_the_display() { - let mut refreshed = buf(); - let mut r = HybridRenderer::with_picking().expect("init"); - coverage_sensitive_scene(r.scene()); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut refreshed) - .expect("render"); - - let mut skipped = buf(); - let mut r = HybridRenderer::with_picking().expect("init"); - r.set_refresh_pick(false); - coverage_sensitive_scene(r.scene()); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut skipped) - .expect("render"); - + assert_eq!(px(&out, 50, 50), [0, 255, 0, 255], "display updated"); assert_eq!( - first_difference(&refreshed, &skipped), - None, - "skipping the pick pass altered the display" + r.pick_at(Point::new(50.0, 50.0)), + Some(22), + "and so did the index" ); - assert!(!r.refreshes_pick()); } -/// The flag is inert on a renderer built without picking. +/// A renderer built without picking answers nothing and says so. #[test] -fn refreshing_the_pick_is_inert_without_picking() { +fn a_renderer_without_picking_holds_no_index() { let mut r = HybridRenderer::new().expect("init"); - assert!(!r.refreshes_pick()); - r.set_refresh_pick(true); - assert!(!r.refreshes_pick(), "no pick scene exists to refresh"); + let mut out = buf(); + fill( + r.scene(), + Rect::new(20.0, 20.0, 80.0, 80.0), + [255, 0, 0], + PickId::Id(11), + ); + r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) + .expect("render"); + assert!(!r.picks()); + assert!(r.pick_index().is_none()); + assert_eq!(r.pick_at(Point::new(50.0, 50.0)), None); } // ─── Color glyphs ─────────────────────────────────────────────────────────── @@ -1352,9 +1269,9 @@ fn a_rotated_bitmap_color_glyph_draws_ink() { assert!(n > 100, "expected rotated emoji ink, found {n} pixels"); } -/// A strike paints the caller's id, not its own colors. Painting the -/// colors is what the rasteriser's strike path does, and reading them back -/// yields a spray of ids that were never drawn. +/// A colour glyph picks as the caller's id, whole. The rasteriser splits it +/// into a bitmap strike drawn as an image, which must not become a pick +/// target of its own. #[test] fn a_bitmap_color_glyph_picks_as_one_id() { let Some((emoji, _)) = emoji_run() else { @@ -1366,7 +1283,7 @@ fn a_bitmap_color_glyph_picks_as_one_id() { let mut ids: Vec = Vec::new(); for y in 0..H { for x in 0..W { - if let Some(id) = r.pick_at(x, y) { + if let Some(id) = r.pick_at(Point::new(f64::from(x), f64::from(y))) { if !ids.contains(&id) { ids.push(id); } diff --git a/tests/mesh.rs b/tests/mesh.rs index a500e10..72b99c3 100644 --- a/tests/mesh.rs +++ b/tests/mesh.rs @@ -126,7 +126,7 @@ fn draw_mesh_pick_round_trip() { let mut buf = vec![0u8; (W * H * 4) as usize]; r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut buf) .expect("render"); - assert_eq!(r.pick_at(100, 100), Some(42)); + assert_eq!(r.pick_at(Point::new(100.0, 100.0)), Some(42)); // Outside the triangle: no hit. - assert_eq!(r.pick_at(5, 5), None); + assert_eq!(r.pick_at(Point::new(5.0, 5.0)), None); } diff --git a/tests/picking.rs b/tests/picking.rs deleted file mode 100644 index 5f9a8e1..0000000 --- a/tests/picking.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! End-to-end picking tests. -//! -//! These exercise the full pipeline: VelloRenderer constructs with picking -//! enabled, the parallel pick scene captures each fill, the second render -//! pass writes a hitmap, readback decodes to ids, and `pick_at` returns -//! the expected value at known pixel positions. - -use hephaestus::backend::vello::VelloRenderer; -use hephaestus::color::rgb8; -use hephaestus::geometry::Point; -use hephaestus::{Affine, Brush, FillRule, PickId, Rect, Renderer, SceneBuilder}; -use kurbo::Shape; - -const W: u32 = 200; -const H: u32 = 200; - -fn fresh_buf() -> Vec { - vec![0u8; (W * H * 4) as usize] -} - -#[test] -fn pick_at_returns_none_when_picking_disabled() { - let mut r = VelloRenderer::new().expect("vello renderer init"); - let mut buf = fresh_buf(); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut buf) - .expect("render"); - assert_eq!(r.pick_at(50, 50), None); - assert!(r.hitmap().is_none()); -} - -#[test] -fn pick_at_returns_id_at_known_positions() { - let mut r = VelloRenderer::with_picking().expect("vello renderer init"); - { - let scene = r.scene(); - let red: Brush = rgb8(220, 60, 60).into(); - let green: Brush = rgb8(60, 220, 60).into(); - let blue: Brush = rgb8(60, 60, 220).into(); - let a = Rect::new(10.0, 10.0, 60.0, 60.0).to_path(0.1); - let b = Rect::new(80.0, 10.0, 130.0, 60.0).to_path(0.1); - let c = Rect::new(150.0, 10.0, 195.0, 60.0).to_path(0.1); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &red, - None, - &a, - PickId::Id(7), - ); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &green, - None, - &b, - PickId::Id(42), - ); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &blue, - None, - &c, - PickId::Id(0xAA_BBCC), - ); - } - let mut buf = fresh_buf(); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut buf) - .expect("render"); - - // Centers of each rect — should report their ids exactly. - assert_eq!(r.pick_at(35, 35), Some(7)); - assert_eq!(r.pick_at(105, 35), Some(42)); - assert_eq!(r.pick_at(170, 35), Some(0xAA_BBCC)); - - // Gaps between rects — no hit. - assert_eq!(r.pick_at(70, 35), None); - assert_eq!(r.pick_at(35, 100), None); - - // Out of bounds. - assert_eq!(r.pick_at(W, 0), None); - assert_eq!(r.pick_at(0, H), None); - - // Hitmap is the right shape and at least one pixel matches. - let map = r.hitmap().expect("hitmap populated"); - assert_eq!(map.len() as u32, W * H); -} - -#[test] -fn block_occludes_underneath_pick() { - let mut r = VelloRenderer::with_picking().expect("vello renderer init"); - { - let scene = r.scene(); - let red: Brush = rgb8(220, 60, 60).into(); - let yellow: Brush = rgb8(220, 220, 60).into(); - let big = Rect::new(0.0, 0.0, W as f64, H as f64).to_path(0.1); - let block = Rect::new(80.0, 80.0, 120.0, 120.0).to_path(0.1); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &red, - None, - &big, - PickId::Id(7), - ); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &yellow, - None, - &block, - PickId::Block, - ); - } - let mut buf = fresh_buf(); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut buf) - .expect("render"); - - // Outside the block: still id 7 from the underlying fill. - assert_eq!(r.pick_at(20, 20), Some(7)); - // Inside the block: overwritten with id 0 → no hit. - assert_eq!(r.pick_at(100, 100), None); -} - -#[test] -fn skip_does_not_disturb_underlying_pick() { - let mut r = VelloRenderer::with_picking().expect("vello renderer init"); - { - let scene = r.scene(); - let red: Brush = rgb8(220, 60, 60).into(); - let yellow: Brush = rgb8(220, 220, 60).into(); - let big = Rect::new(0.0, 0.0, W as f64, H as f64).to_path(0.1); - let overlay = Rect::new(80.0, 80.0, 120.0, 120.0).to_path(0.1); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &red, - None, - &big, - PickId::Id(7), - ); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &yellow, - None, - &overlay, - PickId::Skip, - ); - } - let mut buf = fresh_buf(); - r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut buf) - .expect("render"); - - // Both inside and outside the Skip overlay: id 7 should still be reported. - assert_eq!(r.pick_at(20, 20), Some(7)); - assert_eq!(r.pick_at(100, 100), Some(7)); -} - -/// A mark's antialiased surroundings must not report ids of their own. -/// -/// Pixels the rasteriser never covered come back with alpha `0` and whatever -/// happened to be in their colour channels. Reading those channels as an id -/// reports hits on empty space — and because a low byte value decodes to a low -/// id, the phantom hits cluster on whichever item was numbered first. -#[test] -fn empty_space_around_a_mark_reports_no_id() { - let mut r = VelloRenderer::with_picking().expect("vello renderer init"); - let (w, h) = (128u32, 128u32); - let id = 200u32; - - { - let scene = r.scene(); - scene.clear(); - let brush: Brush = rgb8(200, 90, 40).into(); - let circle = kurbo::Circle::new(Point::new(64.0, 64.0), 30.0).to_path(0.1); - scene.fill( - FillRule::NonZero, - Affine::IDENTITY, - &brush, - None, - &circle, - PickId::Id(id), - ); - } - - let mut pixels = vec![0u8; (w * h * 4) as usize]; - r.render_to_buffer(w, h, rgb8(20, 22, 28), &mut pixels) - .expect("render"); - - // The only ids anywhere in the hitmap are the mark's and "no hit". - let hitmap = r.hitmap().expect("picking enabled"); - let mut phantom = Vec::new(); - for (i, &px) in hitmap.iter().enumerate() { - if let Some(found) = hephaestus::pick::decode(px) { - if found != id { - phantom.push((i % w as usize, i / w as usize, found)); - } - } - } - assert!( - phantom.is_empty(), - "{} pixels reported an id other than {id}; first few: {:?}", - phantom.len(), - &phantom[..phantom.len().min(5)] - ); - - // The mark itself is still hittable, including near its edge. - assert_eq!(r.pick_at(64, 64), Some(id)); - assert_eq!(r.pick_at(64, 100), None); -} From 43fcccc671d29efb83c3c98620c9f2b6001999e3 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 3 Sep 2026 09:57:56 +0200 Subject: [PATCH 3/3] wire renderers, update docs --- CHANGELOG.md | 17 ++- CLAUDE.md | 4 +- src/CLAUDE.md | 64 ++++++++--- src/backend/CLAUDE.md | 8 +- src/backend/hybrid/CLAUDE.md | 65 +++++------ src/backend/hybrid/webgl.rs | 41 ++----- src/backend/hybrid/wgpu_renderer.rs | 45 ++------ src/backend/svg/CLAUDE.md | 18 ++- src/backend/svg/mod.rs | 78 +++++++++++-- src/backend/vello/CLAUDE.md | 15 +-- src/backend/vello/mod.rs | 45 ++------ src/pick/CLAUDE.md | 94 ++++++++++++++++ src/pick/index.rs | 5 +- src/pick/mod.rs | 45 +++----- src/pick/scene.rs | 6 - src/plot/chrome/axis.rs | 12 +- src/plot/chrome/legend/colorbar.rs | 5 + src/plot/chrome/legend/mod.rs | 38 +++++-- src/plot/chrome/linear_axis.rs | 28 ++++- src/plot/chrome/polar.rs | 24 +++- src/plot/chrome/strip.rs | 5 + src/plot/composition.rs | 165 ++++++++++++++++++++++++++-- src/plot/geom/ellipse.rs | 10 +- src/plot/geom/mod.rs | 4 +- src/plot/geom/resolve.rs | 12 +- src/plot/geom/state.rs | 3 +- src/plot/pick.rs | 24 +++- src/plot/plot.rs | 50 +++++++++ src/scene/CLAUDE.md | 11 +- src/window/CLAUDE.md | 14 +-- src/window/webgl_host.rs | 4 +- tests/hybrid.rs | 30 +++-- tests/mesh.rs | 10 +- tests/pick_index.rs | 22 ++++ tests/svg.rs | 91 ++++++++++++++- 35 files changed, 809 insertions(+), 303 deletions(-) create mode 100644 src/pick/CLAUDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a72f0..e1344ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`text::run_layout_rules`** — the underline / strikethrough rules of a plain `TextRun`, positioned like `run_layout_glyphs`' glyphs. - **`text::rich::RichTextRun::last_line_descender`** — counterpart to the plain `TextRun` method. - **In-place registry setters.** `Plot::set_shape_registry` / `shape_registry_mut` / `image_registry_mut` and `PlotComposition::set_shape_registry` / `shape_registry_mut` / `shape_registry_ref`, reachable from inside a `PlotComposition::update_plot` closure where the builder-style form is not. +- **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 colour 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. - **Plot document format major 2, and most future additions no longer move it.** Every named aggregate the format carries is length-prefixed, so a reader decodes the fields it knows and skips what a newer writer appended: a trailing field is now a minor bump. Measured cost on the four-panel test document, 6915 → 7090 bytes. **Breaking:** a major-1 document does not load. - **An unknown chunk is skipped only if its tag says it may be.** A chunk tag's initial letter carries criticality, as PNG's does — uppercase is refused when unknown, lowercase is skipped — so a section that mattered can never be silently dropped. `FONT` / `SHPS` / `IMGS` became `font` / `shps` / `imgs`, being the three whose absence was already tolerated. - **The document container carries a reserved flags word**, and refuses a bit it does not know, so a future change to how the chunks themselves are stored has somewhere to be announced. A repeated chunk tag is refused too. @@ -49,7 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A cross-grid `Extent::TrackOf` is refused on read rather than rebuilt from a meaningless id.** Its wire form names its target symbolically, which is the shape a portable reference will take; the write pass already refused a template carrying one. The `TrackOf` references that drive chrome alignment across nesting boundaries are generated while lowering a template and never travelled. - **Temporal breaks sit on the calendar, not on the range start.** A break's position depends only on its interval: 30-second majors land on :00 and :30 where a range opening at :07 used to put them on :07 and :37, and 15-minute, 6-hour, bimonthly and 25-year majors are likewise on their own grid. `align_date_to_grid` / `align_datetime_to_grid` / `align_time_to_grid` are the count-aware floors that place them, beside the unit-only `align_*_to_interval`, which is kept as a primitive and no longer used by the break path. - **A multi-unit temporal major divides inside its unit.** 12-hour majors take 3-hour minors and 30-second majors 10-second ones, rather than dropping a whole unit to 15-minute and 1-second minors; `derive_minor_interval` splits the major's count into the number of sub-intervals closest to four and falls through to the unit table only for a count of one. -- **Temporal minor breaks sit on the calendar, not on the majors.** A weekly minor under monthly majors is a Monday in every month rather than seven days past the 1st, and 6-hour, 15-minute and quarter minors land on their own grid the same way; the count between two majors follows the calendar. `derive_minor_interval` takes a `TemporalUnit` and stops at one of the finest unit that type resolves — reported by the new `smallest_calendar_unit` — so a day-spaced `Date` axis has no minors while a two-day one takes a minor between each pair. `pick_temporal_interval` respects the same floor, so a `Date` span short enough to miss the day-spaced tick target lands on days rather than on the hours a `Date` cannot address. **Breaking:** `derive_minor_interval` gained the argument. +- **Temporal minor breaks sit on the calendar, not on the majors.** A weekly minor under monthly majors is a Monday in every month rather than seven days past the 1st, and 6-hour, 15-minute and quarter minors land on their own grid the same way; the count between two majors follows the calendar. `derive_minor_interval` takes a `TemporalUnit` and stops at one of the finest unit that type resolves — reported by the new `smallest_calendar_unit` — so a day-spaced `Date` axis has no minors while a two-day one takes a minor between each pair. `pick_temporal_interval` respects the same floor, so a `Date` span short enough to miss the day-spaced tick target lands on days rather than on the hours a `Date` cannot address. **Breaking:** `derive_minor_interval` gained the argument. - **A composition can put content in its panel area.** `Composition::slot(Slot::Panel, …)` and a `Composition::place_at` span covering the panel cell are valid placements, resolving to the rect the facets fill — a shared panel background or an overlay behind or over them. `CompositionError::PanelSlot` and `PanelCovered` remain in the enum, produced by nothing. - **Letter spacing is tracking, in 1/1000 em, and named `tracking` everywhere.** `TextStyle::letter_spacing_pt` → `tracking`, the `"letter_spacing"` channel on `TextGeom` / `TextFitGeom` / `TextPathGeom` → `"tracking"`, `TextDefaults::letter_spacing_pt` / `TextFitDefaults::letter_spacing_pt` → `tracking`, `TextElement::letter_spacing` → `tracking`. A value now survives a change of size, and a fitted label's spacing scales with the size the fit chose. **Breaking:** the renames fail to compile, and `.set("letter_spacing", …)` panics on the unknown-channel check rather than reinterpreting a pt value as an em fraction. To convert, divide by the size it was written against and multiply by 1000 — 2pt at 12pt becomes `166.7`. `TextElement::tracking` still takes `Length::Abs(pt)`, and the theme's document encoding is unaffected. - **Plot documents carry marker shapes.** A `ShapeRegistry` entry a caller registered or replaced travels in a new `shps` chunk, so a reloaded plot resolves a custom `"shape"` / `"*_marker"` name, as a delta against the built-ins compared by *shape* rather than by name; `Shape` gained `PartialEq` for that, and `with_anchor`. A glyph-backed shape travels as `shape::GlyphSource { text, style }` and the reader re-shapes it, so it needs the family present and is dropped where it is missing; `text::try_glyph_marker` is the non-panicking form, and a shape built through `Shape::glyph` carries no source and is reported as `UnsupportedItem::UnnameableShape`. @@ -65,6 +76,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The document reader's intern tables clone in O(1).** `ReadTables` holds its three tables behind an `Arc`, so `read_composition`'s eight per-chunk clones are refcount bumps. - **`tests/image_geom.rs` is registered as a `[[test]]`**, so it carries `required-features` and no longer breaks an `--all-targets` build without `vello`, including the `msrv-document` CI job. The CI feature loop crosses the document directions with the image codec. +### 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. + ### Fixed - **Bitmap colour glyphs render on the sparse-strips backends.** Apple Color Emoji and most Android emoji carry PNG strikes rather than outlines, and `vello-hybrid` / `webgl` drew them as nothing at all. They are now resolved as images, so a strike survives rotation and picks as the caller's id; both gates imply `png` and `skrifa`. 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/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/webgl.rs b/src/backend/hybrid/webgl.rs index a8aea74..d995ca9 100644 --- a/src/backend/hybrid/webgl.rs +++ b/src/backend/hybrid/webgl.rs @@ -31,8 +31,7 @@ use super::{dimension, image_key, recorded_images, HybridScene, Writer}; use crate::backend::BackendError; use crate::color::Color; use crate::geometry::Affine; -use crate::path::{FillRule, Path}; -use crate::pick::{Hit, PickIndexScene}; +use crate::pick::PickIndexScene; /// Hephaestus WebGL2 renderer: owns the canvas's GL context, the recorded /// scene, and the sparse-strip scenes it replays into. @@ -98,41 +97,17 @@ impl HybridWebGlRenderer { (self.width, self.height) } - /// Every hit at `p`, topmost first. - pub fn hits_at(&self, p: crate::geometry::Point) -> Vec> { - self.scene.hits_at(p) - } - - /// The topmost authoring id at `p`. - pub fn pick_at(&self, p: crate::geometry::Point) -> Option { - self.scene.pick_at(p) - } - - /// Every hit whose bounds intersect `rect` — rubber-band brushing. - pub fn hits_in(&self, rect: crate::geometry::Rect) -> Vec> { - self.scene.hits_in(rect) - } - - /// Every hit entirely inside `rect` — a selection marquee. - pub fn hits_within(&self, rect: crate::geometry::Rect) -> Vec> { - self.scene.hits_within(rect) - } - - /// Lasso selection. - pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { - self.scene.hits_in_path(path, rule) - } - - /// The hit index the scene built. `None` when picking is off. + /// 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()) } - /// Whether this renderer's scene records a hit index. - pub fn picks(&self) -> bool { - self.scene.indexes() - } - /// Draw the recorded scene onto the canvas. pub fn present(&mut self, background: Color) -> Result<(), BackendError> { self.upload_images(); diff --git a/src/backend/hybrid/wgpu_renderer.rs b/src/backend/hybrid/wgpu_renderer.rs index 9f3b713..a07c0dd 100644 --- a/src/backend/hybrid/wgpu_renderer.rs +++ b/src/backend/hybrid/wgpu_renderer.rs @@ -15,8 +15,7 @@ use super::{dimension, image_key, recorded_images, unpremultiply, HybridScene, W use crate::backend::{BackendError, Renderer, WgpuRenderer}; use crate::color::Color; use crate::geometry::Affine; -use crate::path::{FillRule, Path}; -use crate::pick::{Hit, PickIndexScene}; +use crate::pick::PickIndexScene; // ---------- Renderer ---------- @@ -93,7 +92,7 @@ struct SizeBound { /// scene, and the per-size rasterisation state. /// /// Hit testing is a property of the scene, not of this renderer: the scene is -/// a [`PickIndexScene`](crate::pick::PickIndexScene), and +/// a [`crate::pick::PickIndexScene`], and /// [`Self::with_picking`] is what turns its indexing on. pub struct HybridRenderer { device: wgpu::Device, @@ -181,43 +180,17 @@ impl HybridRenderer { } } - /// Every hit at `p`, topmost first. Answers from the index the scene - /// built while it was drawn — no second rasterisation, no readback. - pub fn hits_at(&self, p: crate::geometry::Point) -> Vec> { - self.scene.hits_at(p) - } - - /// The topmost authoring id at `p`, or `None` over empty space, an - /// occluder, or when this renderer was not built with picking. - pub fn pick_at(&self, p: crate::geometry::Point) -> Option { - self.scene.pick_at(p) - } - - /// Every hit whose bounds intersect `rect` — rubber-band brushing. - pub fn hits_in(&self, rect: crate::geometry::Rect) -> Vec> { - self.scene.hits_in(rect) - } - - /// Every hit entirely inside `rect` — a selection marquee. - pub fn hits_within(&self, rect: crate::geometry::Rect) -> Vec> { - self.scene.hits_within(rect) - } - - /// Lasso selection. - pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { - self.scene.hits_in_path(path, rule) - } - - /// The hit index the scene built. `None` when picking is off. + /// 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()) } - /// Whether this renderer's scene records a hit index. - pub fn picks(&self) -> bool { - self.scene.indexes() - } - /// Set the texture format [`WgpuRenderer::render_to_texture`] writes. /// /// Defaults to `Rgba8Unorm`. A host that presents straight into its swap 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 ee4babb..92278cf 100644 --- a/src/backend/vello/mod.rs +++ b/src/backend/vello/mod.rs @@ -16,7 +16,7 @@ use crate::geometry::Affine; use crate::mesh::Mesh; use crate::path::{FillRule, Path}; -use crate::pick::{Hit, PickId, PickIndexScene}; +use crate::pick::{PickId, PickIndexScene}; use crate::scene::{GlyphRun, SceneBuilder}; use crate::stroke::Stroke; @@ -253,7 +253,7 @@ impl HeadlessTarget { /// scene being built, and per-size headless targets. /// /// Hit testing is a property of the scene, not of this renderer: the scene is -/// a [`PickIndexScene`](crate::pick::PickIndexScene), and +/// a [`crate::pick::PickIndexScene`], and /// [`Self::with_picking`] is what turns its indexing on. pub struct VelloRenderer { device: wgpu::Device, @@ -380,43 +380,16 @@ impl VelloRenderer { check_draw_budget(self.scene.inner().raw()) } - /// Every hit at `p`, topmost first. Answers from the index the scene - /// built while it was drawn, so it needs no GPU round-trip and no - /// readback — see [`PickIndex::hits_at`](crate::pick::PickIndex::hits_at). - pub fn hits_at(&self, p: crate::geometry::Point) -> Vec> { - self.scene.hits_at(p) - } - - /// The topmost authoring id at `p`, or `None` over empty space, an - /// occluder, or when this renderer was not built with picking. - pub fn pick_at(&self, p: crate::geometry::Point) -> Option { - self.scene.pick_at(p) - } - - /// Every hit whose bounds intersect `rect` — rubber-band brushing. - pub fn hits_in(&self, rect: crate::geometry::Rect) -> Vec> { - self.scene.hits_in(rect) - } - - /// Every hit entirely inside `rect` — a selection marquee. - pub fn hits_within(&self, rect: crate::geometry::Rect) -> Vec> { - self.scene.hits_within(rect) - } - - /// Lasso selection. - pub fn hits_in_path(&self, path: &Path, rule: FillRule) -> Vec> { - self.scene.hits_in_path(path, rule) - } - - /// The hit index the scene built. `None` when picking is off. + /// 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()) } - - /// Whether this renderer's scene records a hit index. - pub fn picks(&self) -> bool { - self.scene.indexes() - } } impl Renderer for VelloRenderer { 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/index.rs b/src/pick/index.rs index 7ad3926..3a0534e 100644 --- a/src/pick/index.rs +++ b/src/pick/index.rs @@ -62,10 +62,11 @@ pub struct Hit<'a> { } impl Hit<'_> { - /// The authoring id, if this hit carries one. + /// 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) if n != 0 => Some(n), + PickId::Id(n) => Some(n), _ => None, } } diff --git a/src/pick/mod.rs b/src/pick/mod.rs index fe8491c..24fc951 100644 --- a/src/pick/mod.rs +++ b/src/pick/mod.rs @@ -7,8 +7,8 @@ //! rectangle and lasso queries afterwards — on the CPU, with no second //! rasterisation and nothing read back from a GPU. //! -//! Ids are caller-managed and span the full `u32` range; `0` is reserved as -//! the no-hit sentinel, which is what [`PickId::Block`] encodes. +//! Ids are caller-managed and span the full `u32` range with nothing reserved. +//! Occlusion is [`PickId::Block`], a variant rather than a sentinel value. //! //! # Beyond ids: scopes //! @@ -61,46 +61,31 @@ pub enum PickId { /// hide what is under it without being interactive itself. Region queries /// are unaffected: a marquee is a spatial query, not a ray. Block, - /// Carry the given id. `Id(0)` is treated identically to [`Self::Block`], - /// `0` being the no-hit sentinel; every other `u32` is reported as given. + /// Carry the given id — any `u32`, `0` included. + /// + /// There is no reserved value: occlusion is [`Self::Block`], a variant of + /// its own, rather than a magic number. `0` was special only while ids + /// were packed into a texture, where an uncovered pixel decoded to it. Id(u32), } -/// Resolve a [`PickId`] to the raw id it reports, or `None` if the call -/// should not be indexed 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::*; #[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)); - // `Id(0)` and `Block` are the same request: occlude, report nothing. - assert_eq!(raw_id(PickId::Id(0)), raw_id(PickId::Block)); - } - - #[test] - fn ids_span_the_whole_u32_range() { - // Nothing packs an id into colour channels any more, so there is no - // width to truncate to. - assert_eq!(raw_id(PickId::Id(0x0100_0001)), Some(0x0100_0001)); - assert_eq!(raw_id(PickId::Id(u32::MAX)), Some(u32::MAX)); - assert_eq!(raw_id(PickId::Id(0x0100_0000)), Some(0x0100_0000)); + fn no_id_value_is_reserved() { + // Nothing packs an id into colour channels any more: there is no + // width to truncate to and no sentinel to avoid. + for n in [0, 1, 0x0100_0000, 0x0100_0001, u32::MAX] { + assert_eq!(PickId::Id(n), PickId::Id(n)); + } + // Occlusion is its own variant, not a value of `Id`. + assert_ne!(PickId::Id(0), PickId::Block); } #[test] fn default_pick_id_carries_no_id() { assert_eq!(PickId::default(), PickId::Skip); - assert_eq!(raw_id(PickId::default()), None); } } diff --git a/src/pick/scene.rs b/src/pick/scene.rs index 8053690..37462ff 100644 --- a/src/pick/scene.rs +++ b/src/pick/scene.rs @@ -52,12 +52,6 @@ impl PickIndexScene { self.enabled } - /// Turn indexing on or off. Takes effect from the next [`Self::clear`]; - /// the index keeps answering from what it already holds until then. - pub fn set_indexing(&mut self, enabled: bool) { - self.enabled = enabled; - } - /// Borrow the wrapped scene. pub fn inner(&self) -> &S { &self.inner diff --git a/src/plot/chrome/axis.rs b/src/plot/chrome/axis.rs index 046b27e..6b6f263 100644 --- a/src/plot/chrome/axis.rs +++ b/src/plot/chrome/axis.rs @@ -791,10 +791,14 @@ mod tests { &Theme::default(), &crate::image_registry::no_images(), ); - assert!( - scene.ops.is_empty(), - "expected no ops for empty breaks; got {}", - scene.ops.len() + // `draw_ops` rather than `ops`: the assertion is about what got + // drawn, and pushing a pick scope changes the op list without + // changing the picture. + assert_eq!( + scene.draw_ops().count(), + 0, + "expected nothing drawn for empty breaks; got {:?}", + scene.ops ); } diff --git a/src/plot/chrome/legend/colorbar.rs b/src/plot/chrome/legend/colorbar.rs index 0195719..f280641 100644 --- a/src/plot/chrome/legend/colorbar.rs +++ b/src/plot/chrome/legend/colorbar.rs @@ -17,6 +17,7 @@ use crate::path::{FillRule, Path}; use crate::pick::PickId; use crate::plot::chrome::linear_axis::AxisTick; use crate::plot::chrome::text::ChromeRun; +use crate::plot::pick::{part_scope, PlotPart}; use crate::plot::scale::ScaleRegistry; use crate::scales::breaks::DEFAULT_BREAK_COUNT; use crate::scales::chrome::LegendSide; @@ -439,6 +440,9 @@ pub(super) fn render_colorbar_body( if let Some(frame_el) = frame { paint_rect_frame(scene, frame_el, palette, bar_rect, dpi, true, false); } + // Bar and frame are one target: hovering the ramp should not report + // something different depending on whether the pointer is over its edge. + scene.push_pick_scope(&part_scope(PlotPart::ColorbarBar)); draw_gradient_bar( domain, spec, @@ -454,6 +458,7 @@ pub(super) fn render_colorbar_body( if let Some(frame_el) = frame { paint_rect_frame(scene, frame_el, palette, bar_rect, dpi, false, true); } + scene.pop_pick_scope(); let _ = samples; // sample count carried on the spec, used inside draw_gradient_bar // Axis along the bar's long edge — uses the shared linear-axis diff --git a/src/plot/chrome/legend/mod.rs b/src/plot/chrome/legend/mod.rs index 267771a..bae1ea9 100644 --- a/src/plot/chrome/legend/mod.rs +++ b/src/plot/chrome/legend/mod.rs @@ -60,6 +60,7 @@ use crate::geometry::{Affine, Point, Rect}; use crate::layout::Measure; use crate::pick::PickId; use crate::plot::chrome::linear_axis::{draw_axis_label, pt_to_px, AxisLabelAt}; +use crate::plot::pick::{item_scope, legend_scope, part_scope, PlotPart}; use crate::plot::scale::ScaleRegistry; // Inter-legend (and panel ↔ legend) gap parents — shared with // `Theme::default()` so the `Length::Rel` resolve parent matches the @@ -346,17 +347,16 @@ pub fn render_legend_stack( // The stack already measured every child to place it; passing // that measure down saves re-shaping every label and re-solving // the discrete-stack grid a second time per legend per frame. + // One frame per block, numbered within this side's stack. The + // index is post-collapse — compatible legends are merged before + // they get here — so it addresses what was drawn, not what was + // asked for. + let block = legends[*orig_idx]; + scene.push_pick_scope(&legend_scope(*orig_idx as u32, &block.domain_scale)); render_legend_with_measure( - legends[*orig_idx], - measure, - registry, - shapes, - images, - sub_rect, - scene, - dpi, - theme, + block, measure, registry, shapes, images, sub_rect, scene, dpi, theme, ); + scene.pop_pick_scope(); cursor += cross + inter_gap_px; } } @@ -425,7 +425,9 @@ pub(crate) fn render_legend_with_measure( // Background fill + border, sourced from the resolved // LegendTheme. Painted under the legend body so keys + text layer // on top. + scene.push_pick_scope(&part_scope(PlotPart::LegendBackground)); paint_legend_background(scene, lt, &theme.palette, draw_rect, dpi); + scene.pop_pick_scope(); match &legend.body { LegendBody::Stack(stack) if stack.binned => render_binned_stack_body( @@ -724,6 +726,7 @@ fn render_stack_body( if let (Some(title), Some(paint)) = (&legend.title, &styles.title) { let run = ChromeRun::shape(title, &paint.style, dpi, paint.rich.as_ref()); + scene.push_pick_scope(&part_scope(PlotPart::LegendTitle)); run.draw( scene, title_x, @@ -733,6 +736,7 @@ fn render_stack_body( Affine::IDENTITY, PickId::Skip, ); + scene.pop_pick_scope(); } let key_frame = lt.key.frame.as_set(); @@ -743,14 +747,22 @@ fn render_stack_body( let (swatch_local, label_local) = &layout.entries[idx]; let swatch_rect = translate_rect(*swatch_local, entries_x, entries_y); let label_rect = translate_rect(*label_local, entries_x, entries_y); + // One item frame per row. The row index addresses the domain + // scale's break list, so a consumer recovers the level it names. + scene.push_pick_scope(&item_scope(idx as u32)); // Per-row key frame: fill paints under the key (so a key // with a transparent rect / point shape lets the frame's // fill show through), stroke paints on top. + // Fill under, stroke over, so a transparent key shows the frame + // through. Both halves are the same target. if let Some(frame_el) = key_frame { + scene.push_pick_scope(&part_scope(PlotPart::LegendKeyFrame)); paint_rect_frame(scene, frame_el, palette, swatch_rect, dpi, true, false); + scene.pop_pick_scope(); } - for key in keys { + for (k, key) in keys.iter().enumerate() { let resolved = resolve_key(key, registry, v); + scene.push_pick_scope(&part_scope(PlotPart::LegendKey).with_index(k as u32)); render_key( key.kind, &resolved, @@ -763,13 +775,17 @@ fn render_stack_body( theme, images, ); + scene.pop_pick_scope(); } if let Some(frame_el) = key_frame { + scene.push_pick_scope(&part_scope(PlotPart::LegendKeyFrame)); paint_rect_frame(scene, frame_el, palette, swatch_rect, dpi, false, true); + scene.pop_pick_scope(); } if let Some(paint) = &styles.label { let label = domain.format(v, locale); let anchor = Point::new(label_rect.x0, (label_rect.y0 + label_rect.y1) * 0.5); + scene.push_pick_scope(&part_scope(PlotPart::LegendLabel)); draw_axis_label( scene, &label, @@ -783,7 +799,9 @@ fn render_stack_body( }, dpi, ); + scene.pop_pick_scope(); } + scene.pop_pick_scope(); } } diff --git a/src/plot/chrome/linear_axis.rs b/src/plot/chrome/linear_axis.rs index 1adf518..9a1b7b3 100644 --- a/src/plot/chrome/linear_axis.rs +++ b/src/plot/chrome/linear_axis.rs @@ -22,6 +22,7 @@ use crate::path::Path; use crate::pick::PickId; use crate::plot::chrome::text::{ChromeRun, RichChrome}; use crate::plot::geom::resolve::build_stroke_for_pattern; +use crate::plot::pick::{item_scope, part_scope, PlotPart}; use crate::plot::theme::{LineElement, RectElement, ResolvedAxis, Theme}; use crate::scene::SceneBuilder; use crate::stroke::{Cap, Join, Stroke}; @@ -243,6 +244,13 @@ impl AxisChromeStyle { /// /// `minors` carries `(break_index, frac)` per minor tick, indexed against /// the scale's minor-break list the same way [`AxisTick::break_index`] is. +/// +/// Pushes a pick scope per piece — line, tick, label — so a hit names which +/// part of the rail it landed on. Every caller gets that: the cartesian axis, +/// the polar radius axis, and both colorbar rails all come through here. The +/// parts are the `Axis*` ones whatever the caller, because it is literally +/// the same rail; the frame *enclosing* this call is what says whether it +/// belongs to an axis or a legend. /// One major tick on an axis rail. /// /// `break_index` addresses the scale's own break list, **not** the position @@ -313,12 +321,18 @@ pub(crate) fn draw_linear_axis_at( // Baseline. if let Some(brush) = &style.line_brush { + scene.push_pick_scope(&part_scope(PlotPart::AxisLine)); stroke_line(scene, &style.line_stroke, brush, start, end); + scene.pop_pick_scope(); } // Minor ticks first so a major drawn at the same frac wins. if let Some(brush) = &style.minor_brush { - for &(_break_index, frac) in minors { + // The part frame is hoisted out of the loop: every minor tick is the + // same part, and one group per tick would nest the SVG output two + // deep for nothing. + scene.push_pick_scope(&part_scope(PlotPart::AxisMinorTick)); + for &(break_index, frac) in minors { if !frac.is_finite() || !(0.0..=1.0).contains(&frac) { continue; } @@ -327,8 +341,11 @@ pub(crate) fn draw_linear_axis_at( pos.x + style.minor_tick_length_px * tx, pos.y + style.minor_tick_length_px * ty, ); + scene.push_pick_scope(&item_scope(break_index as u32)); stroke_line(scene, &style.minor_stroke, brush, pos, tick_end); + scene.pop_pick_scope(); } + scene.pop_pick_scope(); } // Major ticks + labels. @@ -342,8 +359,13 @@ pub(crate) fn draw_linear_axis_at( pos.x + style.tick_length_px * tx, pos.y + style.tick_length_px * ty, ); + let item = item_scope(tick.break_index as u32); if let Some(tick_brush) = &style.tick_brush { + scene.push_pick_scope(&part_scope(PlotPart::AxisTick)); + scene.push_pick_scope(&item); stroke_line(scene, &style.tick_stroke, tick_brush, pos, tick_end); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } if style.draw_labels { @@ -365,6 +387,8 @@ pub(crate) fn draw_linear_axis_at( outward_tick_end.x + style.gap_px * outward_tx, outward_tick_end.y + style.gap_px * outward_ty, ); + scene.push_pick_scope(&part_scope(PlotPart::AxisTickLabel)); + scene.push_pick_scope(&item); draw_axis_label( scene, label, @@ -378,6 +402,8 @@ pub(crate) fn draw_linear_axis_at( }, dpi, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } } } diff --git a/src/plot/chrome/polar.rs b/src/plot/chrome/polar.rs index 5f626e7..87c9670 100644 --- a/src/plot/chrome/polar.rs +++ b/src/plot/chrome/polar.rs @@ -18,6 +18,7 @@ use crate::plot::chrome::linear_axis::{ AxisLabelAt, }; use crate::plot::chrome::text::ChromeRun; +use crate::plot::pick::{item_scope, part_scope, PlotPart}; use crate::plot::projection::PolarProjection; use crate::plot::scale::Scale; use crate::plot::theme::{HAlign, Theme}; @@ -172,7 +173,13 @@ pub fn draw_angular_axis( let style = chrome_style.text_style.clone(); // Minor ticks first so majors paint on top if they coincide. - for v in scale.minor_breaks(DEFAULT_BREAK_COUNT) { + // `enumerate` before the guards, so the ordinal addresses the scale's own + // break list — see the note in `chrome::axis::draw`. + for (break_index, v) in scale + .minor_breaks(DEFAULT_BREAK_COUNT) + .into_iter() + .enumerate() + { if matches!(v, Value::Null) { continue; } @@ -194,6 +201,8 @@ pub fn draw_angular_axis( on_ring.y + minor_tick_px * ry, ); if let Some(minor_brush) = chrome_style.minor_brush.as_ref() { + scene.push_pick_scope(&part_scope(PlotPart::AxisMinorTick)); + scene.push_pick_scope(&item_scope(break_index as u32)); scene.stroke( &chrome_style.minor_stroke, Affine::IDENTITY, @@ -202,10 +211,12 @@ pub fn draw_angular_axis( &segment(on_ring, tick_end), PickId::Skip, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } } - for v in &scale.breaks(DEFAULT_BREAK_COUNT) { + for (break_index, v) in scale.breaks(DEFAULT_BREAK_COUNT).iter().enumerate() { if matches!(v, Value::Null) { continue; } @@ -223,9 +234,12 @@ pub fn draw_angular_axis( let on_ring = PolarProjection::polar_point(Point::new(g.cx, g.cy), ring_r, theta); let (rx, ry) = (tick_sign * theta.cos(), -tick_sign * theta.sin()); let tick_end = Point::new(on_ring.x + tick_px * rx, on_ring.y + tick_px * ry); + let item = item_scope(break_index as u32); if let (Some(tick_brush), tick_stroke) = (chrome_style.tick_brush.as_ref(), &chrome_style.tick_stroke) { + scene.push_pick_scope(&part_scope(PlotPart::AxisTick)); + scene.push_pick_scope(&item); scene.stroke( tick_stroke, Affine::IDENTITY, @@ -234,12 +248,16 @@ pub fn draw_angular_axis( &segment(on_ring, tick_end), PickId::Skip, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } let anchor = Point::new( tick_end.x + label_gap_px * rx, tick_end.y + label_gap_px * ry, ); let text = scale.format(v, &theme.locale); + scene.push_pick_scope(&part_scope(PlotPart::AxisTickLabel)); + scene.push_pick_scope(&item); draw_axis_label( scene, &text, @@ -253,6 +271,8 @@ pub fn draw_angular_axis( }, dpi, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } if let Some(title_text) = title { diff --git a/src/plot/chrome/strip.rs b/src/plot/chrome/strip.rs index 840b756..57b5885 100644 --- a/src/plot/chrome/strip.rs +++ b/src/plot/chrome/strip.rs @@ -25,6 +25,7 @@ use crate::pick::PickId; use crate::plot::chrome::axis::axis_side_to_channel_side; use crate::plot::chrome::linear_axis::{pt_to_px, stroke_from_rect_border}; use crate::plot::chrome::text::{draw_text_element_in_rect, rotated_bbox, text_style_from}; +use crate::plot::pick::{part_scope, PlotPart}; use crate::plot::theme::HAlign; use crate::plot::theme::{ rect_concrete_defaults, text_concrete_defaults, RectElement, Rotation, TextElement, Theme, @@ -326,7 +327,9 @@ pub fn draw_strip( let bg = resolved_background(theme, side); let bg_path = bg.as_ref().map(|el| strip_background_path(el, rect, dpi)); if let (Some(el), Some(path)) = (bg.as_ref(), bg_path.as_ref()) { + scene.push_pick_scope(&part_scope(PlotPart::StripBackground)); paint_strip_background(scene, el, path, theme, dpi); + scene.pop_pick_scope(); } let root_pt = crate::plot::chrome::root_text_pt(theme); @@ -377,6 +380,7 @@ pub fn draw_strip( if let Some(path) = clipping { scene.push_layer(BlendMode::default(), 1.0, Affine::IDENTITY, path); } + scene.push_pick_scope(&part_scope(PlotPart::StripLabel)); draw_text_element_in_rect( scene, text, @@ -392,6 +396,7 @@ pub fn draw_strip( if clipping.is_some() { scene.pop_layer(); } + scene.pop_pick_scope(); } /// Shift the four-side padding so the visible cap-band centers in diff --git a/src/plot/composition.rs b/src/plot/composition.rs index 4f26ea4..ded3872 100644 --- a/src/plot/composition.rs +++ b/src/plot/composition.rs @@ -575,6 +575,10 @@ impl CompositionChrome { ) else { continue; }; + scene.push_pick_scope(&crate::plot::pick::region_scope(slot)); + scene.push_pick_scope(&crate::plot::pick::part_scope( + crate::plot::pick::text_slot_part(slot), + )); draw_text_element_in_rect( scene, text, @@ -587,6 +591,8 @@ impl CompositionChrome { Some(&theme.rich_text), images, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } let text_defaults = text_concrete_defaults(); @@ -611,6 +617,12 @@ impl CompositionChrome { .or(text_defaults.angle) .expect("text_concrete_defaults sets angle"); let style = crate::plot::chrome::text::text_style_from(&el, root_pt); + scene.push_pick_scope(&crate::plot::pick::region_scope(cartesian_axis_title_slot( + side, + ))); + scene.push_pick_scope(&crate::plot::pick::part_scope( + crate::plot::pick::PlotPart::AxisTitle, + )); if matches!(el.markdown, Some(true)) { crate::plot::chrome::text::draw_axis_title_markdown( scene, @@ -625,19 +637,22 @@ impl CompositionChrome { side, angle, ); - continue; + } else { + let run = TextRun::new(title, &style, dpi); + let outline = + crate::plot::chrome::text::text_outline_from(&el, &theme.palette, dpi); + draw_axis_title( + scene, + &run, + rect, + side, + &Brush::Solid(color.resolve(&theme.palette)), + outline.as_ref(), + angle, + ); } - let run = TextRun::new(title, &style, dpi); - let outline = crate::plot::chrome::text::text_outline_from(&el, &theme.palette, dpi); - draw_axis_title( - scene, - &run, - rect, - side, - &Brush::Solid(color.resolve(&theme.palette)), - outline.as_ref(), - angle, - ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } // Must collapse identically to `wire` for the measured space @@ -649,9 +664,11 @@ impl CompositionChrome { continue; } if let Some(rect) = layout.get(comp_id, slot) { + scene.push_pick_scope(&crate::plot::pick::region_scope(slot)); crate::plot::chrome::legend::render_legend_stack( &group, side, rect, registry, shapes, images, scene, dpi, theme, ); + scene.pop_pick_scope(); } } } @@ -1726,6 +1743,130 @@ mod tests { ); } + /// A composition with axes, strips, a legend and text in every slot, so + /// the whole chrome vocabulary is exercised at once. + fn richly_dressed() -> PlotComposition { + use crate::plot::chrome::axis::{Axis, AxisPlacement}; + use crate::plot::geom::PointGeom; + use crate::scales::chrome::AxisSide; + + let mut view = PlotComposition::new(&comp_two().id("outer")).title("Figure"); + view.insert_scale("x", scale::continuous(0.0..=1.0)); + view.insert_scale("y", scale::continuous(0.0..=1.0)); + + let mut plot = crate::plot::Plot::new(&comp_two(), "a"); + plot.set_binding("x", "x"); + plot.set_binding("y", "y"); + plot.add_axis(Axis::rail("x", AxisPlacement::Cartesian(AxisSide::Bottom)).title("X")); + plot.add_axis(Axis::rail("y", AxisPlacement::Cartesian(AxisSide::Left)).title("Y")); + plot.set_strip(AxisSide::Top, Some("facet".to_string())); + plot.set_title("Plot"); + plot.add_geom( + PointGeom::builder() + .set("x", vec![0.2_f64, 0.8]) + .set("y", vec![0.3_f64, 0.7]) + .set("fill", crate::color::Color::new([1.0, 0.0, 0.0, 1.0])) + .set("pick_id", vec![1.0_f64, 2.0]) + .build(), + ); + view.attach_plot(plot); + view + } + + #[test] + fn every_chrome_part_reports_a_well_formed_path() { + use crate::plot::pick::{PlotPart, PlotPath}; + use std::collections::BTreeSet; + + let mut view = richly_dressed(); + let mut sc = + crate::pick::PickIndexScene::new(crate::scene::recording::RecordingScene::new(), true); + view.render(&mut sc, Size::new(800.0, 600.0), 96.0); + + let hits = sc + .index() + .hits_in(crate::geometry::Rect::new(0.0, 0.0, 800.0, 600.0)); + let mut parts: BTreeSet<&str> = BTreeSet::new(); + for h in &hits { + let p = PlotPath::new(h.path); + // Every indexed primitive sits in a composition and a region. + assert!(p.composition().is_some(), "no composition frame"); + let Some(part) = p.part() else { + // The only unpartitioned hits are geom marks, which carry an + // authoring id instead. + assert!(p.geom().is_some() && h.id().is_some(), "orphan hit"); + continue; + }; + assert!(p.region().is_some(), "{} has no region", part.name()); + parts.insert(part.name()); + + // Anything reporting an ordinal must name the scale it indexes. + if p.item().is_some() && matches!(part, PlotPart::AxisTick | PlotPart::AxisTickLabel) { + assert!(p.scale().is_some(), "{} names no scale", part.name()); + } + // Axis chrome carries the axis handle it belongs to. + if matches!( + part, + PlotPart::AxisLine | PlotPart::AxisTick | PlotPart::AxisTickLabel + ) { + assert!(p.axis().is_some(), "{} has no axis frame", part.name()); + } + } + + // `AxisLine` is absent by design: the default theme sets + // `axis.line = Element::Blank`, so no rail is drawn to index. + assert!(!parts.contains(PlotPart::AxisLine.name())); + + // The vocabulary this fixture should light up. + for want in [ + PlotPart::PlotBackground, + PlotPart::PanelBackground, + PlotPart::GridMajor, + PlotPart::AxisTick, + PlotPart::AxisTickLabel, + PlotPart::AxisTitle, + PlotPart::StripBackground, + PlotPart::StripLabel, + PlotPart::Title, + ] { + assert!( + parts.contains(want.name()), + "{} never appeared; got {parts:?}", + want.name() + ); + } + } + + #[test] + fn a_figure_title_has_no_owning_plot() { + use crate::plot::pick::{PlotPart, PlotPath}; + let mut view = richly_dressed(); + let mut sc = + crate::pick::PickIndexScene::new(crate::scene::recording::RecordingScene::new(), true); + view.render(&mut sc, Size::new(800.0, 600.0), 96.0); + + let hits = sc + .index() + .hits_in(crate::geometry::Rect::new(0.0, 0.0, 800.0, 600.0)); + let titles: Vec> = hits + .iter() + .map(|h| PlotPath::new(h.path)) + .filter(|p| p.part() == Some(PlotPart::Title)) + .collect(); + assert!(!titles.is_empty(), "no title was drawn"); + + // Composition chrome is one frame shorter than plot chrome: the + // figure title belongs to the figure, not to either plot. + assert!( + titles.iter().any(|p| p.plot().is_none()), + "the composition title should report no plot" + ); + assert!( + titles.iter().any(|p| p.plot() == Some(("a", 0))), + "the plot title should report its plot" + ); + } + #[test] fn every_drawn_op_sits_in_a_balanced_scope() { use crate::scene::recording::{Op, RecordingScene}; diff --git a/src/plot/geom/ellipse.rs b/src/plot/geom/ellipse.rs index 14e9a8e..24fe866 100644 --- a/src/plot/geom/ellipse.rs +++ b/src/plot/geom/ellipse.rs @@ -726,7 +726,7 @@ mod tests { } #[test] - fn pick_id_zero_maps_to_block() { + fn pick_id_zero_is_an_ordinary_id() { let g = EllipseGeom::builder() .set("x", vec![0.5_f64]) .set("y", vec![0.5_f64]) @@ -742,16 +742,18 @@ mod tests { &mut scene, &ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales), ); - let has_block = scene.ops.iter().any(|op| { + // `0` was the no-hit sentinel only while ids were packed into a + // texture's colour channels. It is now just an id. + let has_zero = scene.ops.iter().any(|op| { matches!( op, Op::Fill { - pick_id: crate::pick::PickId::Block, + pick_id: crate::pick::PickId::Id(0), .. } ) }); - assert!(has_block); + assert!(has_zero); } #[test] diff --git a/src/plot/geom/mod.rs b/src/plot/geom/mod.rs index aa9c49f..65ae114 100644 --- a/src/plot/geom/mod.rs +++ b/src/plot/geom/mod.rs @@ -381,8 +381,8 @@ impl<'a> ScaleResolver for DirectScaleResolver<'a> { /// resolved value is the 24-bit id reported by /// [`pick_at`](crate::backend::vello::VelloRenderer::pick_at). Unset /// channel → `PickId::Skip` for the whole geom (no participation in -/// no authoring id); resolved value `0` → `PickId::Block` (occlude without -/// reporting); otherwise → `PickId::Id(value)`. The context does not +/// no authoring id); any other resolved value → `PickId::Id(value)`, `0` +/// included, since no id value is reserved. The context does not /// allocate or track ids — the user owns the namespace. pub struct GeomContext<'a> { pub panel_rect: Rect, diff --git a/src/plot/geom/resolve.rs b/src/plot/geom/resolve.rs index 3729a6b..3b582c8 100644 --- a/src/plot/geom/resolve.rs +++ b/src/plot/geom/resolve.rs @@ -464,9 +464,8 @@ pub(crate) fn band_width_at(scale: Option<&Scale>, raw: &Value) -> f64 { /// time (an ordinal scale producing a fractional output would be a /// bug; loudly skipping is more discoverable than silently /// truncating). -/// - Value `0` → `PickId::Block` (occlude without reporting). Documented -/// contract so callers whose row indices start at 0 shift to 1+ if -/// they want their rows pickable. +/// - `0` is an ordinary id. It was the no-hit sentinel only while ids were +/// packed into a texture, so a row-index column no longer has to shift. /// /// Grouped geoms (LineGeom / PolygonGeom) call this with the mark's /// `first_row` index so each mark gets one pick id from its first @@ -484,12 +483,7 @@ pub(crate) fn resolve_pick_id( if !n.is_finite() || n < 0.0 || n > MAX_PICK_ID as f64 || n.trunc() != n { return PickId::Skip; } - let id = n as u32; - if id == 0 { - PickId::Block - } else { - PickId::Id(id) - } + PickId::Id(n as u32) } /// Stroke `path` honouring the full linetype contract — marker-free diff --git a/src/plot/geom/state.rs b/src/plot/geom/state.rs index 1efad5d..0037a66 100644 --- a/src/plot/geom/state.rs +++ b/src/plot/geom/state.rs @@ -269,8 +269,7 @@ pub fn validate_channel_lengths(channels: &HashMap, n: usize, g /// deferred to per-row draw resolution (the output depends on draw-time /// scale state and can't be checked here). /// -/// `0` is permitted — it maps to [`PickId::Block`](crate::pick::PickId) -/// per the user-controlled pick-id contract. +/// `0` is permitted and ordinary: no id value is reserved. pub fn validate_pick_id_channel(channels: &HashMap, geom_label: &str) { let ch = match channels.get("pick_id") { Some(c) => c, diff --git a/src/plot/pick.rs b/src/plot/pick.rs index 87ff73a..2575759 100644 --- a/src/plot/pick.rs +++ b/src/plot/pick.rs @@ -40,7 +40,7 @@ pub mod kind { pub const LEGEND: &str = "legend"; /// One geom, numbered by its `GeomId`. pub const GEOM: &str = "geom"; - /// A distinguishable piece of chrome — see [`PlotPart`]. + /// A distinguishable piece of chrome — see [`PlotPart`](super::PlotPart). pub const PART: &str = "part"; /// An ordinal within a part: a break index, a legend row, a key. pub const ITEM: &str = "item"; @@ -77,8 +77,10 @@ pub enum PlotPart { LegendKeyFrame, LegendKey, LegendLabel, + /// The gradient ramp itself. A colorbar's tick rail is drawn by the same + /// code as any axis and reports the `Axis*` parts; the enclosing + /// `legend` frame is what says it belongs to a legend. ColorbarBar, - ColorbarTick, // Free text. Title, Subtitle, @@ -109,7 +111,6 @@ impl PlotPart { PlotPart::LegendKey => "legend_key", PlotPart::LegendLabel => "legend_label", PlotPart::ColorbarBar => "colorbar_bar", - PlotPart::ColorbarTick => "colorbar_tick", PlotPart::Title => "title", PlotPart::Subtitle => "subtitle", PlotPart::Caption => "caption", @@ -138,7 +139,6 @@ impl PlotPart { "legend_key" => PlotPart::LegendKey, "legend_label" => PlotPart::LegendLabel, "colorbar_bar" => PlotPart::ColorbarBar, - "colorbar_tick" => PlotPart::ColorbarTick, "title" => PlotPart::Title, "subtitle" => PlotPart::Subtitle, "caption" => PlotPart::Caption, @@ -147,7 +147,7 @@ impl PlotPart { } /// Every part, in declaration order. - pub const ALL: [PlotPart; 23] = [ + pub const ALL: [PlotPart; 22] = [ PlotPart::PlotBackground, PlotPart::PanelBackground, PlotPart::GridMajor, @@ -167,7 +167,6 @@ impl PlotPart { PlotPart::LegendKey, PlotPart::LegendLabel, PlotPart::ColorbarBar, - PlotPart::ColorbarTick, PlotPart::Title, PlotPart::Subtitle, PlotPart::Caption, @@ -348,6 +347,19 @@ impl<'a> PlotPath<'a> { } } +/// The part a free-text anatomical slot draws. +/// +/// Title, subtitle and caption are the same shape of thing at plot and +/// composition level, so both draw loops route through here rather than +/// each carrying its own match. +pub fn text_slot_part(slot: Slot) -> PlotPart { + match slot { + Slot::Subtitle => PlotPart::Subtitle, + Slot::Caption => PlotPart::Caption, + _ => PlotPart::Title, + } +} + /// The `(channel, side)` coordinate the theme files a region's axis chrome /// under — the pair /// [`Sided::resolve`](crate::plot::theme::cascade::Sided::resolve) and diff --git a/src/plot/plot.rs b/src/plot/plot.rs index ee05970..d117b34 100644 --- a/src/plot/plot.rs +++ b/src/plot/plot.rs @@ -1694,6 +1694,16 @@ impl Plot { let resolve_scale = |name: &str| -> Option<&Scale> { overlay.get(name).or_else(|| registry.get(name)) }; for axis in &self.axes { + // Region and axis frames wrap every axis whatever its placement. + // A cartesian axis sits in its anatomical slot; a polar one is + // drawn inside the panel, so it is the `axis` frame rather than + // the region that tells the two apart. + let region = match axis.placement() { + AxisPlacement::Cartesian(side) => cartesian_axis_slot(side), + _ => Slot::Panel, + }; + scene.push_pick_scope(&crate::plot::pick::region_scope(region)); + scene.push_pick_scope(&crate::plot::pick::axis_scope(axis.id(), axis.scale_name())); match axis.placement() { AxisPlacement::Cartesian(side) => { if let Some(scale_name) = axis.scale_name() { @@ -1762,6 +1772,8 @@ impl Plot { } } } + scene.pop_pick_scope(); + scene.pop_pick_scope(); } } @@ -1780,7 +1792,9 @@ impl Plot { let Some(rect) = layout.get(&self.patch_id, strip_slot(side)) else { continue; }; + scene.push_pick_scope(&crate::plot::pick::region_scope(strip_slot(side))); draw_strip(scene, text, rect, side, theme, dpi, &self.images); + scene.pop_pick_scope(); } } @@ -1861,6 +1875,7 @@ impl Plot { continue; } if let Some(rect) = layout.get(&self.patch_id, slot) { + scene.push_pick_scope(&crate::plot::pick::region_scope(slot)); crate::plot::chrome::legend::render_legend_stack( &group, side, @@ -1872,6 +1887,7 @@ impl Plot { dpi, theme, ); + scene.pop_pick_scope(); } } @@ -1897,6 +1913,9 @@ impl Plot { } let slot_rect = crate::plot::chrome::legend::resolve_anchor(panel, anchor, inset_px, (w, h)); + // An in-panel legend reserves no chrome band, so its region + // is the panel it overlays rather than a legend slot. + scene.push_pick_scope(&crate::plot::pick::region_scope(Slot::Panel)); crate::plot::chrome::legend::render_legend_stack( &group, crate::scales::chrome::LegendSide::Right, @@ -1908,6 +1927,7 @@ impl Plot { dpi, theme, ); + scene.pop_pick_scope(); } } @@ -1931,6 +1951,10 @@ impl Plot { ) else { continue; }; + scene.push_pick_scope(&crate::plot::pick::region_scope(slot)); + scene.push_pick_scope(&crate::plot::pick::part_scope( + crate::plot::pick::text_slot_part(slot), + )); draw_text_element_in_rect( scene, text, @@ -1943,6 +1967,8 @@ impl Plot { Some(&theme.rich_text), &self.images, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } // Axis title slots — sourced from `Axis::title` on each @@ -1985,6 +2011,14 @@ impl Plot { TitleLocation::Outside => { let slot = cartesian_axis_title_slot(side); if let Some(rect) = layout.get(&self.patch_id, slot) { + scene.push_pick_scope(&crate::plot::pick::region_scope(slot)); + scene.push_pick_scope(&crate::plot::pick::axis_scope( + axis.id(), + axis.scale_name(), + )); + scene.push_pick_scope(&crate::plot::pick::part_scope( + crate::plot::pick::PlotPart::AxisTitle, + )); if markdown { let fill_col = color.resolve(&theme.palette); draw_axis_title_markdown( @@ -2011,12 +2045,25 @@ impl Plot { angle, ); } + scene.pop_pick_scope(); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } } TitleLocation::Inside => { let Some(panel) = layout.get(&self.patch_id, Slot::Panel) else { continue; }; + // An inside title reserves no chrome band, so its region + // is the panel it sits against rather than a title slot. + scene.push_pick_scope(&crate::plot::pick::region_scope(Slot::Panel)); + scene.push_pick_scope(&crate::plot::pick::axis_scope( + axis.id(), + axis.scale_name(), + )); + scene.push_pick_scope(&crate::plot::pick::part_scope( + crate::plot::pick::PlotPart::AxisTitle, + )); // Resolve the angle so the strip dims and the // draw helper see a concrete rotation. let baseline_deg: f32 = match side { @@ -2095,6 +2142,9 @@ impl Plot { Some(&theme.rich_text), &self.images, ); + scene.pop_pick_scope(); + scene.pop_pick_scope(); + scene.pop_pick_scope(); } } } diff --git a/src/scene/CLAUDE.md b/src/scene/CLAUDE.md index 197676f..d604ec7 100644 --- a/src/scene/CLAUDE.md +++ b/src/scene/CLAUDE.md @@ -10,7 +10,7 @@ Every method is **self-contained** — no persistent "current transform" or "cur ## Core types -- **`SceneBuilder`** trait — `fill`, `stroke`, `draw_image`, `draw_glyphs`, `draw_mesh`, `push_layer`, `pop_layer`. Every drawing primitive (not `push_layer` / `pop_layer`) takes a `PickId`. +- **`SceneBuilder`** trait — `fill`, `stroke`, `draw_image`, `draw_glyphs`, `draw_mesh`, `push_layer`, `pop_layer`, `push_pick_scope`, `pop_pick_scope`. Every drawing primitive (not the layer or scope pairs) takes a `PickId`. - **`Font`** — opaque handle wrapping `peniko::FontData` (Arc-backed font blob + index). Construct via `Font::new(blob, index)`. - **`Glyph`** — `{ id: u32, x: f32, y: f32 }`. A single positioned glyph in run-local coordinates. - **`GlyphRun<'a>`** — a run of glyphs sharing one font, size, transform, brush, and brush alpha. Borrows the font and glyph slice; the brush is owned by the caller and borrowed by reference. @@ -22,14 +22,15 @@ The trait deliberately consumes already-positioned glyphs — shaping and line-b ## Conventions - **Adding a method on `SceneBuilder` requires adding an `Op` variant in `recording.rs`.** The recording backend is exhaustive over the trait surface; that exhaustiveness is what validates the trait shape (if recording is awkward, the trait is wrong) and what lets future SVG / PDF emitters be one `match` over `Op`. Skipping this step breaks the recording backend and downstream emitters. -- **Picking ids carry through every primitive.** Authoring code chooses `PickId::Skip` (most decorative chrome), `PickId::Block` (opaque backgrounds), or `PickId::Id(n)`. See `src/CLAUDE.md` for the model; `pick.rs` for the encoding. -- **`push_layer` does not take a `PickId`.** The Vello backend normalises blend to `NORMAL` and alpha to `1.0` inside the pick scene's `push_layer` so encoded ids inside the layer don't fade toward the no-hit sentinel. +- **Picking ids carry through every primitive.** Authoring code chooses `PickId::Skip` (no authoring id), `PickId::Block` (occlude without reporting), or `PickId::Id(n)`. See `src/CLAUDE.md` for the model. +- **`push_layer` does not take a `PickId`, and `push_pick_scope` is not a layer.** The two stacks are orthogonal: a scope has no visual effect and imposes no clip, and the two need not nest with one another. A backend that emits groups for both — SVG does — has to tag them, or an interleaved pair closes the wrong element. +- **The two scope methods have default no-op bodies, and they are the only ones that do.** Deliberate: three of the five real implementors want exactly a no-op, it keeps the trait non-breaking for a downstream implementor, and — unlike every other method here — ignoring it still produces a correct *picture*. The intersection-of-backends rule is about visual capabilities, and a scope has none. - **`draw_mesh` shares one `pick_id` across the whole mesh.** Picking does not distinguish individual triangles. No backend currently has a native indexed-mesh primitive — each backend decomposes the mesh into its own draw ops (e.g. one fill with a per-triangle linear-gradient brush in Vello). ## Cross-references -- `backend/vello/` — the only `SceneBuilder` implementation that rasterises today. Pick scene is a parallel `vello::Scene` recorded alongside the display scene. +- `backend/vello/` — one of the two rasterising `SceneBuilder` implementations. Ignores `pick_id`, like every rasteriser: the hit index sits above them. - `backend/svg/` — the vector implementation. Consumes `TextSource` to emit real ``; the reason that field exists. -- `pick.rs` — `PickId` variants and the RGB-channel encoding. +- `pick/` — `PickId`, `PickScope`, and the index a scene records into. - `text/` — produces `GlyphRun` values from shaped strings. - `mesh.rs` — `Mesh` type consumed by `draw_mesh`. diff --git a/src/window/CLAUDE.md b/src/window/CLAUDE.md index ded2ff2..c8df15d 100644 --- a/src/window/CLAUDE.md +++ b/src/window/CLAUDE.md @@ -27,31 +27,30 @@ How many steps a frame takes depends on the backend, and `Backend::can_present_d Both shapes live behind `WindowSurface::draw_frame`, which acquires the frame, hands the renderer whichever view it should draw into, blits if there is an intermediate, and presents. Acquiring *before* rendering is what direct presentation requires, and the blit path does not mind. -`tests/window_blit.rs` pins the indirect path headlessly: same usage flags, same blitter, asserted pixel-identical to `render_to_buffer`. `tests/hybrid.rs::presenting_directly_matches_the_intermediate_format` pins the direct one, and `::picking_survives_a_bgra_target` pins the consequence below. +`tests/window_blit.rs` pins the indirect path headlessly: same usage flags, same blitter, asserted pixel-identical to `render_to_buffer`. `tests/hybrid.rs::presenting_directly_matches_the_intermediate_format` pins the direct one. ## Quirks worth remembering - **Non-sRGB swap chain, deliberately.** `pick_surface_format` accepts only `Rgba8Unorm` / `Bgra8Unorm`. The intermediate is `Rgba8Unorm` and the blit is a plain copy, so an sRGB swap chain would apply the transfer function a second time. -- **Direct presentation puts the pick target in the surface's format.** One `vello_hybrid::Renderer` targets one format and the pick pass shares it, so on the usual `Bgra8Unorm` surface the encoded ids come back with red and blue transposed. `read_hitmap` swaps them; removing that swap makes `picking_survives_a_bgra_target` fail rather than quietly returning wrong ids. - **The target contract is the backend's to state, not this module's.** `WgpuRenderer::REQUIRED_TARGET_USAGE` and `TARGET_IS_PREMULTIPLIED` both differ between backends. `surface.rs` takes the usage as a constructor argument and remembers it so a resize reallocates the same way; it hardcodes only `BLIT_USAGE`, which is its own need. - **Alpha convention differs by backend and the blit ignores it.** The compute-shader backend writes straight alpha, the sparse-strip one premultiplied. Presenting `CompositeAlphaMode::Opaque` makes that irrelevant — the conventions coincide at alpha 255 — which is the only reason one blit serves both. A translucent window would have to consult `TARGET_IS_PREMULTIPLIED` and convert. - **`CompositeAlphaMode::Opaque`.** The renderer emits straight (un-premultiplied) alpha. Presenting opaque means the compositor ignores the alpha channel rather than reading it as premultiplied. A translucent window would need a premultiplying blit shader; that is not built. - **`size` is physical pixels, `dpi` is `96.0 * scale_factor`.** That pair is what makes theme lengths in pt / mm come out the right physical size on a high-density display. `BASE_DPI` in `mod.rs` is the one place 96 appears. - **Zero-sized resizes are ignored.** A minimised window reports 0 × 0, which neither a swap chain nor a texture accepts. - **Redraw is on demand.** A resize schedules a frame; otherwise the app asks via `EventCtx::request_redraw`, or sets `WindowConfig::continuous_redraw`. -- **Picking costs a whole second rasterisation per frame.** When enabled, the pick pass rasterises the scene again and reads the result back, whether or not `pick_at` is ever called — on the sparse-strip backend that is a second CPU strip generation and roughly doubles the frame. Off by default for that reason, and `WindowConfig::pick_interval` caps how often it runs when it is on. A window that redraws faster than a person queries it (a resize drag, an animation) should set one. +- **Picking costs CPU while drawing, not a second rasterisation.** `WindowConfig::picking` makes the scene record a hit index as the frame is authored — measured at ~7 ms per 100k marks, against the ~59 ms the old second rasterisation cost. It is still off by default, because filling the index is paid whether or not `pick_at` is ever called. There is no throttle any more and none is needed: the R-tree is built lazily on the first query after a frame, so a window redrawing faster than it is queried never builds one. - **`resumed` can fire more than once.** The window is built on the first one only; Android-style resume cycles hit this. - **Errors escape through a field.** `ApplicationHandler` methods return `()`, so a failed frame stores the `WindowError` on the driver and exits the loop; `run` returns it. - **winit is not in the public API.** `Event` / `MouseButton` / `PresentMode` are ours, so swapping the windowing backend would not be a breaking change. Keep it that way — nothing winit-shaped should appear outside `app.rs`. `EventCtx` carries a `&Cell` rather than calling `request_redraw` directly for exactly this reason: it is what lets the canvas host share the type. -- **Two browser hosts, and which one a bundle carries is a feature choice.** `CanvasHost` goes through wgpu and therefore needs WebGPU; `WebGlHost` talks WebGL2 directly and needs neither. `EventCtx` reaches its renderer through the `PickSource` trait rather than naming a concrete one, which is what lets both build the same event context — a WebGL2 build has no wgpu types to name. +- **Two browser hosts, and which one a bundle carries is a feature choice.** `CanvasHost` goes through wgpu and therefore needs WebGPU; `WebGlHost` talks WebGL2 directly and needs neither. `EventCtx` holds a `&PickIndex` rather than a renderer, which is what lets both build the same event context — a WebGL2 build has no wgpu types to name, and the index is the same type either way. - **The canvas host requests `BROWSER_WEBGPU` only, on either wgpu backend.** For the compute-shader one it is forced: WebGL2 has no compute stage, so a GL adapter would be found and then fail deep inside pipeline creation. The sparse-strip backend has no such constraint — it rasterises through a render pipeline — but `Cargo.toml` still does not compile wgpu's `webgl` feature on wasm, so there is no GL adapter to find either way. Enabling wgpu's `webgl` feature would work but costs +1.2 MB, since it drags in shader translation. Going below WebGPU is instead `WebGlHost`'s job, which talks WebGL2 directly and carries no wgpu at all. - **Device acquisition is async on the web.** `WindowSurface::new_async` is the real constructor; `new` is a `pollster::block_on` wrapper over it for the desktop path. A browser main thread has nothing to park. -- **Picking never blocks, and so can lag.** `WgpuRenderer::render_to_texture` parks the calling thread on the pick readback, which a browser main thread cannot do. `CanvasHost` uses `VelloRenderer::render_to_texture_deferring_pick` instead: it submits the readback and moves on, and `try_finish_pick` drains it when it lands. The hitmap can therefore describe a frame or two behind what is on screen. A frame whose predecessor is still in flight skips its own pick submit rather than queueing a second `map_async` on a buffer that is still mapped, which would be a validation error. +- **Picking never blocks and never lags.** It used to do both: the readback parked the calling thread, which a browser main thread cannot do, so `CanvasHost` submitted it and drained it a frame or two later — and the answers described an older frame. With the index there is nothing to read back, so `render_to_texture` is the same call on every host and a query always describes what is on screen. ## Files - `mod.rs` — the public surface: `run`, `WindowApp`, `WindowConfig`, `PresentMode`, `Frame`, `EventCtx`, `WindowError`. Also the `compile_error!` that fires when a presentation feature is enabled with no backend behind it. -- `renderer.rs` — `Backend` (public selection) and `HostRenderer` (the boxed per-backend enum, plus the deferred-pick pair the browser host needs). Compiled only where a wgpu backend is. +- `renderer.rs` — `Backend` (public selection) and `HostRenderer` (the boxed per-backend enum). Compiled only where a wgpu backend is. - `webgl_host.rs` — `WebGlHost`: the same `render` / `resize` / `dispatch` surface as `CanvasHost`, against a canvas's WebGL2 context. No surface, no swap chain, no blit — the canvas's default framebuffer is the target — and no wgpu in the build at all. - `event.rs` — `Event` and `MouseButton`. - `surface.rs` — `WindowSurface`: adapter / device selection against the surface, swap-chain config, the intermediate texture, the blit, and `present`. @@ -67,6 +66,7 @@ Both shapes live behind `WindowSurface::draw_frame`, which acquires the frame, h ## Cross-references - `backend/` — `WgpuRenderer`, the trait this module hosts, and the texture contract it documents. -- `backend/vello/` and `backend/hybrid/` — `with_device` / `with_device_and_picking`, and the per-frame pick readback each provides. +- `backend/vello/` and `backend/hybrid/` — `with_device` / `with_device_and_picking`, which is the flag on each renderer's `PickIndexScene`. +- `pick/` — the index `EventCtx` queries. - `examples/window.rs` — the end-to-end demo: resize re-layout plus hover picking. - `crates/hephaestus-wasm/` — the wasm render client built on `CanvasHost`; the page-facing API and the resize / light-dark wiring live there, not here. diff --git a/src/window/webgl_host.rs b/src/window/webgl_host.rs index bc0f087..3a34dcb 100644 --- a/src/window/webgl_host.rs +++ b/src/window/webgl_host.rs @@ -111,7 +111,9 @@ impl WebGlHost { /// Always `None` unless [`WindowConfig::picking`] was enabled. Answers /// from a CPU-side index, so calling it per pointer event is cheap. pub fn pick_at(&self, x: f64, y: f64) -> Option { - self.renderer.pick_at(crate::geometry::Point::new(x, y)) + self.renderer + .pick_index()? + .pick_at(crate::geometry::Point::new(x, y)) } /// The hit index for the last drawn frame, for hits carrying their scope diff --git a/tests/hybrid.rs b/tests/hybrid.rs index 8eba151..b93bd6e 100644 --- a/tests/hybrid.rs +++ b/tests/hybrid.rs @@ -10,6 +10,12 @@ use hephaestus::geometry::Point; use hephaestus::{Affine, Brush, FillRule, PickId, Rect, Renderer, SceneBuilder}; use kurbo::Shape; +/// The topmost id at a point. Renderers expose the index rather than +/// forwarding every query, so a test asks it the same way a host would. +fn pick(r: &HybridRenderer, p: Point) -> Option { + r.pick_index()?.pick_at(p) +} + const W: u32 = 100; const H: u32 = 100; @@ -185,7 +191,7 @@ fn a_mesh_triangle_rasterises() { assert_eq!(px(&out, 50, 40)[1], 200, "mesh interior"); assert_eq!( - r.pick_at(Point::new(50.0, 40.0)), + pick(&r, Point::new(50.0, 40.0)), Some(5), "mesh carries its pick id" ); @@ -229,7 +235,7 @@ fn an_image_is_uploaded_and_sampled() { assert_eq!(px(&out, 75, 25), [0, 255, 0, 255], "top-right"); assert_eq!(px(&out, 25, 75), [0, 0, 255, 255], "bottom-left"); assert_eq!( - r.pick_at(Point::new(50.0, 50.0)), + pick(&r, Point::new(50.0, 50.0)), Some(9), "image carries its pick id" ); @@ -454,8 +460,8 @@ fn picking_survives_the_texture_path() { r.render_to_texture(&view, W, H, rgb8(0, 0, 0)) .expect("texture render"); - assert_eq!(r.pick_at(Point::new(50.0, 50.0)), Some(77)); - assert_eq!(r.pick_at(Point::new(5.0, 5.0)), None); + assert_eq!(pick(&r, Point::new(50.0, 50.0)), Some(77)); + assert_eq!(pick(&r, Point::new(5.0, 5.0)), None); } /// An isolated wgpu device, standing in for the one a window's swap chain @@ -695,7 +701,7 @@ fn picking_does_not_change_the_buffered_display() { ); // And the hitmap is still populated, so the split did not cost the pick. assert_eq!( - r.pick_at(Point::new(44.0, 52.0)), + pick(&r, Point::new(44.0, 52.0)), Some(2), "circle should be hittable" ); @@ -1093,12 +1099,12 @@ fn picking_survives_a_bgra_target() { .expect("render"); } assert_eq!( - r.pick_at(Point::new(50.0, 50.0)), + pick(&r, Point::new(50.0, 50.0)), Some(id), "id came back wrong on a {format:?} target" ); assert_eq!( - r.pick_at(Point::new(5.0, 5.0)), + pick(&r, Point::new(5.0, 5.0)), None, "empty space on {format:?}" ); @@ -1118,7 +1124,7 @@ fn a_redraw_replaces_the_previous_index() { fill(r.scene(), square, [255, 0, 0], PickId::Id(11)); r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) .expect("render"); - assert_eq!(r.pick_at(Point::new(50.0, 50.0)), Some(11)); + assert_eq!(pick(&r, Point::new(50.0, 50.0)), Some(11)); r.scene().clear(); fill(r.scene(), square, [0, 255, 0], PickId::Id(22)); @@ -1126,7 +1132,7 @@ fn a_redraw_replaces_the_previous_index() { .expect("render"); assert_eq!(px(&out, 50, 50), [0, 255, 0, 255], "display updated"); assert_eq!( - r.pick_at(Point::new(50.0, 50.0)), + pick(&r, Point::new(50.0, 50.0)), Some(22), "and so did the index" ); @@ -1145,9 +1151,9 @@ fn a_renderer_without_picking_holds_no_index() { ); r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut out) .expect("render"); - assert!(!r.picks()); assert!(r.pick_index().is_none()); - assert_eq!(r.pick_at(Point::new(50.0, 50.0)), None); + assert!(r.pick_index().is_none()); + assert_eq!(pick(&r, Point::new(50.0, 50.0)), None); } // ─── Color glyphs ─────────────────────────────────────────────────────────── @@ -1283,7 +1289,7 @@ fn a_bitmap_color_glyph_picks_as_one_id() { let mut ids: Vec = Vec::new(); for y in 0..H { for x in 0..W { - if let Some(id) = r.pick_at(Point::new(f64::from(x), f64::from(y))) { + if let Some(id) = pick(&r, Point::new(f64::from(x), f64::from(y))) { if !ids.contains(&id) { ids.push(id); } diff --git a/tests/mesh.rs b/tests/mesh.rs index 72b99c3..acd70cb 100644 --- a/tests/mesh.rs +++ b/tests/mesh.rs @@ -106,6 +106,12 @@ fn draw_mesh_uniform_color_renders_solid() { ); } +/// The topmost id at a point. Renderers expose the index rather than +/// forwarding every query, so a test asks it the same way a host would. +fn pick(r: &VelloRenderer, p: Point) -> Option { + r.pick_index()?.pick_at(p) +} + #[test] fn draw_mesh_pick_round_trip() { let mut r = VelloRenderer::with_picking().expect("vello renderer init"); @@ -126,7 +132,7 @@ fn draw_mesh_pick_round_trip() { let mut buf = vec![0u8; (W * H * 4) as usize]; r.render_to_buffer(W, H, rgb8(0, 0, 0), &mut buf) .expect("render"); - assert_eq!(r.pick_at(Point::new(100.0, 100.0)), Some(42)); + assert_eq!(pick(&r, Point::new(100.0, 100.0)), Some(42)); // Outside the triangle: no hit. - assert_eq!(r.pick_at(Point::new(5.0, 5.0)), None); + assert_eq!(pick(&r, Point::new(5.0, 5.0)), None); } diff --git a/tests/pick_index.rs b/tests/pick_index.rs index d6045be..eea3469 100644 --- a/tests/pick_index.rs +++ b/tests/pick_index.rs @@ -79,6 +79,28 @@ fn block_occludes_what_is_under_it() { assert_eq!(s.hits_at(Point::new(10.0, 10.0)).len(), 1); } +#[test] +fn no_id_value_is_reserved_and_block_is_the_only_occluder() { + // `Id(0)` is an ordinary id. It was the no-hit sentinel only while ids + // were packed into a texture's colour channels. + let mut s = scene(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 100.0), PickId::Id(7)); + fill_rect(&mut s, Rect::new(40.0, 40.0, 60.0, 60.0), PickId::Id(0)); + assert_eq!(s.pick_at(Point::new(50.0, 50.0)), Some(0), "0 is an id"); + assert_eq!( + s.hits_at(Point::new(50.0, 50.0)).len(), + 2, + "and occludes nothing" + ); + + // `Block` is the variant that occludes, and it still does. + let mut s = scene(); + fill_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 100.0), PickId::Id(7)); + fill_rect(&mut s, Rect::new(40.0, 40.0, 60.0, 60.0), PickId::Block); + assert_eq!(s.pick_at(Point::new(50.0, 50.0)), None); + assert!(s.hits_at(Point::new(50.0, 50.0)).is_empty()); +} + #[test] fn skip_is_absent_rather_than_transparent() { let mut s = scene(); diff --git a/tests/svg.rs b/tests/svg.rs index 2e91300..b8cad29 100644 --- a/tests/svg.rs +++ b/tests/svg.rs @@ -13,7 +13,7 @@ use hephaestus::brush::{Brush, Gradient}; use hephaestus::color::Color; use hephaestus::geometry::{Affine, Point, Rect, Shape, Size}; use hephaestus::path::{FillRule, Path}; -use hephaestus::pick::PickId; +use hephaestus::pick::{PickId, PickScope}; use hephaestus::scene::SceneBuilder; use hephaestus::stroke::Stroke; use hephaestus::style_vocab::{FontFeatureSetting, FontVariationSetting, HAlign, Palette}; @@ -781,6 +781,95 @@ fn warnings_are_reported_once_however_often_they_recur() { // ─── Picking ──────────────────────────────────────────────────────────────── +#[test] +fn pick_scopes_become_groups_when_picking_is_on() { + let draw = |s: &mut SvgScene| { + s.push_pick_scope(&PickScope::group("plot").with_name("a").with_index(0)); + s.push_pick_scope(&PickScope::target("part").with_name("axis_tick_label")); + s.push_pick_scope(&PickScope::target("item").with_index(3)); + s.fill( + FillRule::NonZero, + Affine::IDENTITY, + &black(), + None, + &rect_path(Rect::new(10.0, 10.0, 90.0, 60.0)), + PickId::Skip, + ); + s.pop_pick_scope(); + s.pop_pick_scope(); + s.pop_pick_scope(); + }; + + // Gated on the same flag as `data-pick-id`: they are one feature from a + // consumer's side. + let mut off = scene(); + draw(&mut off); + let svg = encode_svg(&off); + assert!(!svg.contains("data-pick-kind"), "{svg}"); + + let mut on = SvgScene::with_config(Size::new(W, H), 96.0, SvgConfig::new().pick_ids(true)); + draw(&mut on); + let svg = encode_svg(&on); + assert!( + svg.contains(r#""#), + "{svg}" + ); + assert!( + svg.contains(r#""#), + "{svg}" + ); + assert!( + svg.contains(r#""#), + "{svg}" + ); + // Balanced: as many closers as openers, and the document is well formed. + assert_eq!( + svg.matches("").count(), + "{svg}" + ); +} + +/// Layers and scopes are independent stacks, so an author can interleave +/// them. Sharing one counter would emit `` against the wrong element. +#[test] +fn interleaved_layers_and_scopes_do_not_produce_malformed_xml() { + let mut s = SvgScene::with_config(Size::new(W, H), 96.0, SvgConfig::new().pick_ids(true)); + s.push_layer( + Default::default(), + 1.0, + Affine::IDENTITY, + &rect_path(Rect::new(0.0, 0.0, 100.0, 100.0)), + ); + s.push_pick_scope(&PickScope::target("part").with_name("grid_major")); + s.fill( + FillRule::NonZero, + Affine::IDENTITY, + &black(), + None, + &rect_path(Rect::new(10.0, 10.0, 20.0, 20.0)), + PickId::Skip, + ); + // Closed out of order on purpose. + s.pop_layer(); + s.pop_pick_scope(); + + let warnings: Vec = s.warnings().to_vec(); + let svg = encode_svg(&s); + // The mismatched pop is refused and reported rather than corrupting the + // tree; the writer closes whatever is still open at the end. + assert!( + warnings.contains(&SvgWarning::UnbalancedLayers) + || warnings.contains(&SvgWarning::UnbalancedScopes), + "expected an unbalanced-group warning, got {warnings:?}" + ); + assert_eq!( + svg.matches("").count(), + "tags must still balance: {svg}" + ); +} + #[test] fn picking_attributes_are_off_by_default_and_complete_when_on() { let draw = |s: &mut SvgScene| {