diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 1dd2e01..3355bf7 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -131,6 +131,21 @@ object is covered); the provenance-preservation contract itself is unchanged. **injective in glyph identity** — swapping two glyphs' names (even with the consulted-name *set*, and so the metrics hash, unchanged) changes the bytes. +- **The render-to-hit-test contract lives at the RenderIR boundary, in world + coordinates.** `RenderIR::hit_test_map` (`hittest.rs`) turns the provenance the + spec calls "the basis of hit-testing, selection, and back-reference navigation" + (Chapter 7 §"RenderIR") into a structured map an editor can use directly: one + `HitRegion` per glyph/stroke carrying the full chain — rendered primitive → + layout object (`stable_id`) → score object (`source`) — plus a selectable + `HitShape` (a glyph's placed `bounding_box`, or a stroke's segment + half-width) + with `contains`/`aabb` and `hit`/`within` queries. Two deliberate boundaries: + (1) shapes are in **staff-space world coords** (the same frame as + `RenderPrimitive.position`, before any renderer's world→screen transform), so the + contract is renderer-independent and a GUI applies the inverse of the same + transform its renderer uses; (2) a glyph's region is its **IR `bounding_box`** + (the boundary's granularity, which I-4a made contain the drawn ink), not the + render-only outline. Tested against the real pipeline, not guessed by the GUI. + - **Comprehensive, rejecting canonical encoding for `ResolvedLayoutIR`.** The canonical output (`ResolvedLayoutIR::canonical_bytes`, via `CanonicalEncode`) covers the *full* resolved layout — every glyph's provenance (source, stable diff --git a/crates/epiphany-layout-ir/src/hittest.rs b/crates/epiphany-layout-ir/src/hittest.rs new file mode 100644 index 0000000..87b81e9 --- /dev/null +++ b/crates/epiphany-layout-ir/src/hittest.rs @@ -0,0 +1,632 @@ +//! The render-to-hit-test contract (Chapter 7 §"RenderIR": provenance "is the +//! basis of hit-testing, selection, and back-reference navigation in the UI"). +//! +//! A [`RenderIR`] already traces every primitive to its score-graph source; an +//! editor additionally needs a *structured* map from a rendered primitive to its +//! **layout object** and its **score object**, with a selectable **shape**, so a +//! click or drag resolves to something to select without the GUI re-deriving +//! geometry or guessing the provenance chain. [`RenderIR::hit_test_map`] is that +//! map, and it is what gets tested at the RenderIR boundary. +//! +//! ## Coordinate frame +//! +//! Shapes are in **staff-space, y-up world** coordinates — the same frame as +//! [`RenderPrimitive::position`] and [`Stroke`] endpoints, *before* any +//! renderer's world→screen transform. A GUI maps a screen point to this frame +//! with the inverse of the same transform its renderer uses for display (for the +//! SVG renderer, the inverse of its single `translate(-min_x, max_y) scale(1,-1)` +//! group), then queries the map. The contract is thus resolution- and +//! renderer-independent. + +use epiphany_core::TypedObjectId; + +use crate::provenance::{LayoutObjectId, SynthesisKind}; +use crate::render::{RenderIR, RenderPrimitive}; +use crate::spatial::{BoundingBox, Point, Transform2D}; + +/// Which [`RenderIR`] primitive a [`HitRegion`] belongs to: an index into +/// [`RenderIR::primitives`] (a glyph) or [`RenderIR::strokes`] (a stroke). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum PrimitiveRef { + Glyph(usize), + Stroke(usize), +} + +impl PrimitiveRef { + /// Whether this is a glyph primitive (vs. a stroke). + #[inline] + pub fn is_glyph(self) -> bool { + matches!(self, PrimitiveRef::Glyph(_)) + } +} + +/// A selectable region's shape, in staff-space, y-up world coordinates. +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum HitShape { + /// A glyph's drawn extent: an axis-aligned world box (its `bounding_box` + /// placed by `position` and any `transform`). + Box(BoundingBox), + /// A stroke (staff line, stem, barline, …): a line segment with a half-width + /// (half the stroke thickness), so a click near the line still selects it. + Segment { + from: Point, + to: Point, + half_width: f32, + }, +} + +impl HitShape { + /// Whether a world `point` lies within this shape — the click-selection test. + /// A box is closed (edges included); a segment is within `half_width` of the + /// line, so thin strokes remain clickable. + pub fn contains(&self, point: Point) -> bool { + match self { + HitShape::Box(b) => box_contains(b, point), + HitShape::Segment { + from, + to, + half_width, + } => distance_point_segment(point, *from, *to) <= *half_width, + } + } + + /// Whether this shape **exactly** intersects an axis-aligned world `rect` — the + /// drag/rubber-band selection test. A box's overlap is exact; a segment's is a + /// true capsule-vs-rectangle test (its half-width included), so a diagonal + /// stroke whose *bounding box* clips a corner of `rect` is not falsely selected. + /// The shape's [`Self::aabb`] is used internally as a broad-phase reject. + pub fn intersects_rect(&self, rect: BoundingBox) -> bool { + if !boxes_overlap(&self.aabb(), &rect) { + return false; // broad-phase reject + } + match self { + // A box equals its AABB, so the broad-phase overlap above was exact. + HitShape::Box(_) => true, + HitShape::Segment { + from, + to, + half_width, + } => segment_intersects_rect(*from, *to, *half_width, &rect), + } + } + + /// The shape's axis-aligned world bounding box — the broad-phase rectangle for + /// drag/rubber-band selection (see [`HitTestMap::within`]). + pub fn aabb(&self) -> BoundingBox { + match self { + HitShape::Box(b) => *b, + HitShape::Segment { + from, + to, + half_width, + } => BoundingBox::new( + from.x.0.min(to.x.0) - half_width, + from.y.0.min(to.y.0) - half_width, + from.x.0.max(to.x.0) + half_width, + from.y.0.max(to.y.0) + half_width, + ), + } + } +} + +/// One hit-test region: a selectable [`HitShape`] plus the provenance chain an +/// editor resolves a click or drag to — the **rendered primitive**, its **layout +/// object** (stable across relayout), and its **score object**. +#[derive(Clone, PartialEq, Debug)] +pub struct HitRegion { + /// The rendered primitive this region covers. + pub primitive: PrimitiveRef, + /// The score-graph object to select when this region is hit + /// (`provenance.source`). + pub source: TypedObjectId, + /// The layout object the primitive manifests (`provenance.stable_id`), stable + /// across re-layouts of an unchanged source — the right anchor for a cursor or + /// a persistent selection that must survive a relayout. + pub layout_object: LayoutObjectId, + /// Set when the primitive is engraver-synthesized (no direct score-graph + /// manifestation), so an editor can treat a generated object specially (e.g. + /// select its source rather than the synthesized mark). + pub synthesis: Option, + /// The selectable shape, in world coordinates. + pub shape: HitShape, + /// The draw layer, used to break ties when regions overlap (a higher layer, or + /// a glyph over a stroke at the same layer, is "on top"). + pub layer: i32, +} + +impl HitRegion { + /// A z-order key matching the renderer's paint order (layer ascending, strokes + /// before glyphs at one layer, then primitive index). A larger key is painted + /// later, i.e. on top. + fn paint_order(&self) -> (i32, u8, usize) { + let (kind_rank, index) = match self.primitive { + PrimitiveRef::Stroke(i) => (0, i), + PrimitiveRef::Glyph(i) => (1, i), + }; + (self.layer, kind_rank, index) + } +} + +/// The hit-test map over a [`RenderIR`]: one [`HitRegion`] per primitive (glyph +/// and stroke). The public [`Self::regions`] vector is stored in construction +/// order (glyph regions first, then stroke regions), not z-order; callers that +/// need ordered selection results should use [`Self::hit`] or [`Self::within`]. +#[derive(Clone, PartialEq, Debug)] +pub struct HitTestMap { + pub regions: Vec, +} + +impl HitTestMap { + /// Every region whose shape contains `point`, **topmost first** (reverse paint + /// order). The first element is what a single-selection click should pick; the + /// rest support cycling through stacked objects. + pub fn hit(&self, point: Point) -> Vec<&HitRegion> { + let mut hits: Vec<&HitRegion> = self + .regions + .iter() + .filter(|r| r.shape.contains(point)) + .collect(); + // Topmost first: descending paint order (later-painted = on top). + hits.sort_by_key(|r| std::cmp::Reverse(r.paint_order())); + hits + } + + /// Every region whose shape **exactly** intersects `rect`, in **ascending paint + /// order** (back-to-front — the renderer's draw order: layer ascending, strokes + /// before glyphs at one layer, then index) — the drag/rubber-band selection + /// result (see [`HitShape::intersects_rect`]). This is the reverse of + /// [`Self::hit`]'s topmost-first order, matching the two queries' uses (a click + /// picks the top object; a drag enumerates a set in draw order). For a cheaper + /// broad-phase pass, a caller can test [`HitShape::aabb`] directly. + pub fn within(&self, rect: BoundingBox) -> Vec<&HitRegion> { + let mut selected: Vec<&HitRegion> = self + .regions + .iter() + .filter(|r| r.shape.intersects_rect(rect)) + .collect(); + selected.sort_by_key(|r| r.paint_order()); + selected + } +} + +impl RenderIR { + /// Builds the [`HitTestMap`]: one [`HitRegion`] per glyph primitive and per + /// stroke. Regions are stored in construction order (glyphs, then strokes); + /// each region's [`HitRegion::layer`] and primitive reference carry the true + /// paint order consumed by [`HitTestMap::hit`] and [`HitTestMap::within`]. + /// Each region's `source`/`layout_object`/`synthesis` come straight from the + /// primitive's preserved [`crate::Provenance`]; its shape is computed in world + /// coordinates. + pub fn hit_test_map(&self) -> HitTestMap { + let mut regions = Vec::with_capacity(self.primitives.len() + self.strokes.len()); + for (i, p) in self.primitives.iter().enumerate() { + regions.push(HitRegion { + primitive: PrimitiveRef::Glyph(i), + source: p.provenance.source, + layout_object: p.provenance.stable_id, + synthesis: p.provenance.synthesis, + shape: HitShape::Box(glyph_world_box(p)), + layer: p.layer, + }); + } + for (i, s) in self.strokes.iter().enumerate() { + regions.push(HitRegion { + primitive: PrimitiveRef::Stroke(i), + source: s.provenance.source, + layout_object: s.provenance.stable_id, + synthesis: s.provenance.synthesis, + shape: HitShape::Segment { + from: s.from, + to: s.to, + half_width: s.thickness.0 / 2.0, + }, + layer: s.layer, + }); + } + HitTestMap { regions } + } +} + +/// A glyph's world-space bounding box: its local `bounding_box`, placed by +/// `position` and any `transform`. The four corners are mapped through the same +/// `translate(position) ∘ transform` the renderer applies (an affine transform may +/// rotate/scale the box past its axis-aligned local extent), then the +/// axis-aligned hull is taken. +fn glyph_world_box(p: &RenderPrimitive) -> BoundingBox { + let bb = p.bounding_box; + let (px, py) = (p.position.x.0, p.position.y.0); + let corners = [ + (bb.left.0, bb.bottom.0), + (bb.left.0, bb.top.0), + (bb.right.0, bb.bottom.0), + (bb.right.0, bb.top.0), + ]; + let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY); + let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY); + for (lx, ly) in corners { + let (wx, wy) = placed(px, py, &p.transform, lx, ly); + min_x = min_x.min(wx); + min_y = min_y.min(wy); + max_x = max_x.max(wx); + max_y = max_y.max(wy); + } + BoundingBox::new(min_x, min_y, max_x, max_y) +} + +/// Maps a glyph-local point `(lx, ly)` to world coordinates through the glyph's +/// optional `transform` and its `position` translate — identical to the placement +/// the renderer applies, so a hit region aligns with the drawn glyph. +fn placed(px: f32, py: f32, transform: &Option, lx: f32, ly: f32) -> (f32, f32) { + let (tx, ty) = match transform { + None => (lx, ly), + Some(t) => { + let m = t.matrix; + ( + m[0][0] * lx + m[0][1] * ly + m[0][2], + m[1][0] * lx + m[1][1] * ly + m[1][2], + ) + } + }; + (px + tx, py + ty) +} + +/// The Euclidean distance from `p` to the segment `a`–`b` (a degenerate segment is +/// a point). +fn distance_point_segment(p: Point, a: Point, b: Point) -> f32 { + let (px, py) = (p.x.0, p.y.0); + let (ax, ay) = (a.x.0, a.y.0); + let (bx, by) = (b.x.0, b.y.0); + let (dx, dy) = (bx - ax, by - ay); + let len_sq = dx * dx + dy * dy; + // Project p onto the segment, clamping the parameter to [0, 1]. + let t = if len_sq <= f32::EPSILON { + 0.0 + } else { + (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0) + }; + let (cx, cy) = (ax + t * dx, ay + t * dy); + ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() +} + +/// Whether two axis-aligned world boxes overlap (touching edges count). +fn boxes_overlap(a: &BoundingBox, b: &BoundingBox) -> bool { + a.left.0 <= b.right.0 && a.right.0 >= b.left.0 && a.bottom.0 <= b.top.0 && a.top.0 >= b.bottom.0 +} + +/// Whether a world `point` lies within the closed box `b` (edges included). +fn box_contains(b: &BoundingBox, point: Point) -> bool { + point.x.0 >= b.left.0 + && point.x.0 <= b.right.0 + && point.y.0 >= b.bottom.0 + && point.y.0 <= b.top.0 +} + +/// Whether a thick segment (capsule: the segment `from`–`to` grown by +/// `half_width`) intersects the axis-aligned `rect`. Exact: a segment whose +/// *bounding box* clips the rect but whose body misses it is correctly rejected. +fn segment_intersects_rect(from: Point, to: Point, half_width: f32, rect: &BoundingBox) -> bool { + // An endpoint inside the rect ⇒ the body touches the rect's interior. + if box_contains(rect, from) || box_contains(rect, to) { + return true; + } + // Otherwise the body is within `half_width` of the rect iff it is within + // `half_width` of one of the four edges (distance 0 means it crosses one). + let c = [ + Point::new(rect.left.0, rect.bottom.0), + Point::new(rect.right.0, rect.bottom.0), + Point::new(rect.right.0, rect.top.0), + Point::new(rect.left.0, rect.top.0), + ]; + (0..4).any(|i| segment_segment_distance(from, to, c[i], c[(i + 1) % 4]) <= half_width) +} + +/// The minimum Euclidean distance between two 2-D segments (0 if they cross). +fn segment_segment_distance(a1: Point, a2: Point, b1: Point, b2: Point) -> f32 { + if segments_cross(a1, a2, b1, b2) { + return 0.0; + } + distance_point_segment(a1, b1, b2) + .min(distance_point_segment(a2, b1, b2)) + .min(distance_point_segment(b1, a1, a2)) + .min(distance_point_segment(b2, a1, a2)) +} + +/// Whether segments `p1`–`p2` and `p3`–`p4` intersect (proper crossing or a +/// collinear touch), by the standard orientation test. +fn segments_cross(p1: Point, p2: Point, p3: Point, p4: Point) -> bool { + fn orient(a: Point, b: Point, c: Point) -> f32 { + (b.x.0 - a.x.0) * (c.y.0 - a.y.0) - (b.y.0 - a.y.0) * (c.x.0 - a.x.0) + } + // Whether collinear point `c` lies within the bounding box of `a`–`b`. + fn on_segment(a: Point, b: Point, c: Point) -> bool { + c.x.0 >= a.x.0.min(b.x.0) + && c.x.0 <= a.x.0.max(b.x.0) + && c.y.0 >= a.y.0.min(b.y.0) + && c.y.0 <= a.y.0.max(b.y.0) + } + let (d1, d2) = (orient(p3, p4, p1), orient(p3, p4, p2)); + let (d3, d4) = (orient(p1, p2, p3), orient(p1, p2, p4)); + if ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) && d1 != 0.0 && d3 != 0.0 { + return true; + } + (d1 == 0.0 && on_segment(p3, p4, p1)) + || (d2 == 0.0 && on_segment(p3, p4, p2)) + || (d3 == 0.0 && on_segment(p1, p2, p3)) + || (d4 == 0.0 && on_segment(p1, p2, p4)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constrained::to_constrained; + use crate::logical::to_logical; + use crate::provenance::Provenance; + use crate::render::to_render; + use crate::solver::{ConstraintSolver, SolverConfig, StubSolver}; + use crate::spatial::StaffSpace; + use crate::{GlyphReference, GlyphStyle, Stroke}; + use epiphany_core::{EventId, StaffId}; + + fn render_of(seed: u64) -> RenderIR { + let constrained = to_constrained(&to_logical( + &epiphany_core::generators::valid_score_rich(seed), + )); + let resolved = StubSolver + .solve(&constrained, &SolverConfig::default()) + .layout; + to_render(&resolved) + } + + fn glyph(position: Point, bbox: BoundingBox, layer: i32) -> RenderPrimitive { + RenderPrimitive { + provenance: Provenance::projected( + TypedObjectId::Event(EventId::from_raw(layer as u128 + 1)), + vec![], + ), + glyph: GlyphReference::borrowed("noteheadBlack"), + position, + transform: None, + bounding_box: bbox, + style: GlyphStyle::default(), + layer, + } + } + + fn stroke(from: Point, to: Point, layer: i32) -> Stroke { + Stroke { + provenance: Provenance::projected(TypedObjectId::Staff(StaffId::from_raw(1)), vec![]), + from, + to, + thickness: StaffSpace(0.2), + layer, + style: GlyphStyle::default(), + } + } + + #[test] + fn a_box_region_is_closed_and_its_aabb_is_itself() { + let s = HitShape::Box(BoundingBox::new(-1.0, -0.5, 1.0, 0.5)); + assert!(s.contains(Point::new(0.0, 0.0))); // centre + assert!(s.contains(Point::new(-1.0, -0.5))); // corner included (closed) + assert!(s.contains(Point::new(1.0, 0.5))); + assert!(!s.contains(Point::new(1.01, 0.0))); // just past the right edge + assert!(!s.contains(Point::new(0.0, 0.6))); // just past the top + assert_eq!(s.aabb(), BoundingBox::new(-1.0, -0.5, 1.0, 0.5)); + } + + #[test] + fn a_segment_region_is_hit_within_its_half_width() { + // A horizontal stroke (0,0)->(4,0), thickness 0.2 => half-width 0.1. + let s = HitShape::Segment { + from: Point::new(0.0, 0.0), + to: Point::new(4.0, 0.0), + half_width: 0.1, + }; + assert!(s.contains(Point::new(2.0, 0.0))); // on the line + assert!(s.contains(Point::new(2.0, 0.09))); // within the half-width + assert!(!s.contains(Point::new(2.0, 0.2))); // beyond it + assert!(!s.contains(Point::new(5.0, 0.0))); // past the endpoint + assert!(s.contains(Point::new(0.0, 0.05))); // near an endpoint, within + // The aabb expands by the half-width on every side. + assert_eq!(s.aabb(), BoundingBox::new(-0.1, -0.1, 4.1, 0.1)); + } + + #[test] + fn a_glyph_world_box_is_its_local_box_placed_by_position_and_transform() { + // No transform: the local box just shifts by the position. + let p = glyph( + Point::new(10.0, 3.0), + BoundingBox::new(-0.5, -0.5, 0.5, 0.5), + 0, + ); + let HitShape::Box(b) = RenderIR { + primitives: vec![p.clone()], + strokes: vec![], + } + .hit_test_map() + .regions[0] + .shape + else { + panic!("glyph region is a box"); + }; + assert_eq!(b, BoundingBox::new(9.5, 2.5, 10.5, 3.5)); + + // An affine transform (scale x by 2) rotates/scales the box past its local + // axis-aligned extent; the hull is taken over the mapped corners. + let mut t = p; + t.transform = Some(Transform2D { + matrix: [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }); + let HitShape::Box(b) = RenderIR { + primitives: vec![t], + strokes: vec![], + } + .hit_test_map() + .regions[0] + .shape + else { + panic!("box"); + }; + assert_eq!(b, BoundingBox::new(9.0, 2.5, 11.0, 3.5)); + } + + #[test] + fn the_map_covers_every_primitive_and_preserves_the_provenance_chain() { + let render = render_of(0x5EED); + let map = render.hit_test_map(); + // One region per glyph and per stroke, none dropped or invented. + assert_eq!( + map.regions.len(), + render.primitives.len() + render.strokes.len() + ); + assert!(!map.regions.is_empty()); + + // Each region carries exactly the primitive's preserved chain: rendered + // primitive -> layout object (stable_id) -> score object (source). + for r in &map.regions { + let (source, stable_id, synthesis) = match r.primitive { + PrimitiveRef::Glyph(i) => { + let p = &render.primitives[i]; + ( + p.provenance.source, + p.provenance.stable_id, + p.provenance.synthesis, + ) + } + PrimitiveRef::Stroke(i) => { + let s = &render.strokes[i]; + ( + s.provenance.source, + s.provenance.stable_id, + s.provenance.synthesis, + ) + } + }; + assert_eq!(r.source, source); + assert_eq!(r.layout_object, stable_id); + assert_eq!(r.synthesis, synthesis); + } + } + + #[test] + fn a_click_on_a_notehead_resolves_to_its_pitch() { + let render = render_of(0x5EED); + let map = render.hit_test_map(); + let (i, p) = render + .primitives + .iter() + .enumerate() + .find(|(_, p)| p.glyph.as_str().starts_with("notehead")) + .expect("the rich fixture renders a notehead"); + let region = map + .regions + .iter() + .find(|r| r.primitive == PrimitiveRef::Glyph(i)) + .unwrap(); + + // The full chain an editor needs from one click. + assert_eq!(region.source, p.provenance.source); + assert_eq!(region.layout_object, p.provenance.stable_id); + assert!( + matches!(region.source, TypedObjectId::Pitch(_)), + "a notehead's score object is a Pitch, got {:?}", + region.source + ); + + // A click at the notehead's centre hits its region (a stem stroke may pass + // through too, but the notehead is among the hits). + let HitShape::Box(b) = region.shape else { + panic!("a glyph region is a box"); + }; + let centre = Point::new((b.left.0 + b.right.0) / 2.0, (b.bottom.0 + b.top.0) / 2.0); + assert!( + map.hit(centre) + .iter() + .any(|h| h.primitive == PrimitiveRef::Glyph(i)), + "the notehead is hit at its own centre" + ); + } + + #[test] + fn overlapping_regions_are_returned_topmost_first() { + // A stroke and a glyph overlap at the origin; at one layer the glyph paints + // over the stroke, so it is the topmost hit. A second glyph on a higher + // layer outranks both. + let render = RenderIR { + primitives: vec![ + glyph(Point::ORIGIN, BoundingBox::new(-1.0, -1.0, 1.0, 1.0), 0), + glyph(Point::ORIGIN, BoundingBox::new(-1.0, -1.0, 1.0, 1.0), 5), + ], + strokes: vec![stroke(Point::new(-2.0, 0.0), Point::new(2.0, 0.0), 0)], + }; + let map = render.hit_test_map(); + let hits = map.hit(Point::ORIGIN); + assert_eq!(hits.len(), 3, "all three overlap the origin"); + // Topmost: the layer-5 glyph, then the layer-0 glyph (glyph over stroke at a + // shared layer), then the layer-0 stroke. + assert_eq!(hits[0].primitive, PrimitiveRef::Glyph(1)); + assert_eq!(hits[1].primitive, PrimitiveRef::Glyph(0)); + assert_eq!(hits[2].primitive, PrimitiveRef::Stroke(0)); + } + + #[test] + fn within_selects_every_region_intersecting_a_drag_rect() { + let render = RenderIR { + primitives: vec![ + glyph( + Point::new(0.0, 0.0), + BoundingBox::new(-0.5, -0.5, 0.5, 0.5), + 0, + ), + glyph( + Point::new(10.0, 0.0), + BoundingBox::new(-0.5, -0.5, 0.5, 0.5), + 0, + ), + ], + strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(3.0, 0.0), 0)], + }; + let map = render.hit_test_map(); + // A rubber-band around the first glyph and the stroke, but not the far glyph. + let selected = map.within(BoundingBox::new(-1.0, -1.0, 4.0, 1.0)); + let picked: Vec<_> = selected.iter().map(|r| r.primitive).collect(); + // Both the near glyph and the stroke are selected; the far glyph is not. + // The result is in ascending paint order — at layer 0 the stroke (drawn + // first) precedes the glyph, even though the map stores glyphs before + // strokes. + assert_eq!( + picked, + vec![PrimitiveRef::Stroke(0), PrimitiveRef::Glyph(0)] + ); + } + + #[test] + fn within_is_exact_not_just_bounding_box_overlap() { + // A diagonal stroke (0,0)->(10,10): its AABB is the whole [0,0,10,10] + // square, which overlaps a small rect at the top-left corner — but the + // stroke's body never goes near there, so an exact `within` must reject it. + let render = RenderIR { + primitives: vec![], + strokes: vec![stroke(Point::new(0.0, 0.0), Point::new(10.0, 10.0), 0)], + }; + let map = render.hit_test_map(); + // AABB-overlapping but body-missing rect near (0, 10): rejected. + assert!( + map.within(BoundingBox::new(0.0, 9.0, 1.0, 10.0)).is_empty(), + "a diagonal stroke whose AABB clips a corner is not falsely selected" + ); + // A rect the stroke actually passes through: selected. + assert_eq!( + map.within(BoundingBox::new(4.0, 4.0, 6.0, 6.0)).len(), + 1, + "a rect the stroke's body crosses selects it" + ); + // A rect that does not cross the line but lies within the stroke's + // half-width of it (nearest corner ≈ 0.035 < 0.1): selected via the capsule. + assert_eq!( + map.within(BoundingBox::new(5.15, 5.0, 5.25, 5.1)).len(), + 1, + "a rect within the stroke's half-width selects it" + ); + } +} diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index 9d23ef7..46eb0f8 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -70,6 +70,7 @@ pub mod constrained; pub mod engrave_theory; pub mod engraving; pub mod glyph; +pub mod hittest; pub mod logical; pub mod provenance; pub mod render; @@ -108,6 +109,7 @@ pub use glyph::{ GlyphAnchor, GlyphBitmap, GlyphCatalog, GlyphCatalogIdentity, GlyphMetric, GlyphReference, GlyphRenderData, PathCommand, SemVer, SmuflVersion, BRAVURA_METRICS, BRAVURA_VERSION, }; +pub use hittest::{HitRegion, HitShape, HitTestMap, PrimitiveRef}; pub use logical::{ to_logical, BarLineLayout, BarlineKind, BeamGroupLayout, ChordLayout, ClefLayout, CompositeLayoutObject, CrossRegionObject, CueLayout, GraphicLayout, GroupLayout,