Files
vectorcraft/crates/render/src/tests_dither.rs
T
Taha-Mahmoodi cc1a660a6d Gradients: Dither in the Gradient panel draws a gradient fill with fine noise that hides banding
Illustrator 30.0's gradient Dither, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
a Dither checkbox in the Gradient panel; dither converts the gradient into
pixels with subtle noise, works with gradient fills only, and follows the
Document Raster Effects resolution.

- Gradient gains `dither` (serde default false, written only when on, so
  older files open unchanged and new files keep it).
- The renderer draws a dithered linear or radial fill as an image paint: the
  gradient sampled a cell per device pixel or finer (quarter-octave steps,
  cached per thread), each channel given seeded triangular noise of up to a
  level before rounding. Same pixels every run; strokes and undithered fills
  draw exactly as before. PNG and other raster exports go through it.
- SVG and PDF write a dithered fill as an image at the document's raster
  effects resolution, clipped to the shape (the freeform-gradient path);
  characters' fills stay vector gradients in SVG, with a warning.
- paint.editGradient and paint.setFill take `dither`; strokes refuse it.
  The Gradient panel's Dither box is dimmed behind the Stroke proxy.
- i18n rows in all 24 catalogs; docs: G13, target-app-parity, ui-parity,
  mcp.md, plugins.md.
2026-10-22 22:38:42 +07:00

101 lines
4.9 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:?}");
}
}
}