Skip to main content

astrelis_geometry/
paint.rs

1//! Paint types for filling and stroking.
2//!
3//! Paints define how geometry is colored - solid colors or gradients.
4
5use astrelis_render::Color;
6use glam::Vec2;
7
8/// A paint defines how to color geometry.
9#[derive(Debug, Clone, PartialEq)]
10pub enum Paint {
11    /// Solid color.
12    Solid(Color),
13    /// Linear gradient.
14    LinearGradient(LinearGradient),
15    /// Radial gradient.
16    RadialGradient(RadialGradient),
17}
18
19impl Paint {
20    /// Create a solid color paint.
21    pub fn solid(color: Color) -> Self {
22        Self::Solid(color)
23    }
24
25    /// Create a linear gradient paint.
26    pub fn linear_gradient(start: Vec2, end: Vec2, stops: Vec<GradientStop>) -> Self {
27        Self::LinearGradient(LinearGradient { start, end, stops })
28    }
29
30    /// Create a radial gradient paint.
31    pub fn radial_gradient(center: Vec2, radius: f32, stops: Vec<GradientStop>) -> Self {
32        Self::RadialGradient(RadialGradient {
33            center,
34            radius,
35            stops,
36        })
37    }
38
39    /// Check if this is a solid color.
40    pub fn is_solid(&self) -> bool {
41        matches!(self, Self::Solid(_))
42    }
43
44    /// Get the solid color if this is a solid paint.
45    pub fn as_solid(&self) -> Option<Color> {
46        match self {
47            Self::Solid(color) => Some(*color),
48            _ => None,
49        }
50    }
51}
52
53impl Default for Paint {
54    fn default() -> Self {
55        Self::Solid(Color::BLACK)
56    }
57}
58
59impl From<Color> for Paint {
60    fn from(color: Color) -> Self {
61        Self::Solid(color)
62    }
63}
64
65/// A linear gradient.
66#[derive(Debug, Clone, PartialEq)]
67pub struct LinearGradient {
68    /// Start point
69    pub start: Vec2,
70    /// End point
71    pub end: Vec2,
72    /// Color stops
73    pub stops: Vec<GradientStop>,
74}
75
76impl LinearGradient {
77    /// Create a new linear gradient.
78    pub fn new(start: Vec2, end: Vec2, stops: Vec<GradientStop>) -> Self {
79        Self { start, end, stops }
80    }
81
82    /// Create a horizontal gradient.
83    pub fn horizontal(width: f32, stops: Vec<GradientStop>) -> Self {
84        Self {
85            start: Vec2::ZERO,
86            end: Vec2::new(width, 0.0),
87            stops,
88        }
89    }
90
91    /// Create a vertical gradient.
92    pub fn vertical(height: f32, stops: Vec<GradientStop>) -> Self {
93        Self {
94            start: Vec2::ZERO,
95            end: Vec2::new(0.0, height),
96            stops,
97        }
98    }
99
100    /// Get the direction vector (normalized).
101    pub fn direction(&self) -> Vec2 {
102        (self.end - self.start).normalize_or_zero()
103    }
104
105    /// Interpolate color at a position.
106    pub fn sample(&self, position: Vec2) -> Color {
107        if self.stops.is_empty() {
108            return Color::TRANSPARENT;
109        }
110        if self.stops.len() == 1 {
111            return self.stops[0].color;
112        }
113
114        let dir = self.end - self.start;
115        let len_sq = dir.length_squared();
116        if len_sq < f32::EPSILON {
117            return self.stops[0].color;
118        }
119
120        // Project position onto gradient line
121        let t = ((position - self.start).dot(dir) / len_sq).clamp(0.0, 1.0);
122
123        interpolate_gradient(&self.stops, t)
124    }
125}
126
127/// A radial gradient.
128#[derive(Debug, Clone, PartialEq)]
129pub struct RadialGradient {
130    /// Center point
131    pub center: Vec2,
132    /// Radius
133    pub radius: f32,
134    /// Color stops
135    pub stops: Vec<GradientStop>,
136}
137
138impl RadialGradient {
139    /// Create a new radial gradient.
140    pub fn new(center: Vec2, radius: f32, stops: Vec<GradientStop>) -> Self {
141        Self {
142            center,
143            radius,
144            stops,
145        }
146    }
147
148    /// Interpolate color at a position.
149    pub fn sample(&self, position: Vec2) -> Color {
150        if self.stops.is_empty() {
151            return Color::TRANSPARENT;
152        }
153        if self.stops.len() == 1 {
154            return self.stops[0].color;
155        }
156
157        let dist = (position - self.center).length();
158        let t = (dist / self.radius).clamp(0.0, 1.0);
159
160        interpolate_gradient(&self.stops, t)
161    }
162}
163
164/// A color stop in a gradient.
165#[derive(Debug, Clone, Copy, PartialEq)]
166pub struct GradientStop {
167    /// Position along the gradient (0.0 to 1.0)
168    pub offset: f32,
169    /// Color at this stop
170    pub color: Color,
171}
172
173impl GradientStop {
174    /// Create a new gradient stop.
175    pub fn new(offset: f32, color: Color) -> Self {
176        Self {
177            offset: offset.clamp(0.0, 1.0),
178            color,
179        }
180    }
181}
182
183/// Interpolate a gradient at a given t value.
184fn interpolate_gradient(stops: &[GradientStop], t: f32) -> Color {
185    if stops.is_empty() {
186        return Color::TRANSPARENT;
187    }
188    if stops.len() == 1 {
189        return stops[0].color;
190    }
191
192    // Find the two stops to interpolate between
193    let mut prev = &stops[0];
194    for stop in &stops[1..] {
195        if t <= stop.offset {
196            // Interpolate between prev and stop
197            let range = stop.offset - prev.offset;
198            if range < f32::EPSILON {
199                return stop.color;
200            }
201            let local_t = (t - prev.offset) / range;
202            return lerp_color(prev.color, stop.color, local_t);
203        }
204        prev = stop;
205    }
206
207    // Past the last stop
208    stops.last().map(|s| s.color).unwrap_or(Color::TRANSPARENT)
209}
210
211/// Linearly interpolate between two colors.
212fn lerp_color(a: Color, b: Color, t: f32) -> Color {
213    Color::rgba(
214        a.r + (b.r - a.r) * t,
215        a.g + (b.g - a.g) * t,
216        a.b + (b.b - a.b) * t,
217        a.a + (b.a - a.a) * t,
218    )
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_solid_paint() {
227        let paint = Paint::solid(Color::RED);
228        assert!(paint.is_solid());
229        assert_eq!(paint.as_solid(), Some(Color::RED));
230    }
231
232    #[test]
233    fn test_linear_gradient_sample() {
234        let gradient = LinearGradient::horizontal(
235            100.0,
236            vec![
237                GradientStop::new(0.0, Color::RED),
238                GradientStop::new(1.0, Color::BLUE),
239            ],
240        );
241
242        let at_start = gradient.sample(Vec2::new(0.0, 0.0));
243        let at_end = gradient.sample(Vec2::new(100.0, 0.0));
244        let at_mid = gradient.sample(Vec2::new(50.0, 0.0));
245
246        assert!((at_start.r - 1.0).abs() < 0.01);
247        assert!((at_end.b - 1.0).abs() < 0.01);
248        // At midpoint, should be purple-ish
249        assert!((at_mid.r - 0.5).abs() < 0.01);
250        assert!((at_mid.b - 0.5).abs() < 0.01);
251    }
252
253    #[test]
254    fn test_gradient_stop() {
255        let stop = GradientStop::new(0.5, Color::GREEN);
256        assert_eq!(stop.offset, 0.5);
257        assert_eq!(stop.color, Color::GREEN);
258    }
259}