use crate::geometry::Matrix;
use crate::objects::tree::{ObjectKind, ObjectTree, PageObject, PathPoint, SubPath};
pub fn hit_test(tree: &ObjectTree, x: f64, y: f64) -> Vec<&PageObject> {
tree.objects
.iter()
.filter(|obj| obj.bbox.contains_point(x, y) && object_hit(obj, x, y))
.collect()
}
fn object_hit(obj: &PageObject, x: f64, y: f64) -> bool {
match &obj.kind {
ObjectKind::Fill | ObjectKind::FillStroke => {
point_in_path_nonzero(&obj.subpaths, x, y, &obj.ctm)
}
ObjectKind::Stroke | ObjectKind::Text(_) | ObjectKind::Image | ObjectKind::FormXObject => {
true
}
}
}
pub fn point_in_path_nonzero(subpaths: &[SubPath], x: f64, y: f64, ctm: &Matrix) -> bool {
let mut winding = 0i32;
for sub in subpaths {
let pts: Vec<(f64, f64)> = sub
.points
.iter()
.filter_map(|p| match *p {
PathPoint::MoveTo(lx, ly) | PathPoint::LineTo(lx, ly) => {
Some(ctm.transform_point(lx, ly))
}
PathPoint::CurveTo(_, _, _, _, lx, ly) => Some(ctm.transform_point(lx, ly)),
PathPoint::Close => None,
})
.collect();
let n = pts.len();
if n < 2 {
continue;
}
for i in 0..n {
let (x0, y0) = pts[i];
let (x1, y1) = pts[(i + 1) % n];
if y0 <= y {
if y1 > y && cross2d(x0, y0, x1, y1, x, y) > 0.0 {
winding += 1;
}
} else if y1 <= y && cross2d(x0, y0, x1, y1, x, y) < 0.0 {
winding -= 1;
}
}
}
winding != 0
}
#[inline]
fn cross2d(x0: f64, y0: f64, x1: f64, y1: f64, qx: f64, qy: f64) -> f64 {
(x1 - x0) * (qy - y0) - (y1 - y0) * (qx - x0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::{Matrix, Rect};
use crate::objects::tree::{
ObjectKind, ObjectTree, OverprintState, PageObject, PathPoint, PdfColor, SubPath,
};
fn rect_fill_object(x: f64, y: f64, w: f64, h: f64) -> PageObject {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(x, y));
sub.points.push(PathPoint::LineTo(x + w, y));
sub.points.push(PathPoint::LineTo(x + w, y + h));
sub.points.push(PathPoint::LineTo(x, y + h));
sub.points.push(PathPoint::Close);
PageObject {
bbox: Rect {
x,
y,
width: w,
height: h,
},
ctm: Matrix::identity(),
kind: ObjectKind::Fill,
fill_color: Some(PdfColor::DeviceGray(0.0)),
stroke_color: None,
stroke_width: 0.0,
overprint: OverprintState::default(),
subpaths: vec![sub],
}
}
#[test]
fn cross2d_left_of_edge_is_positive() {
assert!(cross2d(0.0, 0.0, 1.0, 0.0, 0.5, 1.0) > 0.0);
}
#[test]
fn cross2d_right_of_edge_is_negative() {
assert!(cross2d(0.0, 0.0, 1.0, 0.0, 0.5, -1.0) < 0.0);
}
#[test]
fn inside_unit_square() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(0.0, 0.0));
sub.points.push(PathPoint::LineTo(1.0, 0.0));
sub.points.push(PathPoint::LineTo(1.0, 1.0));
sub.points.push(PathPoint::LineTo(0.0, 1.0));
sub.points.push(PathPoint::Close);
assert!(point_in_path_nonzero(&[sub], 0.5, 0.5, &Matrix::identity()));
}
#[test]
fn outside_unit_square() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(0.0, 0.0));
sub.points.push(PathPoint::LineTo(1.0, 0.0));
sub.points.push(PathPoint::LineTo(1.0, 1.0));
sub.points.push(PathPoint::LineTo(0.0, 1.0));
sub.points.push(PathPoint::Close);
assert!(!point_in_path_nonzero(
&[sub],
2.0,
2.0,
&Matrix::identity()
));
}
#[test]
fn ctm_translation_shifts_hit_region() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(0.0, 0.0));
sub.points.push(PathPoint::LineTo(10.0, 0.0));
sub.points.push(PathPoint::LineTo(10.0, 10.0));
sub.points.push(PathPoint::LineTo(0.0, 10.0));
sub.points.push(PathPoint::Close);
let ctm = Matrix::from_values(1.0, 0.0, 0.0, 1.0, 100.0, 100.0);
assert!(point_in_path_nonzero(&[sub.clone()], 105.0, 105.0, &ctm));
assert!(!point_in_path_nonzero(&[sub], 5.0, 5.0, &ctm));
}
#[test]
fn empty_subpaths_never_hit() {
assert!(!point_in_path_nonzero(&[], 0.5, 0.5, &Matrix::identity()));
}
#[test]
fn single_point_subpath_never_hit() {
let mut sub = SubPath::default();
sub.points.push(PathPoint::MoveTo(5.0, 5.0));
assert!(!point_in_path_nonzero(
&[sub],
5.0,
5.0,
&Matrix::identity()
));
}
#[test]
fn donut_hole_outside_inner_path() {
let mut outer = SubPath::default();
outer.points.push(PathPoint::MoveTo(0.0, 0.0));
outer.points.push(PathPoint::LineTo(100.0, 0.0));
outer.points.push(PathPoint::LineTo(100.0, 100.0));
outer.points.push(PathPoint::LineTo(0.0, 100.0));
outer.points.push(PathPoint::Close);
let mut inner = SubPath::default();
inner.points.push(PathPoint::MoveTo(40.0, 40.0));
inner.points.push(PathPoint::LineTo(60.0, 40.0));
inner.points.push(PathPoint::LineTo(60.0, 60.0));
inner.points.push(PathPoint::LineTo(40.0, 60.0));
inner.points.push(PathPoint::Close);
let result = point_in_path_nonzero(&[outer, inner], 50.0, 50.0, &Matrix::identity());
assert!(result);
}
#[test]
fn hit_test_miss_returns_empty() {
let tree = ObjectTree {
objects: vec![rect_fill_object(0.0, 0.0, 100.0, 100.0)],
};
assert!(hit_test(&tree, 200.0, 200.0).is_empty());
}
#[test]
fn hit_test_single_object_hit() {
let tree = ObjectTree {
objects: vec![rect_fill_object(0.0, 0.0, 100.0, 100.0)],
};
let hits = hit_test(&tree, 50.0, 50.0);
assert_eq!(hits.len(), 1);
}
#[test]
fn hit_test_two_overlapping_objects_both_hit() {
let tree = ObjectTree {
objects: vec![
rect_fill_object(0.0, 0.0, 200.0, 200.0),
rect_fill_object(50.0, 50.0, 100.0, 100.0),
],
};
let hits = hit_test(&tree, 100.0, 100.0);
assert_eq!(hits.len(), 2, "point inside both rects should hit both");
}
#[test]
fn hit_test_preserves_paint_order() {
let back = rect_fill_object(0.0, 0.0, 100.0, 100.0);
let front = rect_fill_object(10.0, 10.0, 80.0, 80.0);
let tree = ObjectTree {
objects: vec![back, front],
};
let hits = hit_test(&tree, 50.0, 50.0);
assert_eq!(hits.len(), 2);
assert!(
(hits[0].bbox.x - 0.0).abs() < 0.01,
"first hit should be the back object"
);
assert!(
(hits[1].bbox.x - 10.0).abs() < 0.01,
"second hit should be the front object"
);
}
#[test]
fn hit_test_empty_tree() {
let tree = ObjectTree { objects: vec![] };
assert!(hit_test(&tree, 50.0, 50.0).is_empty());
}
#[test]
fn hit_test_stroke_object_uses_bbox_only() {
let obj = PageObject {
bbox: Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
},
ctm: Matrix::identity(),
kind: ObjectKind::Stroke,
fill_color: None,
stroke_color: Some(PdfColor::DeviceGray(0.0)),
stroke_width: 2.0,
overprint: OverprintState::default(),
subpaths: vec![],
};
let tree = ObjectTree { objects: vec![obj] };
assert_eq!(hit_test(&tree, 50.0, 50.0).len(), 1);
}
#[test]
fn hit_test_image_object_uses_bbox_only() {
let obj = PageObject {
bbox: Rect {
x: 10.0,
y: 10.0,
width: 200.0,
height: 150.0,
},
ctm: Matrix::identity(),
kind: ObjectKind::Image,
fill_color: None,
stroke_color: None,
stroke_width: 0.0,
overprint: OverprintState::default(),
subpaths: vec![],
};
let tree = ObjectTree { objects: vec![obj] };
assert_eq!(hit_test(&tree, 100.0, 80.0).len(), 1);
assert!(hit_test(&tree, 5.0, 5.0).is_empty());
}
}