mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user