Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/tests/gradient_oracle.rs

6.5 KiB, 13 runs

created by r1870400018:20049, 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//! Shade a shape the way a browser shades it, and check the colours land in the same places.
2//!
3//! The unit tests for [`Gradient`] assert what its own sampler returns, which is a fair check of the
4//! interpolation and no check at all of the conventions around it: whether a position is measured
5//! along the axis or across it, whether the ends pad or repeat, whether a radial gradient's position
6//! is a distance or a squared one, and whether the colour is interpolated straight or premultiplied.
7//! Every one of those can be wrong while the sampler agrees with itself, and the tests written by the
8//! same hand as the sampler would still pass.
9//!
10//! The fixtures in `tests/gradient/` close that gap. Each `.grad` file describes one gradient and the
11//! rectangle it fills, in four line kinds both sides read, and the `.png` beside it is Chromium's
12//! rendering of exactly that description. Nothing in the expected output originates here.
13//!
14//! The tolerance is two levels a channel and the worst divergence observed is one, over a mean of a
15//! tenth. Chromium dithers a gradient to hide the banding a long, shallow ramp would otherwise show,
16//! so its output is deliberately not the exact ramp and two renderers are not obliged to agree to
17//! the bit. The test prints the worst divergence and the mean it saw, so a drift towards the
18//! tolerance is visible before it crosses it.
19//!
20//! To regenerate the PNGs: see `tests/gradient/gen.sh`.
21//!
22//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
23//! Anthropic Claude
24
25use oxedyne_fe2o3_core::prelude::*;
26use oxedyne_fe2o3_graphics::{
27 colour::{
28 Gradient,
29 Rgba,
30 Stop,
31 },
32 path::{
33 Bounds,
34 Path,
35 },
36 pixmap::Pixmap,
37 png,
38 raster::FillRule,
39 transform::Transform,
40};
41
42use std::{
43 fs,
44 path::PathBuf,
45};
46
47const SIZE: usize = 256; // the side of every fixture, in pixels; gen.sh renders at this size
48const TOL: i32 = 2; // the furthest a channel may differ from the browser's
49
50const CASES: &[&str] = &["linear_v", "linear_diag", "linear_pad", "radial", "linear_alpha"];
51
52/// A colour's three channels multiplied by its alpha, which is what is seen.
53///
54/// The comparison is made here rather than on the straight channels because the browser's PNG has
55/// been through a premultiplied buffer, so a colour under a very low alpha has been quantised to
56/// nothing on the way and cannot be recovered: at an alpha of one two-hundred-and-fifty-fifth, a
57/// saturated magenta and a black are the same picture, and demanding that two renderers agree about
58/// which of them it was is demanding agreement about something invisible.
59fn premul(c: Rgba) -> (u8, u8, u8) {
60 let f = |v: u8| -> u8 { (((v as u32) * (c.a as u32) + 127) / 255) as u8 };
61 (f(c.r), f(c.g), f(c.b))
62}
63
64/// A fixture, read from its `.grad` file.
65struct Case {
66 grad: Gradient,
67 rect: Bounds, // the rectangle it fills
68}
69
70/// Reads a `.grad` fixture. The format is described in `gen.sh`, and both read it.
71fn read_case(text: &str) -> Outcome<Case> {
72 let mut axis: Option<(f32, f32, f32, f32)> = None;
73 let mut centre: Option<(f32, f32, f32)> = None;
74 let mut stops = Vec::new();
75 let mut rect = None;
76 for line in text.lines() {
77 let line = line.trim();
78 if line.is_empty() || line.starts_with('#') {
79 continue;
80 }
81 let f: Vec<&str> = line.split_whitespace().collect();
82 let num = |s: &str| -> Outcome<f32> {
83 Ok(res!(s.parse::<f32>(), Invalid, Input, Decode))
84 };
85 match f[0] {
86 "linear" => axis = Some((
87 res!(num(f[1])), res!(num(f[2])), res!(num(f[3])), res!(num(f[4])),
88 )),
89 "radial" => centre = Some((res!(num(f[1])), res!(num(f[2])), res!(num(f[3])))),
90 "stop" => {
91 let hex = f[2];
92 let ch = |i: usize| -> Outcome<u8> {
93 Ok(res!(u8::from_str_radix(&hex[i..i + 2], 16), Invalid, Input, Decode))
94 };
95 stops.push(Stop::new(
96 res!(num(f[1])),
97 Rgba::new(res!(ch(0)), res!(ch(2)), res!(ch(4)), res!(ch(6))),
98 ));
99 },
100 "rect" => {
101 let (x, y, w, h) = (res!(num(f[1])), res!(num(f[2])), res!(num(f[3])), res!(num(f[4])));
102 rect = Some(Bounds::new(x, y, x + w, y + h));
103 },
104 other => return Err(err!("A fixture line begins {:?}, which names nothing.", other;
105 Invalid, Input)),
106 }
107 }
108 let grad = match (axis, centre) {
109 (Some((x0, y0, x1, y1)), None) => Gradient::Linear {
110 from: (x0, y0),
111 to: (x1, y1),
112 stops,
113 },
114 (None, Some((cx, cy, r))) => Gradient::Radial {
115 centre: (cx, cy),
116 radius: r,
117 stops,
118 },
119 _ => return Err(err!("A fixture must name exactly one of a linear or a radial gradient.";
120 Invalid, Input)),
121 };
122 Ok(Case {
123 grad,
124 rect: match rect {
125 Some(r) => r,
126 None => return Err(err!("A fixture must name the rectangle it fills.";
127 Invalid, Input, Missing)),
128 },
129 })
130}
131
132#[test]
133fn test_a_gradient_shades_where_a_browser_shades_00() -> Outcome<()> {
134 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/gradient");
135 let mut worst = 0i32;
136 let mut worst_case = String::new();
137 let mut total = 0u64;
138 let mut count = 0u64;
139
140 for name in CASES {
141 let text = res!(fs::read_to_string(dir.join(format!("{}.grad", name))), IO, File, Read);
142 let case = res!(read_case(&text));
143
144 let mut pm = res!(Pixmap::new(SIZE, SIZE));
145 let path = res!(Path::rect(case.rect));
146 res!(pm.fill_gradient(&path, &Transform::IDENTITY, &case.grad, None, FillRule::NonZero));
147
148 let want = res!(png::decode(&res!(fs::read(dir.join(format!("{}.png", name))), IO, File, Read)));
149 req!(want.width(), SIZE);
150 req!(want.height(), SIZE);
151
152 for y in 0..SIZE {
153 for x in 0..SIZE {
154 let got = match pm.pixel(x, y) {
155 Some(c) => c,
156 None => return Err(err!("Our pixel ({}, {}) is off the pixmap.", x, y;
157 Invalid, Input, Range)),
158 };
159 let exp = match want.pixel(x, y) {
160 Some(c) => c,
161 None => return Err(err!("The browser's pixel ({}, {}) is off the image.", x, y;
162 Invalid, Input, Range)),
163 };
164 let (gp, ep) = (premul(got), premul(exp));
165 let pairs = [(gp.0, ep.0), (gp.1, ep.1), (gp.2, ep.2), (got.a, exp.a)];
166 for i in 0..4 {
167 let d = ((pairs[i].0 as i32) - (pairs[i].1 as i32)).abs();
168 total += d as u64;
169 count += 1;
170 if d > worst {
171 worst = d;
172 worst_case = format!(
173 "{} at ({}, {}): ours {:?}, the browser's {:?}", name, x, y, got, exp);
174 }
175 }
176 }
177 }
178 }
179 println!(
180 "The worst channel differs by {} and the mean by {:.4}. Worst: {}",
181 worst, (total as f64) / (count as f64), worst_case,
182 );
183 if worst > TOL {
184 return Err(err!(
185 "A gradient differs from the browser's by {} levels, over the tolerance of {}. {}",
186 worst, TOL, worst_case;
187 Invalid, Input, Mismatch));
188 }
189 Ok(())
190}