M3.14: gradient swatches and new art fit a placed gradient to themselves

Lossless gradient params carried a saved gradient's vector (document coordinates of the art it
came from) into swatch applications and the default paint, so a swatch applied elsewhere, or the
next shape drawn, got a gradient placed off the object. Applying a gradient swatch now fits it to
each object (keeping its aspect, on type in text space), and the default for new art keeps all
but the placement. Saving a swatch stays lossless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 17:58:05 +07:00
co-authored by Claude Opus 5.5
parent d3dc4a2af3
commit cca1d11032
3 changed files with 63 additions and 13 deletions
+28 -7
View File
@@ -167,17 +167,37 @@ fn aspect_param(p: &Value) -> Parsed<Option<f64>> {
.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<Rect>) -> 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<Rect>) -> 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<Rect>) -> std::result::Result<Paint, String> {
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);
+5 -4
View File
@@ -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<CommandSpec> {
"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<Option<Paint>> {
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
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)?;
+30 -2
View File
@@ -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:?}");
}