#![expect(
clippy::many_single_char_names,
reason = "quadratic coefficients are single-letter by convention"
)]
use super::{read_domain, read_extend};
use crate::names;
use kurbo::Point;
use pdfrum_object::{Dict, Resolve};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Radial {
pub start: Point,
pub start_radius: f32,
pub end: Point,
pub end_radius: f32,
pub t_min: f32,
pub t_max: f32,
pub extend_start: bool,
pub extend_end: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RadialPosition {
At(f32),
Uncovered,
}
impl Radial {
pub(super) fn load(dict: &Dict, r: &impl Resolve) -> Option<Self> {
let coords = dict.array(names::COORDS, r)?;
let at = |i: usize| coords.number_at_or_zero(i);
let (t_min, t_max) = read_domain(dict, r);
let (extend_start, extend_end) = read_extend(dict, r);
Some(Self {
start: Point::new(f64::from(at(0)), f64::from(at(1))),
start_radius: at(2),
end: Point::new(f64::from(at(3)), f64::from(at(4))),
end_radius: at(5),
t_min,
t_max,
extend_start,
extend_end,
})
}
#[must_use]
pub fn is_decreasing(&self) -> bool {
let dx = self.end.x - self.start.x;
let dy = self.end.y - self.start.y;
let dr = self.end_radius - self.start_radius;
#[expect(
clippy::cast_possible_truncation,
reason = "the integer truncation of the hypotenuse is the quirk being ported"
)]
let hypot = dx.hypot(dy) as i32;
dr < 0.0 && f64::from(hypot) < f64::from(-dr)
}
#[must_use]
#[expect(
clippy::cast_possible_truncation,
reason = "the C++ solves this quadratic in f32 throughout, and the \
branch selection is sensitive to that precision"
)]
pub fn position(&self, p: Point) -> RadialPosition {
let dx = (self.end.x - self.start.x) as f32;
let dy = (self.end.y - self.start.y) as f32;
let dr = self.end_radius - self.start_radius;
let a = dx * dx + dy * dy - dr * dr;
let a_is_zero = is_float_zero(a);
let pdx = (p.x - self.start.x) as f32;
let pdy = (p.y - self.start.y) as f32;
let b = -2.0 * (pdx * dx + pdy * dy + self.start_radius * dr);
let c = pdx * pdx + pdy * pdy - self.start_radius * self.start_radius;
if is_float_zero(b) {
return RadialPosition::At((-c / a).sqrt());
}
if a_is_zero {
return RadialPosition::At(-c / b);
}
let disc = b * b - 4.0 * a * c;
if disc < 0.0 {
return RadialPosition::Uncovered;
}
let root = disc.sqrt();
let (mut s1, mut s2) = ((-b - root) / (2.0 * a), (-b + root) / (2.0 * a));
if a <= 0.0 {
std::mem::swap(&mut s1, &mut s2);
}
let s = if self.is_decreasing() {
if s1 >= 0.0 || self.extend_start {
s1
} else {
s2
}
} else if s2 <= 1.0 || self.extend_end {
s2
} else {
s1
};
if self.start_radius + s * dr < 0.0 {
return RadialPosition::Uncovered;
}
RadialPosition::At(s)
}
}
fn is_float_zero(v: f32) -> bool {
f64::from(v).abs() < FLOAT_ZERO
}
const FLOAT_ZERO: f64 = 1e-4;
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{Radial, RadialPosition};
use kurbo::Point;
fn concentric() -> Radial {
Radial {
start: Point::new(0.0, 0.0),
start_radius: 0.0,
end: Point::new(0.0, 0.0),
end_radius: 10.0,
t_min: 0.0,
t_max: 1.0,
extend_start: false,
extend_end: false,
}
}
#[test]
fn a_concentric_gradient_maps_radius_to_position() {
let s = concentric();
let RadialPosition::At(v) = s.position(Point::new(0.0, 0.0)) else {
panic!("the centre should have a position");
};
assert!(v.abs() < 1e-6, "got {v}");
let RadialPosition::At(v) = s.position(Point::new(5.0, 0.0)) else {
panic!("expected a position");
};
assert!((v - 0.5).abs() < 1e-5, "got {v}");
}
#[test]
fn the_linear_branch_runs_when_a_is_zero() {
let s = Radial {
start: Point::new(0.0, 0.0),
start_radius: 0.0,
end: Point::new(3.0, 4.0),
end_radius: 5.0,
..concentric()
};
let RadialPosition::At(v) = s.position(Point::new(1.0, 1.0)) else {
panic!("expected a position");
};
assert!(v.is_finite(), "got {v}");
}
#[test]
fn an_uncovered_pixel_is_reported_rather_than_clamped() {
let s = Radial {
start: Point::new(0.0, 0.0),
start_radius: 1.0,
end: Point::new(100.0, 0.0),
end_radius: 1.0,
..concentric()
};
assert_eq!(
s.position(Point::new(50.0, 500.0)),
RadialPosition::Uncovered
);
}
#[test]
fn the_decreasing_test_truncates_the_centre_distance() {
let s = Radial {
start: Point::new(0.0, 0.0),
start_radius: 4.0,
end: Point::new(3.9, 0.0),
end_radius: 0.5,
..concentric()
};
assert!(s.is_decreasing());
let s = Radial {
end_radius: 1.0,
..s
};
assert!(!s.is_decreasing());
}
}