use crate::geometry::{Point, Rect};
pub const DEFAULT_EDGE_PADDING: f32 = 0.5;
const PROPORTIONAL_PADDING: f32 = 0.02;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct RedactionRegion {
pub bbox: [f32; 4],
pub quad: Option<[f32; 8]>,
pub fill: Option<[f32; 3]>,
}
impl RedactionRegion {
pub fn from_rect(x0: f32, y0: f32, x1: f32, y1: f32, fill: Option<[f32; 3]>) -> Self {
Self {
bbox: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)],
quad: None,
fill,
}
}
pub fn from_quad(quad: [f32; 8], fill: Option<[f32; 3]>) -> Self {
let xs = [quad[0], quad[2], quad[4], quad[6]];
let ys = [quad[1], quad[3], quad[5], quad[7]];
let x0 = xs.iter().copied().fold(f32::INFINITY, f32::min);
let x1 = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let y0 = ys.iter().copied().fold(f32::INFINITY, f32::min);
let y1 = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
Self {
bbox: [x0, y0, x1, y1],
quad: Some(quad),
fill,
}
}
pub fn width(&self) -> f32 {
self.bbox[2] - self.bbox[0]
}
pub fn height(&self) -> f32 {
self.bbox[3] - self.bbox[1]
}
pub fn rect(&self) -> Rect {
Rect::from_points(self.bbox[0], self.bbox[1], self.bbox[2], self.bbox[3])
}
pub fn effective_padding(&self, min_padding: f32) -> f32 {
let floor = if min_padding.is_finite() && min_padding >= 0.0 {
min_padding
} else {
DEFAULT_EDGE_PADDING
};
floor.max(PROPORTIONAL_PADDING * self.height().abs())
}
pub fn padded_rect(&self, min_padding: f32) -> Rect {
let e = self.effective_padding(min_padding);
Rect::from_points(self.bbox[0] - e, self.bbox[1] - e, self.bbox[2] + e, self.bbox[3] + e)
}
pub fn intersects_rect(&self, mark: &Rect, min_padding: f32) -> bool {
self.padded_rect(min_padding).intersects(mark)
}
pub fn contains_point(&self, p: &Point, min_padding: f32) -> bool {
self.padded_rect(min_padding).contains_point(p)
}
pub fn quad_contains_point(&self, p: &Point) -> Option<bool> {
self.quad.map(|q| point_in_convex_quad(p, &q))
}
}
fn point_in_convex_quad(p: &Point, q: &[f32; 8]) -> bool {
let v = [(q[0], q[1]), (q[2], q[3]), (q[4], q[5]), (q[6], q[7])];
let mut saw_pos = false;
let mut saw_neg = false;
for i in 0..4 {
let (ax, ay) = v[i];
let (bx, by) = v[(i + 1) % 4];
let cross = (bx - ax) * (p.y - ay) - (by - ay) * (p.x - ax);
if cross > 0.0 {
saw_pos = true;
} else if cross < 0.0 {
saw_neg = true;
}
if saw_pos && saw_neg {
return false;
}
}
true
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RegionSet {
pub page_index: usize,
pub regions: Vec<RedactionRegion>,
}
impl RegionSet {
pub fn new(page_index: usize) -> Self {
Self {
page_index,
regions: Vec::new(),
}
}
pub fn push(&mut self, region: RedactionRegion) {
self.regions.push(region);
}
pub fn is_empty(&self) -> bool {
self.regions.is_empty()
}
pub fn len(&self) -> usize {
self.regions.len()
}
pub fn any_intersects(&self, mark: &Rect, min_padding: f32) -> bool {
self.regions
.iter()
.any(|r| r.intersects_rect(mark, min_padding))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn from_rect_normalizes_reversed_corners() {
let r = RedactionRegion::from_rect(100.0, 80.0, 10.0, 20.0, None);
assert_eq!(r.bbox, [10.0, 20.0, 100.0, 80.0]);
assert!(approx(r.width(), 90.0));
assert!(approx(r.height(), 60.0));
assert!(r.quad.is_none());
}
#[test]
fn from_rect_keeps_already_normalized() {
let r = RedactionRegion::from_rect(10.0, 20.0, 100.0, 80.0, Some([0.0, 0.0, 0.0]));
assert_eq!(r.bbox, [10.0, 20.0, 100.0, 80.0]);
assert_eq!(r.fill, Some([0.0, 0.0, 0.0]));
}
#[test]
fn from_quad_derives_axis_aligned_envelope() {
let quad = [50.0, 0.0, 100.0, 50.0, 50.0, 100.0, 0.0, 50.0];
let r = RedactionRegion::from_quad(quad, None);
assert_eq!(r.bbox, [0.0, 0.0, 100.0, 100.0]);
assert_eq!(r.quad, Some(quad));
}
#[test]
fn effective_padding_floor_dominates_for_short_region() {
let r = RedactionRegion::from_rect(0.0, 0.0, 100.0, 10.0, None);
assert!(approx(r.effective_padding(DEFAULT_EDGE_PADDING), 0.5));
}
#[test]
fn effective_padding_proportional_dominates_for_tall_region() {
let r = RedactionRegion::from_rect(0.0, 0.0, 20.0, 100.0, None);
assert!(approx(r.effective_padding(DEFAULT_EDGE_PADDING), 2.0));
}
#[test]
fn effective_padding_custom_floor_is_honored() {
let r = RedactionRegion::from_rect(0.0, 0.0, 10.0, 5.0, None);
assert!(approx(r.effective_padding(3.0), 3.0));
}
#[test]
fn effective_padding_rejects_non_finite_floor() {
let r = RedactionRegion::from_rect(0.0, 0.0, 10.0, 5.0, None);
assert!(approx(r.effective_padding(f32::NAN), DEFAULT_EDGE_PADDING));
assert!(approx(r.effective_padding(-1.0), DEFAULT_EDGE_PADDING));
}
#[test]
fn padded_rect_expands_on_all_sides() {
let r = RedactionRegion::from_rect(10.0, 10.0, 30.0, 20.0, None);
let p = r.padded_rect(DEFAULT_EDGE_PADDING);
assert!(approx(p.left(), 9.5));
assert!(approx(p.top(), 9.5));
assert!(approx(p.right(), 30.5));
assert!(approx(p.bottom(), 20.5));
}
#[test]
fn intersects_rect_conservative_overlap() {
let region = RedactionRegion::from_rect(0.0, 0.0, 100.0, 20.0, None);
let inside = Rect::from_points(10.0, 5.0, 20.0, 15.0);
assert!(region.intersects_rect(&inside, DEFAULT_EDGE_PADDING));
let far = Rect::from_points(500.0, 500.0, 520.0, 520.0);
assert!(!region.intersects_rect(&far, DEFAULT_EDGE_PADDING));
let sliver = Rect::from_points(100.3, 5.0, 100.4, 15.0);
assert!(region.intersects_rect(&sliver, DEFAULT_EDGE_PADDING));
let beyond = Rect::from_points(101.0, 5.0, 102.0, 15.0);
assert!(!region.intersects_rect(&beyond, DEFAULT_EDGE_PADDING));
}
#[test]
fn contains_point_uses_padded_bbox() {
let region = RedactionRegion::from_rect(0.0, 0.0, 10.0, 10.0, None);
assert!(region.contains_point(&Point::new(5.0, 5.0), DEFAULT_EDGE_PADDING));
assert!(region.contains_point(&Point::new(10.3, 5.0), DEFAULT_EDGE_PADDING));
assert!(!region.contains_point(&Point::new(50.0, 50.0), DEFAULT_EDGE_PADDING));
}
#[test]
fn quad_contains_point_is_precise_and_winding_independent() {
let quad = [50.0, 0.0, 100.0, 50.0, 50.0, 100.0, 0.0, 50.0];
let r = RedactionRegion::from_quad(quad, None);
assert_eq!(r.quad_contains_point(&Point::new(50.0, 50.0)), Some(true));
assert_eq!(r.quad_contains_point(&Point::new(2.0, 2.0)), Some(false));
assert_eq!(r.quad_contains_point(&Point::new(25.0, 25.0)), Some(true));
let rev = [0.0, 50.0, 50.0, 100.0, 100.0, 50.0, 50.0, 0.0];
let rr = RedactionRegion::from_quad(rev, None);
assert_eq!(rr.quad_contains_point(&Point::new(50.0, 50.0)), Some(true));
assert_eq!(rr.quad_contains_point(&Point::new(2.0, 2.0)), Some(false));
let ax = RedactionRegion::from_rect(0.0, 0.0, 10.0, 10.0, None);
assert_eq!(ax.quad_contains_point(&Point::new(5.0, 5.0)), None);
}
#[test]
fn region_set_basic_and_any_intersects() {
let mut set = RegionSet::new(3);
assert_eq!(set.page_index, 3);
assert!(set.is_empty());
assert_eq!(set.len(), 0);
set.push(RedactionRegion::from_rect(0.0, 0.0, 10.0, 10.0, None));
set.push(RedactionRegion::from_rect(100.0, 100.0, 110.0, 110.0, None));
assert!(!set.is_empty());
assert_eq!(set.len(), 2);
let hits_second = Rect::from_points(105.0, 105.0, 108.0, 108.0);
assert!(set.any_intersects(&hits_second, DEFAULT_EDGE_PADDING));
let hits_none = Rect::from_points(50.0, 50.0, 60.0, 60.0);
assert!(!set.any_intersects(&hits_none, DEFAULT_EDGE_PADDING));
}
#[test]
fn full_page_region_intersects_any_mark() {
let page = RedactionRegion::from_rect(0.0, 0.0, 612.0, 792.0, None);
for mark in [
Rect::from_points(0.0, 0.0, 1.0, 1.0),
Rect::from_points(300.0, 400.0, 320.0, 412.0),
Rect::from_points(611.0, 791.0, 612.0, 792.0),
] {
assert!(page.intersects_rect(&mark, DEFAULT_EDGE_PADDING));
}
}
#[test]
fn non_finite_inputs_do_not_panic() {
let r = RedactionRegion::from_rect(f32::NAN, 0.0, 10.0, f32::INFINITY, None);
let _ = r.width();
let _ = r.height();
let _ = r.effective_padding(DEFAULT_EDGE_PADDING);
let _ = r.padded_rect(DEFAULT_EDGE_PADDING);
let q = RedactionRegion::from_quad([f32::NAN; 8], None);
let _ = q.quad_contains_point(&Point::new(0.0, 0.0));
}
}