Files
vectorcraft/crates/render/src/tests/freeform.rs
T
dotCooCoo ac0bb3c4ce Gradients: freeform points that share a color model mix in that model
The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mixed
freeform points in display RGB, while Object > Expand and Create Gradient
Mesh mixed them in the model the points share. Points that are all CMYK,
Gray, Lab or RGB now mix in that model in those places too. Points in
different models mix as before: in display RGB, and on ink planes as the
inks of each point.

This changes how existing art looks:

- CMYK and Lab points. With the default Generic CMYK working space and no
  ink planes, halfway between C100 and K100 the canvas drew (17, 106, 139)
  and now draws (54, 118, 143).
- RGB points in a working RGB space other than sRGB mix in that space.
- On ink planes (a CMYK document that shows transparency), RGB and Lab
  points mix first and then separate.

Gray mixes keep their values, and CMYK and Gray points on ink planes draw
as before. RGB points in an sRGB working space draw as before except on
ink planes.

- PDF and EPS write a gradient whose points are all CMYK, or all Gray in a
  CMYK file, as a CMYK image. PDF's Output settings convert it as they
  convert any CMYK image.
- Darkening applies to the points before they mix.
- With Appearance of Black set to Rich, the canvas mixes a gradient with a
  pure CMYK black point in display RGB, and the point fades from RGB black.
2026-11-08 17:18:43 -08:00

205 lines
9.1 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.
//! Freeform gradients: the rendered colour grid against the colour field.
use crate::*;
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::{Point, shapes};
/// Red at the top left, blue at the bottom right, a half-transparent green at the top right.
fn freeform() -> Freeform {
let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) };
Freeform {
points: vec![
p(20.0, 20.0, Color::rgb(1.0, 0.0, 0.0), 1.0),
p(80.0, 80.0, Color::rgb(0.0, 0.0, 1.0), 1.0),
p(80.0, 20.0, Color::rgb(0.0, 1.0, 0.0), 0.5),
],
..Default::default()
}
}
fn paint(f: Option<Freeform>) -> Paint {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = f;
Paint::Gradient(Box::new(g))
}
/// A 100 × 100 document with `shape` (on 0..100) painted `fill`, at `opacity`.
fn doc(shape: vectorcraft_geom::PathData, fill: Paint, opacity: f32) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n = Node::path(id, shape, Appearance::basic(fill, Paint::None, 0.0));
n.opacity = opacity;
d.insert(Some(d.layers[0].id), 0, n).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The field's colour at `p` composited over white (8-bit).
fn expected(f: &Freeform, p: Point, opacity: f32) -> [u8; 4] {
let ([r, g, b], a) = f.field(spread_scale(Rect::new(0.0, 0.0, 100.0, 100.0))).sample(p);
let a = a * opacity;
let q = |v: f32| ((v * a + (1.0 - a)) * 255.0).round() as u8;
[q(r), q(g), q(b), 255]
}
fn near(a: [u8; 4], b: [u8; 4], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
/// `a` and `b` on either side of (50.5, 50.5): the point a pixel center and a grid cell share.
fn pair(a: Color, b: Color) -> Freeform {
let p = |x, c| FreeformPoint::new(Point::new(x, 50.5), c);
Freeform { points: vec![p(20.5, a), p(80.5, b)], ..Default::default() }
}
const CYAN: Color = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
const BLACK: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
/// The middle pixel of a square painted with `pair(a, b)`.
fn middle(a: Color, b: Color) -> [u8; 4] {
render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(pair(a, b))), 1.0)).pixel(50, 50)
}
#[test]
fn points_that_share_a_model_mix_in_it() {
let want = Color::cmyk(0.5, 0.0, 0.0, 0.5).to_rgba8(1.0);
assert!(near(middle(CYAN, BLACK), want, 3), "CMYK: {:?} vs {want:?}", middle(CYAN, BLACK));
let (lab1, lab2) = (Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let want = Color::lab(60.0, 0.0, 0.0).to_rgba8(1.0);
assert!(near(middle(lab1, lab2), want, 3), "Lab: {:?} vs {want:?}", middle(lab1, lab2));
let want = Color::gray(0.5).to_rgba8(1.0);
assert!(near(middle(Color::gray(0.2), Color::gray(0.8)), want, 3), "Gray");
}
#[test]
fn rgb_points_and_points_in_different_models_mix_in_display_rgb() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let want = Color::rgb(0.5, 0.0, 0.5).to_rgba8(1.0);
assert!(near(middle(red, blue), want, 3), "RGB: {:?} vs {want:?}", middle(red, blue));
let gray = Color::gray(0.5);
let [r, g, b] = [red.to_rgb(), gray.to_rgb()].into_iter().fold([0.0; 3], |m, c| [m[0] + c[0] / 2.0, m[1] + c[1] / 2.0, m[2] + c[2] / 2.0]);
let want = Color::rgb(r, g, b).to_rgba8(1.0);
assert!(near(middle(red, gray), want, 3), "mixed models: {:?} vs {want:?}", middle(red, gray));
}
/// The middle grid cell of `pair(a, b)` rasterized on a 101-point box with `ink`, as RGB.
fn middle_cell(a: Color, b: Color, ink: crate::ink::Ink) -> [u8; 3] {
let cell = crate::freeform::rasterize(&pair(a, b), Rect::new(0.0, 0.0, 101.0, 101.0), 17, 17, ink).sample(8, 8);
[cell.r, cell.g, cell.b]
}
#[test]
fn ink_planes_separate_the_mix_of_lab_points() {
use crate::ink::Ink;
let (lab1, lab2) = (Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let mid = Color::lab(60.0, 0.0, 0.0);
// A color far out of gamut stays as exact as a solid fill of it.
let far = Color::lab(20.0, -125.0, 125.0);
for ink in [Ink::Cmy, Ink::K] {
for (a, b, c) in [(lab1, lab2, mid), (far, far, far)] {
let want = ink.color(&c, 1.0).to_rgba8();
let got = middle_cell(a, b, ink);
assert!(near([got[0], got[1], got[2], 255], [want.r, want.g, want.b, 255], 2), "{ink:?} {c:?}: {got:?} vs {want:?}");
}
}
}
#[test]
fn ink_planes_separate_the_mix_of_rgb_points() {
use crate::ink::Ink;
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let mid = Color::rgb(0.5, 0.0, 0.5);
for ink in [Ink::Cmy, Ink::K] {
let want = ink.color(&mid, 1.0).to_rgba8();
let got = middle_cell(red, blue, ink);
assert!(near([got[0], got[1], got[2], 255], [want.r, want.g, want.b, 255], 3), "{ink:?}: {got:?} vs {want:?}");
}
}
#[test]
fn lab_points_on_an_ink_plane_use_a_coarser_grid() {
use crate::ink::Ink;
let b = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let lab = pair(Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0));
let (cols, rows) = crate::freeform::cells(&lab, b, 4000.0, Ink::Cmy);
assert_eq!((cols, rows), (64, 64));
assert_eq!(crate::freeform::cells(&lab, b, 4000.0, Ink::K), (64, 64));
let (wide, _) = crate::freeform::cells(&lab, b, 4000.0, Ink::Display);
assert!(wide > 64, "{wide}");
let (cmyk, _) = crate::freeform::cells(&pair(CYAN, BLACK), b, 4000.0, Ink::Cmy);
assert!(cmyk > 64, "{cmyk}");
}
#[test]
fn pure_black_points_fade_from_rgb_black_under_rich_black() {
use crate::ink::Ink;
let white = Color::cmyk(0.0, 0.0, 0.0, 0.0);
let [r, g, b] = white.to_rgb();
let want = [r, g, b].map(|v| (v * 0.5 * 255.0).round() as u8);
let got = middle_cell(BLACK, white, Ink::RichBlack);
assert!(near([got[0], got[1], got[2], 255], [want[0], want[1], want[2], 255], 2), "{got:?} vs {want:?}");
// Without a pure black point, the points mix in their model.
let (a, c) = (Color::cmyk(0.0, 0.0, 0.0, 0.9), white);
let want = Color::cmyk(0.0, 0.0, 0.0, 0.45).to_rgba8(1.0);
let got = middle_cell(a, c, Ink::RichBlack);
assert!(near([got[0], got[1], got[2], 255], want, 2), "{got:?} vs {want:?}");
}
#[test]
fn darkening_changes_the_points_before_they_mix() {
use crate::ink::Ink;
// Half black added to each point: K100 stays K100 and K0 becomes K50, so they meet at K75.
let got = middle_cell(BLACK, Color::cmyk(0.0, 0.0, 0.0, 0.0), Ink::Display.darkened(0.5));
let want = Color::cmyk(0.0, 0.0, 0.0, 0.75).to_rgba8(1.0);
assert!(near([got[0], got[1], got[2], 255], want, 2), "{got:?} vs {want:?}");
}
#[test]
fn the_rendered_grid_follows_the_colour_field() {
let f = freeform();
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 1.0));
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the red point's colour");
assert_eq!(r.pixel(80, 80), [0, 0, 255, 255], "the blue point's colour");
for (x, y) in [(50, 50), (35, 60), (65, 30), (10, 90), (95, 5), (50, 20)] {
let want = expected(&f, Point::new(x as f64 + 0.5, y as f64 + 0.5), 1.0);
assert!(near(r.pixel(x, y), want, 6), "({x}, {y}): {:?} vs {want:?}", r.pixel(x, y));
}
}
#[test]
fn the_grid_is_clipped_to_the_path_and_takes_the_object_opacity() {
let f = freeform();
let r = render(&doc(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(Some(f.clone())), 0.5));
assert_eq!(r.pixel(2, 2), [255, 255, 255, 255], "outside the ellipse");
let want = expected(&f, Point::new(50.5, 50.5), 0.5);
assert!(near(r.pixel(50, 50), want, 6), "{:?} vs {want:?}", r.pixel(50, 50));
}
#[test]
fn unplaced_freeform_gradients_seed_points_on_the_box() {
// Without points: the automatic ones, coloured white → black along the default stops.
let r = render(&doc(shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), paint(None), 1.0));
let auto = Freeform::auto(Rect::new(0.0, 0.0, 100.0, 100.0), &Gradient::default(), &|_| true);
let at = |i: usize| auto.points[i].at;
// (Pixel centres sit half a pixel off the points.)
assert!(near(r.pixel(at(0).x as u32, at(0).y as u32), [255, 255, 255, 255], 3));
assert!(near(r.pixel(at(3).x as u32, at(3).y as u32), [0, 0, 0, 255], 3));
}
#[test]
fn strokes_take_freeform_paint() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let line = shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0));
let n = Node::path(id, line, Appearance::basic(Paint::None, paint(Some(freeform())), 10.0));
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let r = render(&d);
assert_eq!(r.pixel(20, 20), [255, 0, 0, 255], "the stroke's corner sits on the red point");
assert_eq!(r.pixel(50, 50), [255, 255, 255, 255], "unfilled inside");
}