Color: freeform gradients keep CMYK inks when expanded, meshed or edited

Object > Expand and Object > Create Gradient Mesh mixed a freeform
gradient's points in display RGB and turned the result back into the
points' model with the profile-free formula. CMYK inks changed: points
of C50 M20 Y0 K10 gave mesh points of C45 M24 Y0 K22. RGB points were
stored as display sRGB, so with a working RGB space other than sRGB
the mesh showed other colors. Points colored along CMYK stops, a point
paint.freeform.splitLine added between CMYK points and a point
paint.freeform.addPoint added without a color came out as RGB.

Points that share a color model now mix in it: the C, M, Y and K
values mix as inks, and Gray, RGB and Lab components mix as they are.
Points in different models still mix in display RGB. Object > Expand
and Object > Create Gradient Mesh store the result in the working RGB
space, and in a CMYK document they store it as CMYK through the color
settings, as paint.setFill stores an RGB color. Freeform::recolor and
split_line mix with the new Color::lerp_keep_model, which
vectorcraft_doc::blend::lerp_color now calls.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 12:36:25 +07:00
committed by bflatastic
parent 739b0b7335
commit 24d4777d1a
11 changed files with 302 additions and 35 deletions
+10 -15
View File
@@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
let content = if g.gradient.kind == GradientKind::Freeform {
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
} else {
let frame = Frame::new(g, bounds);
@@ -720,29 +720,24 @@ fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
/// colour model their stops (or freeform points) share; display RGB where models differ.
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
/// color model their stops share; display RGB where models differ. Freeform points mix in the
/// model they share ([`vectorcraft_color::Freeform::model_field`]); where their models differ, the
/// RGB color they mix to is stored in `doc_model` (the document's model) the way `paint.setFill`
/// stores an RGB color ([`super::paint::stored_color`]).
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
if g.gradient.kind == GradientKind::Freeform {
let b = bounds.abs();
let f = g.freeform_on(b);
let model = shared_model(f.points.iter().map(|p| p.color));
let field = f.field(spread_scale(b));
let field = g.freeform_on(b).model_field(spread_scale(b));
let store_in = (field.model().is_none() && doc_model != Model::Rgb).then_some(doc_model);
return Box::new(move |p| {
let ([r, g, b], a) = field.sample(p);
let c = Color::rgb(r, g, b);
(model.map_or(c, |m| c.in_model(m)), a)
let (c, a) = field.sample(p);
(super::paint::stored_color(store_in, c), a)
});
}
let geom = g.resolve(bounds);
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
}
/// The colour model all of `colors` share.
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
let m = colors.next()?.model();
colors.all(|c| c.model() == m).then_some(m)
}
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
+2 -1
View File
@@ -1394,6 +1394,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = vec![];
let mut index = None;
let mut refused = None;
let model = d.color_mode.model();
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let m = if let NodeKind::Image(im) = &n.kind {
@@ -1413,7 +1414,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| {
m.highlight(app, highlight);
m
})
+1 -1
View File
@@ -301,7 +301,7 @@ fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Mod
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
/// are returned as given.
fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
pub(crate) fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
match (c, model) {
(Color::Rgb { .. }, Some(m)) => {
let cms = vectorcraft_color::cms::active();
+51
View File
@@ -84,6 +84,57 @@ fn new_stops_recolour_the_points() {
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
}
/// A CMYK color's inks.
fn inks(c: Color) -> [f32; 4] {
match c {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
c => panic!("a CMYK color expected: {c:?}"),
}
}
/// Cyan and black inks only, adding up to one.
fn cyan_and_black(c: Color) -> bool {
let [c, m, y, k] = inks(c);
m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5
}
#[test]
fn cmyk_stops_recolor_the_points_with_inks() {
let cyan_to_black = json!([{"offset": 0, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]);
let (mut s, id) = freeform_rect();
run(&mut s, "paint.editGradient", json!({"stops": cyan_to_black}));
let f = points(&s, id);
let n = f.points.len();
for (i, p) in f.points.iter().enumerate() {
let k = i as f32 / (n - 1) as f32;
let [c, m, y, kk] = inks(p.color);
assert!((c - (1.0 - k)).abs() < 1e-5 && m.abs() < 1e-5 && y.abs() < 1e-5 && (kk - k).abs() < 1e-5, "point {i}: {:?}", p.color);
}
// A CMYK gradient switched to freeform places points with inks.
let mut s = session();
let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0);
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": cyan_to_black}}));
run(&mut s, "paint.editGradient", json!({"kind": "freeform"}));
assert!(points(&s, id).points.iter().all(|p| cyan_and_black(p.color)), "{:?}", points(&s, id).points);
}
#[test]
fn points_added_between_cmyk_points_take_inks() {
let (mut s, id) = freeform_rect();
let n = points(&s, id).points.len();
for i in 0..n {
let c = if i % 2 == 0 { json!({"c": 1, "m": 0, "y": 0, "k": 0}) } else { json!({"c": 0, "m": 0, "y": 0, "k": 1}) };
run(&mut s, "paint.freeform.setPoint", json!({"index": i, "color": c}));
}
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}));
let i = run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 0}))["index"].as_u64().unwrap() as usize;
let split = points(&s, id).points[i].color;
assert!(cyan_and_black(split), "the split point: {split:?}");
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [150, 150]}))["index"].as_u64().unwrap() as usize;
let added = points(&s, id).points[i].color;
assert!(cyan_and_black(added), "the added point: {added:?}");
}
#[test]
fn transforms_and_warps_move_the_points() {
let (mut s, id) = freeform_rect();
+71
View File
@@ -37,6 +37,27 @@ fn gradient_rect(s: &mut Session, r: Rect, gradient: Value, stroke: bool) -> Nod
NodeId(id)
}
/// A selected square `r` with a freeform fill whose four automatic points take `colors` in turn.
fn freeform_rect(s: &mut Session, r: Rect, colors: [Value; 4]) -> NodeId {
let id = gradient_rect(s, r, json!({"kind": "freeform"}), false);
for (i, c) in colors.into_iter().enumerate() {
run(s, "paint.freeform.setPoint", json!({"index": i, "color": c, "stroke": false}));
}
id
}
/// A CMYK color's inks.
fn inks(c: Color) -> [f32; 4] {
match c {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
c => panic!("a CMYK color expected: {c:?}"),
}
}
fn close_inks(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
}
/// The one selected object after a command.
fn selected(s: &Session) -> Node {
let sel = &s.doc().unwrap().selection.objects;
@@ -792,6 +813,18 @@ fn create_gradient_mesh_takes_its_colours_from_the_gradient() {
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
}
#[test]
fn create_gradient_mesh_keeps_the_inks_of_freeform_points() {
let mut s = session();
let one = json!({"c": 0.5, "m": 0.2, "y": 0.0, "k": 0.1});
let id = freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [one.clone(), one.clone(), one.clone(), one]);
run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2}));
let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() };
for p in &m.points {
assert!(close_inks(inks(p.color), [0.5, 0.2, 0.0, 0.1]), "{:?}", p.color);
}
}
#[test]
fn expanded_colours_keep_the_stops_colour_model() {
let mut s = session();
@@ -804,6 +837,44 @@ fn expanded_colours_keep_the_stops_colour_model() {
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
}
#[test]
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
// the gradient.
let expanded = |s: &mut Session, most: f64| {
let before = render(s);
run(s, "object.expand", json!({}));
let diff = mean_diff(&before, &render(s));
assert!(diff < most, "looks the same: {diff}");
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
};
// Points of one ink set give every mesh point that ink set.
let mut s = session();
let one = ink(0.5, 0.2, 0.0, 0.1);
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [one.clone(), one.clone(), one.clone(), one]);
for c in expanded(&mut s, 1.0) {
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
}
// Cyan and black points mix as inks: cyan and black only, adding up to one.
let mut s = session();
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
for c in expanded(&mut s, 5.0) {
let [c, m, y, k] = inks(c);
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
}
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
let gray = json!({"gray": 0.5});
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
for c in expanded(&mut s, 2.5) {
inks(c);
}
}
#[test]
fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
// Fill unchecked: the stroke is outlined, the gradient fill stays a gradient.