Skip to main content

alpha2_model/
generator.rs

1//! Sources the engine draws itself: fills, gradients, shapes, and text.
2
3use crate::{MAX_TEXT_CONTENT_BYTES, MAX_TEXT_GENERATOR_FONT_SIZE, TextStyle, Vec2};
4use serde::{Deserialize, Serialize};
5
6/// Most stops in one gradient.
7pub const MAX_GRADIENT_STOPS: usize = 8;
8
9/// A straight (not premultiplied), sRGB-encoded color with alpha, each
10/// component in `0..=1`.
11#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(deny_unknown_fields)]
14pub struct Rgba {
15    /// Red component.
16    pub red: f32,
17    /// Green component.
18    pub green: f32,
19    /// Blue component.
20    pub blue: f32,
21    /// Coverage.
22    pub alpha: f32,
23}
24
25impl Rgba {
26    /// Creates a color from sRGB-encoded components and alpha.
27    #[must_use]
28    pub const fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
29        Self {
30            red,
31            green,
32            blue,
33            alpha,
34        }
35    }
36
37    pub(crate) fn is_valid(self) -> bool {
38        [self.red, self.green, self.blue, self.alpha]
39            .iter()
40            .all(|value| (0.0..=1.0).contains(value))
41    }
42}
43
44/// The picture of a generated source.
45///
46/// A generated picture has the canvas's size and aspect, like a source that
47/// fills the frame at scale 1. Positions are in its normalized space, `(0, 0)`
48/// top-left to `(1, 1)` bottom-right; lengths (radii, stroke widths) are
49/// fractions of its shorter side, so the picture is the same at any render
50/// resolution. It does not change over time: a clip animates it with its
51/// transform, effects, and masks.
52#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
55pub enum Generator {
56    /// Paint over the whole picture.
57    Fill {
58        /// What covers the picture.
59        paint: Paint,
60    },
61    /// One shape on a transparent picture, filled, stroked, or both.
62    Shape {
63        /// The shape's outline.
64        shape: GeneratedShape,
65        /// Centre of the shape.
66        center: Vec2,
67        /// Full width and height of the shape, before rotation. Non-negative.
68        size: Vec2,
69        /// Clockwise rotation about the centre, in degrees.
70        #[serde(default)]
71        rotation_degrees: f32,
72        /// Paint inside the outline.
73        #[serde(default)]
74        fill: Option<Paint>,
75        /// Paint along the outline, drawn over the fill.
76        #[serde(default)]
77        stroke: Option<Stroke>,
78    },
79    /// Text on a transparent picture, laid out and placed by its style as a
80    /// caption track's cues are: its size and wrapping width fractions of
81    /// the canvas, its anchor at `style.position`. The engine draws it with
82    /// the fonts its host registered.
83    Text {
84        /// The text; `\n` starts a line. Non-empty, at most
85        /// [`MAX_TEXT_CONTENT_BYTES`] bytes.
86        text: String,
87        /// How it looks and where it lies. The font size is at most
88        /// [`MAX_TEXT_GENERATOR_FONT_SIZE`].
89        #[serde(default)]
90        style: TextStyle,
91    },
92}
93
94/// The outline of a [`Generator::Shape`].
95#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97#[serde(tag = "type", rename_all = "snake_case", from = "GeneratedShapeWire")]
98pub enum GeneratedShape {
99    /// A rectangle, optionally with rounded corners.
100    Rectangle {
101        /// Corner radius, as a fraction of the picture's shorter side.
102        /// Non-negative; a radius past half the rectangle is capped.
103        corner_radius: f32,
104    },
105    /// The ellipse inscribed in the size.
106    Ellipse,
107}
108
109/// The wire form of [`GeneratedShape`]: an empty struct variant rejects
110/// extra fields where serde's unit variant would ignore them.
111#[derive(Deserialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[cfg_attr(feature = "schema", schemars(rename = "GeneratedShape"))]
114#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
115pub(crate) enum GeneratedShapeWire {
116    /// A rectangle, optionally with rounded corners.
117    Rectangle {
118        /// Corner radius, as a fraction of the picture's shorter side.
119        /// Non-negative; a radius past half the rectangle is capped.
120        #[serde(default)]
121        corner_radius: f32,
122    },
123    /// The ellipse inscribed in the size.
124    Ellipse {},
125}
126
127impl From<GeneratedShapeWire> for GeneratedShape {
128    fn from(wire: GeneratedShapeWire) -> Self {
129        match wire {
130            GeneratedShapeWire::Rectangle { corner_radius } => Self::Rectangle { corner_radius },
131            GeneratedShapeWire::Ellipse {} => Self::Ellipse,
132        }
133    }
134}
135
136/// A shape's outline paint. The stroke is centred on the outline.
137#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139#[serde(deny_unknown_fields)]
140pub struct Stroke {
141    /// Full width, as a fraction of the picture's shorter side. Positive.
142    pub width: f32,
143    /// What covers the stroke.
144    pub paint: Paint,
145}
146
147/// A color or gradient.
148///
149/// Gradients interpolate between stops premultiplied, in linear light: the
150/// engine's working space, so a gradient between two colors passes through
151/// the physical mix of them, and a stop fading to transparent does not darken
152/// its neighbour.
153#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
156pub enum Paint {
157    /// One color.
158    Solid {
159        /// The color.
160        color: Rgba,
161    },
162    /// Colors along the line from `start` to `end`, constant across it.
163    LinearGradient {
164        /// Where offset 0 lies.
165        start: Vec2,
166        /// Where offset 1 lies. Distinct from `start`.
167        end: Vec2,
168        /// Two to [`MAX_GRADIENT_STOPS`] stops.
169        stops: Vec<GradientStop>,
170        /// What lies beyond the first and last offsets.
171        #[serde(default)]
172        spread: Spread,
173    },
174    /// Colors along the radius of a circle.
175    RadialGradient {
176        /// Where offset 0 lies.
177        center: Vec2,
178        /// The distance of offset 1 from the centre, as a fraction of the
179        /// picture's shorter side. Positive.
180        radius: f32,
181        /// Two to [`MAX_GRADIENT_STOPS`] stops.
182        stops: Vec<GradientStop>,
183        /// What lies beyond the first and last offsets.
184        #[serde(default)]
185        spread: Spread,
186    },
187    /// Colors around a point: offsets 0 to 1 are one clockwise turn.
188    ConicGradient {
189        /// The point the gradient turns around.
190        center: Vec2,
191        /// Where offset 0 lies, in degrees clockwise from straight up.
192        #[serde(default)]
193        angle_degrees: f32,
194        /// Two to [`MAX_GRADIENT_STOPS`] stops.
195        stops: Vec<GradientStop>,
196    },
197}
198
199/// One color of a gradient.
200#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202#[serde(deny_unknown_fields)]
203pub struct GradientStop {
204    /// Position along the gradient, in `0..=1`, not before the previous stop.
205    pub offset: f32,
206    /// Color at that position.
207    pub color: Rgba,
208}
209
210/// What a linear or radial gradient shows outside its first and last stops.
211#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213#[serde(rename_all = "snake_case")]
214pub enum Spread {
215    /// The end colors continue.
216    #[default]
217    Pad,
218    /// The gradient starts again.
219    Repeat,
220    /// The gradient runs back and forth.
221    Reflect,
222}
223
224impl Generator {
225    pub(crate) fn is_valid(&self) -> bool {
226        match self {
227            Self::Fill { paint } => paint.is_valid(),
228            Self::Shape {
229                shape,
230                center,
231                size,
232                rotation_degrees,
233                fill,
234                stroke,
235            } => {
236                let shape_is_valid = match *shape {
237                    GeneratedShape::Rectangle { corner_radius } => {
238                        corner_radius.is_finite() && corner_radius >= 0.0
239                    }
240                    GeneratedShape::Ellipse => true,
241                };
242                shape_is_valid
243                    && finite(*center)
244                    && finite(*size)
245                    && size.x >= 0.0
246                    && size.y >= 0.0
247                    && rotation_degrees.is_finite()
248                    && (fill.is_some() || stroke.is_some())
249                    && fill.as_ref().is_none_or(Paint::is_valid)
250                    && stroke.as_ref().is_none_or(|stroke| {
251                        stroke.width.is_finite() && stroke.width > 0.0 && stroke.paint.is_valid()
252                    })
253            }
254            Self::Text { text, style } => {
255                !text.is_empty()
256                    && text.len() <= MAX_TEXT_CONTENT_BYTES
257                    && style.is_valid(MAX_TEXT_GENERATOR_FONT_SIZE)
258            }
259        }
260    }
261}
262
263impl Paint {
264    fn is_valid(&self) -> bool {
265        match self {
266            Self::Solid { color } => color.is_valid(),
267            Self::LinearGradient {
268                start, end, stops, ..
269            } => finite(*start) && finite(*end) && start != end && valid_stops(stops),
270            Self::RadialGradient {
271                center,
272                radius,
273                stops,
274                ..
275            } => finite(*center) && radius.is_finite() && *radius > 0.0 && valid_stops(stops),
276            Self::ConicGradient {
277                center,
278                angle_degrees,
279                stops,
280            } => finite(*center) && angle_degrees.is_finite() && valid_stops(stops),
281        }
282    }
283}
284
285fn finite(value: Vec2) -> bool {
286    value.x.is_finite() && value.y.is_finite()
287}
288
289fn valid_stops(stops: &[GradientStop]) -> bool {
290    (2..=MAX_GRADIENT_STOPS).contains(&stops.len())
291        && stops
292            .iter()
293            .all(|stop| (0.0..=1.0).contains(&stop.offset) && stop.color.is_valid())
294        && stops
295            .windows(2)
296            .all(|pair| pair[0].offset <= pair[1].offset)
297}