vectorcraft/crates/engine/src/tests_freeform.rs

241 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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));
}