mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 15:52:52 +00:00
A fourth gradient kind, conic (also accepted as `angle` or `conical`), sweeps the stops once around the center (the gradient's start), clockwise on the page from the vector, with a seam where the last stop meets the first. A negative aspect mirrors the sweep, and reflecting an object turns its sweep the other way, also for a conic without a vector or with a flat extent. - Gradient panel: a Conic button in the Type row; the Aspect Ratio field works for conic gradients and keeps a mirrored sweep mirrored. - Gradient tool: the extent ellipse, aspect handle and center ring of a radial gradient, without a focal point. - Rendering, swatch chips, transforms, warps, plug-in round trips, and Object > Expand into wedges or a mesh of columns around the center. - Commands: a negative `aspect` is an error for linear and radial gradients, and so is a `focal` point for a conic gradient. - PDF export writes a function-based shading whose calculator function turns the angle into the ramp, and PDF import reads a function-based shading that turns around the origin of its space back as a conic gradient, also when that origin lies outside the painted area. A PDF/A file, or a conic with more than 97 stops, gets an image instead. - SVG export writes 360 solid wedges clipped to the shape for opaque stops and an image otherwise, and a linear gradient on characters. - CSS export writes `conic-gradient(from ...)`, with the stops mirrored for a mirrored sweep. - EPS and legacy .ai export write 360 solid wedges; the .ai editing data holds a linear gradient along the vector with the same stops. - .af files: conical gradients open as conic gradients. - An export that writes the gradient in another form gives a warning. Supersedes #2326 and includes switch87's code from it. Co-authored-by: Gert Pellin <gert@pellin.be>
750 lines
40 KiB
Rust
750 lines
40 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", "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 classic or perceptual 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 or perceptual"), "{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);
|
||
}
|
||
|
||
/// 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, 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);
|
||
}
|