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 /// Whether every field is in range, as a project needs it to be.
226 #[must_use]
227 pub fn is_valid(&self) -> bool {
228 match self {
229 Self::Fill { paint } => paint.is_valid(),
230 Self::Shape {
231 shape,
232 center,
233 size,
234 rotation_degrees,
235 fill,
236 stroke,
237 } => {
238 let shape_is_valid = match *shape {
239 GeneratedShape::Rectangle { corner_radius } => {
240 corner_radius.is_finite() && corner_radius >= 0.0
241 }
242 GeneratedShape::Ellipse => true,
243 };
244 shape_is_valid
245 && finite(*center)
246 && finite(*size)
247 && size.x >= 0.0
248 && size.y >= 0.0
249 && rotation_degrees.is_finite()
250 && (fill.is_some() || stroke.is_some())
251 && fill.as_ref().is_none_or(Paint::is_valid)
252 && stroke.as_ref().is_none_or(|stroke| {
253 stroke.width.is_finite() && stroke.width > 0.0 && stroke.paint.is_valid()
254 })
255 }
256 Self::Text { text, style } => {
257 !text.is_empty()
258 && text.len() <= MAX_TEXT_CONTENT_BYTES
259 && style.is_valid(MAX_TEXT_GENERATOR_FONT_SIZE)
260 }
261 }
262 }
263}
264
265impl Paint {
266 fn is_valid(&self) -> bool {
267 match self {
268 Self::Solid { color } => color.is_valid(),
269 Self::LinearGradient {
270 start, end, stops, ..
271 } => finite(*start) && finite(*end) && start != end && valid_stops(stops),
272 Self::RadialGradient {
273 center,
274 radius,
275 stops,
276 ..
277 } => finite(*center) && radius.is_finite() && *radius > 0.0 && valid_stops(stops),
278 Self::ConicGradient {
279 center,
280 angle_degrees,
281 stops,
282 } => finite(*center) && angle_degrees.is_finite() && valid_stops(stops),
283 }
284 }
285}
286
287fn finite(value: Vec2) -> bool {
288 value.x.is_finite() && value.y.is_finite()
289}
290
291fn valid_stops(stops: &[GradientStop]) -> bool {
292 (2..=MAX_GRADIENT_STOPS).contains(&stops.len())
293 && stops
294 .iter()
295 .all(|stop| (0.0..=1.0).contains(&stop.offset) && stop.color.is_valid())
296 && stops
297 .windows(2)
298 .all(|pair| pair[0].offset <= pair[1].offset)
299}