Skip to main content

alpha2_model/
generator.rs

1//! Sources the engine draws itself: fills, gradients, shapes, and text.
2
3use crate::{
4    MAX_TEXT_CONTENT_BYTES, MAX_TEXT_GENERATOR_FONT_SIZE, ShapeIssue, ShapeItem, TextStyle, Vec2,
5};
6use serde::{Deserialize, Serialize};
7
8/// Most stops in one gradient.
9pub const MAX_GRADIENT_STOPS: usize = 8;
10
11/// A straight (not premultiplied), sRGB-encoded color with alpha, each
12/// component in `0..=1`.
13#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[serde(deny_unknown_fields)]
16pub struct Rgba {
17    /// Red component.
18    pub red: f32,
19    /// Green component.
20    pub green: f32,
21    /// Blue component.
22    pub blue: f32,
23    /// Coverage.
24    pub alpha: f32,
25}
26
27impl Rgba {
28    /// Creates a color from sRGB-encoded components and alpha.
29    #[must_use]
30    pub const fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
31        Self {
32            red,
33            green,
34            blue,
35            alpha,
36        }
37    }
38
39    pub(crate) fn is_valid(self) -> bool {
40        [self.red, self.green, self.blue, self.alpha]
41            .iter()
42            .all(|value| (0.0..=1.0).contains(value))
43    }
44}
45
46/// The picture of a generated source.
47///
48/// A generated picture has the canvas's size and aspect, like a source that
49/// fills the frame at scale 1. Positions are in its normalized space, `(0, 0)`
50/// top-left to `(1, 1)` bottom-right; lengths (radii, stroke widths) are
51/// fractions of its shorter side, so the picture is the same at any render
52/// resolution. A fill or text does not change over time (a clip animates it
53/// with its transform, effects, and masks); shapes may.
54#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
57pub enum Generator {
58    /// Paint over the whole picture.
59    Fill {
60        /// What covers the picture.
61        paint: Paint,
62    },
63    /// Shapes drawn from an ordered content tree, as an After Effects shape
64    /// layer or a Lottie shape layer draws them. Unlike the other
65    /// generators, its picture changes over time: every value in the tree
66    /// may be keyframed, in the picture's time.
67    Shapes {
68        /// The content tree, front first. See [`ShapeItem`].
69        items: Vec<ShapeItem>,
70    },
71    /// Text on a transparent picture, laid out and placed by its style as a
72    /// caption track's cues are: its size and wrapping width fractions of
73    /// the canvas, its anchor at `style.position`. The engine draws it with
74    /// the fonts its host registered.
75    Text {
76        /// The text; `\n` starts a line. Non-empty, at most
77        /// [`MAX_TEXT_CONTENT_BYTES`] bytes.
78        text: String,
79        /// How it looks and where it lies. The font size is at most
80        /// [`MAX_TEXT_GENERATOR_FONT_SIZE`].
81        #[serde(default)]
82        style: TextStyle,
83    },
84}
85
86/// A color or gradient.
87///
88/// Gradients interpolate between stops premultiplied, in linear light: the
89/// engine's working space, so a gradient between two colors passes through
90/// the physical mix of them, and a stop fading to transparent does not darken
91/// its neighbour.
92#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
95pub enum Paint {
96    /// One color.
97    Solid {
98        /// The color.
99        color: Rgba,
100    },
101    /// Colors along the line from `start` to `end`, constant across it.
102    LinearGradient {
103        /// Where offset 0 lies.
104        start: Vec2,
105        /// Where offset 1 lies. Distinct from `start`.
106        end: Vec2,
107        /// Two to [`MAX_GRADIENT_STOPS`] stops.
108        stops: Vec<GradientStop>,
109        /// What lies beyond the first and last offsets.
110        #[serde(default)]
111        spread: Spread,
112    },
113    /// Colors along the radius of a circle.
114    RadialGradient {
115        /// Where offset 0 lies.
116        center: Vec2,
117        /// The distance of offset 1 from the centre, as a fraction of the
118        /// picture's shorter side. Positive.
119        radius: f32,
120        /// Two to [`MAX_GRADIENT_STOPS`] stops.
121        stops: Vec<GradientStop>,
122        /// What lies beyond the first and last offsets.
123        #[serde(default)]
124        spread: Spread,
125    },
126    /// Colors around a point: offsets 0 to 1 are one clockwise turn.
127    ConicGradient {
128        /// The point the gradient turns around.
129        center: Vec2,
130        /// Where offset 0 lies, in degrees clockwise from straight up.
131        #[serde(default)]
132        angle_degrees: f32,
133        /// Two to [`MAX_GRADIENT_STOPS`] stops.
134        stops: Vec<GradientStop>,
135    },
136}
137
138/// One color of a gradient.
139#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[serde(deny_unknown_fields)]
142pub struct GradientStop {
143    /// Position along the gradient, in `0..=1`, not before the previous stop.
144    pub offset: f32,
145    /// Color at that position.
146    pub color: Rgba,
147}
148
149/// What a linear or radial gradient shows outside its first and last stops.
150#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[serde(rename_all = "snake_case")]
153pub enum Spread {
154    /// The end colors continue.
155    #[default]
156    Pad,
157    /// The gradient starts again.
158    Repeat,
159    /// The gradient runs back and forth.
160    Reflect,
161}
162
163impl Generator {
164    /// Whether every field is in range, as a project needs it to be.
165    #[must_use]
166    pub fn is_valid(&self) -> bool {
167        self.validate().is_ok()
168    }
169
170    /// Checks every field. A shapes generator's problem names where in its
171    /// tree it lies; another generator's is `None`.
172    ///
173    /// # Errors
174    /// Returns the first problem, if any.
175    pub fn validate(&self) -> Result<(), Option<ShapeIssue>> {
176        let valid = match self {
177            Self::Fill { paint } => paint.is_valid(),
178            Self::Shapes { items } => {
179                return crate::shapes::validate_shape_items(items).map_err(Some);
180            }
181            Self::Text { text, style } => {
182                !text.is_empty()
183                    && text.len() <= MAX_TEXT_CONTENT_BYTES
184                    && style.is_valid(MAX_TEXT_GENERATOR_FONT_SIZE)
185            }
186        };
187        valid.then_some(()).ok_or(None)
188    }
189}
190
191impl Paint {
192    pub(crate) fn is_valid(&self) -> bool {
193        match self {
194            Self::Solid { color } => color.is_valid(),
195            Self::LinearGradient {
196                start, end, stops, ..
197            } => finite(*start) && finite(*end) && start != end && valid_stops(stops),
198            Self::RadialGradient {
199                center,
200                radius,
201                stops,
202                ..
203            } => finite(*center) && radius.is_finite() && *radius > 0.0 && valid_stops(stops),
204            Self::ConicGradient {
205                center,
206                angle_degrees,
207                stops,
208            } => finite(*center) && angle_degrees.is_finite() && valid_stops(stops),
209        }
210    }
211}
212
213fn finite(value: Vec2) -> bool {
214    value.x.is_finite() && value.y.is_finite()
215}
216
217fn valid_stops(stops: &[GradientStop]) -> bool {
218    (2..=MAX_GRADIENT_STOPS).contains(&stops.len())
219        && stops
220            .iter()
221            .all(|stop| (0.0..=1.0).contains(&stop.offset) && stop.color.is_valid())
222        && stops
223            .windows(2)
224            .all(|pair| pair[0].offset <= pair[1].offset)
225}