Skip to main content

incular_rendering/
gradients.rs

1use crate::display_list::ImageSampling;
2use incular_core::finite_or_zero;
3use incular_core::{Color, Offset};
4use std::sync::{
5    Arc,
6    atomic::{AtomicU64, Ordering},
7};
8
9static NEXT_GRADIENT_ID: AtomicU64 = AtomicU64::new(1);
10
11fn next_gradient_id() -> GradientId {
12    GradientId(
13        NEXT_GRADIENT_ID
14            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
15            .expect("gradient identity space exhausted"),
16    )
17}
18
19/// Stable identity for immutable normalized gradient stop data.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21pub struct GradientId(u64);
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct GradientStop {
25    pub offset: f32,
26    pub color: Color,
27}
28#[derive(Clone, Debug)]
29pub struct GradientStops {
30    id: GradientId,
31    stops: Arc<[GradientStop]>,
32}
33impl PartialEq for GradientStops {
34    fn eq(&self, other: &Self) -> bool {
35        self.stops == other.stops
36    }
37}
38
39/// Samples normalized stops using pad spread and premultiplied interpolation
40/// in the encoded-sRGB working space (see [`crate::working_rgba`]). At a duplicate offset the last duplicate wins, allowing a
41/// hard transition without division by zero.
42#[must_use]
43pub fn sample_gradient_stops(stops: &GradientStops, t: f32) -> [f32; 4] {
44    let stops = stops.as_slice();
45    let t = finite_or_zero(t);
46    if t <= stops[0].offset {
47        return premultiplied(stops[0].color);
48    }
49    let last = stops.last().expect("normalized stops are non-empty");
50    if t >= last.offset {
51        return premultiplied(last.color);
52    }
53    let mut left = 0;
54    for index in 1..stops.len() {
55        if stops[index].offset <= t {
56            left = index;
57        } else {
58            let a = stops[left];
59            let b = stops[index];
60            let amount = ((t - a.offset) / (b.offset - a.offset)).clamp(0., 1.);
61            let a = premultiplied(a.color);
62            let b = premultiplied(b.color);
63            return [
64                a[0] + (b[0] - a[0]) * amount,
65                a[1] + (b[1] - a[1]) * amount,
66                a[2] + (b[2] - a[2]) * amount,
67                a[3] + (b[3] - a[3]) * amount,
68            ];
69        }
70    }
71    premultiplied(last.color)
72}
73fn premultiplied(color: Color) -> [f32; 4] {
74    crate::premultiplied_working_rgba(color)
75}
76/// Pad-spread linear parameter in local logical coordinates. A zero-length
77/// axis deterministically chooses the last stop (`t = 1`).
78#[must_use]
79pub fn linear_gradient_t(start: Offset, end: Offset, point: Offset) -> f32 {
80    let dx = end.x - start.x;
81    let dy = end.y - start.y;
82    let length_squared = dx * dx + dy * dy;
83    if !length_squared.is_finite() || length_squared <= f32::EPSILON {
84        return 1.;
85    }
86    (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0., 1.)
87}
88/// Pad-spread radial parameter in local logical coordinates. A non-positive
89/// radius deterministically chooses the last stop (`t = 1`).
90#[must_use]
91pub fn radial_gradient_t(center: Offset, radius: f32, point: Offset) -> f32 {
92    if !radius.is_finite() || radius <= 0. {
93        return 1.;
94    }
95    (((point.x - center.x).hypot(point.y - center.y)) / radius).clamp(0., 1.)
96}
97impl GradientStops {
98    /// Stops are clamped to 0..=1 and stable-sorted. Empty becomes transparent;
99    /// one stop is duplicated so shader sampling is always defined.
100    #[must_use]
101    pub fn new(mut stops: Vec<GradientStop>) -> Self {
102        for s in &mut stops {
103            s.offset = if s.offset.is_finite() {
104                s.offset.clamp(0., 1.)
105            } else {
106                0.
107            };
108        }
109        stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
110        if stops.is_empty() {
111            stops.push(GradientStop {
112                offset: 0.,
113                color: Color::TRANSPARENT,
114            });
115        }
116        if stops.len() == 1 {
117            stops.push(GradientStop {
118                offset: 1.,
119                color: stops[0].color,
120            });
121        }
122        Self {
123            id: next_gradient_id(),
124            stops: stops.into(),
125        }
126    }
127    #[must_use]
128    pub fn as_slice(&self) -> &[GradientStop] {
129        &self.stops
130    }
131    /// Identity is computed when normalized stops are constructed, never while
132    /// rendering. Clones intentionally retain the same cached GPU resource.
133    #[must_use]
134    pub const fn id(&self) -> GradientId {
135        self.id
136    }
137}
138impl GradientId {
139    #[must_use]
140    pub const fn get(self) -> u64 {
141        self.0
142    }
143}
144/// Flutter `TileMode`: how a gradient paints outside its `0.0..=1.0`
145/// parameter range.
146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
147pub enum TileMode {
148    /// Extends the edge colors (Flutter's default).
149    #[default]
150    Clamp,
151    /// Repeats the gradient.
152    Repeated,
153    /// Repeats the gradient, reversing every other repetition.
154    Mirror,
155    /// Paints transparent black outside the gradient.
156    Decal,
157}
158
159impl TileMode {
160    /// The stop parameter sampled for a raw gradient parameter `t`, or
161    /// `None` where [`Self::Decal`] paints nothing.
162    #[must_use]
163    pub fn apply(self, t: f32) -> Option<f32> {
164        let t = finite_or_zero(t);
165        match self {
166            Self::Clamp => Some(t.clamp(0., 1.)),
167            Self::Repeated => Some(t - t.floor()),
168            Self::Mirror => {
169                let period = t * 0.5 - (t * 0.5).floor();
170                Some(1. - (period * 2. - 1.).abs())
171            }
172            Self::Decal => (0. ..=1.).contains(&t).then_some(t),
173        }
174    }
175
176    /// The code the GPU shaders read.
177    #[must_use]
178    pub const fn shader_code(self) -> f32 {
179        match self {
180            Self::Clamp => 0.,
181            Self::Repeated => 1.,
182            Self::Mirror => 2.,
183            Self::Decal => 3.,
184        }
185    }
186}
187
188#[derive(Clone, Debug, PartialEq)]
189pub struct LinearGradient {
190    pub start: Offset,
191    pub end: Offset,
192    pub stops: GradientStops,
193    /// Flutter `tileMode`, default [`TileMode::Clamp`].
194    pub tile_mode: TileMode,
195}
196#[derive(Clone, Debug, PartialEq)]
197pub struct RadialGradient {
198    pub center: Offset,
199    pub radius: f32,
200    pub stops: GradientStops,
201    /// Flutter `tileMode`, default [`TileMode::Clamp`].
202    pub tile_mode: TileMode,
203    /// Flutter `focal`: where the gradient's `0.0` circle is centered. With
204    /// `None` it is `center`, a plain radial gradient.
205    pub focal: Option<Offset>,
206    /// Flutter `focalRadius` (default `0.0`): the radius of the `0.0`
207    /// circle around [`Self::focal`].
208    pub focal_radius: f32,
209}
210#[derive(Clone, Debug, PartialEq)]
211pub struct SweepGradient {
212    pub center: Offset,
213    pub start_angle: f32,
214    pub stops: GradientStops,
215    /// Flutter `tileMode`, default [`TileMode::Clamp`].
216    pub tile_mode: TileMode,
217}
218
219impl LinearGradient {
220    /// A clamped linear gradient from `start` to `end`.
221    #[must_use]
222    pub fn new(start: Offset, end: Offset, stops: GradientStops) -> Self {
223        Self {
224            start,
225            end,
226            stops,
227            tile_mode: TileMode::Clamp,
228        }
229    }
230}
231
232impl RadialGradient {
233    /// A clamped radial gradient without a focal point.
234    #[must_use]
235    pub fn new(center: Offset, radius: f32, stops: GradientStops) -> Self {
236        Self {
237            center,
238            radius,
239            stops,
240            tile_mode: TileMode::Clamp,
241            focal: None,
242            focal_radius: 0.,
243        }
244    }
245
246    /// Whether this is Flutter's two-point conical form: a focal point other
247    /// than the center, or a focal radius.
248    #[must_use]
249    pub fn is_focal(&self) -> bool {
250        self.focal.is_some_and(|focal| focal != self.center) || self.focal_radius != 0.
251    }
252}
253
254impl SweepGradient {
255    /// A clamped sweep gradient starting at `start_angle`.
256    #[must_use]
257    pub fn new(center: Offset, start_angle: f32, stops: GradientStops) -> Self {
258        Self {
259            center,
260            start_angle,
261            stops,
262            tile_mode: TileMode::Clamp,
263        }
264    }
265}
266
267/// The raw parameter of Flutter's focal radial gradient (Skia's two-point
268/// conical gradient from the circle `(focal, focal_radius)` at `0.0` to
269/// `(center, radius)` at `1.0`): the largest `t` whose interpolated circle,
270/// with a non-negative radius, passes through `point`. `None` where no such
271/// circle exists, which paints nothing.
272#[must_use]
273pub fn two_point_conical_gradient_t(
274    focal: Offset,
275    focal_radius: f32,
276    center: Offset,
277    radius: f32,
278    point: Offset,
279) -> Option<f32> {
280    let cd = Offset::new(center.x - focal.x, center.y - focal.y);
281    let dr = radius - focal_radius;
282    let pd = Offset::new(point.x - focal.x, point.y - focal.y);
283    let a = cd.x * cd.x + cd.y * cd.y - dr * dr;
284    let b = pd.x * cd.x + pd.y * cd.y + focal_radius * dr;
285    let c = pd.x * pd.x + pd.y * pd.y - focal_radius * focal_radius;
286    let radius_at = |t: f32| focal_radius + t * dr;
287    if a.abs() <= f32::EPSILON {
288        // The circles touch: the quadratic degenerates to `-2bt + c = 0`.
289        if b == 0. {
290            return None;
291        }
292        let t = c / (2. * b);
293        return (radius_at(t) >= 0.).then_some(t);
294    }
295    let discriminant = b * b - a * c;
296    if discriminant < 0. {
297        return None;
298    }
299    let root = discriminant.sqrt();
300    let (high, low) = {
301        let first = (b + root) / a;
302        let second = (b - root) / a;
303        (first.max(second), first.min(second))
304    };
305    if radius_at(high) >= 0. {
306        Some(high)
307    } else if radius_at(low) >= 0. {
308        Some(low)
309    } else {
310        None
311    }
312}
313#[derive(Clone, Debug, PartialEq)]
314pub enum Brush {
315    Solid(Color),
316    LinearGradient(LinearGradient),
317    RadialGradient(RadialGradient),
318    SweepGradient(SweepGradient),
319}
320
321/// A renderer-neutral shader value.
322///
323/// Incular's gradients are already immutable, cacheable shader descriptions;
324/// keeping this alias means widgets and text do not need a second shader
325/// hierarchy that would eventually drift from the renderer's brush model.
326pub type Shader = Brush;
327
328/// Image filtering policy matching Flutter's public quality vocabulary.
329///
330/// The current display-list backend exposes nearest-neighbour and linear
331/// sampling. `Low`, `Medium`, and `High` intentionally lower to linear until
332/// a backend grows a more expensive filter; callers still retain a stable,
333/// renderer-independent policy value.
334#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
335pub enum FilterQuality {
336    None,
337    #[default]
338    Low,
339    Medium,
340    High,
341}
342
343impl FilterQuality {
344    #[must_use]
345    pub const fn sampling(self) -> ImageSampling {
346        match self {
347            Self::None => ImageSampling::Nearest,
348            Self::Low | Self::Medium | Self::High => ImageSampling::Linear,
349        }
350    }
351}
352
353impl From<FilterQuality> for ImageSampling {
354    fn from(value: FilterQuality) -> Self {
355        value.sampling()
356    }
357}
358impl From<Color> for Brush {
359    fn from(color: Color) -> Self {
360        Self::Solid(color)
361    }
362}
363impl From<LinearGradient> for Brush {
364    fn from(gradient: LinearGradient) -> Self {
365        Self::LinearGradient(gradient)
366    }
367}
368impl From<RadialGradient> for Brush {
369    fn from(gradient: RadialGradient) -> Self {
370        Self::RadialGradient(gradient)
371    }
372}
373impl From<SweepGradient> for Brush {
374    fn from(gradient: SweepGradient) -> Self {
375        Self::SweepGradient(gradient)
376    }
377}
378#[must_use]
379pub fn sweep_gradient_t(center: Offset, start_angle: f32, point: Offset) -> f32 {
380    ((point.y - center.y).atan2(point.x - center.x) - start_angle).rem_euclid(std::f32::consts::TAU)
381        / std::f32::consts::TAU
382}