diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 3ce5f8abc..751e23896 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -11,6 +11,7 @@ use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect}; use serde::{Deserialize, Serialize}; +use crate::cms::{self, Model}; use crate::{Color, Gradient, GradientStop}; /// The spread a new point gets. @@ -157,11 +158,12 @@ impl Freeform { f } - /// Colour the points along `g`, first to last. + /// Color the points along `g`, first to last, mixing neighboring stops in the model they share + /// ([`Color::lerp_keep_model`]). pub fn recolor(&mut self, g: &Gradient) { let n = self.points.len(); for (k, p) in self.points.iter_mut().enumerate() { - (p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32); + (p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model); } } @@ -230,10 +232,10 @@ impl Freeform { best } - /// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`. + /// The color and opacity at `p` for a painted box whose [`spread_scale`] is `scale`, as + /// [`Self::model_field`] mixes them. pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) { - let ([r, g, b], a) = self.field(scale).sample(p); - (Color::rgb(r, g, b), a) + self.model_field(scale).sample(p) } /// The colour field, with colours converted and lines tessellated once (for sampling many @@ -265,6 +267,32 @@ impl Freeform { Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) } } + /// [`Self::field`] mixing the colors in the model every point shares instead of display RGB: + /// the C, M, Y and K inks (as a CMYK document drawn by ink planes mixes them), the Gray ink, + /// or the RGB or Lab components. Points in different models mix in display RGB, as + /// [`Self::field`] mixes them, and the field gives that color in the working RGB space. + pub fn model_field(&self, scale: f64) -> ModelField { + let Some(model) = self.shared_model() else { return ModelField { model: None, first: self.field(scale), k: None } }; + let parts = |c: &Color| match *c { + Color::Rgb { r, g, b } => [r, g, b, 0.0], + Color::Cmyk { c, m, y, k } => [c, m, y, k], + Color::Gray { k } => [k, 0.0, 0.0, 0.0], + Color::Lab { l, a, b } => [l, a, b, 0.0], + }; + let first = self.field_with(scale, &|c| { + let [x, y, z, _] = parts(c); + [x, y, z] + }); + let k = (model == Model::Cmyk).then(|| self.field_with(scale, &|c| [parts(c)[3], 0.0, 0.0])); + ModelField { model: Some(model), first, k } + } + + /// The color model every point is in (`None` without points or when their models differ). + pub fn shared_model(&self) -> Option { + let m = self.points.first()?.color.model(); + self.points.iter().all(|p| p.color.model() == m).then_some(m) + } + /// Add a point; returns its index. pub fn add_point(&mut self, p: FreeformPoint) -> usize { self.points.push(p); @@ -324,8 +352,9 @@ impl Freeform { true } - /// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour, - /// opacity and spread between the segment's ends. Returns the new point's index. + /// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the color + /// (in the model the ends share, [`Color::lerp_keep_model`]), opacity and spread between the + /// segment's ends. Returns the new point's index. pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option { let c = *self.segments(line).get(segment)?; let l = &self.lines[line]; @@ -334,7 +363,7 @@ impl Freeform { let u = t as f32; let p = FreeformPoint { at: c.eval(t), - color: a.color.lerp(&b.color, u), + color: a.color.lerp_keep_model(&b.color, u), opacity: a.opacity + (b.opacity - a.opacity) * u, spread: a.spread + (b.spread - a.spread) * u, }; @@ -440,6 +469,41 @@ impl Field { } } +/// A freeform gradient ready to sample in its points' color model ([`Freeform::model_field`]). +#[derive(Clone, Debug)] +pub struct ModelField { + /// The model the points share (`None` when their models differ). + model: Option, + /// The first three components (C, M and Y; R, G and B; L, a and b; display RGB when the + /// models differ), or the Gray ink as the first of the three values. + first: Field, + /// The K ink of a CMYK field. + k: Option, +} + +impl ModelField { + /// The model the points share (`None` when their models differ). + pub fn model(&self) -> Option { + self.model + } + + /// The color and opacity at `p`: in the points' shared model, else in the working RGB space. + pub fn sample(&self, p: Point) -> (Color, f32) { + let ([x, y, z], a) = self.first.sample(p); + let c = match self.model { + None => { + let [r, g, b] = cms::active().srgb_to_rgb([x, y, z]); + Color::rgb(r, g, b) + } + Some(Model::Rgb) => Color::rgb(x, y, z), + Some(Model::Cmyk) => Color::cmyk(x, y, z, self.k.as_ref().map_or(0.0, |k| k.sample(p).0[0])), + Some(Model::Gray) => Color::gray(x), + Some(Model::Lab) => Color::lab(x, y, z), + }; + (c, a) + } +} + #[cfg(test)] mod tests { use super::*; @@ -477,6 +541,31 @@ mod tests { assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}"); } + #[test] + fn the_sample_mixes_in_the_model_the_points_share() { + let halfway = |a: Color, b: Color| { + let mut f = two(0.0); + (f.points[0].color, f.points[1].color) = (a, b); + f.sample(Point::new(50.0, 0.0), 50.0).0 + }; + let near = |a: Color, b: Color| match (a, b) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => { + [c - c2, m - m2, y - y2, k - k2].iter().all(|d| d.abs() < 1e-5) + } + (Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() < 1e-5, + (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|d| d.abs() < 1e-4), + _ => false, + }; + let c = halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + assert!(near(c, Color::cmyk(0.5, 0.0, 0.0, 0.5)), "{c:?}"); + let c = halfway(Color::gray(0.2), Color::gray(0.6)); + assert!(near(c, Color::gray(0.4)), "{c:?}"); + let c = halfway(Color::lab(50.0, 10.0, -20.0), Color::lab(70.0, 30.0, 0.0)); + assert!(near(c, Color::lab(60.0, 20.0, -10.0)), "{c:?}"); + // Points in different models mix in RGB. + assert!(matches!(halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), red()), Color::Rgb { .. })); + } + #[test] fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() { // Scale 100: a 0.2 spread is a 20 pt disc. diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index ff10699be..46439d29d 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -241,6 +241,17 @@ impl Color { let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) } + /// Linear interpolation in the model both colors share when it is CMYK, Gray or Lab, else in + /// display RGB ([`Color::lerp`]). + pub fn lerp_keep_model(&self, other: &Color, t: f32) -> Color { + let l = |x: f32, y: f32| x + (y - x) * t; + match (*self, *other) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), + (Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)), + _ => self.lerp(other, t), + } + } /// The colour model this colour is authored in. pub fn model(&self) -> cms::Model { match self { diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 1849077af..119e75abc 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -33,13 +33,7 @@ use crate::text::TextObject; /// Interpolate two colours in their shared model (display RGB when the models differ). pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color { - let l = |x: f32, y: f32| x + (y - x) * t; - match (*a, *b) { - (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), - (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), - (Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)), - _ => a.lerp(b, t), - } + a.lerp_keep_model(b, t) } fn lerp_f32(x: f32, y: f32, t: f32) -> f32 { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 9500cf625..3fec18331 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { /// 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 { 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 (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 (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) -> Option { - 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 diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 925bcb2ec..7c3434a52 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -1394,6 +1394,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { 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 { 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 }) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 8371b1e80..0cbab116e 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -301,7 +301,7 @@ fn new_color_model(s: &Session, p: &Value) -> Option, c: Color) -> Color { +pub(crate) fn stored_color(model: Option, c: Color) -> Color { match (c, model) { (Color::Rgb { .. }, Some(m)) => { let cms = vectorcraft_color::cms::active(); diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs index 71f5a6823..26ec4203f 100644 --- a/crates/engine/src/tests_freeform.rs +++ b/crates/engine/src/tests_freeform.rs @@ -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(); diff --git a/crates/engine/src/tests_objexpand.rs b/crates/engine/src/tests_objexpand.rs index 2be0547aa..ea92bca1f 100644 --- a/crates/engine/src/tests_objexpand.rs +++ b/crates/engine/src/tests_objexpand.rs @@ -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::>() + }; + // 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. diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 7a5dcab68..ba9539de8 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -261,3 +261,51 @@ fn the_eyedropper_takes_an_image_pixel_into_the_working_rgb_space() { let [r, g, b, _] = shown.unwrap_or_default(); assert!(r.abs_diff(83) <= 1 && g.abs_diff(170) <= 1 && b.abs_diff(223) <= 1, "{shown:?} for 83/170/223"); } + +/// With a working RGB space other than sRGB, a freeform gradient of RGB points expands to mesh +/// points with the points' own RGB values, and points in different models expand to mesh points +/// that show the colors the gradient shows there. +#[test] +fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() { + use vectorcraft_color::freeform::spread_scale; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::NodeKind; + use vectorcraft_geom::Rect; + let _g = settings(); + let old = cms::active_settings(); + cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..old.clone() }).unwrap(); + // Each mesh point's color, the display color it shows and the display color the gradient + // shows at its place. + let expand = |colors: [serde_json::Value; 4]| { + let mut s = session(); + let id = rect(&mut s, 20.0, 20.0, 160.0, 160.0); + select(&mut s, &[id]); + exec(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}})); + exec(&mut s, "paint.setStroke", json!({"none": true})); + for (i, c) in colors.into_iter().enumerate() { + exec(&mut s, "paint.freeform.setPoint", json!({"index": i, "color": c, "stroke": false})); + } + let b = Rect::new(20.0, 20.0, 180.0, 180.0); + let field = match s.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint()) { + Some(Paint::Gradient(g)) => g.freeform_on(b).field(spread_scale(b)), + p => panic!("a gradient fill: {p:?}"), + }; + exec(&mut s, "object.expand", json!({})); + let d = &s.doc().unwrap(); + let mesh = d.selection.objects.first().and_then(|id| d.doc.node(*id)).map(|n| n.kind.clone()); + let Some(NodeKind::Mesh(m)) = mesh else { panic!("a mesh: {mesh:?}") }; + m.points.iter().map(|p| (p.color, p.color.to_rgb(), field.sample(p.p).0)).collect::>() + }; + let rgb = expand([json!("#3399e6"), json!("#3399e6"), json!("#3399e6"), json!("#3399e6")]); + let mixed = expand([json!("#3399e6"), json!("#3399e6"), json!({"gray": 0.5}), json!({"gray": 0.5})]); + cms::set_active(&old).unwrap(); + let want = Color::rgb8(0x33, 0x99, 0xe6); + for (c, ..) in &rgb { + let (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) = (*c, want) else { panic!("an RGB point: {c:?}") }; + assert!((r - r2).abs() < 1e-5 && (g - g2).abs() < 1e-5 && (b - b2).abs() < 1e-5, "{c:?} for {want:?}"); + } + for (c, shown, field) in &mixed { + let close = shown.iter().zip(field).all(|(a, b)| (a - b).abs() < 2.0 / 255.0); + assert!(matches!(c, Color::Rgb { .. }) && close, "{c:?} shows {shown:?} for {field:?}"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 6ef5c0ab0..6b714f98b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -743,7 +743,10 @@ Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more object (coloured along the stops; `mode: "points"|"lines"` is the Draw toggle). `paint.freeform.get` lists the points (document coordinates), lines and the selected point; `paint.freeform.addPoint {at, color?, opacity?, spread?, line?}`, `setPoint {index?, …}`, `deletePoint {index?}`, `addLine {points}`, `splitLine {line, segment, t?}` and -`selectPoint {index|null}` edit them (`index` defaults to the selected point; each edit is one undo step). With the +`selectPoint {index|null}` edit them (`index` defaults to the selected point; each edit is one undo step). The color +of a point colored along the stops, of a point `splitLine` adds and of a point `addPoint` adds without `color` is a mix +of the colors around it (the two stops, the line's two points, or all the points) in the color model those colors +share, so CMYK stops and points give CMYK points. With the Gradient tool on a freeform gradient a click on a point selects it (drag to move it), a click on a line adds a point on it, a click elsewhere on the art adds a point (in Lines mode joined to the selected one), and Delete removes the selected point: @@ -1821,7 +1824,10 @@ outlines strokes into filled paths; `fill` (on) expands gradient fills: with `gr no seams show; concentric ellipses for a radial one, largest first; just the bands when stops are translucent), with `gradient: "mesh"` into a gradient mesh that paints the gradient (columns or rings where its colour changes, sharp at coincident stops), each inside a clip group shaped like the object. A freeform gradient becomes a mesh shaped like the -object either way. Expanded colours keep the colour model their stops share. `fill` also expands pattern fills: each +object either way. Expanded colours keep the colour model their stops share. Expand mixes a freeform gradient's point +colors in the model they share to color the mesh points; it mixes C, M, Y and K as inks. When the models differ, the +colors mix in display RGB and are stored in the working RGB space, and a CMYK document stores them as CMYK through the +color settings. `fill` also expands pattern fills: each becomes the pattern's tiles that reach the object's box, placed by the pattern's transform, in a clip group shaped like the object that isolates their blending, with the fill's opacity, blend mode and effects; the object's other fills and strokes stay on copies of it in paint order. The tiles' stroke weights and effect distances scale with the @@ -1841,7 +1847,7 @@ box, as the canvas paints the fill; the expanded art does not keep them. With `f stay live. `object.expand.info` answers which options have something to expand in the selection (`{object, fill, stroke, gradient}`; `gradient` says whether Fill has a gradient fill, which Expand Gradient To applies to), and `object.mesh.create` on a gradient-filled object colours the mesh points as the -gradient paints them. +gradient paints them, with the colors Object › Expand gives them. ```json {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c2ce648fc..d5044bfca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -658,6 +658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. + - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`).