241 lines
12 KiB
Rust
241 lines
12 KiB
Rust
//! Freeform gradients through commands: the first application (M3.81), transforms and warps, and
|
||
//! the point and line commands (M3.82).
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Freeform, GradientKind, GradientPaint, Paint};
|
||
use vectorcraft_geom::Point;
|
||
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn shape(s: &mut Session, cmd: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
let r = s.execute(cmd, &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:?}"),
|
||
}
|
||
}
|
||
|
||
fn points(s: &Session, id: NodeId) -> Freeform {
|
||
fill_gradient(s, id).freeform.expect("placed freeform points")
|
||
}
|
||
|
||
/// A selected 100 × 100 rectangle at (100, 100) with a freeform fill.
|
||
fn freeform_rect() -> (Session, NodeId) {
|
||
let mut s = session();
|
||
let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0);
|
||
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||
}
|
||
|
||
fn close(a: Point, b: Point) -> bool {
|
||
a.distance(b) < 1e-6
|
||
}
|
||
|
||
// ---------- M3.81: the first application, transforms, warps and saving ----------
|
||
|
||
#[test]
|
||
fn the_first_application_places_points_inside_the_shape() {
|
||
let mut s = session();
|
||
// An ellipse: the box's corners are outside it.
|
||
let id = shape(&mut s, "shape.ellipse", 100.0, 100.0, 300.0, 100.0);
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap();
|
||
let g = fill_gradient(&s, id);
|
||
assert_eq!(g.gradient.kind, GradientKind::Freeform);
|
||
let f = g.freeform.as_ref().unwrap();
|
||
let inside = node(&s, id).contains_fn();
|
||
assert!(f.points.len() >= 4 && f.points.iter().all(|p| inside(p.at)), "{:?}", f.points);
|
||
// The stops follow the points' colours (swatch chips and exports show them).
|
||
assert_eq!(g.gradient.stops.iter().map(|st| st.color).collect::<Vec<_>>(), f.points.iter().map(|p| p.color).collect::<Vec<_>>());
|
||
// Draw mode: freeform only.
|
||
s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap();
|
||
assert_eq!(points(&s, id).mode, vectorcraft_color::FreeformMode::Lines);
|
||
assert_eq!(points(&s, id).points, f.points, "the points stay");
|
||
s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap();
|
||
assert!(fill_gradient(&s, id).freeform.is_none(), "a linear gradient drops the points");
|
||
let e = s.execute("paint.editGradient", &json!({"mode": "lines"})).unwrap_err().to_string();
|
||
assert!(e.contains("freeform"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn new_stops_recolour_the_points() {
|
||
let (mut s, id) = freeform_rect();
|
||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||
s.execute("paint.editGradient", &json!({"stops": stops})).unwrap();
|
||
let f = points(&s, id);
|
||
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn transforms_and_warps_move_the_points() {
|
||
let (mut s, id) = freeform_rect();
|
||
let before = points(&s, id);
|
||
run(&mut s, "object.move", json!({"dx": 10, "dy": 5}));
|
||
let moved = points(&s, id);
|
||
assert!(before.points.iter().zip(&moved.points).all(|(a, b)| close(b.at, a.at + vectorcraft_geom::Vec2::new(10.0, 5.0))));
|
||
run(&mut s, "object.rotate", json!({"angle": 90, "origin": [160, 155]}));
|
||
let turned = points(&s, id);
|
||
// The y-down document turns (x, y) about the origin to (x', y') = (160 + (y − 155), 155 − (x − 160)).
|
||
for (a, b) in moved.points.iter().zip(&turned.points) {
|
||
let want = Point::new(160.0 + (a.at.y - 155.0), 155.0 - (a.at.x - 160.0));
|
||
assert!(close(b.at, want), "{:?} → {:?}, want {want:?}", a.at, b.at);
|
||
}
|
||
// A perspective distort maps each point exactly: a point on a corner stays on it.
|
||
let (mut s, id) = freeform_rect();
|
||
run(&mut s, "paint.freeform.addPoint", json!({"at": [200, 200]}));
|
||
run(&mut s, "object.distort", json!({"corners": [[100, 100], [200, 100], [260, 240], [100, 200]]}));
|
||
let f = points(&s, id);
|
||
assert!(close(f.points.last().unwrap().at, Point::new(260.0, 240.0)), "{:?}", f.points.last());
|
||
}
|
||
|
||
#[test]
|
||
fn freeform_paints_survive_saving_and_old_files_load() {
|
||
let (mut s, id) = freeform_rect();
|
||
run(&mut s, "paint.editGradient", json!({"mode": "lines"}));
|
||
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1, 2]}));
|
||
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&s.doc().unwrap().doc).unwrap();
|
||
// A gradient saved before freeform points existed has none.
|
||
let old: GradientPaint = serde_json::from_value(json!({"gradient": {"kind": "Freeform", "stops": []}, "angle": 0.0})).unwrap();
|
||
assert!(old.freeform.is_none());
|
||
// The command params are lossless: setting the paint read back reproduces it.
|
||
let g = fill_gradient(&s, id);
|
||
let other = shape(&mut s, "shape.rectangle", 300.0, 300.0, 50.0, 50.0);
|
||
run(&mut s, "paint.setFill", json!({"gradient": vectorcraft_tools::params::gradient_params(&g), "ids": [other.0]}));
|
||
assert_eq!(fill_gradient(&s, other), g);
|
||
}
|
||
|
||
#[test]
|
||
fn reapplied_gradients_place_their_points_afresh() {
|
||
let (mut s, id) = freeform_rect();
|
||
run(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#ff0000"}));
|
||
// The last gradient (`.`) on other art: points inside it, coloured like the first object's.
|
||
let other = shape(&mut s, "shape.rectangle", 400.0, 300.0, 100.0, 100.0);
|
||
run(&mut s, "paint.lastGradient", json!({}));
|
||
let (a, (b, _)) = (points(&s, id), node(&s, other).proxy_freeform(false, None).unwrap());
|
||
assert!(b.points.iter().all(|p| p.at.x >= 400.0 && p.at.y >= 300.0), "{:?}", b.points);
|
||
assert_eq!(a.points.iter().map(|p| p.color.to_hex()).collect::<Vec<_>>(), b.points.iter().map(|p| p.color.to_hex()).collect::<Vec<_>>());
|
||
}
|
||
|
||
// ---------- M3.82: points and lines ----------
|
||
|
||
#[test]
|
||
fn add_set_and_delete_points() {
|
||
let (mut s, id) = freeform_rect();
|
||
let n = points(&s, id).points.len();
|
||
let field = points(&s, id);
|
||
let r = run(&mut s, "paint.freeform.addPoint", json!({"at": [150, 150]}));
|
||
assert_eq!(r["index"], json!(n));
|
||
assert_eq!(s.selected_freeform_point(), Some(n), "the new point is selected");
|
||
let p = points(&s, id).points[n];
|
||
// Without a colour it takes the gradient's colour there.
|
||
let (want, _) = field.sample(Point::new(150.0, 150.0), 50.0);
|
||
assert!(close(p.at, Point::new(150.0, 150.0)) && p.color.to_hex() == want.to_hex(), "{p:?} vs {want:?}");
|
||
// The selected point takes edits without an index; opacity and spread take 0..1 or percentages.
|
||
run(&mut s, "paint.freeform.setPoint", json!({"color": "#00ff00", "opacity": 50, "spread": 0.4, "at": [160, 170]}));
|
||
let p = points(&s, id).points[n];
|
||
assert_eq!((p.color.to_hex().as_str(), p.opacity, p.spread, p.at), ("#00ff00", 0.5, 0.4, Point::new(160.0, 170.0)));
|
||
assert_eq!(fill_gradient(&s, id).gradient.stops[n].color.to_hex(), "#00ff00", "the stops follow");
|
||
// One undo step per command.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(points(&s, id).points[n].color.to_hex(), want.to_hex());
|
||
run(&mut s, "edit.redo", json!({}));
|
||
run(&mut s, "paint.freeform.deletePoint", json!({}));
|
||
assert_eq!(points(&s, id).points.len(), n);
|
||
assert_eq!(s.selected_freeform_point(), None);
|
||
for i in (1..n).rev() {
|
||
run(&mut s, "paint.freeform.deletePoint", json!({"index": i}));
|
||
}
|
||
let e = s.execute("paint.freeform.deletePoint", &json!({"index": 0})).unwrap_err().to_string();
|
||
assert!(e.contains("at least one"), "{e}");
|
||
let e = s.execute("paint.freeform.setPoint", &json!({"index": 5, "color": "#000000"})).unwrap_err().to_string();
|
||
assert!(e.contains("no point 5"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn lines_join_split_and_follow_the_selection() {
|
||
let (mut s, id) = freeform_rect();
|
||
assert_eq!(run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}))["line"], json!(0));
|
||
run(&mut s, "paint.freeform.selectPoint", json!({"index": 1}));
|
||
// Adding a point with `line` extends the line ending at the selected point.
|
||
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [190, 190], "line": true}))["index"].as_u64().unwrap() as usize;
|
||
assert_eq!(points(&s, id).lines, vec![vec![0, 1, i]]);
|
||
let j = run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 1}))["index"].as_u64().unwrap() as usize;
|
||
assert_eq!((points(&s, id).lines[0].clone(), s.selected_freeform_point()), (vec![0, 1, j, i], Some(j)));
|
||
assert!(s.execute("paint.freeform.splitLine", &json!({"line": 0, "segment": 3})).is_err());
|
||
assert!(s.execute("paint.freeform.addLine", &json!({"points": [0, 99]})).is_err());
|
||
// The query reports points in document coordinates, the lines and the selection.
|
||
let g = run(&mut s, "paint.freeform.get", json!({}));
|
||
assert_eq!((g["lines"].clone(), g["selected"].clone(), g["mode"].clone()), (json!([[0, 1, j, i]]), json!(j), json!("points")));
|
||
assert_eq!(g["points"][i]["at"], json!([190.0, 190.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn point_commands_need_a_freeform_gradient() {
|
||
let mut s = session();
|
||
shape(&mut s, "shape.rectangle", 0.0, 0.0, 50.0, 50.0);
|
||
for paint in [json!({"color": "#ff0000"}), json!({"gradient": {"kind": "radial"}})] {
|
||
run(&mut s, "paint.setFill", paint);
|
||
for (cmd, p) in [
|
||
("paint.freeform.addPoint", json!({"at": [10, 10]})),
|
||
("paint.freeform.setPoint", json!({"index": 0, "color": "#000000"})),
|
||
("paint.freeform.deletePoint", json!({"index": 0})),
|
||
("paint.freeform.addLine", json!({"points": [0, 1]})),
|
||
("paint.freeform.splitLine", json!({"line": 0, "segment": 0})),
|
||
("paint.freeform.selectPoint", json!({"index": 0})),
|
||
("paint.freeform.get", json!({})),
|
||
] {
|
||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||
assert!(e.contains("not a freeform gradient"), "{cmd}: {e}");
|
||
}
|
||
}
|
||
// Nothing selected: nothing to edit.
|
||
run(&mut s, "select.none", json!({}));
|
||
assert!(s.execute("paint.freeform.addPoint", &json!({"at": [1, 1]})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn selection_follows_the_object_and_the_proxy() {
|
||
let (mut s, id) = freeform_rect();
|
||
run(&mut s, "paint.freeform.selectPoint", json!({"index": 2}));
|
||
assert_eq!(s.selected_freeform_point(), Some(2));
|
||
run(&mut s, "paint.toggleActive", json!({}));
|
||
assert_eq!(s.selected_freeform_point(), None, "the Stroke proxy has its own paint");
|
||
run(&mut s, "paint.toggleActive", json!({}));
|
||
assert_eq!(s.selected_freeform_point(), Some(2));
|
||
run(&mut s, "select.none", json!({}));
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
assert_eq!(s.selected_freeform_point(), Some(2), "same object, same proxy");
|
||
assert!(s.execute("paint.freeform.selectPoint", &json!({"index": 99})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn type_objects_keep_points_in_text_space() {
|
||
let mut s = session();
|
||
let r = run(&mut s, "text.create", json!({"x": 100, "y": 200, "text": "Freeform", "size": 40}));
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
run(&mut s, "paint.editGradient", json!({"kind": "freeform"}));
|
||
run(&mut s, "object.rotate", json!({"angle": 90, "origin": [100, 200]}));
|
||
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [90, 150], "color": "#ff00ff"}))["index"].as_u64().unwrap() as usize;
|
||
let (f, _) = node(&s, id).proxy_freeform(false, None).unwrap();
|
||
assert!(close(f.points[i].at, Point::new(90.0, 150.0)), "back in document coordinates: {:?}", f.points[i].at);
|
||
assert_eq!(f.points[i].color, Color::rgb(1.0, 0.0, 1.0));
|
||
}
|