mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
The issue asked for one-step symmetric shapes, splitting all selected art along a line and cropping art to a rectangle as real geometry. Only the freehand Knife and the Scissors existed, and neither works on compound paths as one shape. New commands path.mirrorCut, path.lineCut and path.rectCut cut the selected paths and compound paths (inside groups too) with pathops booleans against a half-plane or rectangle, and split open paths with the Knife/Path Eraser helpers. Mirror & Cut reflects the kept half and unites it with itself (open halves are joined on the axis). The draw2 piece helpers now rebuild compound paths too. Three tools (Eraser group, original icons) preview the cut live while dragging and commit it as one undo step; Mirror & Cut's axis and kept side are tool options shown in the Control bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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"));
|
|
}
|
|
}
|