vectorcraft/crates/engine/src/tests_cut.rs

269 lines
12 KiB
Rust

//! Tests for the cutting commands: Mirror & Cut, Line Cut and Rectangle Cut.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{FillRule, PathData, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn path(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
}
fn geometry(s: &Session, id: NodeId) -> (PathData, FillRule) {
let n = s.doc().unwrap().doc.node(id).cloned().unwrap();
match &n.kind {
NodeKind::Path { path, rule, .. } => (path.clone(), *rule),
NodeKind::Compound { children, rule } => {
(PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule)
}
_ => panic!("not a path"),
}
}
fn area(s: &Session, id: NodeId) -> f64 {
let (p, r) = geometry(s, id);
vectorcraft_pathops::area(&p, r)
}
fn bounds(s: &Session, id: NodeId) -> Rect {
geometry(s, id).0.bounds().unwrap()
}
fn near(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.05)
}
fn undo_depth(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn line_cut_splits_a_shape_in_one_undo_step() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
let before = undo_depth(&s);
let out = s.execute("path.lineCut", &json!({"from": [30, -10], "to": [30, 10]})).unwrap();
let pieces = ids(&out);
assert_eq!(pieces.len(), 2);
let mut areas: Vec<f64> = pieces.iter().map(|id| area(&s, *id)).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 3000.0).abs() < 1.0 && (areas[1] - 7000.0).abs() < 1.0, "{areas:?}");
// The pieces are selected, and the cut is one undo step.
assert_eq!(s.doc().unwrap().selection.len(), 2);
assert_eq!(undo_depth(&s), before + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
}
#[test]
fn line_cut_keeps_compound_holes_and_splits_open_paths() {
let mut s = session();
let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// Wound against the outer rectangle, so the non-zero compound has a hole.
let hole = path(&mut s, &[(40.0, 40.0), (40.0, 60.0), (60.0, 60.0), (60.0, 40.0)], true);
select(&mut s, &[outer, hole]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
assert!((area(&s, c) - 9600.0).abs() < 1.0);
let line = path(&mut s, &[(0.0, 200.0), (100.0, 200.0)], false);
select(&mut s, &[c, line]);
// A vertical line at x = 20 misses the hole: the right piece keeps it.
let out = s.execute("path.lineCut", &json!({"from": [20, 0], "to": [20, 300]})).unwrap();
let pieces = ids(&out);
assert_eq!(pieces.len(), 4, "two compound pieces and two line pieces");
let doc = &s.doc().unwrap().doc;
let compounds: Vec<NodeId> = pieces.iter().copied().filter(|id| matches!(doc.node(*id).unwrap().kind, NodeKind::Compound { .. })).collect();
assert_eq!(compounds.len(), 2, "both pieces of a compound path stay compound paths");
let mut areas: Vec<f64> = compounds.iter().map(|id| area(&s, *id)).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 2000.0).abs() < 1.0 && (areas[1] - 7600.0).abs() < 1.0, "{areas:?}");
let open: Vec<Rect> = pieces.iter().filter(|id| !compounds.contains(id)).map(|id| bounds(&s, *id)).collect();
assert!(open.iter().any(|b| (b.x1 - 20.0).abs() < 0.01) && open.iter().any(|b| (b.x0 - 20.0).abs() < 0.01), "{open:?}");
}
#[test]
fn line_cut_that_misses_changes_nothing() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
let before = undo_depth(&s);
let out = s.execute("path.lineCut", &json!({"from": [200, 0], "to": [200, 10]})).unwrap();
assert!(ids(&out).is_empty());
assert_eq!(undo_depth(&s), before);
assert!(s.execute("path.lineCut", &json!({"from": [5, 5], "to": [5, 5]})).is_err());
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
}
#[test]
fn rect_cut_crops_to_real_geometry() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let outside = rect(&mut s, 300.0, 300.0, 10.0, 10.0);
let inside = rect(&mut s, 60.0, 20.0, 10.0, 10.0);
let line = path(&mut s, &[(0.0, 50.0), (200.0, 50.0)], false);
select(&mut s, &[a, outside, inside, line]);
let out = s.execute("path.rectCut", &json!({"rect": [50, 0, 100, 200]})).unwrap();
assert_eq!(out["removed"], 1);
let doc = &s.doc().unwrap().doc;
assert!(doc.node(outside).is_none(), "art wholly outside is deleted");
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Path { .. }), "no clipping mask");
assert!(near(bounds(&s, a), Rect::new(50.0, 0.0, 100.0, 100.0)));
assert!((area(&s, a) - 5000.0).abs() < 1.0);
assert!(near(bounds(&s, inside), Rect::new(60.0, 20.0, 70.0, 30.0)), "art inside is untouched");
assert!(near(bounds(&s, line), Rect::new(50.0, 50.0, 150.0, 50.0)));
// Two corners work too.
select(&mut s, &[a]);
s.execute("path.rectCut", &json!({"from": [100, 100], "to": [75, 50]})).unwrap();
assert!(near(bounds(&s, a), Rect::new(75.0, 50.0, 100.0, 100.0)));
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 0, 10]})).is_err());
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err());
}
#[test]
fn mirror_cut_makes_a_symmetric_shape() {
let mut s = session();
// A right triangle: mirrored at x = 50 keeping the left, it becomes a house-like pentagon.
let t = path(&mut s, &[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
select(&mut s, &[t]);
let out = s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
assert_eq!(ids(&out), vec![t]);
let (pd, _) = geometry(&s, t);
assert_eq!(pd.subpaths.len(), 1, "both halves are joined into one closed path");
assert!(pd.subpaths[0].closed);
assert!((area(&s, t) - 7500.0).abs() < 1.0);
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 100.0)));
// Keeping the right half instead.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0], "keep": "right"})).unwrap();
assert!((area(&s, t) - 2500.0).abs() < 1.0);
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 50.0)));
}
#[test]
fn mirror_cut_axes_and_sides() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
let gone = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
select(&mut s, &[a, gone]);
// A free axis at x = 50 (drawn upward): art on the discarded side is deleted.
let out = s.execute("path.mirrorCut", &json!({"axis": "free", "from": [50, 100], "to": [50, 0], "keep": "left"})).unwrap();
assert_eq!(out["removed"], 1);
assert!(s.doc().unwrap().doc.node(gone).is_none());
// Wholly on the kept side: the shape and its reflection, one object.
let (pd, _) = geometry(&s, a);
assert_eq!(pd.subpaths.len(), 2);
assert!(near(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
// A horizontal axis through the selection's centre keeps the top by default.
let b = rect(&mut s, 0.0, 200.0, 100.0, 60.0);
select(&mut s, &[b]);
s.execute("path.mirrorCut", &json!({"axis": "horizontal"})).unwrap();
assert!(near(bounds(&s, b), Rect::new(0.0, 200.0, 100.0, 260.0)));
assert!(s.execute("path.mirrorCut", &json!({"axis": "vertical", "keep": "top"})).is_err());
assert!(s.execute("path.mirrorCut", &json!({"axis": "diagonal"})).is_err());
assert!(s.execute("path.mirrorCut", &json!({"axis": "free", "from": [1, 1], "to": [1, 1]})).is_err());
}
#[test]
fn mirror_cut_closes_an_open_half() {
let mut s = session();
let p = path(&mut s, &[(50.0, 0.0), (0.0, 50.0), (50.0, 100.0)], false);
select(&mut s, &[p]);
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
let (pd, _) = geometry(&s, p);
assert_eq!(pd.subpaths.len(), 1);
assert!(pd.subpaths[0].closed);
assert_eq!(pd.subpaths[0].anchors.len(), 4);
assert!(near(bounds(&s, p), Rect::new(0.0, 0.0, 100.0, 100.0)));
}
#[test]
fn cut_commands_need_paths() {
let mut s = session();
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err(), "nothing selected");
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A"})).unwrap());
select(&mut s, &[t]);
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err());
}
mod tools {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
use crate::tooling::ViewInfo;
fn view() -> ViewInfo {
ViewInfo { smart_guides: false, ..Default::default() }
}
fn press(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), view()).unwrap();
}
#[test]
fn line_cut_tool_previews_live_and_commits_one_step() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
let before = undo_depth(&s);
s.select_tool("lineCut", view()).unwrap();
press(&mut s, PointerKind::Down, 50.0, -20.0);
press(&mut s, PointerKind::Drag, 50.0, 40.0);
// The preview already shows the cut.
assert!(s.in_interaction());
assert_eq!(s.doc().unwrap().selection.len(), 2);
press(&mut s, PointerKind::Drag, 50.0, 120.0);
press(&mut s, PointerKind::Up, 50.0, 120.0);
assert!(!s.in_interaction());
assert_eq!(undo_depth(&s), before + 1);
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.lineCut"));
assert!((area(&s, r) - 5000.0).abs() < 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
}
#[test]
fn rect_and_mirror_tools_cut_and_escape_restores() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
s.select_tool("rectCut", view()).unwrap();
press(&mut s, PointerKind::Down, 50.0, 50.0);
press(&mut s, PointerKind::Drag, 150.0, 150.0);
assert!(near(bounds(&s, r), Rect::new(50.0, 50.0, 100.0, 100.0)));
s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap();
assert!(!s.in_interaction());
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 100.0, 100.0)));
// Mirror & Cut with a vertical axis placed by a click at x = 80.
s.select_tool("mirrorCut", view()).unwrap();
s.set_tool_option("axis", &json!("vertical"));
press(&mut s, PointerKind::Down, 80.0, 50.0);
press(&mut s, PointerKind::Up, 80.0, 50.0);
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 160.0, 100.0)));
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.mirrorCut"));
}
}