Skip to main content

cranpose_render_common/
brush_sampling.rs

1use cranpose_ui_graphics::{Brush, Color, Rect, TileMode};
2
3const TRANSPARENT: Color = Color(0.0, 0.0, 0.0, 0.0);
4
5#[doc(hidden)]
6pub fn color_to_rgba(color: Color) -> [f32; 4] {
7    [
8        color.0.clamp(0.0, 1.0),
9        color.1.clamp(0.0, 1.0),
10        color.2.clamp(0.0, 1.0),
11        color.3.clamp(0.0, 1.0),
12    ]
13}
14
15#[doc(hidden)]
16pub fn sample_brush_rgba(brush: &Brush, rect: Rect, x: f32, y: f32) -> [f32; 4] {
17    match brush {
18        Brush::Solid(color) => color_to_rgba(*color),
19        Brush::LinearGradient {
20            colors,
21            stops,
22            start,
23            end,
24            tile_mode,
25        } => {
26            let sx = resolve_gradient_point(rect.x, rect.width, start.x);
27            let sy = resolve_gradient_point(rect.y, rect.height, start.y);
28            let ex = resolve_gradient_point(rect.x, rect.width, end.x);
29            let ey = resolve_gradient_point(rect.y, rect.height, end.y);
30            let dx = ex - sx;
31            let dy = ey - sy;
32            let denom = (dx * dx + dy * dy).max(f32::EPSILON);
33            let t = ((x - sx) * dx + (y - sy) * dy) / denom;
34            match normalize_gradient_t(t, *tile_mode) {
35                Some(sample_t) => {
36                    color_to_rgba(interpolate_colors(colors, stops.as_deref(), sample_t))
37                }
38                None => color_to_rgba(TRANSPARENT),
39            }
40        }
41        Brush::RadialGradient {
42            colors,
43            stops,
44            center,
45            radius,
46            tile_mode,
47        } => {
48            let cx = rect.x + center.x;
49            let cy = rect.y + center.y;
50            let radius = (*radius).max(f32::EPSILON);
51            let dx = x - cx;
52            let dy = y - cy;
53            let distance = (dx * dx + dy * dy).sqrt();
54            let t = distance / radius;
55            match normalize_gradient_t(t, *tile_mode) {
56                Some(sample_t) => {
57                    color_to_rgba(interpolate_colors(colors, stops.as_deref(), sample_t))
58                }
59                None => color_to_rgba(TRANSPARENT),
60            }
61        }
62        Brush::SweepGradient {
63            colors,
64            stops,
65            center,
66        } => {
67            let cx = rect.x + center.x;
68            let cy = rect.y + center.y;
69            let dx = x - cx;
70            let dy = y - cy;
71            let angle = dy.atan2(dx);
72            let t = (angle / std::f32::consts::TAU + 0.5).clamp(0.0, 1.0);
73            color_to_rgba(interpolate_colors(colors, stops.as_deref(), t))
74        }
75    }
76}
77
78fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
79    if value.is_finite() {
80        origin + value
81    } else if value.is_sign_positive() {
82        origin + extent
83    } else {
84        origin
85    }
86}
87
88#[doc(hidden)]
89pub fn normalize_gradient_t(t: f32, tile_mode: TileMode) -> Option<f32> {
90    match tile_mode {
91        TileMode::Clamp => Some(t.clamp(0.0, 1.0)),
92        TileMode::Decal => {
93            if (0.0..=1.0).contains(&t) {
94                Some(t)
95            } else {
96                None
97            }
98        }
99        TileMode::Repeated => Some(t.rem_euclid(1.0)),
100        TileMode::Mirror => {
101            let wrapped = t.rem_euclid(2.0);
102            if wrapped <= 1.0 {
103                Some(wrapped)
104            } else {
105                Some(2.0 - wrapped)
106            }
107        }
108    }
109}
110
111fn interpolate_colors(colors: &[Color], stops: Option<&[f32]>, t: f32) -> Color {
112    if colors.is_empty() {
113        return TRANSPARENT;
114    }
115    if colors.len() == 1 {
116        return colors[0];
117    }
118    let clamped = t.clamp(0.0, 1.0);
119
120    if let Some(stops) = stops {
121        if stops.len() == colors.len() {
122            if clamped <= stops[0] {
123                return colors[0];
124            }
125            for index in 0..(stops.len() - 1) {
126                let start = stops[index];
127                let end = stops[index + 1];
128                if clamped <= end {
129                    let span = (end - start).max(f32::EPSILON);
130                    let frac = ((clamped - start) / span).clamp(0.0, 1.0);
131                    return lerp_color(colors[index], colors[index + 1], frac);
132                }
133            }
134            return last_color(colors);
135        }
136    }
137
138    let segments = (colors.len() - 1) as f32;
139    let scaled = clamped * segments;
140    let index = scaled.floor() as usize;
141    if index >= colors.len() - 1 {
142        return last_color(colors);
143    }
144    let frac = scaled - index as f32;
145    lerp_color(colors[index], colors[index + 1], frac)
146}
147
148fn last_color(colors: &[Color]) -> Color {
149    colors.last().copied().unwrap_or(TRANSPARENT)
150}
151
152fn lerp_color(a: Color, b: Color, t: f32) -> Color {
153    let lerp = |start: f32, end: f32| start + (end - start) * t;
154    Color(
155        lerp(a.0, b.0),
156        lerp(a.1, b.1),
157        lerp(a.2, b.2),
158        lerp(a.3, b.3),
159    )
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use cranpose_ui_graphics::Point;
166
167    fn sample_rect() -> Rect {
168        Rect {
169            x: 0.0,
170            y: 0.0,
171            width: 100.0,
172            height: 40.0,
173        }
174    }
175
176    #[test]
177    fn empty_gradient_samples_transparent_instead_of_panicking() {
178        let brush =
179            Brush::linear_gradient_range(Vec::new(), Point::new(0.0, 0.0), Point::new(100.0, 0.0));
180        assert_eq!(
181            sample_brush_rgba(&brush, sample_rect(), 50.0, 10.0),
182            [0.0, 0.0, 0.0, 0.0]
183        );
184    }
185
186    #[test]
187    fn clamped_gradient_samples_last_color_at_end() {
188        let brush = Brush::linear_gradient_range(
189            vec![Color::RED, Color::BLUE],
190            Point::new(0.0, 0.0),
191            Point::new(100.0, 0.0),
192        );
193        assert_eq!(
194            sample_brush_rgba(&brush, sample_rect(), 120.0, 10.0),
195            color_to_rgba(Color::BLUE)
196        );
197    }
198
199    #[test]
200    fn mirror_tile_mode_normalizes_across_repeated_segments() {
201        assert_eq!(normalize_gradient_t(1.25, TileMode::Mirror), Some(0.75));
202        assert_eq!(normalize_gradient_t(1.75, TileMode::Mirror), Some(0.25));
203    }
204}