Skip to main content

pdfrum_page/shading/
radial.rs

1//! Type 3: radial shadings (ISO 32000-1 §8.7.4.5.4).
2//!
3//! A gradient between two circles. Finding a pixel's parametric position
4//! means solving a quadratic, and the C++'s solver has **three branches with
5//! different amounts of guarding** — a fact worth stating plainly, because
6//! two of them are visibly under-guarded and both are ported as they are:
7//!
8//! | Branch | Condition | Guards |
9//! |---|---|---|
10//! | 1 | `b` ≈ 0 | **none at all**: no discriminant check, no root selection, no radius check, and no `a == 0` guard, so `sqrt(-c/a)` may be NaN |
11//! | 2 | `a` ≈ 0 | linear, `-c/b`; **no radius check** |
12//! | 3 | otherwise | discriminant check, root selection by direction, and a negative-radius check |
13//!
14//! The "decreasing" test that picks between the roots truncates a hypotenuse
15//! to an integer before comparing it, which is its own small quirk.
16
17// The quadratic's coefficients are `a`, `b` and `c`, and the deltas `dx`,
18// `dy`, `dr`; these are the names ISO 32000-1 §8.7.4.5.4 and every derivation
19// of it use.
20#![expect(
21    clippy::many_single_char_names,
22    reason = "quadratic coefficients are single-letter by convention"
23)]
24
25use super::{read_domain, read_extend};
26use crate::names;
27use kurbo::Point;
28use pdfrum_object::{Dict, Resolve};
29
30/// A type 3 shading's geometry.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Radial {
33    /// The first circle's centre.
34    pub start: Point,
35    /// The first circle's radius.
36    pub start_radius: f32,
37    /// The second circle's centre.
38    pub end: Point,
39    /// The second circle's radius.
40    pub end_radius: f32,
41    /// `/Domain`'s low bound.
42    pub t_min: f32,
43    /// `/Domain`'s high bound.
44    pub t_max: f32,
45    /// Whether the gradient continues past the first circle.
46    pub extend_start: bool,
47    /// Whether it continues past the second.
48    pub extend_end: bool,
49}
50
51/// What the quadratic solver produced for one point.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub enum RadialPosition {
54    /// A parametric position along the gradient.
55    At(f32),
56    /// The pixel is not covered: the discriminant was negative, or the
57    /// interpolated radius would be.
58    Uncovered,
59}
60
61impl Radial {
62    /// Load from a shading dictionary. `/Coords` is six numbers, again with
63    /// no length check.
64    pub(super) fn load(dict: &Dict, r: &impl Resolve) -> Option<Self> {
65        let coords = dict.array(names::COORDS, r)?;
66        let at = |i: usize| coords.number_at_or_zero(i);
67        let (t_min, t_max) = read_domain(dict, r);
68        let (extend_start, extend_end) = read_extend(dict, r);
69        Some(Self {
70            start: Point::new(f64::from(at(0)), f64::from(at(1))),
71            start_radius: at(2),
72            end: Point::new(f64::from(at(3)), f64::from(at(4))),
73            end_radius: at(5),
74            t_min,
75            t_max,
76            extend_start,
77            extend_end,
78        })
79    }
80
81    /// Whether the circles shrink as the gradient advances, which decides
82    /// which root of the quadratic to prefer.
83    ///
84    /// The centre distance is **truncated to an integer** before the
85    /// comparison, so a shading whose centres are 3.9 units apart with
86    /// `dr == -3.5` counts as decreasing while one with `dr == -3.0` does
87    /// not. Reproduced.
88    #[must_use]
89    pub fn is_decreasing(&self) -> bool {
90        let dx = self.end.x - self.start.x;
91        let dy = self.end.y - self.start.y;
92        let dr = self.end_radius - self.start_radius;
93        #[expect(
94            clippy::cast_possible_truncation,
95            reason = "the integer truncation of the hypotenuse is the quirk being ported"
96        )]
97        let hypot = dx.hypot(dy) as i32;
98        dr < 0.0 && f64::from(hypot) < f64::from(-dr)
99    }
100
101    /// The parametric position of a point, or that it is uncovered.
102    #[must_use]
103    #[expect(
104        clippy::cast_possible_truncation,
105        reason = "the C++ solves this quadratic in f32 throughout, and the \
106                  branch selection is sensitive to that precision"
107    )]
108    pub fn position(&self, p: Point) -> RadialPosition {
109        let dx = (self.end.x - self.start.x) as f32;
110        let dy = (self.end.y - self.start.y) as f32;
111        let dr = self.end_radius - self.start_radius;
112        let a = dx * dx + dy * dy - dr * dr;
113        let a_is_zero = is_float_zero(a);
114
115        let pdx = (p.x - self.start.x) as f32;
116        let pdy = (p.y - self.start.y) as f32;
117        let b = -2.0 * (pdx * dx + pdy * dy + self.start_radius * dr);
118        let c = pdx * pdx + pdy * pdy - self.start_radius * self.start_radius;
119
120        // Branch 1: no guards whatsoever, so this may well be NaN.
121        if is_float_zero(b) {
122            return RadialPosition::At((-c / a).sqrt());
123        }
124        // Branch 2: linear, and with no radius check.
125        if a_is_zero {
126            return RadialPosition::At(-c / b);
127        }
128        // Branch 3: the fully guarded one.
129        let disc = b * b - 4.0 * a * c;
130        if disc < 0.0 {
131            return RadialPosition::Uncovered;
132        }
133        let root = disc.sqrt();
134        let (mut s1, mut s2) = ((-b - root) / (2.0 * a), (-b + root) / (2.0 * a));
135        if a <= 0.0 {
136            // Ensure `s1 <= s2`.
137            std::mem::swap(&mut s1, &mut s2);
138        }
139        let s = if self.is_decreasing() {
140            if s1 >= 0.0 || self.extend_start {
141                s1
142            } else {
143                s2
144            }
145        } else if s2 <= 1.0 || self.extend_end {
146            s2
147        } else {
148            s1
149        };
150        // A negative interpolated radius means the pixel is outside both
151        // circles' sweep.
152        if self.start_radius + s * dr < 0.0 {
153            return RadialPosition::Uncovered;
154        }
155        RadialPosition::At(s)
156    }
157}
158
159/// Whether `v` is zero to the shading tolerance: `|v| < 1e-4`.
160///
161/// A **fixed 1e-4 tolerance**, not a machine epsilon — roughly 840 times
162/// wider than `f32::EPSILON`. `a` is `dx² + dy² - dr²`, a catastrophic
163/// cancellation whenever the start point sits on the end circle, and the two
164/// tolerances then disagree about which branch runs: the linear `a == 0` one,
165/// which has no negative-radius skip, or the quadratic one, which does.
166/// `radial_shading_point_at_border` lands in exactly that gap at `a ≈ 2.4e-7`.
167///
168/// The comparison widens to `f64` before the test, so a `f32` operand is not
169/// rounded against a `f32` bound.
170// The oracle's `FXSYS_IsFloatZero` (`fx_system.h:36`),
171// `(f) < 0.0001 && (f) > -0.0001`: its operand is a `float` but the literals
172// are `double`, so the comparison happens in `double` there too.
173fn is_float_zero(v: f32) -> bool {
174    f64::from(v).abs() < FLOAT_ZERO
175}
176
177/// The `FXSYS_IsFloatZero` tolerance.
178const FLOAT_ZERO: f64 = 1e-4;
179
180#[cfg(test)]
181mod tests {
182    // Test fixtures quote the oracle's own vectors, compare floats exactly
183    // where the behaviour being pinned is exact, and index arrays whose
184    // length the fixture itself fixes.
185    #![allow(
186        clippy::unreadable_literal,
187        clippy::float_cmp,
188        clippy::indexing_slicing,
189        clippy::cast_precision_loss,
190        clippy::cast_possible_truncation,
191        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
192    )]
193
194    use super::{Radial, RadialPosition};
195    use kurbo::Point;
196
197    fn concentric() -> Radial {
198        Radial {
199            start: Point::new(0.0, 0.0),
200            start_radius: 0.0,
201            end: Point::new(0.0, 0.0),
202            end_radius: 10.0,
203            t_min: 0.0,
204            t_max: 1.0,
205            extend_start: false,
206            extend_end: false,
207        }
208    }
209
210    #[test]
211    fn a_concentric_gradient_maps_radius_to_position() {
212        let s = concentric();
213        // At the centre `b` is zero, so branch 1 runs.
214        let RadialPosition::At(v) = s.position(Point::new(0.0, 0.0)) else {
215            panic!("the centre should have a position");
216        };
217        assert!(v.abs() < 1e-6, "got {v}");
218        // Halfway out.
219        let RadialPosition::At(v) = s.position(Point::new(5.0, 0.0)) else {
220            panic!("expected a position");
221        };
222        assert!((v - 0.5).abs() < 1e-5, "got {v}");
223    }
224
225    #[test]
226    fn the_linear_branch_runs_when_a_is_zero() {
227        // Equal radii and coincident centres make `a` zero for a translated
228        // gradient: dx² + dy² == dr².
229        let s = Radial {
230            start: Point::new(0.0, 0.0),
231            start_radius: 0.0,
232            end: Point::new(3.0, 4.0),
233            end_radius: 5.0,
234            ..concentric()
235        };
236        // `a = 9 + 16 - 25 = 0`.
237        let RadialPosition::At(v) = s.position(Point::new(1.0, 1.0)) else {
238            panic!("expected a position");
239        };
240        assert!(v.is_finite(), "got {v}");
241    }
242
243    #[test]
244    fn an_uncovered_pixel_is_reported_rather_than_clamped() {
245        // Two separated circles leave points outside their sweep uncovered.
246        let s = Radial {
247            start: Point::new(0.0, 0.0),
248            start_radius: 1.0,
249            end: Point::new(100.0, 0.0),
250            end_radius: 1.0,
251            ..concentric()
252        };
253        assert_eq!(
254            s.position(Point::new(50.0, 500.0)),
255            RadialPosition::Uncovered
256        );
257    }
258
259    #[test]
260    fn the_decreasing_test_truncates_the_centre_distance() {
261        // Centres 3.9 apart with dr = −3.5: the truncated hypotenuse is 3,
262        // which is below 3.5, so the shading counts as decreasing.
263        let s = Radial {
264            start: Point::new(0.0, 0.0),
265            start_radius: 4.0,
266            end: Point::new(3.9, 0.0),
267            end_radius: 0.5,
268            ..concentric()
269        };
270        assert!(s.is_decreasing());
271        // With dr = −3.0 the truncated 3 is not below 3, so it is not.
272        let s = Radial {
273            end_radius: 1.0,
274            ..s
275        };
276        assert!(!s.is_decreasing());
277    }
278}