Skip to main content

rasterrocket_render/shading/
radial.rs

1//! Radial gradient pattern (PDF §8.7.4.5, type 3).
2//!
3//! Solves the quadratic `|p - (c0 + t·(c1-c0))|² = (r0 + t·(r1-r0))²`
4//! and takes the largest real root (the "outside" intersection, per PDF §8.7.4.5).
5//! The root is then clamped to `[t0, t1]` or rejected when extension is off.
6//!
7//! # Out-of-range pixels
8//!
9//! When the quadratic has no real roots, or the solution falls outside
10//! `[t0, t1]` and extension is disabled, `fill_span` writes `[0, 0, 0]`.
11//! As with [`axial`], this is only correct when the caller clips to the
12//! shading bounding path.
13//!
14//! [`axial`]: super::axial
15
16use super::lerp_color;
17use crate::pipe::Pattern;
18
19/// Gradient between two circles (centre `c0`, radius `r0`) and (`c1`, `r1`).
20pub struct RadialPattern {
21    color0: [u8; 3],
22    color1: [u8; 3],
23    c0x: f64,
24    c0y: f64,
25    /// Δc = c1 - c0.
26    dcx: f64,
27    dcy: f64,
28    r0: f64,
29    /// Δr = r1 - r0.
30    dr: f64,
31    t0: f64,
32    t1: f64,
33    extend_start: bool,
34    extend_end: bool,
35    /// `|Δc|² - Δr²` — the `t²` coefficient of the quadratic.
36    /// Zero when the gradient is linear (conic section degenerate).
37    a: f64,
38}
39
40impl RadialPattern {
41    /// Create a radial gradient.
42    ///
43    /// - `color0` / `color1`: RGB at inner / outer circle.
44    /// - `(c0x, c0y, r0)`: inner circle centre and radius (≥ 0).
45    /// - `(c1x, c1y, r1)`: outer circle centre and radius (≥ 0).
46    /// - `t0`, `t1`: parameter range mapping to `color0` / `color1`.
47    ///   May be inverted (`t0 > t1`).
48    /// - `extend_start` / `extend_end`: extend colour beyond the gradient circles.
49    #[must_use]
50    #[expect(
51        clippy::too_many_arguments,
52        reason = "mirrors PDF shading dict: 2 colors + 2 circles (cx,cy,r each) + t range + 2 extend flags"
53    )]
54    pub fn new(
55        color0: [u8; 3],
56        color1: [u8; 3],
57        c0x: f64,
58        c0y: f64,
59        r0: f64,
60        c1x: f64,
61        c1y: f64,
62        r1: f64,
63        t0: f64,
64        t1: f64,
65        extend_start: bool,
66        extend_end: bool,
67    ) -> Self {
68        let dcx = c1x - c0x;
69        let dcy = c1y - c0y;
70        let dr = r1 - r0;
71        // a = |Δc|² - Δr²
72        let a = dr.mul_add(-dr, dcx.mul_add(dcx, dcy * dcy));
73        Self {
74            color0,
75            color1,
76            c0x,
77            c0y,
78            dcx,
79            dcy,
80            r0,
81            dr,
82            t0,
83            t1,
84            extend_start,
85            extend_end,
86            a,
87        }
88    }
89
90    /// Solve for `t` at pixel `(xi, yi)`.
91    ///
92    /// Quadratic `a·t² + 2·b·t + c = 0` where:
93    /// - `b = (p - c0)·Δc - r0·Δr`
94    /// - `c = |p - c0|² - r0²`
95    ///
96    /// Returns `None` when no real solution exists in the gradient range.
97    fn t_for(&self, xi: i32, yi: i32) -> Option<f64> {
98        let rel_x = f64::from(xi) - self.c0x;
99        let rel_y = f64::from(yi) - self.c0y;
100
101        let b = self
102            .r0
103            .mul_add(-self.dr, rel_x.mul_add(self.dcx, rel_y * self.dcy));
104        let c = self
105            .r0
106            .mul_add(-self.r0, rel_x.mul_add(rel_x, rel_y * rel_y));
107
108        let t = if self.a.abs() < 1e-12 {
109            // Linear equation: 2·b·t + c = 0.
110            if b.abs() < 1e-12 {
111                return None; // fully degenerate — no solution
112            }
113            -c / (b + b)
114        } else {
115            let disc = b.mul_add(b, -(self.a * c));
116            if disc < 0.0 {
117                return None; // no real intersection
118            }
119            let sq = disc.sqrt();
120            // Take the larger root — the "outside" intersection (PDF §8.7.4.5).
121            f64::max((-b + sq) / self.a, (-b - sq) / self.a)
122        };
123
124        // Handle both t0 < t1 and t0 > t1 (inverted gradient).
125        let (lo, hi) = if self.t0 <= self.t1 {
126            (self.t0, self.t1)
127        } else {
128            (self.t1, self.t0)
129        };
130        if t < lo {
131            if self.extend_start {
132                Some(self.t0)
133            } else {
134                None
135            }
136        } else if t > hi {
137            if self.extend_end { Some(self.t1) } else { None }
138        } else {
139            Some(t)
140        }
141    }
142}
143
144impl Pattern for RadialPattern {
145    fn fill_span(&self, y: i32, x0: i32, x1: i32, out: &mut [u8]) {
146        // out.len() == (x1-x0+1)*3 is an invariant guaranteed by render_span.
147        let t_span = self.t1 - self.t0;
148        let mut off = 0usize;
149        for xi in x0..=x1 {
150            if let Some(t) = self.t_for(xi, y) {
151                let frac = if t_span.abs() < f64::EPSILON {
152                    0_u32
153                } else {
154                    #[expect(clippy::cast_sign_loss, reason = "value clamped to 0.0..=256.0")]
155                    #[expect(clippy::cast_possible_truncation, reason = "value ≤ 256")]
156                    {
157                        (((t - self.t0) / t_span).clamp(0.0, 1.0) * 256.0) as u32
158                    }
159                };
160                lerp_color(self.color0, self.color1, frac, &mut out[off..off + 3]);
161            } else {
162                out[off] = 0;
163                out[off + 1] = 0;
164                out[off + 2] = 0;
165            }
166            off += 3;
167        }
168    }
169
170    fn is_static_color(&self) -> bool {
171        false
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    /// Concentric radial: c0=c1=(4,4), r0=0, r1=4.
180    fn make_concentric() -> RadialPattern {
181        RadialPattern::new(
182            [0, 0, 0],
183            [255, 255, 255],
184            4.0,
185            4.0,
186            0.0,
187            4.0,
188            4.0,
189            4.0,
190            0.0,
191            1.0,
192            true,
193            true,
194        )
195    }
196
197    #[test]
198    fn centre_is_color0() {
199        let p = make_concentric();
200        let mut out = [42u8; 3];
201        p.fill_span(4, 4, 4, &mut out);
202        // Distance 0 from centre → t → 0 → color0 = black.
203        assert!(out[0] < 10, "centre should be near-black, got {}", out[0]);
204    }
205
206    #[test]
207    fn outer_ring_is_color1() {
208        let p = make_concentric();
209        let mut out = [0u8; 3];
210        p.fill_span(4, 8, 8, &mut out); // distance 4 = r1 → t = 1 → color1 = white
211        assert!(
212            out[0] > 240,
213            "outer ring should be near-white, got {}",
214            out[0]
215        );
216    }
217
218    #[test]
219    fn pixel_on_inner_circle_maps_to_color0() {
220        // r0=2: pixel at distance 2 from centre → t=0 → color0.
221        let p = RadialPattern::new(
222            [255, 0, 0],
223            [0, 0, 255],
224            4.0,
225            4.0,
226            2.0,
227            4.0,
228            4.0,
229            6.0,
230            0.0,
231            1.0,
232            false,
233            false,
234        );
235        let mut out = [0u8; 3];
236        // (6, 4) is exactly 2 units from (4,4) — on the inner circle.
237        p.fill_span(4, 6, 6, &mut out);
238        assert!(out[0] > 240, "inner circle should be near color0 (red)");
239        assert!(out[2] < 20, "inner circle should have near-zero blue");
240    }
241
242    #[test]
243    fn no_real_intersection_writes_zeros() {
244        // Eccentric gradient where a pixel far off-axis has no real solution.
245        let p = RadialPattern::new(
246            [255, 0, 0],
247            [0, 255, 0],
248            0.0,
249            0.0,
250            1.0,
251            10.0,
252            0.0,
253            1.0,
254            0.0,
255            1.0,
256            false,
257            false,
258        );
259        let mut out = [42u8; 3]; // non-zero sentinel
260        p.fill_span(100, 100, 100, &mut out);
261        assert_eq!(out, [0, 0, 0], "no intersection should write zeros");
262    }
263
264    #[test]
265    fn degenerate_a_linear_fallback() {
266        // When |Δc|² == Δr², a=0 and we use the linear path.
267        // c0=(0,0,r0=0), c1=(3,4,r1=5): |Δc|²=25, Δr=5, Δr²=25 → a=0.
268        let p = RadialPattern::new(
269            [0, 0, 0],
270            [255, 255, 255],
271            0.0,
272            0.0,
273            0.0,
274            3.0,
275            4.0,
276            5.0,
277            0.0,
278            1.0,
279            true,
280            true,
281        );
282        let mut out = [0u8; 15]; // 5 pixels × 3 bytes
283        // Must not panic.
284        p.fill_span(0, 0, 4, &mut out);
285    }
286
287    #[test]
288    fn extend_clamps_outside_to_endpoints() {
289        let p = make_concentric();
290        let mut out = [42u8; 3];
291        // Distance 10 > r1=4; with extend_end=true should clamp to color1 (white).
292        p.fill_span(4, 14, 14, &mut out);
293        assert!(
294            out[0] > 240,
295            "beyond outer with extend should clamp to color1 (white)"
296        );
297    }
298}