diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 938e7d344..d31edd859 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -167,17 +167,37 @@ fn aspect_param(p: &Value) -> Parsed> { .transpose() } -/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is -/// placed on the bounds so the aspect sticks. -pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { - match (paint, p.get("gradient").and_then(|g| g.get("aspect"))) { - (Paint::Gradient(g), Some(a)) if g.geom.is_none() => { - apply_gradient_edit(paint, &json!({ "aspect": a }), bounds).unwrap_or_else(|_| paint.clone()) - } +/// `paint` without the placement it had on the art it came from (a swatch, or the default paint +/// for new art): a placed gradient keeps its angle and fits each object it lands on. +pub(crate) fn unplaced(paint: &Paint) -> Paint { + match paint { + Paint::Gradient(g) if g.geom.is_some() => Paint::Gradient(Box::new(GradientPaint { geom: None, ..(**g).clone() })), _ => paint.clone(), } } +/// Is `p` applying a swatch (whose gradient placement belongs to the art it was saved from)? +fn applies_swatch(p: &Value) -> bool { + p.get("swatch").is_some() +} + +/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is +/// placed on the bounds so the aspect sticks, and a gradient swatch fits the object (keeping its +/// aspect) instead of the art it was saved from. +pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option) -> Paint { + let Paint::Gradient(g) = paint else { return paint.clone() }; + let aspect = match g.geom { + Some(geom) if applies_swatch(p) => (geom.aspect != 1.0).then(|| json!(geom.aspect * 100.0)), + None => p.get("gradient").and_then(|g| g.get("aspect")).cloned(), + Some(_) => return paint.clone(), + }; + let fitted = unplaced(paint); + match aspect { + Some(a) => apply_gradient_edit(&fitted, &json!({ "aspect": a }), bounds).unwrap_or(fitted), + None => fitted, + } +} + /// Apply the gradient edits in `p` to `paint` (pure; unit-tested). pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { let mut gp = match paint { @@ -308,6 +328,7 @@ pub(crate) fn place_run_paint(paint: &Paint, p: &Value, xf: Affine, bounds: Rect let mut out = place_paint(paint, p, Some(bounds)); if let (Paint::Gradient(src), Paint::Gradient(g)) = (paint, &mut out) && src.geom.is_some() + && !applies_swatch(p) && let Some(inv) = invert(xf) { g.transform(inv); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e958c1de1..6d21a4543 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, NodeKind}; -use super::gradient::{place_paint, place_run_paint, run_paint_mut}; +use super::gradient::{place_paint, place_run_paint, run_paint_mut, unplaced}; use super::*; use crate::EngineError; @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -75,10 +75,11 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?; + // New art gets the paint fitted to itself, not placed where this one is. if fill { - s.paint.fill = paint.clone(); + s.paint.fill = unplaced(&paint); } else { - s.paint.stroke = paint.clone(); + s.paint.stroke = unplaced(&paint); } s.fill_active = fill; let ids = paint_targets(s, p)?; diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 19964c9f8..5db0f87b9 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -61,8 +61,9 @@ fn gradient_params_round_trip_through_fill_and_swatches() { assert_eq!(got.gradient.stops, g.gradient.stops); let (a, b) = (got.geom.unwrap(), g.geom.unwrap()); assert!(a.start.distance(b.start) < 1e-9 && a.end.distance(b.end) < 1e-9 && (a.aspect - b.aspect).abs() < 1e-9, "{a:?}"); - // The default proxy keeps it too, and saving it as a swatch is lossless. - assert_eq!(s.paint.fill, Paint::Gradient(Box::new(got.clone()))); + // The default for new art keeps all but the placement (new art fits it to itself), and saving + // it as a swatch is lossless. + assert_eq!(s.paint.fill, Paint::Gradient(Box::new(GradientPaint { geom: None, ..got.clone() }))); let mut q = gradient_param(&g); q["name"] = json!("Three"); s.execute("swatch.new", &q).unwrap(); @@ -346,3 +347,30 @@ fn the_selected_stop_belongs_to_its_gradient() { s.execute("paint.toggleActive", &json!({})).unwrap(); assert_eq!(s.selected_stop(), None); } + +#[test] +fn swatches_and_new_art_fit_a_placed_gradient_to_themselves() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let mut g = three_stops(); + g.gradient.kind = GradientKind::Radial; + let mut q = gradient_param(&g); + s.execute("paint.setFill", &q).unwrap(); + q["name"] = json!("Placed"); + s.execute("swatch.new", &q).unwrap(); + // New art drawn elsewhere fits the gradient to itself rather than to the first rectangle. + let b = rect(&mut s, 500.0, 300.0, 100.0, 100.0); + assert_eq!(fill_gradient(&s, b).geom, None); + // Applying the swatch fits its gradient to each object, keeping the aspect it was saved with. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap(); + let geom = fill_geom(&s, b); + assert_eq!(geom.start, Point::new(550.0, 350.0)); + assert!((geom.aspect - 0.5).abs() < 1e-9 && (geom.length() - 50.0).abs() < 1e-9, "{geom:?}"); + // On type, in text space. + let t = text(&mut s, 100.0, 400.0); + s.execute("paint.setFill", &json!({"swatch": "Placed"})).unwrap(); + let (_, doc) = node(&s, t).proxy_gradient(false).unwrap(); + let tb = node(&s, t).geometric_bounds().unwrap(); + assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}"); +}