mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
The Gradient panel's Method menu gets two methods of VectorCraft's own beside Classic and Perceptual. They mix a linear, radial, conic or diamond gradient's colors around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, and the hue angle keeps one direction, as the fixed-direction rules of https://www.w3.org/TR/css-color-4/#hue-interpolation do. Hue Clockwise lowers the angle (red, magenta, blue), which is clockwise on the color wheels, and Hue Counterclockwise raises it (red, yellow, green). Two stops whose hues are less than half a color level apart count as one hue and keep it, so a color and a tint or shade of it do not turn the whole wheel. A stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black takes that stop's saturation too. - `paint.editGradient` and `paint.setFill` take `method: hue-clockwise` or `hue-counterclockwise`, in any case, with a space or an underscore for the hyphen or without it; other spellings are an error. The native file writes `HueClockwise` and `HueCounterclockwise`. - The canvas and raster exports draw the gradient through stops on its curve, within half a color level. - Reverse turns the method the other way with the stops, and a Reflect spread is repeated from the curve's stops, so both keep the colors. - SVG and PDF write the gradient as an RGB image, CMYK and spot color stops included, with the spot warning. EPS writes a shading in process colors with a warning for spot stops. A legacy .ai file and the .ai editing data get the colors between the stops as stops of their own, in process colors, at most 256, with a warning. CSS gets those stops too. - The `method` error names the four values, in every catalog. - The Method menu is as wide as its longest name in the interface language, within the panel. Docs: docs/mcp.md, docs/plugins.md, docs/target-app-parity.md, ROADMAP.
1085 lines
61 KiB
Rust
1085 lines
61 KiB
Rust
//! Gradients through commands: lossless, validated params (M3.14), transforms (M3.15), the active
|
||
//! proxy and type (M3.16) and the interactive annotator (M3.17).
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||
use vectorcraft_geom::Point;
|
||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||
|
||
use crate::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||
NodeId(r["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node {
|
||
s.doc().unwrap().doc.node(id).unwrap()
|
||
}
|
||
|
||
fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
|
||
match node(s, id).appearance.fill_paint() {
|
||
Paint::Gradient(g) => *g,
|
||
p => panic!("expected a gradient fill, got {p:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_gradient_defaults_follow_the_cmyk_document_mode() {
|
||
for kind in ["linear", "radial", "conic", "diamond", "freeform"] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":200,"height":200,"colorMode":"cmyk"})).unwrap();
|
||
let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||
s.execute("paint.editGradient", &json!({"kind":kind})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert!(g.gradient.stops.iter().all(|stop| matches!(stop.color, Color::Cmyk { .. })), "{kind}: {:?}", g.gradient.stops);
|
||
if let Some(freeform) = g.freeform {
|
||
assert!(freeform.points.iter().all(|point| matches!(point.color, Color::Cmyk { .. })), "freeform points must use the same color model");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editing_an_explicit_rgb_gradient_in_cmyk_keeps_its_color_model() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":200,"height":200,"colorMode":"cmyk"})).unwrap();
|
||
let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||
s.execute("paint.editGradient", &json!({"kind":"linear","keepModel":true})).unwrap();
|
||
let before = fill_gradient(&s, id).gradient.stops;
|
||
assert!(before.iter().all(|stop| matches!(stop.color, Color::Rgb { .. })));
|
||
s.execute("paint.editGradient", &json!({"angle":45})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.stops, before);
|
||
}
|
||
|
||
#[test]
|
||
fn drawing_a_new_gradient_vector_uses_cmyk_default_stops() {
|
||
for stroke in [false, true] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width":200,"height":200,"colorMode":"cmyk"})).unwrap();
|
||
let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||
s.execute("paint.setGradientGeom", &json!({"start":[10,10],"end":[110,10],"stroke":stroke})).unwrap();
|
||
let appearance = &node(&s, id).appearance;
|
||
let paint = if stroke { appearance.stroke_paint() } else { appearance.fill_paint() };
|
||
let Paint::Gradient(g) = paint else { panic!("not a gradient") };
|
||
assert!(g.gradient.stops.iter().all(|stop| matches!(stop.color, Color::Cmyk { .. })), "stroke={stroke}: {:?}", g.gradient.stops);
|
||
}
|
||
}
|
||
|
||
/// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector.
|
||
fn three_stops() -> GradientPaint {
|
||
let stop = |offset, color, opacity, midpoint| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) };
|
||
let mut g = GradientPaint::new(Gradient::new(
|
||
GradientKind::Freeform,
|
||
vec![
|
||
stop(0.0, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.3),
|
||
stop(0.4, Color::cmyk(0.1, 0.2, 0.3, 0.4), 0.5, 0.5),
|
||
stop(1.0, Color::gray(0.25), 0.75, 0.5),
|
||
],
|
||
));
|
||
g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5, focal: None });
|
||
g.angle = g.geom.unwrap().angle_deg();
|
||
g
|
||
}
|
||
|
||
fn gradient_param(g: &GradientPaint) -> Value {
|
||
json!({ "gradient": vectorcraft_tools::params::gradient_params(g) })
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_params_round_trip_through_fill_and_swatches() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let g = three_stops();
|
||
s.execute("paint.setFill", &gradient_param(&g)).unwrap();
|
||
let got = fill_gradient(&s, id);
|
||
assert_eq!(got.gradient.kind, GradientKind::Freeform);
|
||
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 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();
|
||
assert_eq!(s.doc().unwrap().doc.swatch("Three").unwrap().paint, Paint::Gradient(Box::new(got)));
|
||
}
|
||
|
||
#[test]
|
||
fn bad_gradients_are_rejected_without_editing() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let before = node(&s, id).appearance.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
for bad in [
|
||
json!({"stops": [{"offset": 0, "color": "#ff0000"}]}),
|
||
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1}]}),
|
||
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"color": "#000000"}]}),
|
||
json!({"stops": [{"offset": "a", "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}),
|
||
json!({"stops": "red"}),
|
||
json!({"kind": "spiral"}),
|
||
json!({"start": [0, 0]}),
|
||
json!({"aspect": "wide"}),
|
||
] {
|
||
assert!(s.execute("paint.setFill", &json!({ "gradient": bad })).is_err(), "accepted {bad}");
|
||
// `start` isn't a paint.editGradient param (it would just apply the gradient).
|
||
if bad.get("start").is_none() {
|
||
assert!(s.execute("paint.editGradient", &bad).is_err(), "editGradient accepted {bad}");
|
||
}
|
||
}
|
||
assert_eq!(node(&s, id).appearance, before);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||
}
|
||
|
||
/// The gradient commands refuse a `reverse` or `stroke` that is not true or false, and an `angle`
|
||
/// or a stop's `tint` that is not a number, given as text or as another value of the wrong kind:
|
||
/// the object and the undo history stay as they were. True, false, numbers and null still work.
|
||
#[test]
|
||
fn gradient_commands_refuse_a_number_or_boolean_given_as_text() {
|
||
use crate::tests::{not_a_number, not_a_switch, not_refused};
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
let red = s.execute("swatch.new", &json!({"color": "#ff0000", "global": true})).unwrap()["name"].clone();
|
||
let stops = |tint: Value| json!([{"offset": 0, "color": "#ff0000", "swatch": red, "tint": tint}, {"offset": 1, "color": "#000000"}]);
|
||
let calls = [
|
||
not_a_switch("paint.editGradient", json!({"reverse": "true"}), "reverse"),
|
||
not_a_switch("paint.editGradient", json!({"angle": 45, "stroke": "false"}), "stroke"),
|
||
not_a_switch("paint.setGradientGeom", json!({"aspect": 50, "stroke": 0}), "stroke"),
|
||
not_a_number("paint.editGradient", json!({"angle": "45"}), "angle"),
|
||
("paint.setFill".to_string(), json!({"gradient": {"angle": "45"}}), "`angle` must be a number, not \"45\"".to_string()),
|
||
("paint.editGradient".to_string(), json!({"stops": stops(json!("50"))}), "stop 0: `tint` must be a number".to_string()),
|
||
("paint.setFill".to_string(), json!({"gradient": {"stops": stops(json!([50]))}}), "stop 0: `tint` must be a number".to_string()),
|
||
];
|
||
let wrong = not_refused(&mut s, &calls);
|
||
assert!(wrong.is_empty(), "not refused: {wrong:#?}");
|
||
s.execute("paint.editGradient", &json!({"angle": 30, "reverse": false, "stroke": null, "stops": stops(json!(50))})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!((g.angle, g.gradient.stops[0].tint), (30.0, 0.5));
|
||
}
|
||
|
||
#[test]
|
||
fn stop_opacity_and_offsets_are_normalised() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute(
|
||
"paint.setFill",
|
||
&json!({"gradient": {"stops": [{"offset": 1.5, "color": "#000000", "opacity": 50}, {"offset": -1, "color": "#ffffff", "opacity": 0.25, "midpoint": 0.99}]}}),
|
||
)
|
||
.unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
// Sorted, offsets clamped, 0..100 opacity normalised, midpoint clamped to the diamond's range.
|
||
let st = &g.gradient.stops;
|
||
assert_eq!((st[0].offset, st[0].opacity, st[0].midpoint), (0.0, 0.25, 0.87));
|
||
assert_eq!((st[1].offset, st[1].opacity), (1.0, 0.5));
|
||
assert!(g.geom.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn geometry_and_aspect_in_one_call() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "start": [200, 150], "end": [260, 150], "aspect": 40}})).unwrap();
|
||
let geom = fill_gradient(&s, id).geom.unwrap();
|
||
assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(260.0, 150.0)));
|
||
assert!((geom.aspect - 0.4).abs() < 1e-9);
|
||
// An aspect without a vector places the gradient on the object's bounds so the aspect sticks.
|
||
s.execute("paint.setStroke", &json!({"gradient": {"kind": "radial", "aspect": 25}})).unwrap();
|
||
let Paint::Gradient(sg) = node(&s, id).appearance.stroke_paint() else { panic!() };
|
||
let geom = sg.geom.unwrap();
|
||
assert_eq!(geom.start, Point::new(200.0, 150.0));
|
||
assert!((geom.aspect - 0.25).abs() < 1e-9 && (geom.length() - 100.5).abs() < 1e-9, "fits the stroke-inflated bounds: {geom:?}");
|
||
// appearance.setItem takes the same gradient object.
|
||
let g = three_stops();
|
||
let mut q = gradient_param(&g);
|
||
q["index"] = json!(0);
|
||
s.execute("appearance.setItem", &q).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient, g.gradient);
|
||
}
|
||
|
||
// ---------- M3.15: gradients follow every transform ----------
|
||
|
||
fn fill_geom(s: &Session, id: NodeId) -> GradientGeom {
|
||
fill_gradient(s, id).geom.expect("placed")
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_reflect_and_scale_carry_unplaced_gradients() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||
assert!(fill_gradient(&s, id).geom.is_none(), "a move keeps refitting");
|
||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||
let g = fill_geom(&s, id);
|
||
assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}");
|
||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||
let r = fill_geom(&s, id);
|
||
// The vertical vector is centred on the object, so the reflection swaps its ends.
|
||
assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}");
|
||
|
||
// A circle's radial gradient becomes an ellipse under a non-uniform scale.
|
||
let c = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
|
||
let c = NodeId(c["id"].as_u64().unwrap());
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
|
||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||
let e = fill_geom(&s, c);
|
||
assert!((e.aspect - 0.5).abs() < 1e-9 && (e.length() - 100.0).abs() < 1e-9, "{e:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn free_distort_maps_gradients_once() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||
// An affine distort (a sheared parallelogram) maps the gradient exactly like object.transform.
|
||
s.execute("object.distort", &json!({"corners": [[20, 0], [120, 0], [100, 100], [0, 100]]})).unwrap();
|
||
let g = fill_geom(&s, id);
|
||
let mut want = GradientGeom::fit(GradientKind::Linear, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
|
||
want.transform(vectorcraft_geom::Affine::new([1.0, 0.0, -0.2, 1.0, 20.0, 0.0]), GradientKind::Linear);
|
||
assert!(g.start.distance(want.start) < 1e-6 && g.end.distance(want.end) < 1e-6, "{g:?} vs {want:?}");
|
||
}
|
||
|
||
// ---------- M3.16: the active proxy and type objects ----------
|
||
|
||
fn text(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||
let r = s.execute("text.create", &json!({"x": x, "y": y, "text": "Gradient", "size": 40})).unwrap();
|
||
NodeId(r["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn text_obj(s: &Session, id: NodeId) -> &vectorcraft_doc::TextObject {
|
||
match &node(s, id).kind {
|
||
vectorcraft_doc::NodeKind::Text(t) => t,
|
||
k => panic!("not text: {k:?}"),
|
||
}
|
||
}
|
||
|
||
fn run_gradient(p: &Paint) -> &GradientPaint {
|
||
match p {
|
||
Paint::Gradient(g) => g,
|
||
p => panic!("expected a gradient, got {p:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_vector_follows_the_active_proxy_or_an_item_index() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.toggleActive", &json!({})).unwrap();
|
||
s.execute("paint.setGradientGeom", &json!({"start": [0, 50], "end": [100, 50]})).unwrap();
|
||
let ap = &node(&s, id).appearance;
|
||
assert!(matches!(ap.fill_paint(), Paint::Solid { .. }), "the fill stays solid");
|
||
let Paint::Gradient(g) = ap.stroke_paint() else { panic!("the stroke proxy is in front") };
|
||
assert_eq!(g.geom.map(|g| (g.start, g.end)), Some((Point::new(0.0, 50.0), Point::new(100.0, 50.0))));
|
||
// `index` targets one appearance item (item 0 is the fill), whichever proxy is in front.
|
||
s.execute("paint.setGradientGeom", &json!({"start": [50, 0], "end": [50, 100], "index": 0})).unwrap();
|
||
assert_eq!(fill_geom(&s, id).start, Point::new(50.0, 0.0));
|
||
let err = s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [1, 1], "index": 7})).unwrap_err();
|
||
assert!(err.to_string().contains("item 7"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn type_objects_take_the_vector_on_their_runs_in_text_space() {
|
||
let mut s = session();
|
||
let id = text(&mut s, 100.0, 200.0);
|
||
s.execute("paint.setGradientGeom", &json!({"start": [100, 190], "end": [300, 190]})).unwrap();
|
||
let t = text_obj(&s, id);
|
||
let geom = run_gradient(&t.runs[0].style.fill).geom.unwrap();
|
||
// Text space starts at the first baseline (100, 200).
|
||
assert!(geom.start.distance(Point::new(0.0, -10.0)) < 1e-9 && geom.end.distance(Point::new(200.0, -10.0)) < 1e-9, "{geom:?}");
|
||
// The annotator reads it back in document coordinates, and it turns with the text.
|
||
let (_, doc) = node(&s, id).proxy_gradient(false, None).unwrap();
|
||
assert!(doc.start.distance(Point::new(100.0, 190.0)) < 1e-9, "{doc:?}");
|
||
s.execute("object.rotate", &json!({"angle": 90, "origin": [100, 200]})).unwrap();
|
||
let (_, doc) = node(&s, id).proxy_gradient(false, None).unwrap();
|
||
assert!(doc.start.distance(Point::new(90.0, 200.0)) < 1e-9 && doc.end.distance(Point::new(90.0, 0.0)) < 1e-9, "{doc:?}");
|
||
// paint.setFill's vector is in document coordinates too.
|
||
s.execute("paint.setFill", &json!({"gradient": {"start": [90, 200], "end": [90, 100]}})).unwrap();
|
||
let (_, doc) = node(&s, id).proxy_gradient(false, None).unwrap();
|
||
assert!(doc.end.distance(Point::new(90.0, 100.0)) < 1e-9, "{doc:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn aspect_works_on_type_and_run_strokes_fit_the_inflated_box() {
|
||
let mut s = session();
|
||
let id = text(&mut s, 100.0, 200.0);
|
||
s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": 50, "stroke": false})).unwrap();
|
||
let t = text_obj(&s, id);
|
||
let lb = t.local_bounds();
|
||
let geom = run_gradient(&t.runs[0].style.fill).geom.expect("an aspect places the gradient");
|
||
assert!((geom.aspect - 0.5).abs() < 1e-9 && geom.start.distance(lb.center()) < 1e-9, "{geom:?} on {lb:?}");
|
||
// A stroke gradient fits the layout box grown by half the run's stroke weight.
|
||
s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": 200, "stroke": true})).unwrap();
|
||
let t = text_obj(&s, id);
|
||
let w = t.runs[0].style.stroke_width;
|
||
let geom = run_gradient(&t.runs[0].style.stroke).geom.unwrap();
|
||
assert!((geom.length() - (lb.width().max(lb.height()) + w) / 2.0).abs() < 1e-9, "{geom:?}");
|
||
// The Stroke proxy's vector lands on the runs' strokes.
|
||
s.execute("paint.setGradientGeom", &json!({"start": [100, 190], "end": [150, 190], "stroke": true})).unwrap();
|
||
let (g, doc) = node(&s, id).proxy_gradient(true, None).unwrap();
|
||
assert!(doc.end.distance(Point::new(150.0, 190.0)) < 1e-9 && (doc.aspect - 2.0).abs() < 1e-9, "aspect kept: {doc:?}");
|
||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||
}
|
||
|
||
// ---------- M3.17: the interactive annotator ----------
|
||
|
||
/// A 100 × 100 rectangle at (100, 100) with a horizontal fill gradient across it, and the
|
||
/// Gradient tool.
|
||
fn annotated() -> (Session, NodeId) {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"start": [100, 150], "end": [200, 150]}})).unwrap();
|
||
s.select_tool("gradient", crate::ViewInfo::default()).unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
fn gesture(s: &mut Session, events: &[(PointerKind, f64, f64)]) {
|
||
for (k, x, y) in events {
|
||
s.pointer(&PointerEvent::new(*k, *x, *y), crate::ViewInfo::default()).unwrap();
|
||
}
|
||
}
|
||
|
||
fn stop_count(s: &Session, id: NodeId) -> usize {
|
||
fill_gradient(s, id).gradient.stops.len()
|
||
}
|
||
|
||
#[test]
|
||
fn select_stop_is_validated_and_shared() {
|
||
let (mut s, _) = annotated();
|
||
assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1}));
|
||
assert_eq!(s.selected_stop(), Some(1));
|
||
assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops");
|
||
// A 32-bit build read 2^32 + 1 as stop 1.
|
||
assert!(s.execute("gradient.selectStop", &json!({"index": (1u64 << 32) + 1})).is_err(), "past u32");
|
||
assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err());
|
||
assert!(s.execute("gradient.selectStop", &json!({})).is_err());
|
||
s.execute("gradient.selectStop", &json!({"index": null})).unwrap();
|
||
assert_eq!(s.selected_stop(), None);
|
||
// The stroke proxy's paint is solid: nothing to select there.
|
||
s.execute("paint.toggleActive", &json!({})).unwrap();
|
||
assert!(s.execute("gradient.selectStop", &json!({"index": 0})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn annotator_gestures_edit_the_gradient_as_single_undo_steps() {
|
||
let (mut s, id) = annotated();
|
||
// A click on the bar adds a stop there and selects it.
|
||
gesture(&mut s, &[(PointerKind::Down, 130.0, 150.0), (PointerKind::Up, 130.0, 150.0)]);
|
||
let stops = fill_gradient(&s, id).gradient.stops;
|
||
assert_eq!(stops.len(), 3);
|
||
assert!((stops[1].offset - 0.3).abs() < 1e-6);
|
||
assert_eq!(s.selected_stop(), Some(1));
|
||
// Dragging the end handle changes only the end, as one undo step.
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
gesture(
|
||
&mut s,
|
||
&[(PointerKind::Down, 200.0, 150.0), (PointerKind::Drag, 210.0, 160.0), (PointerKind::Drag, 220.0, 170.0), (PointerKind::Up, 220.0, 170.0)],
|
||
);
|
||
let g = fill_geom(&s, id);
|
||
assert_eq!((g.start, g.end), (Point::new(100.0, 150.0), Point::new(220.0, 170.0)));
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
// The selected stop takes Delete ahead of edit.clear; the object stays.
|
||
let view = crate::ViewInfo::default();
|
||
assert!(s.tool_claims_key(ToolKey::Delete, view));
|
||
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
|
||
assert_eq!(stop_count(&s, id), 2);
|
||
assert_eq!(s.selected_stop(), Some(1));
|
||
// Never below two stops.
|
||
s.tool_key(ToolKey::Backspace, Mods::default(), view).unwrap();
|
||
assert_eq!(stop_count(&s, id), 2);
|
||
assert!(s.doc().unwrap().doc.node(id).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn the_selected_stop_belongs_to_its_gradient() {
|
||
let (mut s, a) = annotated();
|
||
let b = rect(&mut s, 300.0, 100.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
s.execute("gradient.selectStop", &json!({"index": 1})).unwrap();
|
||
let view = crate::ViewInfo::default();
|
||
assert!(s.tool_claims_key(ToolKey::Delete, view));
|
||
// Other art selected: no stop is selected there, so Delete is not the tool's.
|
||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||
assert_eq!(s.selected_stop(), None);
|
||
assert!(!s.tool_claims_key(ToolKey::Delete, view));
|
||
// Back on the art it was selected on, it is selected again; the other proxy has none.
|
||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||
assert_eq!(s.selected_stop(), Some(1));
|
||
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, None).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:?}");
|
||
}
|
||
|
||
/// Dither (Illustrator 30's Gradient panel checkbox): set and cleared through the gradient
|
||
/// commands, refused on strokes (it applies to fills), kept by swatches, the params and a native
|
||
/// save and load.
|
||
#[test]
|
||
fn dither_is_a_fill_gradient_option_that_saves_and_loads() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
assert!(!fill_gradient(&s, id).gradient.dither, "off by default");
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.execute("paint.editGradient", &json!({"dither": true})).unwrap();
|
||
assert!(fill_gradient(&s, id).gradient.dither);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||
// The params carry it, so a swatch and a copied paint keep it.
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!(vectorcraft_tools::params::gradient_params(&g)["dither"], json!(true));
|
||
let mut q = gradient_param(&g);
|
||
q["name"] = json!("Smooth");
|
||
s.execute("swatch.new", &q).unwrap();
|
||
let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch("Smooth").map(|w| w.paint.clone()) else { panic!("a gradient swatch") };
|
||
assert!(sw.gradient.dither);
|
||
// Saved and opened again.
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
|
||
assert!(saved.gradient.dither, "the native file keeps dither");
|
||
// Written only when on, so files without it (older ones) open with it off.
|
||
assert_eq!(serde_json::to_value(&saved.gradient).unwrap()["dither"], json!(true));
|
||
assert!(serde_json::to_value(Gradient::default()).unwrap().get("dither").is_none());
|
||
let old: Gradient = serde_json::from_value(json!({"kind": "Linear", "stops": []})).unwrap();
|
||
assert!(!old.dither);
|
||
s.execute("paint.editGradient", &json!({"dither": false})).unwrap();
|
||
assert!(!fill_gradient(&s, id).gradient.dither);
|
||
// Strokes: refused, nothing changed.
|
||
s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap();
|
||
let before = node(&s, id).appearance.clone();
|
||
assert!(s.execute("paint.editGradient", &json!({"dither": true, "stroke": true})).is_err());
|
||
assert!(s.execute("paint.setStroke", &json!({"gradient": {"dither": true}})).is_err());
|
||
assert!(s.execute("paint.setStroke", &json!({"swatch": null, "gradient": {"dither": true}})).is_err(), "a null swatch names none");
|
||
assert!(s.execute("paint.editGradient", &json!({"dither": "yes"})).is_err());
|
||
assert!(s.execute("paint.setFill", &json!({"gradient": {"dither": 1}})).is_err());
|
||
assert_eq!(node(&s, id).appearance, before);
|
||
}
|
||
|
||
/// A stroke ignores dither, so one that holds it anyway (swapped from a fill) outlines to a fill
|
||
/// without it: Outline Stroke keeps the look.
|
||
#[test]
|
||
fn an_outlined_stroke_leaves_dither_behind() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap();
|
||
s.execute("paint.setFill", &json!({"gradient": {"dither": true}})).unwrap();
|
||
s.execute("paint.swap", &json!({})).unwrap();
|
||
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
|
||
assert!(st.gradient.dither, "the swapped stroke keeps the option");
|
||
s.execute("object.path.outlineStroke", &json!({})).unwrap();
|
||
let mut fills = vec![];
|
||
s.doc().unwrap().doc.walk(|n| {
|
||
if let Paint::Gradient(g) = n.appearance.fill_paint() {
|
||
fills.push(g.gradient.dither);
|
||
}
|
||
});
|
||
assert_eq!(fills, [false, false], "the fill and the outline, neither dithered");
|
||
}
|
||
|
||
/// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient
|
||
/// commands, on fills and strokes, kept by the params, swatches and a native save and load;
|
||
/// anything but the four method names is refused without editing.
|
||
#[test]
|
||
fn method_is_a_gradient_option_that_saves_and_loads() {
|
||
use vectorcraft_color::GradientMethod;
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let stops = json!([{"offset": 0, "color": "#0000ff"}, {"offset": 1, "color": "#ffff00"}]);
|
||
s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::Classic, "classic by default");
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.execute("paint.editGradient", &json!({"method": "perceptual"})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!(g.gradient.method, GradientMethod::Perceptual);
|
||
assert_eq!(g.gradient.stops.len(), 2, "the stops stay as they were");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||
// The params carry it, so a swatch and a copied paint keep it.
|
||
assert_eq!(vectorcraft_tools::params::gradient_params(&g)["method"], json!("perceptual"));
|
||
let mut q = gradient_param(&g);
|
||
q["name"] = json!("Even");
|
||
s.execute("swatch.new", &q).unwrap();
|
||
let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch("Even").map(|w| w.paint.clone()) else { panic!("a gradient swatch") };
|
||
assert_eq!(sw.gradient.method, GradientMethod::Perceptual);
|
||
// Saved and opened again.
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
|
||
assert_eq!(saved.gradient.method, GradientMethod::Perceptual, "the native file keeps the method");
|
||
// Strokes take it too.
|
||
s.execute("paint.setStroke", &json!({"gradient": {"method": "perceptual"}})).unwrap();
|
||
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
|
||
assert_eq!(st.gradient.method, GradientMethod::Perceptual);
|
||
s.execute("paint.editGradient", &json!({"method": "classic", "stroke": true})).unwrap();
|
||
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
|
||
assert_eq!(st.gradient.method, GradientMethod::Classic);
|
||
// Bad values: refused, nothing changed.
|
||
let before = node(&s, id).appearance.clone();
|
||
for bad in [json!("smooth"), json!(1), json!(true)] {
|
||
let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
|
||
assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{e}");
|
||
assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err());
|
||
}
|
||
assert_eq!(node(&s, id).appearance, before);
|
||
s.execute("paint.editGradient", &json!({"method": "Classic", "stroke": false})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::Classic);
|
||
assert!(vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)).get("method").is_none());
|
||
// With dither: one edit sets both, and the params carry both back.
|
||
s.execute("paint.editGradient", &json!({"method": "perceptual", "dither": true, "stroke": false})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert!(g.gradient.dither && g.gradient.method == GradientMethod::Perceptual);
|
||
let p = vectorcraft_tools::params::gradient_params(&g);
|
||
assert_eq!((&p["method"], &p["dither"]), (&json!("perceptual"), &json!(true)));
|
||
s.execute("paint.setFill", &json!({"gradient": p})).unwrap();
|
||
let back = fill_gradient(&s, id);
|
||
assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual);
|
||
}
|
||
|
||
/// The hue methods: `method` hue-clockwise and hue-counterclockwise set through the gradient
|
||
/// commands on fills and strokes, as one undo step, kept by the params, a swatch, a saved preset
|
||
/// and a native save and load, and written with or without the hyphen.
|
||
#[test]
|
||
fn the_hue_methods_are_set_saved_and_loaded() {
|
||
use vectorcraft_color::GradientMethod;
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||
s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap();
|
||
for (name, method, middle) in
|
||
[("hue-clockwise", GradientMethod::HueClockwise, "#ff00ff"), ("hue-counterclockwise", GradientMethod::HueCounterclockwise, "#00ff00")]
|
||
{
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.execute("paint.editGradient", &json!({"method": name})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!(g.gradient.method, method);
|
||
assert_eq!(g.gradient.stops.len(), 2, "{name}: the stops stay as they were");
|
||
assert_eq!(g.gradient.sample(0.5).0.to_hex(), middle, "{name}: halfway around the wheel");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{name}: one undo step");
|
||
// The params carry it, so a swatch, a preset and a copied paint keep it.
|
||
let p = vectorcraft_tools::params::gradient_params(&g);
|
||
assert_eq!(p["method"], json!(name));
|
||
let mut q = gradient_param(&g);
|
||
q["name"] = json!(format!("Wheel {name}"));
|
||
s.execute("swatch.new", &q).unwrap();
|
||
let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch(&format!("Wheel {name}")).map(|w| w.paint.clone()) else {
|
||
panic!("a gradient swatch")
|
||
};
|
||
assert_eq!(sw.gradient.method, method, "{name}: the swatch");
|
||
s.execute("gradient.presets.save", &json!({"name": format!("Preset {name}")})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"method": "classic"})).unwrap();
|
||
s.execute("gradient.presets.apply", &json!({"name": format!("Preset {name}")})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.method, method, "{name}: the preset");
|
||
// Saved and opened again.
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
|
||
assert_eq!(saved.gradient.method, method, "{name}: the native file keeps the method");
|
||
}
|
||
// Strokes take it too, and the name reads without the hyphen and in any case.
|
||
for (name, method) in [("hue-clockwise", GradientMethod::HueClockwise), ("hue-counterclockwise", GradientMethod::HueCounterclockwise)] {
|
||
for written in [name.replace('-', " "), name.replace('-', "").to_uppercase()] {
|
||
s.execute("paint.setStroke", &json!({"gradient": {"method": "classic"}})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"method": written, "stroke": true})).unwrap();
|
||
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
|
||
assert_eq!(st.gradient.method, method, "{written}");
|
||
}
|
||
}
|
||
// Reverse gives the same ramp the other way round: the hue method turns with the stops, so
|
||
// the middle keeps its color, unless the edit names a method too.
|
||
s.execute("paint.editGradient", &json!({"method": "hue-clockwise", "stroke": false})).unwrap();
|
||
let before = fill_gradient(&s, id).gradient;
|
||
s.execute("paint.editGradient", &json!({"reverse": true, "stroke": false})).unwrap();
|
||
let after = fill_gradient(&s, id).gradient;
|
||
assert_eq!(after.method, GradientMethod::HueCounterclockwise);
|
||
assert_eq!((after.stops[0].color.to_hex(), after.stops[1].color.to_hex()), ("#0000ff".to_string(), "#ff0000".to_string()));
|
||
for t in [0.25, 0.5, 0.75] {
|
||
assert_eq!(after.sample(t).0.to_hex(), before.sample(1.0 - t).0.to_hex(), "reversed at {t}");
|
||
}
|
||
s.execute("paint.editGradient", &json!({"reverse": true, "method": "hue-counterclockwise", "stroke": false})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::HueCounterclockwise, "a method in the edit wins");
|
||
// Half a name is refused, nothing changed.
|
||
let before = node(&s, id).appearance.clone();
|
||
for bad in ["hue", "clockwise", "hue-clock"] {
|
||
let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
|
||
assert!(e.contains("`method` must be classic, perceptual, hue-clockwise or hue-counterclockwise"), "{bad}: {e}");
|
||
}
|
||
assert_eq!(node(&s, id).appearance, before);
|
||
}
|
||
|
||
/// A conic gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params
|
||
/// agents and swatches read, and the native format.
|
||
#[test]
|
||
fn a_conic_gradient_is_set_edited_transformed_and_saved() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "conic"}})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!(g.gradient.kind, GradientKind::Conic);
|
||
// Unplaced, it sweeps from the right about the box's center.
|
||
let geom = g.resolve(node(&s, id).geometric_bounds().unwrap());
|
||
assert_eq!(geom.start, Point::new(200.0, 150.0));
|
||
assert!((geom.param_at(GradientKind::Conic, Point::new(200.0, 190.0)) - 0.25).abs() < 1e-9);
|
||
// `angle` and `conical` name it too.
|
||
for name in ["angle", "conical", "CONICAL"] {
|
||
s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": name})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Conic, "{name}");
|
||
}
|
||
let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string();
|
||
assert!(e.contains("(linear, radial, conic, diamond, freeform)"), "{e}");
|
||
// Placed, an angle turns the vector about the center.
|
||
s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"angle": 90})).unwrap();
|
||
let g = fill_geom(&s, id);
|
||
assert!(g.start.distance(Point::new(200.0, 150.0)) < 1e-9 && g.end.distance(Point::new(200.0, 90.0)) < 1e-9, "{g:?}");
|
||
// A reflection mirrors the sweep (a negative aspect): each point keeps its color.
|
||
let p = Point::new(230.0, 170.0);
|
||
let before = g.param_at(GradientKind::Conic, p);
|
||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||
let m = fill_geom(&s, id);
|
||
assert!(m.is_mirrored(), "{m:?}");
|
||
assert!((m.param_at(GradientKind::Conic, Point::new(170.0, 170.0)) - before).abs() < 1e-9);
|
||
// An aspect edit sets what it is given: the Gradient panel sends a mirrored conic's sign.
|
||
s.execute("paint.editGradient", &json!({"aspect": -80})).unwrap();
|
||
assert_eq!(fill_geom(&s, id).aspect, -0.8);
|
||
s.execute("paint.setGradientGeom", &json!({"aspect": 40})).unwrap();
|
||
assert_eq!(fill_geom(&s, id).aspect, 0.4);
|
||
// A rotation carries it along.
|
||
let g = fill_geom(&s, id);
|
||
let before = g.param_at(GradientKind::Conic, p);
|
||
s.execute("object.rotate", &json!({"angle": 30, "origin": [200, 150]})).unwrap();
|
||
let r = vectorcraft_geom::Affine::rotate_about(-30f64.to_radians(), Point::new(200.0, 150.0));
|
||
let after = fill_geom(&s, id).param_at(GradientKind::Conic, r * p);
|
||
assert!((after - before).abs() < 1e-9, "{before} vs {after}");
|
||
// The params give it back, and the native format keeps it.
|
||
s.execute("paint.editGradient", &json!({"aspect": -60})).unwrap();
|
||
let want = fill_gradient(&s, id);
|
||
let params = vectorcraft_tools::params::gradient_params(&want);
|
||
assert_eq!((params["kind"].as_str(), params["aspect"].as_f64()), (Some("conic"), Some(-60.0)));
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
|
||
let got = fill_gradient(&s, id);
|
||
assert_eq!((&got.gradient, got.geom), (&want.gradient, want.geom));
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") };
|
||
assert_eq!((g.gradient.kind, g.geom), (GradientKind::Conic, want.geom));
|
||
}
|
||
|
||
/// A free distort that is not affine moves a conic as it moves a radial gradient: by the warp
|
||
/// near its center, not near the middle of its vector.
|
||
#[test]
|
||
fn a_warp_moves_a_conic_by_its_center() {
|
||
let warped = |kind: &str| {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [20, 30], "end": [90, 30], "aspect": 60}})).unwrap();
|
||
s.execute("object.distort", &json!({"corners": [[0, 0], [100, 0], [140, 130], [-20, 100]]})).unwrap();
|
||
fill_geom(&s, id)
|
||
};
|
||
let (conic, radial, linear) = (warped("conic"), warped("radial"), warped("linear"));
|
||
assert!(conic.start.distance(radial.start) < 1e-9, "{conic:?} vs {radial:?}");
|
||
assert!(conic.start.distance(linear.start) > 0.1, "a linear gradient moves by its middle: {linear:?}");
|
||
}
|
||
|
||
/// A negative aspect mirrors a conic's sweep where the gradient is given one, and a mirrored
|
||
/// conic swatch lands mirrored on other art. Other kinds refuse a negative aspect, and a conic
|
||
/// refuses a focal point.
|
||
#[test]
|
||
fn a_negative_aspect_mirrors_only_a_conic() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "aspect": -50}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).geom.map(|g| g.aspect), Some(-0.5));
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [0, 0], "end": [10, 0], "aspect": -200}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).geom.map(|g| g.aspect), Some(-2.0));
|
||
let mut q = gradient_param(&fill_gradient(&s, id));
|
||
q["name"] = json!("Mirrored");
|
||
s.execute("swatch.new", &q).unwrap();
|
||
let b = rect(&mut s, 500.0, 300.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"swatch": "Mirrored"})).unwrap();
|
||
assert_eq!(fill_gradient(&s, b).geom.map(|g| g.aspect), Some(-2.0));
|
||
// Radial (and linear) gradients have no mirror: a negative aspect is an error, whichever
|
||
// command gives it, and the gradient stays as it was.
|
||
let before = fill_gradient(&s, b);
|
||
for kind in ["radial", "linear"] {
|
||
let e = s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "aspect": -50}})).unwrap_err().to_string();
|
||
assert!(e.contains("aspect"), "{kind}: {e}");
|
||
let e = s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [0, 0], "end": [10, 0], "aspect": -50}})).unwrap_err();
|
||
assert!(e.to_string().contains("aspect"), "{kind}: {e}");
|
||
}
|
||
assert_eq!(fill_gradient(&s, b), before);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "aspect": 50}})).unwrap();
|
||
let radial = fill_gradient(&s, b);
|
||
assert!(s.execute("paint.editGradient", &json!({"aspect": -50})).is_err());
|
||
assert!(s.execute("paint.setGradientGeom", &json!({"aspect": -50})).is_err());
|
||
assert!(s.execute("paint.editGradient", &json!({"kind": "linear", "aspect": -50})).is_err());
|
||
assert_eq!(fill_gradient(&s, b), radial);
|
||
// Turned conic in the same edit, a negative aspect mirrors it.
|
||
s.execute("paint.editGradient", &json!({"kind": "conic", "aspect": -50})).unwrap();
|
||
assert_eq!(fill_gradient(&s, b).geom.map(|g| g.aspect), Some(-0.5));
|
||
// A conic has no focal point: one given is an error, and null is allowed.
|
||
let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [500, 300], "end": [550, 300], "focal": [510, 300]}}));
|
||
assert!(e.unwrap_err().to_string().contains("focal"));
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [500, 300], "end": [550, 300], "focal": null}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, b).geom.unwrap().focal, None);
|
||
assert!(s.execute("paint.setGradientGeom", &json!({"focal": [510, 300]})).unwrap_err().to_string().contains("focal"));
|
||
// A selected conic takes a negative aspect while the default paint is linear: the default
|
||
// takes its size.
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": "conic"})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"aspect": -70})).unwrap();
|
||
assert_eq!(fill_gradient(&s, b).geom.map(|g| g.aspect), Some(-0.7));
|
||
// A linear gradient keeps no focal point, as paint.setGradientGeom leaves it.
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "start": [500, 300], "end": [550, 300], "focal": [510, 300]}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, b).geom.unwrap().focal, None);
|
||
}
|
||
|
||
/// Saved as `.ai`, as PDF with its editing data and as SVG with its editing data, then opened, a
|
||
/// mirrored, squashed conic comes back as it was; a PDF without editing data gives it back from
|
||
/// its sweep shading.
|
||
#[test]
|
||
fn a_conic_survives_saving_as_ai_pdf_and_svg() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "start": [180, 140], "end": [240, 110], "aspect": -60}})).unwrap();
|
||
let want = fill_gradient(&s, id);
|
||
let reopened = |s: &mut Session, format: &str, p: Value| {
|
||
let mut p = p;
|
||
p["format"] = json!(format);
|
||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||
let mut back = session();
|
||
back.execute("document.open", &json!({"name": format!("t.{format}"), "dataBase64": r["dataBase64"]})).unwrap();
|
||
let mut found = None;
|
||
back.doc().unwrap().doc.walk(|n| {
|
||
if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) {
|
||
found = Some(*g.clone());
|
||
}
|
||
});
|
||
found.unwrap_or_else(|| panic!("{format}: the gradient is lost"))
|
||
};
|
||
for (format, p) in [("ai", json!({})), ("pdf", json!({"preserveEditing": true})), ("svg", json!({"preserveEditing": true}))] {
|
||
let got = reopened(&mut s, format, p);
|
||
assert_eq!((got.gradient.kind, got.geom), (GradientKind::Conic, want.geom), "{format}");
|
||
}
|
||
let got = reopened(&mut s, "pdf", json!({"preserveEditing": false}));
|
||
assert_eq!(got.gradient.kind, GradientKind::Conic);
|
||
let (g, w) = (got.geom.unwrap(), want.geom.unwrap());
|
||
assert!(g.is_mirrored() && g.start.distance(w.start) < 0.05, "{g:?}");
|
||
for p in [Point::new(120.0, 120.0), Point::new(280.0, 180.0), Point::new(200.0, 105.0)] {
|
||
assert!((g.param_at(GradientKind::Conic, p) - w.param_at(GradientKind::Conic, p)).abs() < 2e-3, "{p:?}: {g:?}");
|
||
}
|
||
}
|
||
|
||
/// Turning a gradient conic is one undo step, and undoing it gives the gradient back as it was.
|
||
#[test]
|
||
fn making_a_gradient_conic_is_one_undo_step() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
|
||
let (before, steps) = (fill_gradient(&s, id), s.doc().unwrap().history.undo.len());
|
||
s.execute("paint.editGradient", &json!({"kind": "conic", "angle": 45})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id), before);
|
||
}
|
||
|
||
/// A diamond gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params
|
||
/// agents and swatches read, and the native format.
|
||
#[test]
|
||
fn a_diamond_gradient_is_set_edited_transformed_and_saved() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond"}})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!(g.gradient.kind, GradientKind::Diamond);
|
||
// Unplaced, it runs from the box's center out to a square whose edges pass through the box's
|
||
// corners.
|
||
let geom = g.resolve(node(&s, id).geometric_bounds().unwrap());
|
||
assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(350.0, 150.0)));
|
||
for (p, t) in [
|
||
(Point::new(300.0, 200.0), 1.0),
|
||
(Point::new(100.0, 100.0), 1.0),
|
||
(Point::new(250.0, 150.0), 1.0 / 3.0),
|
||
(Point::new(200.0, 175.0), 1.0 / 6.0),
|
||
] {
|
||
assert!((geom.param_at(GradientKind::Diamond, p) - t).abs() < 1e-9, "{p:?}");
|
||
}
|
||
s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": "DIAMOND"})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Diamond);
|
||
let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string();
|
||
assert!(e.contains("(linear, radial, conic, diamond, freeform)"), "{e}");
|
||
// Placed, an angle turns the vector about the center.
|
||
s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"angle": 90})).unwrap();
|
||
let g = fill_geom(&s, id);
|
||
assert!(g.start.distance(Point::new(200.0, 150.0)) < 1e-9 && g.end.distance(Point::new(200.0, 90.0)) < 1e-9, "{g:?}");
|
||
// A reflection keeps each point's color.
|
||
let p = Point::new(230.0, 170.0);
|
||
let before = g.param_at(GradientKind::Diamond, p);
|
||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||
let m = fill_geom(&s, id);
|
||
assert!(m.aspect > 0.0, "{m:?}");
|
||
assert!((m.param_at(GradientKind::Diamond, Point::new(170.0, 170.0)) - before).abs() < 1e-9);
|
||
// A rotation carries it along.
|
||
let before = m.param_at(GradientKind::Diamond, p);
|
||
s.execute("object.rotate", &json!({"angle": 30, "origin": [200, 150]})).unwrap();
|
||
let r = vectorcraft_geom::Affine::rotate_about(-30f64.to_radians(), Point::new(200.0, 150.0));
|
||
let after = fill_geom(&s, id).param_at(GradientKind::Diamond, r * p);
|
||
assert!((after - before).abs() < 1e-9, "{before} vs {after}");
|
||
// The params give it back, and the native format keeps it.
|
||
s.execute("paint.editGradient", &json!({"aspect": 60})).unwrap();
|
||
let want = fill_gradient(&s, id);
|
||
let params = vectorcraft_tools::params::gradient_params(&want);
|
||
assert_eq!((params["kind"].as_str(), params["aspect"].as_f64()), (Some("diamond"), Some(60.0)));
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
|
||
let got = fill_gradient(&s, id);
|
||
assert_eq!((&got.gradient, got.geom), (&want.gradient, want.geom));
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") };
|
||
assert_eq!((g.gradient.kind, g.geom), (GradientKind::Diamond, want.geom));
|
||
}
|
||
|
||
/// A diamond refuses a negative aspect and a focal point, from every command that gives one, and
|
||
/// the gradient stays as it was; a null focal point is allowed.
|
||
#[test]
|
||
fn a_diamond_refuses_a_negative_aspect_and_a_focal_point() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "aspect": 50}})).unwrap();
|
||
let before = fill_gradient(&s, id);
|
||
for p in [
|
||
json!({"gradient": {"kind": "diamond", "aspect": -50}}),
|
||
json!({"gradient": {"kind": "diamond", "start": [0, 0], "end": [10, 0], "aspect": -50}}),
|
||
] {
|
||
assert!(s.execute("paint.setFill", &p).unwrap_err().to_string().contains("aspect"), "{p}");
|
||
}
|
||
assert!(s.execute("paint.editGradient", &json!({"aspect": -50})).is_err());
|
||
assert!(s.execute("paint.setGradientGeom", &json!({"aspect": -50})).is_err());
|
||
assert!(s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": -50})).is_err());
|
||
let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": [210, 150]}}));
|
||
assert!(e.unwrap_err().to_string().contains("focal"));
|
||
assert!(s.execute("paint.setGradientGeom", &json!({"focal": [210, 150]})).unwrap_err().to_string().contains("focal"));
|
||
assert_eq!(fill_gradient(&s, id), before);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": null}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).geom.unwrap().focal, None);
|
||
}
|
||
|
||
/// A free distort that is not affine moves a diamond by the warp near its center, as it moves a
|
||
/// radial gradient.
|
||
#[test]
|
||
fn a_warp_moves_a_diamond_by_its_center() {
|
||
let warped = |kind: &str| {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [20, 30], "end": [90, 30], "aspect": 60}})).unwrap();
|
||
s.execute("object.distort", &json!({"corners": [[0, 0], [100, 0], [140, 130], [-20, 100]]})).unwrap();
|
||
fill_geom(&s, id)
|
||
};
|
||
let (diamond, radial) = (warped("diamond"), warped("radial"));
|
||
assert!(diamond.start.distance(radial.start) < 1e-9, "{diamond:?} vs {radial:?}");
|
||
}
|
||
|
||
/// Saved as `.ai`, as PDF with its editing data and as SVG with its editing data, then opened, a
|
||
/// turned, squashed diamond comes back as it was.
|
||
#[test]
|
||
fn a_diamond_survives_saving_as_ai_pdf_and_svg() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [180, 140], "end": [240, 110], "aspect": 60}})).unwrap();
|
||
let want = fill_gradient(&s, id);
|
||
for format in ["ai", "pdf", "svg"] {
|
||
let r = s.execute("file.saveCopy", &json!({"format": format, "preserveEditing": true})).unwrap();
|
||
let mut back = session();
|
||
back.execute("document.open", &json!({"name": format!("t.{format}"), "dataBase64": r["dataBase64"]})).unwrap();
|
||
let mut found = None;
|
||
back.doc().unwrap().doc.walk(|n| {
|
||
if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) {
|
||
found = Some(*g.clone());
|
||
}
|
||
});
|
||
let got = found.unwrap_or_else(|| panic!("{format}: the gradient is lost"));
|
||
assert_eq!((got.gradient.kind, got.geom), (GradientKind::Diamond, want.geom), "{format}");
|
||
}
|
||
}
|
||
|
||
/// Turning a gradient diamond is one undo step, and undoing it gives the gradient back as it was.
|
||
#[test]
|
||
fn making_a_gradient_diamond_is_one_undo_step() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
|
||
let (before, steps) = (fill_gradient(&s, id), s.doc().unwrap().history.undo.len());
|
||
s.execute("paint.editGradient", &json!({"kind": "diamond", "angle": 45})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id), before);
|
||
}
|
||
|
||
/// `paint.setFill` and `paint.editGradient` take a spread and a count for each kind of gradient
|
||
/// that has ends; the params give them back, the native format keeps them, and a change is one
|
||
/// undo step while an edit that changes nothing records none.
|
||
#[test]
|
||
fn a_gradient_takes_a_spread_and_a_count() {
|
||
use vectorcraft_color::GradientSpread;
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
for kind in ["linear", "radial", "conic", "diamond"] {
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "spread": "Reflect", "repeats": 4}})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Reflect, 4), "{kind}");
|
||
let params = vectorcraft_tools::params::gradient_params(&g);
|
||
assert_eq!((params["spread"].as_str(), params["repeats"].as_u64()), (Some("reflect"), Some(4)), "{kind}");
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.spread, GradientSpread::Pad, "a new gradient pads");
|
||
s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient, g.gradient, "{kind}");
|
||
}
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step");
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 2));
|
||
s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing changed: no step");
|
||
s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id).gradient.repeats, 2, "Pad keeps the count for later");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(fill_gradient(&s, id), g);
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") };
|
||
assert_eq!((saved.gradient.spread, saved.gradient.repeats), (GradientSpread::Repeat, 2));
|
||
// The params of a gradient that pads give back the count it keeps.
|
||
s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap();
|
||
let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id));
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
|
||
assert_eq!((fill_gradient(&s, id).gradient.spread, fill_gradient(&s, id).gradient.repeats), (GradientSpread::Pad, 2), "{params}");
|
||
}
|
||
|
||
/// A spread or count a gradient can't take is an error, from every command that sets one, and the
|
||
/// gradient stays as it was: an unknown spread, a count that is not a whole number from 1 to 100,
|
||
/// and a spread other than pad on a freeform gradient.
|
||
#[test]
|
||
fn a_bad_spread_or_count_is_refused() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "spread": "repeat", "repeats": 3}})).unwrap();
|
||
let before = fill_gradient(&s, id);
|
||
let steps = s.doc().unwrap().history.undo.len();
|
||
for bad in [json!({"spread": "mirror"}), json!({"spread": 1}), json!({"spread": true})] {
|
||
let e = s.execute("paint.editGradient", &bad).unwrap_err().to_string();
|
||
assert!(e.contains("`spread` must be pad, repeat or reflect"), "{bad}: {e}");
|
||
}
|
||
for n in [json!(0), json!(-2), json!(1.5), json!(101), json!(1e9), json!("3"), json!(f64::MAX)] {
|
||
let e = s.execute("paint.editGradient", &json!({ "repeats": n })).unwrap_err().to_string();
|
||
assert!(e.contains("`repeats` must be a whole number from 1 to 100"), "{n}: {e}");
|
||
let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "repeats": n}})).unwrap_err().to_string();
|
||
assert!(e.contains("`repeats`"), "{n}: {e}");
|
||
}
|
||
let freeform = json!({"kind": "freeform", "spread": "reflect"});
|
||
let e = s.execute("paint.editGradient", &freeform).unwrap_err().to_string();
|
||
assert!(e.contains("`spread` applies to linear, radial, conic and diamond gradients"), "{e}");
|
||
let e = s.execute("paint.setFill", &json!({ "gradient": freeform })).unwrap_err().to_string();
|
||
assert!(e.contains("`spread` applies to"), "{e}");
|
||
assert_eq!(fill_gradient(&s, id), before);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||
// Pad on a freeform gradient is allowed (it has nothing to spread), and 100 is the most.
|
||
s.execute("paint.editGradient", &json!({"repeats": 100})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": "freeform", "spread": "pad"})).unwrap();
|
||
// A repeating gradient turned freeform: its params, which carry no spread, set it again.
|
||
s.execute("paint.editGradient", &json!({"kind": "radial", "spread": "reflect"})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
|
||
let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id));
|
||
assert!(params.get("spread").is_none(), "{params}");
|
||
s.execute("paint.setFill", &json!({ "gradient": params })).unwrap();
|
||
}
|
||
|
||
/// A spread for a selected gradient applies while the gradient used last, the default, is a
|
||
/// freeform one, which keeps no spread.
|
||
#[test]
|
||
fn a_spread_for_a_selected_gradient_leaves_a_freeform_default_alone() {
|
||
use vectorcraft_color::GradientSpread;
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform"}})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"ids": [a.0], "spread": "repeat", "repeats": 3})).unwrap();
|
||
let g = fill_gradient(&s, a);
|
||
assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 3));
|
||
}
|
||
|
||
/// A preset keeps its spread and count: saved from a gradient, applied to another object.
|
||
#[test]
|
||
fn a_gradient_preset_keeps_its_spread() {
|
||
use vectorcraft_color::GradientSpread;
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "spread": "reflect", "repeats": 5}})).unwrap();
|
||
s.execute("gradient.presets.save", &json!({"name": "Fan"})).unwrap();
|
||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
||
s.execute("gradient.presets.apply", &json!({"name": "Fan"})).unwrap();
|
||
let g = fill_gradient(&s, b);
|
||
assert_eq!((g.gradient.kind, g.gradient.spread, g.gradient.repeats), (GradientKind::Conic, GradientSpread::Reflect, 5));
|
||
assert_eq!(fill_gradient(&s, a).gradient.repeats, 5);
|
||
}
|
||
|
||
/// A gradient set back to Pad keeps its count for later, and still matches a saved preset and a
|
||
/// swatch of the same gradient; while it repeats, the spread tells them apart.
|
||
#[test]
|
||
fn a_padding_gradient_matches_whatever_count_it_keeps() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let ramp = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]});
|
||
s.execute("paint.setFill", &json!({ "gradient": ramp })).unwrap();
|
||
s.execute("gradient.presets.save", &json!({"name": "Plain"})).unwrap();
|
||
s.execute("swatch.new", &json!({"name": "Plain", "paint": {"gradient": ramp}})).unwrap();
|
||
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().contains(&json!("Plain"));
|
||
assert!(!unused(&mut s));
|
||
s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 5})).unwrap();
|
||
assert_eq!(s.active_saved_gradient_preset(), None, "a repeating gradient is another ramp");
|
||
assert!(unused(&mut s), "nothing paints the swatch's gradient");
|
||
s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap();
|
||
assert_eq!(fill_gradient(&s, a).gradient.repeats, 5);
|
||
assert_eq!(s.active_saved_gradient_preset(), Some("Plain"));
|
||
assert!(!unused(&mut s), "the gradient paints as the swatch does");
|
||
}
|