Skip to main content

motion_core/drawing/
paint.rs

1//! Gradient and static image-pattern descriptions.
2//!
3//! ```
4//! use motion_core::prelude::*;
5//! use motion_core::{
6//!     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
7//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
8//! };
9//! let brush = LinearGradient::new(Point::ORIGIN, Point::new(1.0, 0.0))
10//!     .stop(0.0, Color::from_hex(0x1080c0))
11//!     .stop(1.0, Color::WHITE)
12//!     .space(GradientSpace::Oklab);
13//! Node::from(Rect::new(0.0, 0.0, 8.0, 8.0).fill(brush)).compile(
14//!     Size::new(8.0, 8.0),
15//!     PixelSize::new(8, 8),
16//!     ValidationLimits::default(),
17//! )?;
18//! # Ok::<(), motion_core::Error>(())
19//! ```
20
21use std::sync::Arc;
22
23use crate::{Color, Point, Rect, Transform, ValidationError};
24
25/// A preloaded static image sampled in local pixel coordinates, without I/O.
26#[derive(Clone, Debug)]
27pub struct ImagePattern {
28    /// Shared immutable pixels and mip levels; no decoder or renderer is retained.
29    pub image: PatternImage,
30    /// Maps image pixel coordinates into shape-local coordinates.
31    pub transform: Transform,
32    /// Addressing policy outside the image's normalized extent.
33    pub extend: GradientExtend,
34    /// Texel filtering and transform-derived mip selection policy.
35    pub sampling: crate::Sampling,
36    /// Makes samples outside the image extent transparent instead of extending edges.
37    pub decal: bool,
38}
39
40/// Shared image sampling contract captured by a paint, independent of content decoders.
41#[derive(Clone, Debug)]
42pub struct PatternImage(Arc<dyn crate::pixels::ImagePixels>);
43
44impl PatternImage {
45    /// Base mip width in pixels.
46    pub fn width(&self) -> u32 {
47        self.0.width()
48    }
49
50    /// Base mip height in pixels.
51    pub fn height(&self) -> u32 {
52        self.0.height()
53    }
54
55    /// Number of retained mip levels, including the base image.
56    pub fn levels(&self) -> usize {
57        self.0.level_count()
58    }
59
60    /// Width and height of a valid retained mip level.
61    pub fn size(&self, level: usize) -> (u32, u32) {
62        self.0.level_size(level)
63    }
64
65    /// Reads a linear-premultiplied texel; level and pixel indices must be valid.
66    pub fn texel(&self, level: usize, column: usize, row: usize) -> [f32; 4] {
67        self.0.texel(level, column, row)
68    }
69
70    /// Appends a whole level in row-major order, four `f32` per texel.
71    pub fn extend_level(&self, level: usize, out: &mut Vec<f32>) {
72        self.0.extend_level(level, out);
73    }
74
75    fn same(&self, other: &Self) -> bool {
76        Arc::ptr_eq(&self.0, &other.0) || self.0.content_eq(other.0.as_ref())
77    }
78}
79
80impl PartialEq for ImagePattern {
81    fn eq(&self, other: &Self) -> bool {
82        self.transform == other.transform
83            && self.extend == other.extend
84            && self.sampling == other.sampling
85            && self.decal == other.decal
86            && self.image.same(&other.image)
87    }
88}
89
90impl ImagePattern {
91    /// Captures an already resolved backend-independent image result.
92    pub fn from_pixels(image: Arc<dyn crate::ImagePixels>) -> Self {
93        Self {
94            image: PatternImage(image),
95            transform: Transform::IDENTITY,
96            extend: GradientExtend::Repeat,
97            sampling: crate::Sampling::Trilinear,
98            decal: false,
99        }
100    }
101
102    /// Captures a linear RGBA16F mip chain without decoding/copying pixels.
103    pub fn from_asset<Image: crate::pixels::ImagePixels>(image: Arc<Image>) -> Self {
104        Self {
105            image: PatternImage(image),
106            transform: Transform::IDENTITY,
107            extend: GradientExtend::Repeat,
108            sampling: crate::Sampling::Trilinear,
109            decal: false,
110        }
111    }
112
113    /// Selects nearest, bilinear or transform-derived trilinear sampling.
114    pub fn sampling(mut self, sampling: crate::Sampling) -> Self {
115        self.sampling = sampling;
116        self
117    }
118
119    /// Maps image pixels into shape-local coordinates.
120    pub fn transform(mut self, transform: Transform) -> Self {
121        self.transform = transform;
122        self
123    }
124
125    /// Selects clamped, repeated or mirrored image edges.
126    pub fn extend(mut self, extend: GradientExtend) -> Self {
127        self.extend = extend;
128        self
129    }
130
131    /// Rejects nonfinite or noninvertible image-to-shape transforms.
132    pub fn validate(&self) -> Result<(), ValidationError> {
133        if !self.transform.is_finite() || self.transform.inverse().is_none() {
134            return Err(invalid(
135                "Image pattern transform must be finite and invertible",
136            ));
137        }
138        Ok(())
139    }
140}
141
142impl From<ImagePattern> for super::Paint {
143    fn from(value: ImagePattern) -> Self {
144        Self::Pattern(value)
145    }
146}
147
148/// Color interpolation space for gradients.
149#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
150pub enum GradientSpace {
151    /// Perceptual Oklab, matching Color interpolation.
152    #[default]
153    Oklab,
154    /// Straight linear-light RGB and linear alpha.
155    LinearRgb,
156    /// Straight encoded sRGB and linear alpha.
157    Srgb,
158}
159
160/// Coordinates used by gradient geometry.
161#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162pub enum GradientUnits {
163    /// Coordinates are fractions of the original shape's local bounds.
164    #[default]
165    ObjectBoundingBox,
166    /// Coordinates are local logical drawing units.
167    UserSpace,
168}
169
170/// Behavior outside a gradient's normalized interval.
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
172pub enum GradientExtend {
173    /// Hold endpoint colors.
174    #[default]
175    Pad,
176    /// Repeat every unit interval.
177    Repeat,
178    /// Reflect every second interval.
179    Reflect,
180}
181
182impl GradientExtend {
183    /// Maps a normalized coordinate into the selected clamped or periodic interval.
184    pub fn apply(self, position: f64) -> f64 {
185        match self {
186            Self::Pad => position.clamp(0.0, 1.0),
187            Self::Repeat => position.rem_euclid(1.0),
188            Self::Reflect => {
189                let value = position.rem_euclid(2.0);
190                if value > 1.0 { 2.0 - value } else { value }
191            }
192        }
193    }
194}
195
196#[derive(Clone, Debug, PartialEq)]
197/// Gradient geometry in the coordinate system selected by [`GradientUnits`].
198pub enum Geometry {
199    /// Projection from the first point to the second point.
200    Linear(Point, Point),
201    /// Distance from a center divided by a positive radius.
202    Radial(Point, f64),
203    /// Focal radial gradient: center, positive radius and focus strictly inside the circle.
204    RadialFocal(Point, f64, Point),
205    /// Angular gradient around a center, with increasing start/end radians.
206    Sweep(Point, f64, f64),
207}
208
209#[derive(Clone, Debug, PartialEq)]
210/// Shared gradient description retained by paint and validated before preparation.
211pub struct Gradient {
212    /// Finite, nondegenerate shape of the gradient coordinate field.
213    pub geometry: Geometry,
214    /// Two to 64 ordered stops including positions zero and one; equal positions make hard stops.
215    pub stops: Arc<[(f64, Color)]>,
216    /// Straight-color interpolation space; sampled output is linear premultiplied RGBA.
217    pub space: GradientSpace,
218    /// Whether geometry uses local logical units or normalized shape bounds.
219    pub units: GradientUnits,
220    /// Coordinate policy before interpolation between the stop colors.
221    pub extend: GradientExtend,
222}
223
224fn invalid(message: &str) -> ValidationError {
225    ValidationError::invalid("paint.gradient", "parameters", message)
226}
227
228impl Gradient {
229    fn new(geometry: Geometry) -> Self {
230        Self {
231            geometry,
232            stops: Arc::from([]),
233            space: GradientSpace::default(),
234            units: GradientUnits::default(),
235            extend: GradientExtend::default(),
236        }
237    }
238
239    fn stop(mut self, position: f64, color: Color) -> Self {
240        if self.stops.len() < 65 {
241            let mut stops = self.stops.to_vec();
242            stops.push((position, color));
243            self.stops = stops.into();
244        }
245        self
246    }
247
248    /// Checks ordered endpoint-inclusive stops and finite, nondegenerate geometry.
249    pub fn validate(&self) -> Result<(), ValidationError> {
250        if !(2..=64).contains(&self.stops.len())
251            || self.stops.first().unwrap().0 != 0.0
252            || self.stops.last().unwrap().0 != 1.0
253            || self
254                .stops
255                .iter()
256                .any(|(position, _)| !position.is_finite() || !(0.0..=1.0).contains(position))
257            || self.stops.windows(2).any(|pair| pair[0].0 > pair[1].0)
258        {
259            return Err(invalid(
260                "Require 2..=64 ordered stops including zero and one",
261            ));
262        }
263        match self.geometry {
264            Geometry::Linear(first, second)
265                if [first.x, first.y, second.x, second.y]
266                    .iter()
267                    .all(|value| value.is_finite())
268                    && first != second
269                    && first.distance(second).is_finite() =>
270            {
271                Ok(())
272            }
273            Geometry::Radial(center, radius)
274                if center.x.is_finite()
275                    && center.y.is_finite()
276                    && radius.is_finite()
277                    && radius > 0.0 =>
278            {
279                Ok(())
280            }
281            Geometry::RadialFocal(center, radius, focus)
282                if center.x.is_finite()
283                    && center.y.is_finite()
284                    && focus.x.is_finite()
285                    && focus.y.is_finite()
286                    && radius.is_finite()
287                    && radius > 0.0
288                    && center.distance(focus) < radius =>
289            {
290                Ok(())
291            }
292            Geometry::Sweep(center, start, end)
293                if center.x.is_finite()
294                    && center.y.is_finite()
295                    && start.is_finite()
296                    && end.is_finite()
297                    && end > start
298                    && end - start <= std::f64::consts::TAU =>
299            {
300                Ok(())
301            }
302            _ => Err(invalid(
303                "Gradient geometry must be finite and nondegenerate",
304            )),
305        }
306    }
307
308    /// Converts a local point to an extended normalized coordinate.
309    /// Object-bounds units require finite positive width and height; validate geometry first.
310    pub fn position(&self, point: Point, bounds: Rect) -> Result<f64, ValidationError> {
311        let point = match self.units {
312            GradientUnits::UserSpace => point,
313            GradientUnits::ObjectBoundingBox => {
314                if !bounds.width().is_finite()
315                    || !bounds.height().is_finite()
316                    || bounds.width() <= 0.0
317                    || bounds.height() <= 0.0
318                {
319                    return Err(invalid(
320                        "Object bounding-box gradient requires finite nonzero shape bounds",
321                    ));
322                }
323                Point::new(
324                    (point.x - bounds.left) / bounds.width(),
325                    (point.y - bounds.top) / bounds.height(),
326                )
327            }
328        };
329        let position = match self.geometry {
330            Geometry::Linear(start, end) => {
331                let direction = end - start;
332                let length = direction.length();
333                (point - start).dot(direction / length) / length
334            }
335            Geometry::Radial(center, radius) => point.distance(center) / radius,
336            Geometry::RadialFocal(center, radius, focus) => {
337                let offset = point - focus;
338                let displacement = focus - center;
339                let square = offset.dot(offset);
340                if square == 0.0 {
341                    0.0
342                } else {
343                    let projection = offset.dot(displacement);
344                    square
345                        / (-projection
346                            + libm::sqrt(
347                                projection * projection
348                                    + square * (radius * radius - displacement.dot(displacement)),
349                            ))
350                }
351            }
352            Geometry::Sweep(center, start, end) => {
353                let angle = libm::atan2(point.y - center.y, point.x - center.x);
354                (angle - start).rem_euclid(std::f64::consts::TAU) / (end - start)
355            }
356        };
357        if !position.is_finite() {
358            return Err(invalid("Gradient sample position must remain finite"));
359        }
360        Ok(self.extend.apply(position))
361    }
362
363    /// Interpolates validated stops to linear-premultiplied RGBA at a normalized position.
364    /// Call [`Self::validate`] first and apply coordinate extension before sampling.
365    pub fn color(&self, position: f64) -> [f64; 4] {
366        let upper = self
367            .stops
368            .partition_point(|(offset, _)| *offset <= position);
369        if upper == self.stops.len() {
370            return self.stops[upper - 1].1.to_linear_premultiplied();
371        }
372        let (start, first) = self.stops[upper.saturating_sub(1)];
373        let (end, second) = self.stops[upper];
374        let progress = ((position - start) / (end - start)).clamp(0.0, 1.0);
375        if progress == 0.0 {
376            return first.to_linear_premultiplied();
377        }
378        if progress == 1.0 {
379            return second.to_linear_premultiplied();
380        }
381        match self.space {
382            GradientSpace::Oklab => {
383                let first = first.to_rgba_float();
384                let second = second.to_rgba_float();
385                let alpha = first[3] * (1.0 - progress) + second[3] * progress;
386                if alpha == 0.0 {
387                    return [0.0; 4];
388                }
389                let left = crate::color::srgb_to_oklab([first[0], first[1], first[2]]);
390                let right = crate::color::srgb_to_oklab([second[0], second[1], second[2]]);
391                let rgb = crate::color::oklab_to_srgb(std::array::from_fn(|index| {
392                    left[index] * (1.0 - progress) + right[index] * progress
393                }))
394                .map(crate::color::srgb_to_linear);
395                [rgb[0] * alpha, rgb[1] * alpha, rgb[2] * alpha, alpha]
396            }
397            GradientSpace::LinearRgb => {
398                let first = first.to_rgba_float();
399                let second = second.to_rgba_float();
400                let alpha = first[3] * (1.0 - progress) + second[3] * progress;
401                std::array::from_fn(|channel| {
402                    if channel == 3 {
403                        alpha
404                    } else {
405                        (crate::color::srgb_to_linear(first[channel]) * (1.0 - progress)
406                            + crate::color::srgb_to_linear(second[channel]) * progress)
407                            * alpha
408                    }
409                })
410            }
411            GradientSpace::Srgb => {
412                let first = first.to_rgba_float();
413                let second = second.to_rgba_float();
414                let alpha = first[3] * (1.0 - progress) + second[3] * progress;
415                let decode = crate::color::srgb_to_linear;
416                [
417                    decode(first[0] * (1.0 - progress) + second[0] * progress) * alpha,
418                    decode(first[1] * (1.0 - progress) + second[1] * progress) * alpha,
419                    decode(first[2] * (1.0 - progress) + second[2] * progress) * alpha,
420                    alpha,
421                ]
422            }
423        }
424    }
425}
426
427macro_rules! gradient {
428    ($name:ident, $variant:ident, $description:literal) => {
429        #[doc = $description]
430        #[derive(Clone, Debug, PartialEq)]
431        pub struct $name(pub Gradient);
432        impl $name {
433            /// Appends a sorted color stop; invalid/excessive inputs fail recording.
434            pub fn stop(self, position: f64, color: Color) -> Self {
435                Self(self.0.stop(position, color))
436            }
437            /// Selects the interpolation space.
438            pub fn space(mut self, space: GradientSpace) -> Self {
439                self.0.space = space;
440                self
441            }
442            /// Selects object-relative or local user-space coordinates.
443            pub fn units(mut self, units: GradientUnits) -> Self {
444                self.0.units = units;
445                self
446            }
447            /// Selects pad/repeat/reflect outside the color interval.
448            pub fn extend(mut self, extend: GradientExtend) -> Self {
449                self.0.extend = extend;
450                self
451            }
452            /// Checked linear-premultiplied sample, useful for reference checks.
453            pub fn sample(&self, point: Point, bounds: Rect) -> Result<[f64; 4], ValidationError> {
454                self.0.validate()?;
455                Ok(self.0.color(self.0.position(point, bounds)?))
456            }
457        }
458        impl From<$name> for super::Paint {
459            fn from(value: $name) -> Self {
460                Self::$variant(value)
461            }
462        }
463    };
464}
465
466gradient!(
467    LinearGradient,
468    Linear,
469    "A linear gradient in f64 authoring coordinates."
470);
471
472impl LinearGradient {
473    /// Creates geometry without implicit stops.
474    pub fn new(start: Point, end: Point) -> Self {
475        Self(Gradient::new(Geometry::Linear(start, end)))
476    }
477}
478
479gradient!(
480    RadialGradient,
481    Radial,
482    "A circular radial gradient; object bounds can stretch it elliptically."
483);
484
485impl RadialGradient {
486    /// Moves the zero-radius focus inside the outer circle. Invalid focus or
487    /// degenerate geometry is rejected during recording/sampling.
488    pub fn focal(mut self, focus: Point) -> Self {
489        if let Geometry::Radial(center, radius) | Geometry::RadialFocal(center, radius, _) =
490            self.0.geometry
491        {
492            self.0.geometry = Geometry::RadialFocal(center, radius, focus);
493        }
494        self
495    }
496
497    /// Creates geometry with a positive radius.
498    pub fn new(center: Point, radius: f64) -> Self {
499        Self(Gradient::new(Geometry::Radial(center, radius)))
500    }
501}
502
503gradient!(
504    SweepGradient,
505    Sweep,
506    "A clockwise angular gradient in radians."
507);
508
509impl SweepGradient {
510    /// Creates an increasing angular interval at most one turn.
511    pub fn new(center: Point, start_angle: f64, end_angle: f64) -> Self {
512        Self(Gradient::new(Geometry::Sweep(
513            center,
514            start_angle,
515            end_angle,
516        )))
517    }
518}
519
520/// Floating paint retained without implicit gamut clipping or byte quantization.
521#[derive(Clone, Debug, PartialEq)]
522#[non_exhaustive]
523pub enum Paint {
524    /// A straight-alpha, floating encoded-sRGB color.
525    Solid(Color),
526    /// Linear gradient with shared CPU-generated color data.
527    Linear(LinearGradient),
528    /// Circular/elliptical radial gradient.
529    Radial(RadialGradient),
530    /// Angular gradient.
531    Sweep(SweepGradient),
532    /// Bilinear linear-premultiplied sampling of a preloaded image.
533    Pattern(ImagePattern),
534}
535
536impl Paint {
537    /// Borrows gradient data for gradient paints, otherwise returns `None`.
538    pub fn gradient(&self) -> Option<&Gradient> {
539        match self {
540            Self::Linear(value) => Some(&value.0),
541            Self::Radial(value) => Some(&value.0),
542            Self::Sweep(value) => Some(&value.0),
543            _ => None,
544        }
545    }
546
547    /// Validates gradient geometry/stops or image transforms without sampling pixels.
548    pub fn validate(&self) -> Result<(), ValidationError> {
549        match self {
550            Self::Solid(_) => Ok(()),
551            Self::Linear(value) => value.0.validate(),
552            Self::Radial(value) => value.0.validate(),
553            Self::Sweep(value) => value.0.validate(),
554            Self::Pattern(value) => value.validate(),
555        }
556    }
557}
558
559impl From<Color> for Paint {
560    fn from(color: Color) -> Self {
561        Self::Solid(color)
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    #[test]
568    fn focal_radial_uses_ray_circle_intersection_without_changing_end_colors() {
569        use super::*;
570        let gradient = RadialGradient::new(Point::new(0.5, 0.5), 0.5)
571            .focal(Point::new(0.25, 0.5))
572            .stop(0.0, Color::BLACK)
573            .stop(1.0, Color::WHITE)
574            .space(GradientSpace::LinearRgb);
575        let bounds = Rect::new(0.0, 0.0, 1.0, 1.0);
576        assert_eq!(
577            gradient.sample(Point::new(0.25, 0.5), bounds).unwrap(),
578            [0.0, 0.0, 0.0, 1.0]
579        );
580        assert_eq!(
581            gradient.sample(Point::new(1.0, 0.5), bounds).unwrap(),
582            [1.0; 4]
583        );
584        let center = gradient.sample(Point::new(0.5, 0.5), bounds).unwrap();
585        assert!((center[0] - 1.0 / 3.0).abs() < 1e-12);
586    }
587
588    use super::*;
589
590    #[test]
591    fn gradients_preserve_spaces_units_extension_and_hard_stops() {
592        let bounds = Rect::new(10.0, 20.0, 30.0, 40.0);
593        let linear = LinearGradient::new(Point::ORIGIN, Point::new(1.0, 0.0))
594            .stop(0.0, Color::BLACK)
595            .stop(1.0, Color::WHITE)
596            .space(GradientSpace::LinearRgb);
597        assert_eq!(
598            linear.sample(Point::new(20.0, 30.0), bounds).unwrap(),
599            [0.5, 0.5, 0.5, 1.0]
600        );
601        assert_eq!(
602            linear
603                .clone()
604                .extend(GradientExtend::Repeat)
605                .sample(Point::new(40.0, 30.0), bounds)
606                .unwrap(),
607            [0.5, 0.5, 0.5, 1.0]
608        );
609        assert_eq!(
610            linear
611                .clone()
612                .extend(GradientExtend::Reflect)
613                .sample(Point::new(40.0, 30.0), bounds)
614                .unwrap(),
615            [0.5, 0.5, 0.5, 1.0]
616        );
617        let radial = RadialGradient::new(Point::new(0.5, 0.5), 0.5)
618            .stop(0.0, Color::BLACK)
619            .stop(1.0, Color::WHITE)
620            .space(GradientSpace::LinearRgb);
621        assert_eq!(
622            radial.sample(Point::new(20.0, 30.0), bounds).unwrap(),
623            [0.0, 0.0, 0.0, 1.0]
624        );
625        assert_eq!(
626            radial.sample(Point::new(30.0, 30.0), bounds).unwrap(),
627            [1.0; 4]
628        );
629        let sweep = SweepGradient::new(Point::ORIGIN, 0.0, std::f64::consts::TAU)
630            .stop(0.0, Color::BLACK)
631            .stop(1.0, Color::WHITE)
632            .space(GradientSpace::LinearRgb)
633            .units(GradientUnits::UserSpace);
634        assert_eq!(
635            sweep.sample(Point::new(0.0, 1.0), bounds).unwrap(),
636            [0.25, 0.25, 0.25, 1.0]
637        );
638        assert!(
639            LinearGradient::new(Point::ORIGIN, Point::ORIGIN)
640                .stop(0.0, Color::WHITE)
641                .stop(1.0, Color::BLACK)
642                .sample(Point::ORIGIN, bounds)
643                .is_err()
644        );
645        let hard = LinearGradient::new(Point::ORIGIN, Point::new(1.0, 0.0))
646            .stop(0.0, Color::BLACK)
647            .stop(0.5, Color::BLACK)
648            .stop(0.5, Color::WHITE)
649            .stop(1.0, Color::WHITE);
650        assert_eq!(
651            hard.sample(Point::new(20.0, 20.0), bounds).unwrap(),
652            [1.0; 4]
653        );
654        let encoded = linear
655            .clone()
656            .space(GradientSpace::Srgb)
657            .sample(Point::new(20.0, 30.0), bounds)
658            .unwrap();
659        assert!((encoded[0] - 0.21404114048223255).abs() < 1e-12);
660        let perceptual = linear
661            .clone()
662            .space(GradientSpace::Oklab)
663            .sample(Point::new(20.0, 30.0), bounds)
664            .unwrap();
665        for channel in &perceptual[..3] {
666            assert!((*channel - 0.125).abs() < 1e-6);
667        }
668        let alpha = LinearGradient::new(Point::ORIGIN, Point::new(1.0, 0.0))
669            .stop(0.0, Color::rgb(255, 0, 0))
670            .stop(1.0, Color::rgb(0, 255, 0).with_alpha(0.0))
671            .space(GradientSpace::LinearRgb);
672        assert_eq!(
673            alpha.sample(Point::new(20.0, 30.0), bounds).unwrap(),
674            [0.25, 0.25, 0.0, 0.5]
675        );
676    }
677}