Merge pull request #6268 from dotCooCoo/fix/freeform-mixes-in-point-model

Gradients: freeform points that share a color model mix in that model
This commit is contained in:
BFlat
2026-11-09 10:08:22 +08:00
committed by GitHub
19 changed files with 602 additions and 82 deletions
+31 -11
View File
@@ -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| {
+27 -1
View File
@@ -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);
+108
View File
@@ -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();