use kurbo::{Affine, Point};
use pdfrum_page::Radial;
use crate::pixmap::Pixmap;
use crate::shading::steps::ColorSteps;
fn is_float_zero(v: f64) -> bool {
v.abs() < FLOAT_ZERO
}
const FLOAT_ZERO: f64 = 1e-4;
#[must_use]
pub fn is_decreasing(radial: &Radial) -> bool {
let dx = radial.end.x - radial.start.x;
let dy = radial.end.y - radial.start.y;
let dr = f64::from(radial.end_radius) - f64::from(radial.start_radius);
#[expect(
clippy::cast_possible_truncation,
reason = "the integer truncation is the ported behaviour"
)]
let truncated = dx.hypot(dy) as i32;
dr < 0.0 && f64::from(truncated) < -dr
}
#[must_use]
pub fn position(radial: &Radial, pos: Point, decreasing: bool) -> Option<f64> {
let x0 = radial.start.x;
let y0 = radial.start.y;
let r0 = f64::from(radial.start_radius);
let dx = radial.end.x - x0;
let dy = radial.end.y - y0;
let dr = f64::from(radial.end_radius) - r0;
let a = dx * dx + dy * dy - dr * dr;
let a_is_zero = is_float_zero(a);
let pdx = pos.x - x0;
let pdy = pos.y - y0;
let b = -2.0 * (pdx * dx + pdy * dy + r0 * dr);
let c = pdx * pdx + pdy * pdy - r0 * r0;
if is_float_zero(b) {
return Some((-c / a).sqrt());
}
if a_is_zero {
return Some(-c / b);
}
let disc = b * b - 4.0 * a * c;
if disc < 0.0 {
return None;
}
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 decreasing {
if s1 >= 0.0 || radial.extend_start {
s1
} else {
s2
}
} else if s2 <= 1.0 || radial.extend_end {
s2
} else {
s1
};
(r0 + s * dr >= 0.0).then_some(s)
}
pub fn draw(dest: &mut Pixmap, radial: &Radial, steps: &ColorSteps, to_bitmap: Affine) {
let determinant = to_bitmap.determinant();
if determinant == 0.0 || !determinant.is_finite() {
return;
}
let inverse = to_bitmap.inverse();
let decreasing = is_decreasing(radial);
for row in 0..dest.height() {
for col in 0..dest.width() {
let pos = inverse * Point::new(f64::from(col), f64::from(row));
let Some(s) = position(radial, pos, decreasing) else {
continue;
};
#[expect(
clippy::cast_possible_truncation,
reason = "the ramp lookup ports the truncating cast"
)]
let Some(color) = steps.lookup(s as f32, radial.extend_start, radial.extend_end) else {
continue;
};
dest.set_pixel(col, row, crate::pixmap::premultiply(color.to_peniko()));
}
}
}
#[cfg(test)]
mod tests {
use pdfrum_page::Rgb;
use super::*;
use crate::shading::steps::STEPS;
fn ramp() -> ColorSteps {
let mut colors = [Rgb::BLACK; STEPS];
for (i, c) in colors.iter_mut().enumerate() {
#[expect(clippy::cast_precision_loss, reason = "i < 256 is exact")]
let v = i as f32 / 255.0;
*c = Rgb { r: v, g: v, b: v };
}
ColorSteps::from_colors(colors, 255)
}
fn concentric(r0: f32, r1: f32, extend: (bool, bool)) -> Radial {
Radial {
start: Point::new(0.0, 0.0),
start_radius: r0,
end: Point::new(0.0, 0.0),
end_radius: r1,
t_min: 0.0,
t_max: 1.0,
extend_start: extend.0,
extend_end: extend.1,
}
}
#[test]
fn concentric_circles_ramp_by_distance() {
let r = concentric(0.0, 10.0, (true, true));
assert_eq!(position(&r, Point::new(0.0, 0.0), false), Some(0.0));
let s = position(&r, Point::new(5.0, 0.0), false).expect("covered");
assert!((s - 0.5).abs() < 1e-9, "s = {s}");
}
#[test]
fn b_zero_branch_has_no_sign_check_and_yields_nan() {
let r = Radial {
start: Point::new(0.0, 0.0),
start_radius: 0.0,
end: Point::new(10.0, 0.0),
end_radius: 1.0,
t_min: 0.0,
t_max: 1.0,
extend_start: false,
extend_end: false,
};
let s = position(&r, Point::new(0.0, 4.0), false);
assert!(
s.is_some_and(f64::is_nan),
"the b == 0 branch is unguarded: {s:?}"
);
assert_eq!(ramp().lookup(f32::NAN, false, false), None);
}
#[test]
fn decreasing_test_truncates_the_hypotenuse() {
let r = Radial {
start: Point::new(0.0, 0.0),
start_radius: 2.0,
end: Point::new(1.9, 0.0),
end_radius: 0.5,
t_min: 0.0,
t_max: 1.0,
extend_start: false,
extend_end: false,
};
assert!(
is_decreasing(&r),
"the integer truncation is what makes this fire"
);
assert!(!is_decreasing(&concentric(0.0, 10.0, (false, false))));
}
#[test]
fn radial_root_selection_over_all_eight_combinations() {
for decreasing in [false, true] {
for extend_start in [false, true] {
for extend_end in [false, true] {
let wide = Radial {
start: Point::new(0.0, 0.0),
start_radius: 1.0,
end: Point::new(20.0, 0.0),
end_radius: 2.0,
t_min: 0.0,
t_max: 1.0,
extend_start,
extend_end,
};
let s = position(&wide, Point::new(3.0, 1.0), decreasing);
if let Some(s) = s {
assert!(1.0 + s * 1.0 >= 0.0, "negative radius escaped the skip");
}
}
}
}
}
#[test]
fn negative_radius_skip_is_absent_from_the_a_zero_branch() {
let r = Radial {
start: Point::new(0.0, 0.0),
start_radius: 1.0,
end: Point::new(5.0, 0.0),
end_radius: 6.0,
t_min: 0.0,
t_max: 1.0,
extend_start: true,
extend_end: true,
};
let s = position(&r, Point::new(-100.0, 0.0), false);
assert!(
s.is_some(),
"the a == 0 branch never applies the radius skip"
);
assert!(
s.is_some_and(|s| 1.0 + s * 5.0 < 0.0),
"and the radius really is negative"
);
}
#[test]
fn the_float_zero_tolerance_is_1e_4_not_a_machine_epsilon() {
assert!(is_float_zero(9.9e-5), "just inside the tolerance");
assert!(!is_float_zero(1.01e-4), "just outside it");
let a = 2.4e-7;
assert!(
is_float_zero(a),
"a machine epsilon would call this nonzero"
);
assert!(a > f64::from(f32::EPSILON), "and it really is above one");
let border = Radial {
start: Point::new(-0.223_151, -0.974_784),
start_radius: 0.0,
end: Point::new(0.0, 0.0),
end_radius: 1.0,
t_min: 0.0,
t_max: 1.0,
extend_start: true,
extend_end: true,
};
let decreasing = is_decreasing(&border);
for (x, y) in [(-3.0, -0.4), (-3.0, -1.98), (-2.0, -0.8)] {
let s = position(&border, Point::new(x, y), decreasing);
assert!(
s.is_some(),
"({x}, {y}) takes the linear branch, which has no skip"
);
assert!(
s.is_some_and(|s| s < 0.0),
"and lands below the ramp, where /Extend clamps it to C0"
);
}
}
#[test]
fn a_disc_below_zero_paints_nothing() {
let r = Radial {
start: Point::new(0.0, 0.0),
start_radius: 1.0,
end: Point::new(1.0, 0.0),
end_radius: 1.0,
t_min: 0.0,
t_max: 1.0,
extend_start: false,
extend_end: false,
};
assert_eq!(position(&r, Point::new(0.5, 50.0), false), None);
}
#[test]
fn draws_a_disc() {
let mut p = Pixmap::filled(21, 21, peniko::Color::from_rgba8(9, 9, 9, 255));
let r = concentric(0.0, 10.0, (false, false));
draw(&mut p, &r, &ramp(), Affine::translate((10.0, 10.0)));
assert_eq!(p.pixel(10, 10).map(|px| px[0]), Some(0));
assert_eq!(p.pixel(0, 0).map(|px| px[0]), Some(9));
}
}