Skip to main content

rasterrocket_render/shading/
axial.rs

1//! Axial (linear) gradient pattern (PDF §8.7.4.5, type 2).
2//!
3//! Colour at pixel (x, y) is determined by projecting onto the gradient axis
4//! and linearly interpolating between `color0` and `color1`.
5//!
6//! # Out-of-range pixels
7//!
8//! When `extend_start` / `extend_end` is `false` and the pixel projects outside
9//! the `[t0, t1]` range, `fill_span` writes black (`[0,0,0]`).  This is only
10//! correct when the caller clips the shading to its bounding path — which
11//! `shaded_fill` guarantees.  Do not use this pattern without a bounding clip.
12
13use super::lerp_color;
14use crate::pipe::Pattern;
15
16/// Linear gradient between two points in device space.
17///
18/// `t = dot(p - p0, axis) / |axis|²`, clamped to `[t0, t1]`
19/// (or extended to the nearest endpoint when `extend_start` / `extend_end` is set).
20pub struct AxialPattern {
21    color0: [u8; 3],
22    color1: [u8; 3],
23    ax: f64,
24    ay: f64,
25    p0x: f64,
26    p0y: f64,
27    /// `1 / (ax² + ay²)`; zero when the axis is degenerate (zero-length).
28    inv_len_sq: f64,
29    t0: f64,
30    t1: f64,
31    extend_start: bool,
32    extend_end: bool,
33}
34
35impl AxialPattern {
36    /// Create an axial gradient.
37    ///
38    /// - `color0`, `color1`: RGB endpoints.
39    /// - `(p0x, p0y)`, `(p1x, p1y)`: axis endpoints in device pixels.
40    /// - `t0`, `t1`: parameter range mapping to `color0` / `color1`.
41    ///   May be inverted (`t0 > t1`).
42    /// - `extend_start` / `extend_end`: extend colour beyond axis endpoints.
43    ///
44    /// # Degenerate case
45    ///
46    /// When `p0 == p1` (zero-length axis) every pixel returns `None` from
47    /// `t_for`, so `fill_span` writes zeros (per PDF §8.7.4.5).
48    #[must_use]
49    #[expect(
50        clippy::too_many_arguments,
51        reason = "mirrors PDF shading dict: 2 colors + 2 points + t range + 2 extend flags"
52    )]
53    pub fn new(
54        color0: [u8; 3],
55        color1: [u8; 3],
56        p0x: f64,
57        p0y: f64,
58        p1x: f64,
59        p1y: f64,
60        t0: f64,
61        t1: f64,
62        extend_start: bool,
63        extend_end: bool,
64    ) -> Self {
65        let ax = p1x - p0x;
66        let ay = p1y - p0y;
67        let len_sq = ax.mul_add(ax, ay * ay);
68        let inv_len_sq = if len_sq > 0.0 { 1.0 / len_sq } else { 0.0 };
69        Self {
70            color0,
71            color1,
72            ax,
73            ay,
74            p0x,
75            p0y,
76            inv_len_sq,
77            t0,
78            t1,
79            extend_start,
80            extend_end,
81        }
82    }
83
84    /// Compute the gradient parameter `t ∈ [t0, t1]` for pixel `(x, y)`.
85    ///
86    /// Returns `None` when the axis is degenerate or the pixel is outside the
87    /// gradient range and extension is disabled.
88    fn t_for(&self, x: i32, y: i32) -> Option<f64> {
89        if self.inv_len_sq == 0.0 {
90            return None;
91        }
92        let dx = f64::from(x) - self.p0x;
93        let dy = f64::from(y) - self.p0y;
94        // Project onto axis and map to [t0, t1].
95        let t_raw = dx.mul_add(self.ax, dy * self.ay) * self.inv_len_sq;
96        let t = t_raw.mul_add(self.t1 - self.t0, self.t0);
97
98        // Handle both t0 < t1 and t0 > t1 (inverted gradient).
99        let (lo, hi) = if self.t0 <= self.t1 {
100            (self.t0, self.t1)
101        } else {
102            (self.t1, self.t0)
103        };
104        if t < lo {
105            if self.extend_start {
106                Some(self.t0)
107            } else {
108                None
109            }
110        } else if t > hi {
111            if self.extend_end { Some(self.t1) } else { None }
112        } else {
113            Some(t)
114        }
115    }
116}
117
118impl Pattern for AxialPattern {
119    fn fill_span(&self, y: i32, x0: i32, x1: i32, out: &mut [u8]) {
120        // out.len() == (x1-x0+1)*3 is an invariant guaranteed by render_span.
121        let t_span = self.t1 - self.t0;
122        let mut off = 0usize;
123        for x in x0..=x1 {
124            if let Some(t) = self.t_for(x, y) {
125                // Normalise t to [0,1] regardless of t0/t1 ordering.
126                let frac = if t_span.abs() < f64::EPSILON {
127                    0_u32
128                } else {
129                    #[expect(clippy::cast_sign_loss, reason = "value clamped to 0.0..=256.0")]
130                    #[expect(clippy::cast_possible_truncation, reason = "value ≤ 256")]
131                    {
132                        (((t - self.t0) / t_span).clamp(0.0, 1.0) * 256.0) as u32
133                    }
134                };
135                lerp_color(self.color0, self.color1, frac, &mut out[off..off + 3]);
136            } else {
137                out[off] = 0;
138                out[off + 1] = 0;
139                out[off + 2] = 0;
140            }
141            off += 3;
142        }
143    }
144
145    fn is_static_color(&self) -> bool {
146        false
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn make_axial(extend: bool) -> AxialPattern {
155        AxialPattern::new(
156            [0, 0, 0],
157            [255, 255, 255],
158            0.0,
159            0.0,
160            8.0,
161            0.0,
162            0.0,
163            1.0,
164            extend,
165            extend,
166        )
167    }
168
169    #[test]
170    fn midpoint_is_grey() {
171        let p = make_axial(true);
172        let mut out = [0u8; 3];
173        p.fill_span(0, 4, 4, &mut out);
174        assert!(out[0] >= 126 && out[0] <= 129, "mid R={}", out[0]);
175    }
176
177    #[test]
178    fn start_is_black() {
179        let p = make_axial(true);
180        let mut out = [0u8; 3];
181        p.fill_span(0, 0, 0, &mut out);
182        assert_eq!(out[0], 0, "start should be black");
183    }
184
185    #[test]
186    fn end_is_white() {
187        let p = make_axial(true);
188        let mut out = [0u8; 3];
189        p.fill_span(0, 8, 8, &mut out);
190        assert_eq!(out[0], 255, "end should be white");
191    }
192
193    #[test]
194    fn outside_no_extend_writes_zero() {
195        let p = make_axial(false);
196        let mut out = [0u8; 3];
197        p.fill_span(0, -1, -1, &mut out);
198        assert_eq!(
199            out,
200            [0, 0, 0],
201            "before start with no-extend should write zero"
202        );
203        p.fill_span(0, 9, 9, &mut out);
204        assert_eq!(out, [0, 0, 0], "after end with no-extend should write zero");
205    }
206
207    #[test]
208    fn outside_extend_clamps_to_endpoints() {
209        let p = make_axial(true);
210        let mut out = [0u8; 3];
211        p.fill_span(0, -5, -5, &mut out);
212        assert_eq!(out[0], 0, "before start with extend should clamp to color0");
213        p.fill_span(0, 100, 100, &mut out);
214        assert_eq!(out[0], 255, "after end with extend should clamp to color1");
215    }
216
217    #[test]
218    fn degenerate_axis_writes_zeros() {
219        let p = AxialPattern::new(
220            [255, 0, 0],
221            [0, 255, 0],
222            5.0,
223            5.0,
224            5.0,
225            5.0,
226            0.0,
227            1.0,
228            false,
229            false,
230        );
231        let mut out = [42u8; 3];
232        p.fill_span(5, 5, 5, &mut out);
233        assert_eq!(out, [0, 0, 0], "degenerate axis should write zeros");
234    }
235
236    #[test]
237    fn gradient_increases_left_to_right() {
238        let p = make_axial(true);
239        let mut out = vec![0u8; 5 * 3];
240        p.fill_span(0, 0, 4, &mut out);
241        assert!(out[0] < out[3], "gradient R should increase left-to-right");
242        assert!(out[3] < out[6], "gradient R should increase left-to-right");
243    }
244
245    #[test]
246    fn inverted_t_range_reverses_gradient() {
247        // t0=1 t1=0: color0 at right end, color1 at left end.
248        let p = AxialPattern::new(
249            [255, 0, 0],
250            [0, 0, 255],
251            0.0,
252            0.0,
253            4.0,
254            0.0,
255            1.0,
256            0.0, // inverted
257            true,
258            true,
259        );
260        let mut start = [0u8; 3];
261        let mut end = [0u8; 3];
262        p.fill_span(0, 0, 0, &mut start);
263        p.fill_span(0, 4, 4, &mut end);
264        // t=1.0 at x=0 → color0=red; t=0.0 at x=4 → color1=blue.
265        assert!(start[0] > 200, "x=0 should be near red (color0)");
266        assert!(end[2] > 200, "x=4 should be near blue (color1)");
267    }
268}