Skip to main content

vyre_libs/visual/gradient/
mod.rs

1//! CSS-compatible linear gradient rasterization.
2//!
3//! Rasterizes a linear gradient with up to 16 color stops.
4//! Category A composition  -  pure IR expressions.
5
6use vyre_foundation::ir::model::expr::GeneratorRef;
7use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
8
9const OP_ID: &str = "vyre-libs::visual::gradient";
10
11/// A color stop with position (0.0..=1.0) and packed RGBA color.
12#[derive(Clone, Copy, Debug)]
13pub struct ColorStop {
14    /// Normalized position along the gradient axis (0.0 = start, 1.0 = end).
15    pub position: f32,
16    /// Packed RGBA color.
17    pub color: u32,
18}
19
20/// Build a Program that rasterizes a linear gradient into `output`.
21///
22/// - `output`: `[u32; width * height]`  -  rasterized gradient (packed RGBA)
23/// - `angle_deg`: CSS angle (0 = bottom-to-top, 90 = left-to-right)
24/// - `stops`: color stops (must be sorted by position, 2..=16)
25#[must_use]
26pub fn linear_gradient(
27    output: &str,
28    width: u32,
29    height: u32,
30    angle_deg: f32,
31    stops: &[ColorStop],
32) -> Program {
33    try_linear_gradient(output, width, height, angle_deg, stops).unwrap_or_else(|error| {
34        crate::builder::invalid_builder_trap_program(
35            OP_ID,
36            output,
37            DataType::U32,
38            format!("Fix: {error}"),
39        )
40    })
41}
42
43/// Fallible linear-gradient builder.
44///
45/// # Errors
46///
47/// Returns an error when the stop count is outside the supported
48/// 2..=16 interval.
49pub fn try_linear_gradient(
50    output: &str,
51    width: u32,
52    height: u32,
53    angle_deg: f32,
54    stops: &[ColorStop],
55) -> Result<Program, String> {
56    let count = width.saturating_mul(height);
57
58    if !(2..=16).contains(&stops.len()) {
59        return Err(format!(
60            "linear_gradient needs 2..=16 stops, got {}. Fix: provide at least two color stops and at most sixteen.",
61            stops.len()
62        ));
63    }
64
65    // CSS linear-gradient angle convention: 0deg points upward and 90deg
66    // points right. The parameter is the pixel projection shifted by the
67    // minimum projection of the image corners, then divided by the corner
68    // projection range. That keeps negative directions, such as 0deg and
69    // 270deg, exact instead of clamping half the image to the first stop.
70
71    let angle_rad = angle_deg.to_radians();
72    let dx = angle_rad.sin();
73    let dy = -angle_rad.cos();
74
75    // Direction vector scaled to fixed-point.
76    let dx_fp = (dx * 65536.0).round() as i32;
77    let dy_fp = (dy * 65536.0).round() as i32;
78
79    let width_extent = width.saturating_sub(1) as i64;
80    let height_extent = height.saturating_sub(1) as i64;
81    let corner_projections = [
82        0i64,
83        width_extent * i64::from(dx_fp),
84        height_extent * i64::from(dy_fp),
85        width_extent * i64::from(dx_fp) + height_extent * i64::from(dy_fp),
86    ];
87    let min_projection = corner_projections.into_iter().min().unwrap_or(0);
88    let max_projection = corner_projections.into_iter().max().unwrap_or(0);
89    let projection_offset = min_projection
90        .saturating_neg()
91        .clamp(0, i64::from(u32::MAX)) as u32;
92    let projection_range = (max_projection - min_projection).max(1);
93    let projection_range_pixels =
94        ((projection_range + 65_535) / 65_536).clamp(1, i64::from(u32::MAX)) as u32;
95
96    // Precompute stop positions in fixed-point and colors per channel.
97    let stop_positions: Vec<u32> = stops
98        .iter()
99        .map(|s| (s.position.clamp(0.0, 1.0) * 65536.0).round() as u32)
100        .collect();
101
102    let stop_r: Vec<u32> = stops.iter().map(|s| s.color & 0xFF).collect();
103    let stop_g: Vec<u32> = stops.iter().map(|s| (s.color >> 8) & 0xFF).collect();
104    let stop_b: Vec<u32> = stops.iter().map(|s| (s.color >> 16) & 0xFF).collect();
105    let stop_a: Vec<u32> = stops.iter().map(|s| s.color >> 24).collect();
106
107    // Build the body. For each pixel:
108    // 1. Compute t (parametric position along gradient)
109    // 2. Find enclosing stop pair
110    // 3. Lerp between stops
111
112    let mut body = vec![Node::let_bind("idx", Expr::gid_x())];
113
114    body.push(Node::if_then(
115        Expr::lt(Expr::var("idx"), Expr::u32(count)),
116        {
117            let mut inner = vec![
118                Node::let_bind("px", Expr::rem(Expr::var("idx"), Expr::u32(width.max(1)))),
119                Node::let_bind("py", Expr::div(Expr::var("idx"), Expr::u32(width.max(1)))),
120            ];
121
122            // Compute dot product: dp = px * dx + py * dy
123            // Handle signed direction with select.
124            let dp_x = if dx_fp >= 0 {
125                Expr::mul(Expr::var("px"), Expr::u32(dx_fp as u32))
126            } else {
127                // Negative: dp_x = -(px * |dx|)
128                // We'll handle sign at the end.
129                Expr::mul(Expr::var("px"), Expr::u32((-dx_fp) as u32))
130            };
131            let dp_y = if dy_fp >= 0 {
132                Expr::mul(Expr::var("py"), Expr::u32(dy_fp as u32))
133            } else {
134                Expr::mul(Expr::var("py"), Expr::u32((-dy_fp) as u32))
135            };
136
137            // Signed projection is represented as positive and negative
138            // unsigned parts, then shifted by `-min_corner_projection`.
139            let pos_part = Expr::add(
140                if dx_fp >= 0 {
141                    dp_x.clone()
142                } else {
143                    Expr::u32(0)
144                },
145                if dy_fp >= 0 {
146                    dp_y.clone()
147                } else {
148                    Expr::u32(0)
149                },
150            );
151            let neg_part = Expr::add(
152                if dx_fp < 0 { dp_x } else { Expr::u32(0) },
153                if dy_fp < 0 { dp_y } else { Expr::u32(0) },
154            );
155
156            inner.push(Node::let_bind("pos_dp", pos_part));
157            inner.push(Node::let_bind("neg_dp", neg_part));
158
159            // t = (dot(pixel, direction) - min_corner_projection) / range.
160            // `raw_dp` is still fixed-point 16.16, so division by the
161            // pixel-space range preserves a 16.16 normalized parameter while
162            // avoiding a wide multiply on backends without native u64.
163            let shifted_pos = Expr::add(Expr::var("pos_dp"), Expr::u32(projection_offset));
164            inner.push(Node::let_bind(
165                "raw_dp",
166                Expr::select(
167                    Expr::ge(shifted_pos.clone(), Expr::var("neg_dp")),
168                    Expr::sub(shifted_pos, Expr::var("neg_dp")),
169                    Expr::u32(0),
170                ),
171            ));
172            inner.push(Node::let_bind(
173                "t",
174                Expr::select(
175                    Expr::gt(
176                        Expr::div(Expr::var("raw_dp"), Expr::u32(projection_range_pixels)),
177                        Expr::u32(65536),
178                    ),
179                    Expr::u32(65536),
180                    Expr::div(Expr::var("raw_dp"), Expr::u32(projection_range_pixels)),
181                ),
182            ));
183
184            // Find enclosing stop pair and lerp.
185            // For simplicity with IR, we do a flat scan: pick the last stop
186            // whose position <= t, then lerp between it and the next.
187            inner.push(Node::let_bind("out_r", Expr::u32(stop_r[0])));
188            inner.push(Node::let_bind("out_g", Expr::u32(stop_g[0])));
189            inner.push(Node::let_bind("out_b", Expr::u32(stop_b[0])));
190            inner.push(Node::let_bind("out_a", Expr::u32(stop_a[0])));
191
192            for i in 0..stops.len() - 1 {
193                let t0 = stop_positions[i];
194                let t1 = stop_positions[i + 1];
195                let span = if t1 > t0 { t1 - t0 } else { 1 }; // avoid div by 0
196
197                // If t >= t0 AND t < t1: lerp between stop[i] and stop[i+1]
198                // Channel delta is rounded in fixed-point stop space.
199                let lerp_ch = |ch: &str, c0: u32, c1: u32| -> Node {
200                    let stop_delta = Expr::sub(Expr::var("t"), Expr::u32(t0));
201                    let rounded_delta = |delta: u32| {
202                        Expr::div(
203                            Expr::add(
204                                Expr::mul(Expr::u32(delta), stop_delta.clone()),
205                                Expr::u32(span / 2),
206                            ),
207                            Expr::u32(span),
208                        )
209                    };
210                    Node::assign(
211                        ch,
212                        Expr::select(
213                            Expr::and(
214                                Expr::ge(Expr::var("t"), Expr::u32(t0)),
215                                Expr::lt(Expr::var("t"), Expr::u32(t1)),
216                            ),
217                            // lerp: c0 + round((c1 - c0) * (t - t0) / span)
218                            if c1 >= c0 {
219                                Expr::add(Expr::u32(c0), rounded_delta(c1 - c0))
220                            } else {
221                                Expr::sub(Expr::u32(c0), rounded_delta(c0 - c1))
222                            },
223                            Expr::var(ch),
224                        ),
225                    )
226                };
227
228                inner.push(lerp_ch("out_r", stop_r[i], stop_r[i + 1]));
229                inner.push(lerp_ch("out_g", stop_g[i], stop_g[i + 1]));
230                inner.push(lerp_ch("out_b", stop_b[i], stop_b[i + 1]));
231                inner.push(lerp_ch("out_a", stop_a[i], stop_a[i + 1]));
232            }
233
234            // If t >= last stop position, use last stop color.
235            let last = stops.len() - 1;
236            inner.push(Node::assign(
237                "out_r",
238                Expr::select(
239                    Expr::ge(Expr::var("t"), Expr::u32(stop_positions[last])),
240                    Expr::u32(stop_r[last]),
241                    Expr::var("out_r"),
242                ),
243            ));
244            inner.push(Node::assign(
245                "out_g",
246                Expr::select(
247                    Expr::ge(Expr::var("t"), Expr::u32(stop_positions[last])),
248                    Expr::u32(stop_g[last]),
249                    Expr::var("out_g"),
250                ),
251            ));
252            inner.push(Node::assign(
253                "out_b",
254                Expr::select(
255                    Expr::ge(Expr::var("t"), Expr::u32(stop_positions[last])),
256                    Expr::u32(stop_b[last]),
257                    Expr::var("out_b"),
258                ),
259            ));
260            inner.push(Node::assign(
261                "out_a",
262                Expr::select(
263                    Expr::ge(Expr::var("t"), Expr::u32(stop_positions[last])),
264                    Expr::u32(stop_a[last]),
265                    Expr::var("out_a"),
266                ),
267            ));
268
269            // Pack output.
270            inner.push(Node::let_bind(
271                "packed",
272                Expr::bitor(
273                    Expr::bitor(
274                        Expr::var("out_r"),
275                        Expr::shl(Expr::var("out_g"), Expr::u32(8)),
276                    ),
277                    Expr::bitor(
278                        Expr::shl(Expr::var("out_b"), Expr::u32(16)),
279                        Expr::shl(Expr::var("out_a"), Expr::u32(24)),
280                    ),
281                ),
282            ));
283            inner.push(Node::let_bind(
284                "oidx",
285                Expr::add(
286                    Expr::mul(Expr::var("py"), Expr::u32(width)),
287                    Expr::var("px"),
288                ),
289            ));
290            inner.push(Node::store(output, Expr::var("oidx"), Expr::var("packed")));
291            inner
292        },
293    ));
294
295    Ok(Program::wrapped(
296        vec![
297            BufferDecl::storage(output, 0, BufferAccess::ReadWrite, DataType::U32)
298                .with_count(count),
299        ],
300        super::PIXEL_WORKGROUP_SIZE,
301        vec![crate::region::wrap_anonymous(
302            OP_ID,
303            vec![crate::region::wrap_child(
304                vyre_primitives::visual::packed_rgba_map::OP_ID,
305                GeneratorRef {
306                    name: OP_ID.to_string(),
307                },
308                body,
309            )],
310        )],
311    ))
312}
313
314inventory::submit! {
315    vyre_foundation::operation::OperationRegistration {
316        semantic_version: 1,
317        signature: None,
318        tier: vyre_foundation::operation::OperationTier::Library,
319        laws: &[],
320        tolerance: vyre_foundation::operation::TolerancePolicy::EXACT,
321        id: OP_ID,
322        build: Some(|| linear_gradient(
323            "output", 4, 1, 90.0,
324            &[
325                ColorStop { position: 0.0, color: 0xFF_0000FF }, // red
326                ColorStop { position: 1.0, color: 0xFF_FF0000 }, // blue
327            ],
328        )),
329        test_inputs: Some(|| {
330            vec![vec![vec![0u8; 16]]]  // initial 4×1 output buffer
331        }),
332        expected_output: Some(|| {
333            // 4-pixel horizontal gradient: red → blue.
334            // Pixel 0: pure red, Pixel 3: pure blue.
335            // Exact values depend on interpolation rounding.
336            let expected = [0xFF_0000FFu32, 0xFF_5500AAu32, 0xFF_AA0055u32, 0xFF_FF0000u32];
337            vec![vec![crate::visual::byte_helpers::u32_words_to_le_bytes(&expected)]]
338        }),
339        category: Some("visual"),
340    }
341}