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