Skip to main content

motion_core/drawing/
mesh.rs

1//! Mesh gradients with analytic paint descriptors and explicit image baking.
2
3use std::any::Any;
4use std::sync::Arc;
5
6use crate::budget::Budget;
7use crate::{
8    Color, GradientExtend, ImagePattern, ImagePixels, PixelSize, Point, RasterLimits, Rect,
9    Sampling, Transform, ValidationError,
10};
11
12mod evaluate;
13
14pub use evaluate::MeshPatch;
15
16#[cfg(test)]
17mod tests;
18
19/// Maximum control points along one axis of a [`MeshGradient`].
20pub const MAX_MESH_POINTS_PER_AXIS: u32 = 64;
21
22/// Maximum subdivisions per lattice cell edge when rasterizing a [`MeshGradient`].
23pub const MAX_MESH_SUBDIVISIONS: u32 = 32;
24
25/// A [`MeshGradient`] evaluated independently by each paint backend.
26///
27/// Size and subdivisions remain the resolution of the explicit [`Self::pattern`] bake;
28/// they do not quantize analytic paint evaluation.
29#[derive(Clone, Debug, PartialEq)]
30pub struct MeshPaint {
31    /// Control lattice to paint with.
32    pub mesh: MeshGradient,
33    /// Baked image size in pixels.
34    pub size: PixelSize,
35    /// Subdivisions per lattice cell edge, in `1..=`[`MAX_MESH_SUBDIVISIONS`].
36    pub subdivisions: u32,
37}
38
39impl MeshPaint {
40    /// Pairs a mesh with its bake resolution.
41    pub fn new(mesh: MeshGradient, size: PixelSize, subdivisions: u32) -> Self {
42        Self {
43            mesh,
44            size,
45            subdivisions,
46        }
47    }
48
49    /// Checks the bake parameters without allocating.
50    pub fn validate(&self) -> Result<(), ValidationError> {
51        if !(1..=MAX_MESH_SUBDIVISIONS).contains(&self.subdivisions) {
52            return Err(ValidationError::limit(
53                LABEL,
54                "subdivisions",
55                "Subdivisions must be between 1 and 32",
56            ));
57        }
58        if self.size.width == 0 || self.size.height == 0 {
59            return Err(ValidationError::invalid(
60                LABEL,
61                "size",
62                "Mesh image must have positive dimensions",
63            ));
64        }
65        for row in 0..self.mesh.rows() - 1 {
66            for column in 0..self.mesh.columns() - 1 {
67                self.mesh.patch(column, row)?;
68            }
69        }
70        Ok(())
71    }
72
73    /// Bakes the mesh over `bounds` as an extend-padded bilinear pattern.
74    pub fn pattern(
75        &self,
76        bounds: Rect,
77        limits: RasterLimits,
78    ) -> Result<ImagePattern, ValidationError> {
79        self.mesh
80            .pattern(bounds, self.size, self.subdivisions, limits)
81    }
82}
83
84/// Barycentric slack that keeps pixels on shared triangle edges covered.
85const EDGE_SLACK: f64 = 1e-9;
86
87const LABEL: &str = "mesh_gradient";
88
89/// One lattice control point: a position in unit object space and its color.
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct MeshVertex {
92    /// Position as fractions of the painted bounds; finite, usually within `0..=1`.
93    pub position: Point,
94    /// Color assigned to this control point.
95    pub color: Color,
96}
97
98/// A grid of colored control points blended smoothly across each cell.
99///
100/// Each cell blends its four corner colors in Oklab with smoothstep weights, so color
101/// is continuous across cells and flat at every control point. Cell geometry is the
102/// bilinear patch of its corner positions; moving control points warps the colors.
103/// Alpha interpolates linearly. Authored paints evaluate inverse-bilinear patches
104/// independently on CPU/GPU; explicit rasterization retains the tessellated image
105/// baking path for existing artwork.
106///
107/// ```
108/// use motion_core::{
109///     Color, MeshGradient, PixelSize, RasterLimits, Rect,
110/// };
111/// let mesh = MeshGradient::grid(2, 2, &[
112///     Color::from_hex(0xff0000), Color::from_hex(0x00ff00),
113///     Color::from_hex(0x0000ff), Color::WHITE,
114/// ])?;
115/// let pattern = mesh.pattern(
116///     Rect::new(0.0, 0.0, 64.0, 64.0),
117///     PixelSize::new(16, 16),
118///     4,
119///     RasterLimits::default(),
120/// )?;
121/// assert_eq!(pattern.image.width(), 16);
122/// # Ok::<(), motion_core::ValidationError>(())
123/// ```
124#[derive(Clone, Debug, PartialEq)]
125pub struct MeshGradient {
126    columns: u32,
127    rows: u32,
128    vertices: Vec<MeshVertex>,
129}
130
131impl MeshGradient {
132    /// Creates a mesh from row-major control points.
133    ///
134    /// Requires at least two points per axis, at most [`MAX_MESH_POINTS_PER_AXIS`], and
135    /// exactly `columns * rows` finite vertices.
136    pub fn new(
137        columns: u32,
138        rows: u32,
139        vertices: Vec<MeshVertex>,
140    ) -> Result<Self, ValidationError> {
141        if !(2..=MAX_MESH_POINTS_PER_AXIS).contains(&columns)
142            || !(2..=MAX_MESH_POINTS_PER_AXIS).contains(&rows)
143        {
144            return Err(ValidationError::limit(
145                LABEL,
146                "size",
147                "Mesh needs between 2 and 64 control points per axis",
148            ));
149        }
150        if vertices.len() != columns as usize * rows as usize {
151            return Err(ValidationError::invalid(
152                LABEL,
153                "vertices",
154                "Vertex count must equal columns times rows",
155            ));
156        }
157        if vertices
158            .iter()
159            .any(|vertex| !vertex.position.x.is_finite() || !vertex.position.y.is_finite())
160        {
161            return Err(ValidationError::invalid(
162                LABEL,
163                "position",
164                "Mesh positions must be finite",
165            ));
166        }
167        Ok(Self {
168            columns,
169            rows,
170            vertices,
171        })
172    }
173
174    /// Creates a regular lattice covering the unit square from row-major colors.
175    pub fn grid(columns: u32, rows: u32, colors: &[Color]) -> Result<Self, ValidationError> {
176        let vertices = colors
177            .iter()
178            .enumerate()
179            .map(|(index, color)| {
180                let column = index as u32 % columns.max(1);
181                let row = index as u32 / columns.max(1);
182                MeshVertex {
183                    position: Point::new(
184                        f64::from(column) / f64::from(columns.max(2) - 1),
185                        f64::from(row) / f64::from(rows.max(2) - 1),
186                    ),
187                    color: *color,
188                }
189            })
190            .collect();
191        Self::new(columns, rows, vertices)
192    }
193
194    /// Control points along the horizontal axis.
195    pub fn columns(&self) -> u32 {
196        self.columns
197    }
198
199    /// Control points along the vertical axis.
200    pub fn rows(&self) -> u32 {
201        self.rows
202    }
203
204    /// Row-major control points.
205    pub fn vertices(&self) -> &[MeshVertex] {
206        &self.vertices
207    }
208
209    /// Creates one admitted analytic patch without allocating an image or lattice.
210    ///
211    /// Rejects out-of-range indices or coordinates/colors outside shared shader support.
212    pub fn patch(&self, column: u32, row: u32) -> Result<MeshPatch, ValidationError> {
213        if column >= self.columns - 1 || row >= self.rows - 1 {
214            return Err(ValidationError::invalid(
215                LABEL,
216                "patch",
217                "Patch index is outside the mesh",
218            ));
219        }
220        let at = |dx, dy| self.vertices[((row + dy) * self.columns + column + dx) as usize];
221        MeshPatch::new([at(0, 0), at(1, 0), at(0, 1), at(1, 1)])
222    }
223
224    /// Rasterizes the unit square into a `size` image, splitting every cell edge into
225    /// `subdivisions` pieces. Pixels outside the mesh stay transparent.
226    ///
227    /// Admits pixels, storage and triangle work through `limits` before allocating.
228    pub fn rasterize(
229        &self,
230        size: PixelSize,
231        subdivisions: u32,
232        limits: RasterLimits,
233    ) -> Result<MeshImage, ValidationError> {
234        if !(1..=MAX_MESH_SUBDIVISIONS).contains(&subdivisions) {
235            return Err(ValidationError::limit(
236                LABEL,
237                "subdivisions",
238                "Subdivisions must be between 1 and 32",
239            ));
240        }
241        if size.width == 0 || size.height == 0 {
242            return Err(ValidationError::invalid(
243                LABEL,
244                "size",
245                "Mesh image must have positive dimensions",
246            ));
247        }
248        let mut budget = Budget::new(size, limits)?;
249        let texels = u64::from(size.width) * u64::from(size.height);
250        let lattice_columns = u64::from((self.columns - 1) * subdivisions + 1);
251        let lattice_rows = u64::from((self.rows - 1) * subdivisions + 1);
252        budget.bytes(texels * 16, LABEL)?;
253        budget.bytes(lattice_columns * lattice_rows * 48, LABEL)?;
254        budget.work(lattice_columns * lattice_rows, LABEL)?;
255        let mut lattice = Vec::new();
256        lattice
257            .try_reserve((lattice_columns * lattice_rows) as usize)
258            .map_err(|_| {
259                ValidationError::limit(LABEL, "max_bytes", "Could not reserve mesh lattice")
260            })?;
261        for row in 0..lattice_rows as u32 {
262            for column in 0..lattice_columns as u32 {
263                lattice.push(self.sample(column, row, subdivisions)?);
264            }
265        }
266        let mut pixels = Vec::new();
267        pixels.try_reserve(texels as usize).map_err(|_| {
268            ValidationError::limit(LABEL, "max_bytes", "Could not reserve mesh pixels")
269        })?;
270        pixels.resize(texels as usize, [0.0f32; 4]);
271        let stride = lattice_columns as usize;
272        for row in 0..lattice_rows as usize - 1 {
273            for column in 0..stride - 1 {
274                let corners = [
275                    lattice[row * stride + column],
276                    lattice[row * stride + column + 1],
277                    lattice[(row + 1) * stride + column],
278                    lattice[(row + 1) * stride + column + 1],
279                ];
280                for triangle in [[0, 1, 2], [1, 3, 2]] {
281                    let corners = triangle.map(|index| corners[index]);
282                    fill_triangle(&mut pixels, size, corners, &mut budget)?;
283                }
284            }
285        }
286        Ok(MeshImage {
287            width: size.width,
288            height: size.height,
289            pixels,
290        })
291    }
292
293    /// Rasterizes the mesh and maps it onto `bounds` as an extend-padded bilinear pattern.
294    pub fn pattern(
295        &self,
296        bounds: Rect,
297        size: PixelSize,
298        subdivisions: u32,
299        limits: RasterLimits,
300    ) -> Result<ImagePattern, ValidationError> {
301        let image = self.rasterize(size, subdivisions, limits)?;
302        let scale_x = bounds.width() / f64::from(size.width);
303        let scale_y = bounds.height() / f64::from(size.height);
304        let transform = Transform::scale_non_uniform(scale_x, scale_y)
305            .then(Transform::translate(bounds.left, bounds.top));
306        let pattern = ImagePattern::from_pixels(Arc::new(image))
307            .transform(transform)
308            .extend(GradientExtend::Pad)
309            .sampling(Sampling::Bilinear);
310        pattern.validate()?;
311        Ok(pattern)
312    }
313
314    fn sample(&self, column: u32, row: u32, subdivisions: u32) -> Result<Lattice, ValidationError> {
315        let cell_column = (column / subdivisions).min(self.columns - 2);
316        let cell_row = (row / subdivisions).min(self.rows - 2);
317        let u = f64::from(column - cell_column * subdivisions) / f64::from(subdivisions);
318        let v = f64::from(row - cell_row * subdivisions) / f64::from(subdivisions);
319        let at = |dx: u32, dy: u32| {
320            self.vertices[((cell_row + dy) * self.columns + cell_column + dx) as usize]
321        };
322        let [a, b, c, d] = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)];
323        let (su, sv) = (smooth(u), smooth(v));
324        let top = a.color.try_interpolate(&b.color, su)?;
325        let bottom = c.color.try_interpolate(&d.color, su)?;
326        let color = top.try_interpolate(&bottom, sv)?;
327        let mix = |p: fn(&MeshVertex) -> f64| {
328            let upper = p(&a) + (p(&b) - p(&a)) * u;
329            let lower = p(&c) + (p(&d) - p(&c)) * u;
330            upper + (lower - upper) * v
331        };
332        Ok(Lattice {
333            x: mix(|vertex| vertex.position.x),
334            y: mix(|vertex| vertex.position.y),
335            color: color.to_linear_premultiplied(),
336        })
337    }
338}
339
340/// A single-level linear-premultiplied image produced by [`MeshGradient::rasterize`].
341#[derive(Clone, Debug, PartialEq)]
342pub struct MeshImage {
343    width: u32,
344    height: u32,
345    pixels: Vec<[f32; 4]>,
346}
347
348impl ImagePixels for MeshImage {
349    fn width(&self) -> u32 {
350        self.width
351    }
352
353    fn height(&self) -> u32 {
354        self.height
355    }
356
357    fn level_count(&self) -> usize {
358        1
359    }
360
361    fn level_size(&self, _level: usize) -> (u32, u32) {
362        (self.width, self.height)
363    }
364
365    fn texel(&self, _level: usize, column: usize, row: usize) -> [f32; 4] {
366        self.pixels[row * self.width as usize + column]
367    }
368
369    fn content_eq(&self, other: &dyn ImagePixels) -> bool {
370        other
371            .as_any()
372            .downcast_ref::<Self>()
373            .is_some_and(|other| self == other)
374    }
375
376    fn as_any(&self) -> &dyn Any {
377        self
378    }
379}
380
381#[derive(Clone, Copy)]
382struct Lattice {
383    x: f64,
384    y: f64,
385    color: [f64; 4],
386}
387
388fn smooth(value: f64) -> f64 {
389    value * value * (3.0 - 2.0 * value)
390}
391
392fn fill_triangle(
393    pixels: &mut [[f32; 4]],
394    size: PixelSize,
395    corners: [Lattice; 3],
396    budget: &mut Budget,
397) -> Result<(), ValidationError> {
398    let [a, b, c] = corners.map(|corner| {
399        (
400            corner.x * f64::from(size.width),
401            corner.y * f64::from(size.height),
402        )
403    });
404    let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0);
405    if area == 0.0 {
406        return Ok(());
407    }
408    let clamp = |low: f64, high: f64, limit: u32| {
409        (
410            (libm::floor(low - 0.5).max(0.0)) as u32,
411            (libm::ceil(high - 0.5).max(-1.0).min(f64::from(limit) - 1.0)) as i64,
412        )
413    };
414    let (first_column, last_column) =
415        clamp(a.0.min(b.0).min(c.0), a.0.max(b.0).max(c.0), size.width);
416    let (first_row, last_row) = clamp(a.1.min(b.1).min(c.1), a.1.max(b.1).max(c.1), size.height);
417    if last_column < i64::from(first_column) || last_row < i64::from(first_row) {
418        return Ok(());
419    }
420    let columns = last_column as u64 - u64::from(first_column) + 1;
421    let rows = last_row as u64 - u64::from(first_row) + 1;
422    budget.work(columns * rows, LABEL)?;
423    for row in first_row..=last_row as u32 {
424        for column in first_column..=last_column as u32 {
425            let point = (f64::from(column) + 0.5, f64::from(row) + 0.5);
426            let w1 = ((point.0 - a.0) * (c.1 - a.1) - (point.1 - a.1) * (c.0 - a.0)) / area;
427            let w2 = ((b.0 - a.0) * (point.1 - a.1) - (b.1 - a.1) * (point.0 - a.0)) / area;
428            let w0 = 1.0 - w1 - w2;
429            if w0 < -EDGE_SLACK || w1 < -EDGE_SLACK || w2 < -EDGE_SLACK {
430                continue;
431            }
432            let texel = &mut pixels[row as usize * size.width as usize + column as usize];
433            for (channel, slot) in texel.iter_mut().enumerate() {
434                *slot = (w0 * corners[0].color[channel]
435                    + w1 * corners[1].color[channel]
436                    + w2 * corners[2].color[channel]) as f32;
437            }
438        }
439    }
440    Ok(())
441}