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-linear color
40/// interpolation. 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    let [r, g, b, a] = color.to_linear_rgba();
75    [r * a, g * a, b * a, a]
76}
77/// Pad-spread linear parameter in local logical coordinates. A zero-length
78/// axis deterministically chooses the last stop (`t = 1`).
79#[must_use]
80pub fn linear_gradient_t(start: Offset, end: Offset, point: Offset) -> f32 {
81    let dx = end.x - start.x;
82    let dy = end.y - start.y;
83    let length_squared = dx * dx + dy * dy;
84    if !length_squared.is_finite() || length_squared <= f32::EPSILON {
85        return 1.;
86    }
87    (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0., 1.)
88}
89/// Pad-spread radial parameter in local logical coordinates. A non-positive
90/// radius deterministically chooses the last stop (`t = 1`).
91#[must_use]
92pub fn radial_gradient_t(center: Offset, radius: f32, point: Offset) -> f32 {
93    if !radius.is_finite() || radius <= 0. {
94        return 1.;
95    }
96    (((point.x - center.x).hypot(point.y - center.y)) / radius).clamp(0., 1.)
97}
98impl GradientStops {
99    /// Stops are clamped to 0..=1 and stable-sorted. Empty becomes transparent;
100    /// one stop is duplicated so shader sampling is always defined.
101    #[must_use]
102    pub fn new(mut stops: Vec<GradientStop>) -> Self {
103        for s in &mut stops {
104            s.offset = if s.offset.is_finite() {
105                s.offset.clamp(0., 1.)
106            } else {
107                0.
108            };
109        }
110        stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
111        if stops.is_empty() {
112            stops.push(GradientStop {
113                offset: 0.,
114                color: Color::TRANSPARENT,
115            });
116        }
117        if stops.len() == 1 {
118            stops.push(GradientStop {
119                offset: 1.,
120                color: stops[0].color,
121            });
122        }
123        Self {
124            id: next_gradient_id(),
125            stops: stops.into(),
126        }
127    }
128    #[must_use]
129    pub fn as_slice(&self) -> &[GradientStop] {
130        &self.stops
131    }
132    /// Identity is computed when normalized stops are constructed, never while
133    /// rendering. Clones intentionally retain the same cached GPU resource.
134    #[must_use]
135    pub const fn id(&self) -> GradientId {
136        self.id
137    }
138}
139impl GradientId {
140    #[must_use]
141    pub const fn get(self) -> u64 {
142        self.0
143    }
144}
145#[derive(Clone, Debug, PartialEq)]
146pub struct LinearGradient {
147    pub start: Offset,
148    pub end: Offset,
149    pub stops: GradientStops,
150}
151#[derive(Clone, Debug, PartialEq)]
152pub struct RadialGradient {
153    pub center: Offset,
154    pub radius: f32,
155    pub stops: GradientStops,
156}
157#[derive(Clone, Debug, PartialEq)]
158pub struct SweepGradient {
159    pub center: Offset,
160    pub start_angle: f32,
161    pub stops: GradientStops,
162}
163#[derive(Clone, Debug, PartialEq)]
164pub enum Brush {
165    Solid(Color),
166    LinearGradient(LinearGradient),
167    RadialGradient(RadialGradient),
168    SweepGradient(SweepGradient),
169}
170
171/// A renderer-neutral shader value.
172///
173/// Incular's gradients are already immutable, cacheable shader descriptions;
174/// keeping this alias means widgets and text do not need a second shader
175/// hierarchy that would eventually drift from the renderer's brush model.
176pub type Shader = Brush;
177
178/// Image filtering policy matching Flutter's public quality vocabulary.
179///
180/// The current display-list backend exposes nearest-neighbour and linear
181/// sampling. `Low`, `Medium`, and `High` intentionally lower to linear until
182/// a backend grows a more expensive filter; callers still retain a stable,
183/// renderer-independent policy value.
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
185pub enum FilterQuality {
186    None,
187    #[default]
188    Low,
189    Medium,
190    High,
191}
192
193impl FilterQuality {
194    #[must_use]
195    pub const fn sampling(self) -> ImageSampling {
196        match self {
197            Self::None => ImageSampling::Nearest,
198            Self::Low | Self::Medium | Self::High => ImageSampling::Linear,
199        }
200    }
201}
202
203impl From<FilterQuality> for ImageSampling {
204    fn from(value: FilterQuality) -> Self {
205        value.sampling()
206    }
207}
208impl From<Color> for Brush {
209    fn from(color: Color) -> Self {
210        Self::Solid(color)
211    }
212}
213impl From<LinearGradient> for Brush {
214    fn from(gradient: LinearGradient) -> Self {
215        Self::LinearGradient(gradient)
216    }
217}
218impl From<RadialGradient> for Brush {
219    fn from(gradient: RadialGradient) -> Self {
220        Self::RadialGradient(gradient)
221    }
222}
223impl From<SweepGradient> for Brush {
224    fn from(gradient: SweepGradient) -> Self {
225        Self::SweepGradient(gradient)
226    }
227}
228#[must_use]
229pub fn sweep_gradient_t(center: Offset, start_angle: f32, point: Offset) -> f32 {
230    ((point.y - center.y).atan2(point.x - center.x) - start_angle).rem_euclid(std::f32::consts::TAU)
231        / std::f32::consts::TAU
232}