mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 17:32:42 -06:00
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.
This commit is contained in:
@@ -10,6 +10,7 @@ use std::cell::RefCell;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::cms::Model;
|
||||
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
|
||||
use vectorcraft_color::{Freeform, GradientPaint};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
@@ -24,6 +25,10 @@ thread_local! {
|
||||
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX));
|
||||
}
|
||||
|
||||
/// Most cells along a side of a grid of Lab points on an ink plane, where each cell separates its
|
||||
/// color through the profile; the paint smooths the grid.
|
||||
const MAX_LAB_PLANE_CELLS: u16 = 64;
|
||||
|
||||
/// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active
|
||||
/// colour settings, which turn colours into display RGB or inks).
|
||||
fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
|
||||
@@ -38,18 +43,33 @@ fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The grid size of `f` on box `b` drawn `device` pixels long with `ink`.
|
||||
pub(crate) fn cells(f: &Freeform, b: Rect, device: f64, ink: Ink) -> (u16, u16) {
|
||||
let (cols, rows) = grid_size(b, device);
|
||||
if f.shared_model() == Some(Model::Lab) && matches!(ink.base(), Ink::Cmy | Ink::K) {
|
||||
return (cols.min(MAX_LAB_PLANE_CELLS), rows.min(MAX_LAB_PLANE_CELLS));
|
||||
}
|
||||
(cols, rows)
|
||||
}
|
||||
|
||||
/// Sample `f` at the centres of a `cols` × `rows` grid over `b` (premultiplied, colours as `ink`
|
||||
/// paints them).
|
||||
/// paints them). Points that share a color model mix in that model ([`Freeform::model_field`]);
|
||||
/// points in different models, and points that `ink` paints as RGB black, mix as `ink` paints each
|
||||
/// of them.
|
||||
pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> Pixmap {
|
||||
let field = f.field_with(spread_scale(b), &|c| ink.rgb(c));
|
||||
let data = field
|
||||
.grid(b, cols, rows)
|
||||
.map(|([r, g, bl], a)| {
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
|
||||
})
|
||||
.collect();
|
||||
let adjusted = ink.freeform(f);
|
||||
let (f, ink) = adjusted.as_ref().map_or((f, ink), |a| (a, ink.base()));
|
||||
let cell = |[r, g, bl]: [f32; 3], a: f32| {
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * a * 255.0).round() as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: q(r), g: q(g), b: q(bl), a: (a * 255.0).round() as u8 }
|
||||
};
|
||||
let scale = spread_scale(b);
|
||||
let data = if f.shared_model().is_some() && !ink.blacks_a_point(f) {
|
||||
f.model_field(scale).grid(b, cols, rows).map(|(c, a)| cell(ink.rgb_sampled(&c), a)).collect()
|
||||
} else {
|
||||
f.field_with(scale, &|c| ink.rgb(c)).grid(b, cols, rows).map(|(rgb, a)| cell(rgb, a)).collect()
|
||||
};
|
||||
Pixmap::from_parts(data, cols, rows)
|
||||
}
|
||||
|
||||
@@ -58,8 +78,8 @@ pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -
|
||||
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
|
||||
let Some(b) = painted_box(bounds) else { return false };
|
||||
let device = b.width().max(b.height()) * ctx.transform().determinant().abs().sqrt();
|
||||
let (cols, rows) = grid_size(b, device);
|
||||
let f = g.freeform_on(b);
|
||||
let (cols, rows) = cells(&f, b, device, ink);
|
||||
let k = key(&f, b, cols, rows, ink);
|
||||
let pm = GRIDS
|
||||
.with(|c| {
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
|
||||
use vectorcraft_color::cms::{self, Cms, CmykLut, ProofLut, lab};
|
||||
use vectorcraft_color::{Color, Gradient};
|
||||
use vectorcraft_color::{Color, Freeform, Gradient};
|
||||
use vectorcraft_doc::cmyk::Inks;
|
||||
use vectorcraft_doc::{ColorMode, Document, ImageBlob};
|
||||
use vello_cpu::Pixmap;
|
||||
@@ -126,6 +126,23 @@ impl Ink {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Transform original freeform points before their colors mix, as [`Self::gradient`] does for
|
||||
/// stops.
|
||||
pub(crate) fn freeform(self, f: &Freeform) -> Option<Freeform> {
|
||||
let amount = self.darkness()?;
|
||||
let mut f = f.clone();
|
||||
for point in &mut f.points {
|
||||
point.color = Self::darkened_color(&point.color, amount, self.black_endpoint());
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// Whether this ink paints a point of `f` as RGB black: Appearance of Black's Rich setting and
|
||||
/// a point of pure CMYK black.
|
||||
pub(crate) fn blacks_a_point(self, f: &Freeform) -> bool {
|
||||
self.base() == Ink::RichBlack && f.points.iter().any(|p| matches!(p.color, Color::Cmyk { c, m, y, k } if is_pure_black([c, m, y, k])))
|
||||
}
|
||||
|
||||
/// The three values painted for colour `c`.
|
||||
pub(crate) fn rgb(self, c: &Color) -> [f32; 3] {
|
||||
if let Some(amount) = self.darkness() {
|
||||
@@ -138,6 +155,15 @@ impl Ink {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::rgb`] for colors sampled densely (a freeform gradient's grid): on an ink plane, RGB
|
||||
/// colors go through the separation tables placed images use.
|
||||
pub(crate) fn rgb_sampled(self, c: &Color) -> [f32; 3] {
|
||||
match (self, c) {
|
||||
(Ink::Cmy | Ink::K, Color::Rgb { .. }) => separation().0[(self == Ink::K) as usize].apply(c.to_rgb()),
|
||||
_ => self.rgb(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::rgb`] as a paint, at `alpha`.
|
||||
pub(crate) fn color(self, c: &Color, alpha: f32) -> peniko::Color {
|
||||
let [r, g, b] = self.rgb(c);
|
||||
|
||||
@@ -51,6 +51,114 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user