Files
vectorcraft/crates/render/src/tests_dither.rs
T
Taha-Mahmoodi 2bad646821 Gradients: a Perceptual gradient mixes its colours in OKLab, and SVG and PDF write it as an image
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.

- Gradient gains `method` (Classic | Perceptual; serde default Classic,
  written only when Perceptual, so older files open as Classic and Classic
  files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
  sample_keep_model for the stroke meshes, Expand and blends); opacity stays
  linear. Renderers that mix linearly get stops on the perceptual curve,
  placed by halving each half segment where a straight line strays more than
  0.4 of a colour level (a nearly straight segment needs none). Classic
  gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
  Perceptual fill or stroke as an image at the document's raster effects
  resolution, clipped to what it paints. A gradient along or across a stroke
  stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
  noise (the canvas's dithered grids are keyed by method too); SVG and PDF
  write it as an image with the noise, and a dithered Perceptual stroke as an
  image without it, as the canvas draws it (GradientPaint::pixels, SVG's
  raster_paint and PDF's area are told whether they paint a fill).
2026-10-23 01:53:29 +07:00

173 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dithered gradient fills: fine noise instead of bands, the same colours on average, the same
//! pixels on every render, and nothing changed without dither or on strokes.
use super::*;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
const W: u32 = 400;
const H: u32 = 40;
/// A shallow grey ramp, #404040 → #4a4a4a: ten levels over 400 pixels, bands 40 pixels wide.
fn shallow(kind: GradientKind, dither: bool) -> Paint {
let mut g =
Gradient::new(kind, vec![GradientStop::new(0.0, Color::rgb8(0x40, 0x40, 0x40)), GradientStop::new(1.0, Color::rgb8(0x4a, 0x4a, 0x4a))]);
g.dither = dither;
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
/// A W × H document with a rectangle over it painted `fill`, stroked `stroke` 10 wide.
fn doc(fill: Paint, stroke: Paint) -> Document {
let mut d = Document::new(W as f64, H as f64);
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, W as f64, H as f64)), Appearance::basic(fill, stroke, 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document, xf: Affine) -> Rendered {
Renderer::new().render(d, W, H, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The longest run of identical pixels along row `y` (the stroke's 5 pixels at each end left out).
fn longest_run(r: &Rendered, y: u32) -> usize {
let row: Vec<[u8; 4]> = (5..W - 5).map(|x| r.pixel(x, y)).collect();
row.chunk_by(|a, b| a == b).map(<[_]>::len).max().unwrap_or(0)
}
#[test]
fn a_dithered_shallow_gradient_has_no_bands_where_the_plain_one_does() {
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY);
let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), Affine::IDENTITY);
for y in [3, 20, 36] {
assert!(longest_run(&plain, y) >= 30, "the plain ramp bands: row {y}, longest run {}", longest_run(&plain, y));
assert!(longest_run(&dithered, y) <= 12, "dithered: row {y}, longest run {}", longest_run(&dithered, y));
}
// The noise moves no colour: each 20-pixel stretch averages what the plain ramp does.
for x0 in (0..W).step_by(20) {
let mean = |r: &Rendered| {
(x0..x0 + 20).flat_map(|x| (0..H).map(move |y| (x, y))).map(|(x, y)| f64::from(r.pixel(x, y)[0])).sum::<f64>() / f64::from(20 * H)
};
assert!((mean(&plain) - mean(&dithered)).abs() < 0.35, "x {x0}: {} vs {}", mean(&plain), mean(&dithered));
}
// Noise of at most a level or so either way.
for (x, y) in [(10, 10), (200, 20), (390, 30)] {
let (a, b) = (i32::from(plain.pixel(x, y)[0]), i32::from(dithered.pixel(x, y)[0]));
assert!((a - b).abs() <= 2, "({x}, {y}): {a} vs {b}");
}
}
#[test]
fn dithered_renders_are_the_same_every_time() {
let d = doc(shallow(GradientKind::Radial, true), Paint::None);
let (a, b) = (render(&d, Affine::IDENTITY), render(&d, Affine::IDENTITY));
assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))));
// And with the grid drawn afresh on another renderer, panned by a whole pixel.
let moved = Renderer::new().render(
&d,
W,
H,
Affine::translate((1.0, 0.0)),
&RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() },
);
assert!((0..H).all(|y| (1..W).all(|x| moved.pixel(x, y) == a.pixel(x - 1, y))), "the noise moves with the art");
}
#[test]
fn dither_leaves_strokes_and_undithered_fills_as_they_were() {
// Dither applies to fills only: a stroke draws its gradient as before.
let fill = Paint::solid(Color::WHITE);
let a = render(&doc(fill.clone(), shallow(GradientKind::Linear, false)), Affine::IDENTITY);
let b = render(&doc(fill, shallow(GradientKind::Linear, true)), Affine::IDENTITY);
assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))), "a dithered gradient on a stroke draws as an undithered one");
// Off: the vector gradient, its bands where they always were.
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY);
assert_eq!(plain.pixel(0, 20)[0], 0x40);
assert_eq!(plain.pixel(W - 1, 20)[0], 0x4a);
}
#[test]
fn a_zoomed_or_rotated_dithered_gradient_keeps_its_colours() {
for xf in [Affine::scale(3.0), Affine::rotate_about(0.3, vectorcraft_geom::Point::new(200.0, 20.0)), Affine::scale(40.0)] {
let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), xf);
let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), xf);
for (x, y) in [(50, 10), (150, 20), (250, 30)] {
let (a, b) = (plain.pixel(x, y), dithered.pixel(x, y));
assert!(a.iter().zip(b).all(|(p, q)| (i32::from(*p) - i32::from(q)).abs() <= 2), "{xf:?} ({x}, {y}): {a:?} vs {b:?}");
}
}
}
#[test]
fn a_large_dithered_box_mostly_off_screen_samples_only_what_shows() {
// A 2000-unit square, the shallow ramp across it: a 2048-cell grid a side, 16 MiB.
const S: u32 = 2000;
let mut d = Document::new(f64::from(S), f64::from(S));
let id = d.alloc_id();
let n = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, f64::from(S), f64::from(S))),
Appearance::basic(shallow(GradientKind::Linear, true), Paint::None, 0.0),
);
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let whole = Renderer::new().render(&d, S, S, Affine::IDENTITY, &opts);
// A 64-pixel view of its middle draws the pixels the whole render has there...
let mut part = None;
let bytes = dither::grid_bytes_after(|| part = Some(Renderer::new().render(&d, 64, 64, Affine::translate((-1000.0, -1000.0)), &opts)));
let part = part.unwrap();
assert!((0..64).all(|y| (0..64).all(|x| part.pixel(x, y) == whole.pixel(1000 + x, 1000 + y))), "the view draws what the whole render does");
// ...from the cells around it only, not the whole grid.
assert!(bytes > 0 && bytes <= 512 * 512 * 4, "{bytes} bytes of grid for a 64-pixel view");
}
#[test]
fn a_dithered_gradient_without_stops_draws_nothing_and_samples_nothing() {
let mut g = Gradient::new(GradientKind::Linear, Vec::new());
g.dither = true;
let d = doc(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None);
let mut r = None;
let bytes = dither::grid_bytes_after(|| r = Some(render(&d, Affine::IDENTITY)));
assert_eq!(bytes, 0, "no grid is sampled");
let r = r.unwrap();
assert!((0..H).all(|y| (0..W).all(|x| r.pixel(x, y) == [255, 255, 255, 255])), "nothing is drawn");
}
/// Blue to yellow across the document, mixed `method`'s way, dithered or not.
fn blue_yellow(method: vectorcraft_color::GradientMethod, dither: bool) -> Paint {
let mut g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
);
(g.method, g.dither) = (method, dither);
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
#[test]
fn a_dithered_perceptual_gradient_is_the_perceptual_mix_with_noise() {
use vectorcraft_color::GradientMethod::{Classic, Perceptual};
// Classic first: the two share stops and box, and must not share a dithered grid.
let classic = render(&doc(blue_yellow(Classic, true), Paint::None), Affine::IDENTITY);
let dithered = render(&doc(blue_yellow(Perceptual, true), Paint::None), Affine::IDENTITY);
// Each pixel within a level or so of the perceptual mix across it (its grid cell's centre lies
// in it; the noise, rounding, and the mix's stops within half a level), and dither's noise
// moving many off the rounded mix at its centre.
let Paint::Gradient(want) = blue_yellow(Perceptual, false) else { panic!("a gradient") };
let mix = |x: f32| want.gradient.sample(x / W as f32).0.to_rgb().map(|v| v.clamp(0.0, 1.0) * 255.0);
let mut noisy = 0;
for x in 5..W - 5 {
let p = dithered.pixel(x, 20);
let (a, m, b) = (mix(x as f32), mix(x as f32 + 0.5), mix(x as f32 + 1.0));
let near = |k: usize| (a[k].min(b[k]) - 2.0..=a[k].max(b[k]) + 2.0).contains(&f32::from(p[k]));
assert!((0..3).all(near), "({x}, 20): {p:?} vs {a:?} to {b:?}");
noisy += usize::from((0..3).any(|k| p[k] != m[k].round() as u8));
}
assert!(noisy > 100, "dither's noise: {noisy} pixels off the rounded mix");
// The middle: a blue-grey (OKLab), where Classic+Dither gives the display RGB mid grey.
let [r, g, b, _] = dithered.pixel(W / 2, 20).map(i32::from);
assert!(b > r + 20 && b > g + 10, "perceptual: {:?}", [r, g, b]);
let [r, g, b, _] = classic.pixel(W / 2, 20).map(i32::from);
assert!((r - b).abs() <= 4 && (g - b).abs() <= 4, "classic: {:?}", [r, g, b]);
}