269 lines
12 KiB
Rust
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"));
|
|
}
|
|
}
|