Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/src/colour.rs

20.2 KiB, 52 runs

created by r1870400018:13938, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Colours and alpha compositing.
2//!
3//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
4//! Anthropic Claude
5
6use oxedyne_fe2o3_core::prelude::*;
7
8/// An 8-bit-per-channel colour with straight, non-premultiplied alpha.
9///
10/// Straight alpha is stored rather than premultiplied because it is what a PNG carries and what a
11/// caller names a colour with. The premultiplication happens inside [`Rgba::over`], where it
12/// belongs.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14pub struct Rgba {
15 pub r: u8,
16 pub g: u8,
17 pub b: u8,
18 pub a: u8, // 0 transparent to 255 opaque
19}
20
21impl Rgba {
22
23 pub const TRANSPARENT: Self = Self { r: 0, g: 0, b: 0, a: 0 };
24 pub const BLACK: Self = Self { r: 0, g: 0, b: 0, a: 255 };
25 pub const WHITE: Self = Self { r: 255, g: 255, b: 255, a: 255 };
26
27 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
28 Self { r, g, b, a }
29 }
30
31 pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
32 Self { r, g, b, a: 255 }
33 }
34
35 /// Parses a colour from a hexadecimal string, with or without a leading `#`, in either `rgb`,
36 /// `rrggbb` or `rrggbbaa` form.
37 pub fn from_hex(s: &str) -> Outcome<Self> {
38 let h = s.strip_prefix('#').unwrap_or(s);
39 let nyb = |c: u8| -> Outcome<u8> {
40 match c {
41 b'0'..=b'9' => Ok(c - b'0'),
42 b'a'..=b'f' => Ok(c - b'a' + 10),
43 b'A'..=b'F' => Ok(c - b'A' + 10),
44 _ => Err(err!(
45 "'{}' is not a hexadecimal digit, in the colour \"{}\".", c as char, s;
46 Invalid, Input)),
47 }
48 };
49 let b = h.as_bytes();
50 match b.len() {
51 3 => Ok(Self::opaque(
52 res!(nyb(b[0])) * 17,
53 res!(nyb(b[1])) * 17,
54 res!(nyb(b[2])) * 17,
55 )),
56 6 => Ok(Self::opaque(
57 (res!(nyb(b[0])) << 4) | res!(nyb(b[1])),
58 (res!(nyb(b[2])) << 4) | res!(nyb(b[3])),
59 (res!(nyb(b[4])) << 4) | res!(nyb(b[5])),
60 )),
61 8 => Ok(Self::new(
62 (res!(nyb(b[0])) << 4) | res!(nyb(b[1])),
63 (res!(nyb(b[2])) << 4) | res!(nyb(b[3])),
64 (res!(nyb(b[4])) << 4) | res!(nyb(b[5])),
65 (res!(nyb(b[6])) << 4) | res!(nyb(b[7])),
66 )),
67 n => Err(err!(
68 "A hexadecimal colour has 3, 6 or 8 digits, but \"{}\" has {}.", s, n;
69 Invalid, Input)),
70 }
71 }
72
73 /// Renders this colour as a hexadecimal string with a leading `#`.
74 ///
75 /// The inverse of [`Rgba::from_hex`]: an opaque colour comes back as `#rrggbb`, and one with
76 /// alpha as `#rrggbbaa`, so a colour written out and read back in is the colour it began as. The
77 /// three-digit short form is never emitted, since most colours do not fit it and a writer that
78 /// only sometimes shortened would be the harder thing to reason about.
79 pub fn to_hex(&self) -> String {
80 if self.is_opaque() {
81 fmt!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
82 } else {
83 fmt!("#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a)
84 }
85 }
86
87 pub fn is_transparent(&self) -> bool {
88 self.a == 0
89 }
90
91 pub fn is_opaque(&self) -> bool {
92 self.a == 255
93 }
94
95 /// The coverage runs from 0 to 1. This is how the rasteriser's anti-aliasing reaches the
96 /// pixel: a pixel the shape half covers is painted with a colour of half the alpha.
97 pub fn with_coverage(&self, cov: f32) -> Self {
98 let c = cov.clamp(0.0, 1.0);
99 Self {
100 a: ((self.a as f32) * c + 0.5) as u8,
101 ..*self
102 }
103 }
104
105 /// Composites this colour, as the source, over `dst`, the destination: Porter-Duff source-over.
106 ///
107 /// Both operands carry straight alpha, so each is premultiplied, combined, and un-premultiplied
108 /// on the way out.
109 pub fn over(&self, dst: Self) -> Self {
110 if self.is_opaque() || dst.is_transparent() {
111 return *self;
112 }
113 if self.is_transparent() {
114 return dst;
115 }
116 let sa = (self.a as f32) / 255.0;
117 let da = (dst.a as f32) / 255.0;
118 let oa = sa + da * (1.0 - sa); // Output alpha, never zero here.
119 let chan = |s: u8, d: u8| -> u8 {
120 let sc = (s as f32) / 255.0;
121 let dc = (d as f32) / 255.0;
122 let oc = (sc * sa + dc * da * (1.0 - sa)) / oa;
123 (oc * 255.0 + 0.5).clamp(0.0, 255.0) as u8
124 };
125 Self {
126 r: chan(self.r, dst.r),
127 g: chan(self.g, dst.g),
128 b: chan(self.b, dst.b),
129 a: (oa * 255.0 + 0.5).clamp(0.0, 255.0) as u8,
130 }
131 }
132
133 /// The WCAG relative luminance of this colour, from 0 for black to 1 for white.
134 ///
135 /// Each channel is taken back from the display-encoded sRGB the colour is stored in to the
136 /// linear-light value the eye weighs, by the sRGB transfer function, and the three are then
137 /// combined with the luminance weights the standard gives. This is the quantity two colours'
138 /// contrast is measured from, so it is worked in `f64`: the contrast of near-black text against
139 /// black turns on small differences the wider type keeps.
140 ///
141 /// Alpha is ignored. Luminance is a property of a colour once it is on the screen, and a colour
142 /// with alpha is not yet on the screen -- a caller wanting the luminance of a translucent colour
143 /// over a background should composite it with [`Rgba::over`] first.
144 pub fn relative_luminance(&self) -> f64 {
145 // The sRGB transfer function, taking one channel from encoded 0..255 to linear 0..1.
146 let lin = |c: u8| -> f64 {
147 let c = (c as f64) / 255.0;
148 if c <= 0.03928 {
149 c / 12.92
150 } else {
151 ((c + 0.055) / 1.055).powf(2.4)
152 }
153 };
154 0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b)
155 }
156
157 /// The WCAG contrast ratio between this colour and another, from 1 for two equal colours to 21
158 /// for black against white.
159 ///
160 /// The ratio is `(L1 + 0.05) / (L2 + 0.05)`, where `L1` is the lighter of the two relative
161 /// luminances and `L2` the darker, and the `0.05` is the flare the standard adds for the light a
162 /// real screen reflects even where it shows black. The result does not depend on which colour is
163 /// named first: the brighter is always taken as `L1`.
164 ///
165 /// WCAG asks 4.5 of body text and 3 of large text for its AA level, and 7 and 4.5 for AAA.
166 pub fn contrast_ratio(&self, other: &Self) -> f64 {
167 let a = self.relative_luminance();
168 let b = other.relative_luminance();
169 let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
170 (hi + 0.05) / (lo + 0.05)
171 }
172
173 /// This colour as it would look to an eye with a colour-vision deficiency.
174 ///
175 /// The simulation is a single linear map of the sRGB channels, the standard matrix for each of
176 /// the three dichromacies (see [`ColourVision`]). It is the approximation used across the web
177 /// tooling that checks a palette for the roughly one man in twelve who cannot tell red from
178 /// green: not a model of the retina, but enough to show whether two colours a design leans on
179 /// collapse into one for a viewer who lacks a cone. Alpha is carried through unchanged.
180 pub fn simulate(&self, cvd: ColourVision) -> Self {
181 let m = cvd.matrix();
182 // The channels stay in sRGB: these matrices are fitted to the encoded values, not to
183 // linear light, so no transfer function is applied on the way through.
184 let (r, g, b) = (self.r as f32, self.g as f32, self.b as f32);
185 let ch = |row: [f32; 3]| -> u8 {
186 (row[0] * r + row[1] * g + row[2] * b + 0.5).clamp(0.0, 255.0) as u8
187 };
188 Self {
189 r: ch(m[0]),
190 g: ch(m[1]),
191 b: ch(m[2]),
192 a: self.a,
193 }
194 }
195}
196
197/// One colour of a gradient, at a position along it.
198#[derive(Clone, Copy, Debug, PartialEq)]
199pub struct Stop {
200 pub at: f32, // zero at the start of the gradient, one at the end
201 pub colour: Rgba,
202}
203
204impl Stop {
205
206 pub fn new(at: f32, colour: Rgba) -> Self {
207 Self { at, colour }
208 }
209}
210
211/// A paint whose colour varies with position, as a concrete pair rather than a trait object.
212///
213/// Both forms carry their stops in the same order and are read the same way, so the only difference
214/// between them is what "along the gradient" means: a distance along an axis, or a distance from a
215/// centre. A position before the first stop takes the first stop's colour and one past the last
216/// takes the last's, which is the padding an SVG gradient does unless it is told otherwise.
217#[derive(Clone, Debug, PartialEq)]
218pub enum Gradient {
219 Linear { // along the line from one point to another, in the path's own coordinates
220 from: (f32, f32), // where the gradient starts
221 to: (f32, f32), // and where it ends
222 stops: Vec<Stop>, // the colours along it, which need not be sorted
223 },
224 Radial { // outwards from a centre, in the path's own coordinates
225 centre: (f32, f32), // position zero
226 radius: f32, // where position one is reached; must be positive
227 stops: Vec<Stop>, // the colours along it, which need not be sorted
228 },
229}
230
231impl Gradient {
232
233 /// A gradient of two colours along a line.
234 pub fn two(from: (f32, f32), to: (f32, f32), start: Rgba, end: Rgba) -> Self {
235 Self::Linear {
236 from,
237 to,
238 stops: vec![Stop::new(0.0, start), Stop::new(1.0, end)],
239 }
240 }
241
242 /// The stops, in the order they were given.
243 pub fn stops(&self) -> &[Stop] {
244 match self {
245 Self::Linear { stops, .. } => stops,
246 Self::Radial { stops, .. } => stops,
247 }
248 }
249
250 /// Checks the gradient can be sampled, and sorts its stops into order.
251 ///
252 /// A gradient with no stops paints nothing and one whose radius is not positive divides by
253 /// zero, so both are refused here rather than at a pixel. The stops are sorted because the
254 /// sampling walks them in order and a caller listing them out of order means the positions
255 /// rather than the order they typed.
256 pub fn prepare(&self) -> Outcome<Self> {
257 let mut g = self.clone();
258 let stops = match &mut g {
259 Self::Linear { stops, .. } => stops,
260 Self::Radial { radius, stops, .. } => {
261 if !(*radius > 0.0) || !radius.is_finite() {
262 return Err(err!(
263 "A radial gradient's radius is {}, and must be a positive number.", radius;
264 Invalid, Input));
265 }
266 stops
267 },
268 };
269 if stops.is_empty() {
270 return Err(err!("A gradient must carry at least one stop, and carries none.";
271 Invalid, Input, Missing));
272 }
273 for s in stops.iter() {
274 if !s.at.is_finite() {
275 return Err(err!("A gradient stop sits at {}, which is not a position.", s.at;
276 Invalid, Input));
277 }
278 }
279 stops.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap_or(std::cmp::Ordering::Equal));
280 Ok(g)
281 }
282
283 /// Where the point `(x, y)` falls along the gradient, from zero to one, unclamped.
284 ///
285 /// A linear gradient whose two points coincide has no direction and no length, so every point
286 /// is at its end: the whole shape takes the last stop's colour, which is what a gradient of no
287 /// extent degenerates to and is better than a division by zero.
288 pub fn position(&self, x: f32, y: f32) -> f32 {
289 match self {
290 Self::Linear { from, to, .. } => {
291 let (dx, dy) = (to.0 - from.0, to.1 - from.1);
292 let len2 = dx * dx + dy * dy;
293 if len2 <= 0.0 {
294 return 1.0;
295 }
296 ((x - from.0) * dx + (y - from.1) * dy) / len2
297 },
298 Self::Radial { centre, radius, .. } => {
299 let (dx, dy) = (x - centre.0, y - centre.1);
300 (dx * dx + dy * dy).sqrt() / radius
301 },
302 }
303 }
304
305 /// The colour at a position along the gradient, the stops taken as already sorted.
306 ///
307 /// Interpolation is linear in straight, non-premultiplied sRGB on all four channels, which is
308 /// what an SVG gradient specifies and so what a caller comparing against a browser will see.
309 pub fn sample(&self, t: f32) -> Rgba {
310 let stops = self.stops();
311 let first = match stops.first() {
312 Some(s) => s,
313 None => return Rgba::TRANSPARENT,
314 };
315 if t <= first.at {
316 return first.colour;
317 }
318 let last = &stops[stops.len() - 1];
319 if t >= last.at {
320 return last.colour;
321 }
322 for pair in stops.windows(2) {
323 let (a, b) = (&pair[0], &pair[1]);
324 if t >= a.at && t <= b.at {
325 let span = b.at - a.at;
326 // Two stops at the same position are a hard edge, and the second wins.
327 if span <= 0.0 {
328 return b.colour;
329 }
330 let f = (t - a.at) / span;
331 let ch = |p: u8, q: u8| -> u8 {
332 ((p as f32) + ((q as f32) - (p as f32)) * f + 0.5).clamp(0.0, 255.0) as u8
333 };
334 return Rgba {
335 r: ch(a.colour.r, b.colour.r),
336 g: ch(a.colour.g, b.colour.g),
337 b: ch(a.colour.b, b.colour.b),
338 a: ch(a.colour.a, b.colour.a),
339 };
340 }
341 }
342 last.colour
343 }
344}
345
346/// A form of colour blindness, for [`Rgba::simulate`] to show a colour through.
347///
348/// The three dichromacies, each the loss of one of the eye's three cones. Protanopia and
349/// deuteranopia are the two red-green kinds and together much the most common; tritanopia, the
350/// blue-yellow kind, is rare.
351#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
352pub enum ColourVision {
353 Protanopia, // red-blind: the long-wavelength cone is missing
354 Deuteranopia, // green-blind: the medium-wavelength cone is missing
355 Tritanopia, // blue-blind: the short-wavelength cone is missing
356}
357
358impl ColourVision {
359
360 /// The simulation matrix for this deficiency: three rows, each the weights that make one output
361 /// channel from the three input channels.
362 ///
363 /// These are the widely used dichromat matrices that operate directly on sRGB. Each row sums to
364 /// one, so a grey is left where it was and only the hues that the missing cone distinguished are
365 /// folded together.
366 fn matrix(&self) -> [[f32; 3]; 3] {
367 match self {
368 Self::Protanopia => [
369 [0.567, 0.433, 0.000],
370 [0.558, 0.442, 0.000],
371 [0.000, 0.242, 0.758],
372 ],
373 Self::Deuteranopia => [
374 [0.625, 0.375, 0.000],
375 [0.700, 0.300, 0.000],
376 [0.000, 0.300, 0.700],
377 ],
378 Self::Tritanopia => [
379 [0.950, 0.050, 0.000],
380 [0.000, 0.433, 0.567],
381 [0.000, 0.475, 0.525],
382 ],
383 }
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_opaque_source_replaces_destination_00() {
393 let src = Rgba::new(10, 20, 30, 255);
394 assert_eq!(src.over(Rgba::WHITE), src);
395 }
396
397 #[test]
398 fn test_transparent_source_leaves_destination_01() {
399 assert_eq!(Rgba::TRANSPARENT.over(Rgba::WHITE), Rgba::WHITE);
400 }
401
402 #[test]
403 fn test_half_alpha_black_over_white_is_grey_02() {
404 let src = Rgba::new(0, 0, 0, 128);
405 let out = src.over(Rgba::WHITE);
406 assert_eq!(out.a, 255);
407 // 128/255 of the way from white to black.
408 assert!(out.r >= 126 && out.r <= 128, "expected mid grey, found {}", out.r);
409 }
410
411 #[test]
412 fn test_coverage_scales_alpha_03() {
413 let c = Rgba::new(1, 2, 3, 200).with_coverage(0.5);
414 assert_eq!(c.a, 100);
415 assert_eq!((c.r, c.g, c.b), (1, 2, 3));
416 }
417
418 #[test]
419 fn test_hex_forms_04() -> Outcome<()> {
420 assert_eq!(res!(Rgba::from_hex("#fff")), Rgba::WHITE);
421 assert_eq!(res!(Rgba::from_hex("000000")), Rgba::BLACK);
422 assert_eq!(res!(Rgba::from_hex("#0a141e80")), Rgba::new(10, 20, 30, 128));
423 assert!(Rgba::from_hex("#xyz").is_err());
424 assert!(Rgba::from_hex("#ffff").is_err());
425 Ok(())
426 }
427
428 #[test]
429 fn test_hex_round_trips_through_the_reader_05() -> Outcome<()> {
430 // A colour written out and read back is the colour it began as: to_hex is the inverse of
431 // from_hex, opaque in six digits and translucent in eight.
432 for c in [
433 Rgba::BLACK,
434 Rgba::WHITE,
435 Rgba::new(10, 20, 30, 255),
436 Rgba::new(10, 20, 30, 128),
437 Rgba::new(1, 2, 3, 0),
438 ] {
439 assert_eq!(res!(Rgba::from_hex(&c.to_hex())), c, "{} did not round trip", c.to_hex());
440 }
441 assert_eq!(Rgba::WHITE.to_hex(), "#ffffff");
442 assert_eq!(Rgba::new(10, 20, 30, 128).to_hex(), "#0a141e80");
443 Ok(())
444 }
445
446 #[test]
447 fn test_black_on_white_is_the_maximum_contrast_06() {
448 // The published anchor: black on white is exactly 21:1, and white on itself is 1:1. These
449 // are the two ends of the WCAG scale and fix both the luminances and the ratio formula.
450 let bw = Rgba::BLACK.contrast_ratio(&Rgba::WHITE);
451 assert!((bw - 21.0).abs() < 1e-6, "black on white should be 21:1, found {}", bw);
452 let ww = Rgba::WHITE.contrast_ratio(&Rgba::WHITE);
453 assert!((ww - 1.0).abs() < 1e-6, "white on white should be 1:1, found {}", ww);
454 }
455
456 #[test]
457 fn test_the_contrast_ratio_does_not_depend_on_order_07() {
458 // The lighter colour is always taken as L1, so naming the pair either way gives one answer.
459 let a = Rgba::new(0x76, 0x76, 0x76, 255);
460 assert_eq!(a.contrast_ratio(&Rgba::WHITE), Rgba::WHITE.contrast_ratio(&a));
461 }
462
463 #[test]
464 fn test_the_aa_reference_grey_meets_the_threshold_08() {
465 // #767676 on white is the grey WCAG's own reference gives as ~4.54:1 -- the darkest grey
466 // that clears the 4.5:1 AA bar for body text. This checks the sRGB linearisation against a
467 // published value, not against the formula restated.
468 let grey = Rgba::new(0x76, 0x76, 0x76, 255);
469 let r = grey.contrast_ratio(&Rgba::WHITE);
470 assert!((r - 4.54).abs() < 0.02, "#767676 on white should be ~4.54:1, found {}", r);
471 }
472
473 #[test]
474 fn test_a_grey_is_unmoved_by_a_deficiency_09() {
475 // Every simulation matrix has rows that sum to one, so an achromatic colour, which loses no
476 // hue because it has none, comes back where it was.
477 let grey = Rgba::new(128, 128, 128, 200);
478 for cvd in [ColourVision::Protanopia, ColourVision::Deuteranopia, ColourVision::Tritanopia] {
479 let out = grey.simulate(cvd);
480 assert!((out.r as i32 - 128).abs() <= 1, "{:?} moved a grey to {}", cvd, out.r);
481 assert!((out.g as i32 - 128).abs() <= 1, "{:?} moved a grey to {}", cvd, out.g);
482 assert!((out.b as i32 - 128).abs() <= 1, "{:?} moved a grey to {}", cvd, out.b);
483 assert_eq!(out.a, 200, "alpha must be carried through");
484 }
485 }
486
487 #[test]
488 fn test_red_and_green_collapse_under_protanopia_10() {
489 // The point of the simulation: a red and a green a design might rely on to differ become
490 // nearly the same colour to a red-green-blind eye. Measured as a distance through the RGB
491 // cube, which turns on hue and not just brightness, the gap between them falls to a fraction
492 // of what it was once both are seen through protanopia.
493 let red = Rgba::new(230, 40, 40, 255);
494 let green = Rgba::new(40, 180, 40, 255);
495 let dist = |a: Rgba, b: Rgba| -> f32 {
496 let dr = a.r as f32 - b.r as f32;
497 let dg = a.g as f32 - b.g as f32;
498 let db = a.b as f32 - b.b as f32;
499 (dr * dr + dg * dg + db * db).sqrt()
500 };
501 let normal = dist(red, green);
502 let seen = dist(
503 red.simulate(ColourVision::Protanopia),
504 green.simulate(ColourVision::Protanopia),
505 );
506 assert!(
507 seen < 0.4 * normal,
508 "red and green stood {} apart but should collapse under protanopia, found {}",
509 normal, seen,
510 );
511 }
512 #[test]
513 fn test_a_gradient_pads_at_both_ends_11() -> Outcome<()> {
514 let g = res!(Gradient::two((10.0, 0.0), (20.0, 0.0), Rgba::BLACK, Rgba::WHITE).prepare());
515 // Before the start and after the end, the end stops hold rather than repeating or
516 // reflecting, which is what an SVG gradient does unless told otherwise.
517 req!(g.sample(g.position(0.0, 0.0)), Rgba::BLACK);
518 req!(g.sample(g.position(-500.0, 0.0)), Rgba::BLACK);
519 req!(g.sample(g.position(20.0, 0.0)), Rgba::WHITE);
520 req!(g.sample(g.position(500.0, 0.0)), Rgba::WHITE);
521 req!(g.sample(g.position(15.0, 0.0)), Rgba::new(128, 128, 128, 255));
522 Ok(())
523 }
524
525 #[test]
526 fn test_a_gradient_is_read_along_its_axis_and_not_across_it_12() -> Outcome<()> {
527 // The axis runs down the page, so moving across it must change nothing at all. A sampler
528 // that took a distance rather than a projection would shade this in rings.
529 let g = res!(Gradient::two((0.0, 0.0), (0.0, 100.0), Rgba::BLACK, Rgba::WHITE).prepare());
530 let at = g.sample(g.position(0.0, 40.0));
531 req!(g.sample(g.position(-300.0, 40.0)), at);
532 req!(g.sample(g.position(900.0, 40.0)), at);
533 Ok(())
534 }
535
536 #[test]
537 fn test_a_radial_gradient_is_read_as_a_distance_13() -> Outcome<()> {
538 let g = res!(Gradient::Radial {
539 centre: (50.0, 50.0),
540 radius: 10.0,
541 stops: vec![Stop::new(0.0, Rgba::BLACK), Stop::new(1.0, Rgba::WHITE)],
542 }.prepare());
543 req!(g.sample(g.position(50.0, 50.0)), Rgba::BLACK);
544 // Every point at the radius is at the end, whichever way it lies from the centre.
545 req!(g.sample(g.position(60.0, 50.0)), Rgba::WHITE);
546 req!(g.sample(g.position(50.0, 40.0)), Rgba::WHITE);
547 // The position is the distance and not its square: half way out is half way along.
548 req!(g.sample(g.position(55.0, 50.0)), Rgba::new(128, 128, 128, 255));
549 Ok(())
550 }
551
552 #[test]
553 fn test_a_gradient_refuses_what_it_cannot_sample_14() {
554 let none = Gradient::Linear { from: (0.0, 0.0), to: (1.0, 0.0), stops: Vec::new() };
555 assert!(none.prepare().is_err(), "a gradient with no stops must be refused");
556 let flat = Gradient::Radial { centre: (0.0, 0.0), radius: 0.0, stops: vec![
557 Stop::new(0.0, Rgba::BLACK)] };
558 assert!(flat.prepare().is_err(), "a radial gradient of no radius must be refused");
559 }
560
561 #[test]
562 fn test_stops_out_of_order_are_sorted_rather_than_believed_15() -> Outcome<()> {
563 let g = res!(Gradient::Linear {
564 from: (0.0, 0.0),
565 to: (10.0, 0.0),
566 stops: vec![
567 Stop::new(1.0, Rgba::WHITE),
568 Stop::new(0.0, Rgba::BLACK),
569 ],
570 }.prepare());
571 req!(g.sample(0.0), Rgba::BLACK);
572 req!(g.sample(1.0), Rgba::WHITE);
573 req!(g.sample(0.5), Rgba::new(128, 128, 128, 255));
574 Ok(())
575 }
576
577}