mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 18:54:55 +00:00
Make Compound Path leaves each member's graphic style id in place. A member copied or cut on its own took the compound's appearance but kept that old id, so the clipboard carried an unrelated style and the pasted path was linked to a style that did not match its look. The copied member or piece now takes the compound's link while it has that style's look, and no link otherwise (no style on the compound, or a gradient pinned to the compound's bounds). It also takes the compound's knockout setting with the rest of its transparency.
1295 lines
64 KiB
Rust
1295 lines
64 KiB
Rust
//! Direct-selection Copy and Cut tests (#2122).
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{NodeId, NodeKind};
|
|
use vectorcraft_engine::Session;
|
|
use vectorcraft_geom::FillRule;
|
|
use vectorcraft_testkit::invariants::check_session;
|
|
|
|
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
|
s.execute(command, ¶ms).unwrap()
|
|
}
|
|
|
|
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
|
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap())
|
|
}
|
|
|
|
#[test]
|
|
fn copy_and_paste_direct_selected_segment_of_rect() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Select segment 0 of the rectangle.
|
|
run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
if let NodeKind::Path { live, .. } = &pasted_node.kind {
|
|
assert!(live.is_none(), "extracted partial path must not retain live shape metadata");
|
|
}
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
let sp = &path.subpaths[0];
|
|
assert!(!sp.closed);
|
|
assert_eq!(sp.anchors.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_and_paste_direct_selected_adjacent_anchors_of_rect() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Direct-select two adjacent anchors: 0 and 1.
|
|
run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
let sp = &path.subpaths[0];
|
|
assert!(!sp.closed);
|
|
assert_eq!(sp.anchors.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_single_anchor_of_rect_copies_three_anchors_with_two_segments() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Direct-select only anchor 1 of the closed 4-anchor rectangle.
|
|
run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
let sp = &path.subpaths[0];
|
|
assert!(!sp.closed);
|
|
// Copying a single anchor on a closed path copies the anchor and both its incident segments.
|
|
assert_eq!(sp.anchors.len(), 3);
|
|
assert_eq!(sp.segment_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_disjoint_segments_does_not_copy_unselected_segment_in_between() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Rectangle has 4 segments (0, 1, 2, 3). Select disjoint segments 0 and 2.
|
|
run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0], [0, 2]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
// Two separate open path pieces should be created, each with 2 anchors.
|
|
assert_eq!(ids.len(), 2);
|
|
for id in ids {
|
|
let node = s.doc().unwrap().doc.node(NodeId(id)).unwrap();
|
|
let path = node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
let sp = &path.subpaths[0];
|
|
assert!(!sp.closed);
|
|
assert_eq!(sp.anchors.len(), 2);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cut_direct_selected_segment_cuts_source_and_pastes_segment() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Select segment 0 and cut it.
|
|
run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0]]}));
|
|
run(&mut s, "edit.cut", json!({}));
|
|
|
|
// Source rectangle should now have segment 0 removed, turning it into an open path.
|
|
let src_node = s.doc().unwrap().doc.node(r).unwrap();
|
|
let src_path = src_node.path_data().unwrap();
|
|
assert_eq!(src_path.subpaths.len(), 1);
|
|
assert!(!src_path.subpaths[0].closed);
|
|
|
|
// Paste the cut segment:
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let pasted_path = pasted_node.path_data().unwrap();
|
|
assert_eq!(pasted_path.subpaths.len(), 1);
|
|
assert!(!pasted_path.subpaths[0].closed);
|
|
assert_eq!(pasted_path.subpaths[0].anchors.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_compound_path_direct_selected_segment_inherits_appearance() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
|
|
let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
|
|
|
// Select segment 0 of the inner member.
|
|
run(&mut s, "select.segments", json!({"id": inner.0, "segments": [[0, 0]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let compound_node = s.doc().unwrap().doc.node(compound).unwrap();
|
|
assert_eq!(pasted_node.appearance, compound_node.appearance);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_selection_copy_preserves_fill_rule_and_attributes() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}));
|
|
|
|
run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
assert!(matches!(pasted_node.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn pen_tool_drawing_copies_whole_path_not_single_anchor() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
// Start drawing with Pen tool
|
|
s.select_tool("pen", vectorcraft_engine::ViewInfo::default()).unwrap();
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 50}));
|
|
|
|
// Pen tool has the last anchor in sel.anchors, but active tool is pen
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
// The whole 3-point path must be copied, not just the last anchor!
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert_eq!(path.subpaths[0].anchors.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn cut_adjacent_anchors_takes_them_out_of_the_source() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Direct-select two adjacent anchors: 1 and 2 of the 4-anchor rectangle.
|
|
run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1], [0, 2]], "mode": "set"}));
|
|
run(&mut s, "edit.cut", json!({}));
|
|
|
|
// Cut placed 1 segment on the clipboard (anchors 1 to 2).
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
let clip_path = s.clipboard.nodes[0].path_data().unwrap();
|
|
assert_eq!(clip_path.subpaths[0].segment_count(), 1);
|
|
assert_eq!(clip_path.subpaths[0].anchors.len(), 2);
|
|
|
|
// The source keeps anchors 3 and 0 and the one segment between them.
|
|
let src_node = s.doc().unwrap().doc.node(r).unwrap();
|
|
let src_path = src_node.path_data().unwrap();
|
|
assert_eq!(src_path.subpaths[0].segment_count(), 1);
|
|
assert_eq!(src_path.subpaths[0].anchors.len(), 2);
|
|
assert!(!src_path.subpaths[0].closed);
|
|
}
|
|
|
|
/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor
|
|
/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path.
|
|
fn contours_after(command: &str, picked: Value, compound: bool) -> Vec<Vec<[f64; 2]>> {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80}));
|
|
if compound {
|
|
let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [id.0, other.0]}));
|
|
run(&mut s, "object.compoundPath.make", json!({}));
|
|
}
|
|
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"}));
|
|
run(&mut s, command, json!({}));
|
|
check_session(&s).unwrap();
|
|
contours(&s)
|
|
}
|
|
|
|
/// Every contour in the document, as `[x, y]` lists.
|
|
fn contours(s: &Session) -> Vec<Vec<[f64; 2]>> {
|
|
let mut left = vec![];
|
|
for layer in &s.doc().unwrap().doc.layers {
|
|
layer.walk(&mut |n| {
|
|
if let Some(path) = n.path_data() {
|
|
left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::<Vec<_>>()));
|
|
}
|
|
});
|
|
}
|
|
left
|
|
}
|
|
|
|
#[test]
|
|
fn cut_end_anchors_leaves_the_source_as_clear_does() {
|
|
for compound in [false, true] {
|
|
let picked = || json!([[0, 0], [0, 1]]);
|
|
let left = contours_after("edit.cut", picked(), compound);
|
|
assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}");
|
|
assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cut_interior_anchors_leaves_the_source_as_clear_does() {
|
|
let picked = || json!([[0, 1], [0, 2]]);
|
|
let left = contours_after("edit.cut", picked(), true);
|
|
assert_eq!(left, contours_after("edit.clear", picked(), true));
|
|
assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn cut_one_end_anchor_of_a_compound_member_shortens_it() {
|
|
let left = contours_after("edit.cut", json!([[0, 3]]), true);
|
|
assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]);
|
|
}
|
|
|
|
#[test]
|
|
fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() {
|
|
let after = |command: &str| {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 400, "height": 400}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
|
|
let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false});
|
|
run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]}));
|
|
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}));
|
|
run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"}));
|
|
run(&mut s, command, json!({}));
|
|
check_session(&s).unwrap();
|
|
s
|
|
};
|
|
let cut = after("edit.cut");
|
|
assert_eq!(contours(&cut), contours(&after("edit.clear")));
|
|
assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut));
|
|
// The clipboard holds what left the source: the picked segment alone.
|
|
assert_eq!(cut.clipboard.nodes.len(), 1);
|
|
let clip = cut.clipboard.nodes[0].path_data().unwrap();
|
|
assert_eq!(clip.subpaths.len(), 1);
|
|
assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::<Vec<_>>(), [[60.0, 120.0], [110.0, 120.0]]);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_non_adjacent_anchors_on_rect_copies_full_object() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Select non-adjacent anchors 1 and 3 of the 4-anchor rectangle.
|
|
// Incident segments for anchor 1 are segments 0 and 1; for anchor 3 are segments 2 and 3.
|
|
// Together they cover all 4 segments, so the path is copied whole.
|
|
run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1], [0, 3]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
if let NodeKind::Path { live, .. } = &pasted_node.kind {
|
|
assert!(live.is_some(), "copying all segments keeps live shape metadata");
|
|
}
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert!(path.subpaths[0].closed);
|
|
assert_eq!(path.subpaths[0].segment_count(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_non_adjacent_anchors_on_five_point_path_extracts_selected_segments() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 50}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 30, "y": 80}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 10, "y": 50}));
|
|
run(&mut s, "path.close", json!({"id": id.0}));
|
|
|
|
// Path has 5 anchors (0..4) and 5 segments (0..4).
|
|
// Select non-adjacent anchors 1 and 3.
|
|
// Anchor 1 gives segments 0, 1. Anchor 3 gives segments 2, 3. Segment 4 is unselected.
|
|
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 1], [0, 3]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert!(!path.subpaths[0].closed);
|
|
assert_eq!(path.subpaths[0].segment_count(), 4);
|
|
assert_eq!(path.subpaths[0].anchors.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_when_all_segments_selected_preserves_live_shape() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Select all 4 segments explicitly (like Select > Object > Direction Handles).
|
|
run(&mut s, "select.segments", json!({"id": r.0, "segments": [[0, 0], [0, 1], [0, 2], [0, 3]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
if let NodeKind::Path { live, .. } = &pasted_node.kind {
|
|
assert!(live.is_some(), "all segments selected preserves live shape");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compound_member_selected_whole_with_direct_selection_copies_only_the_member() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
|
|
let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
|
|
|
let standalone = rect(&mut s, 120.0, 10.0, 50.0, 50.0);
|
|
|
|
// Select outer compound member whole, and direct-select anchor 0 on standalone.
|
|
let outer_anchors: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();
|
|
run(
|
|
&mut s,
|
|
"select.anchorsMany",
|
|
json!({
|
|
"items": [
|
|
{"id": outer.0, "anchors": outer_anchors},
|
|
{"id": standalone.0, "anchors": [[0, 0]]}
|
|
]
|
|
}),
|
|
);
|
|
|
|
// outer is selected whole, standalone is partially selected.
|
|
assert!(s.active().unwrap().selection.partial(outer).is_none());
|
|
assert!(s.active().unwrap().selection.partial(standalone).is_some());
|
|
|
|
let undo_before = s.doc().unwrap().history.undo.len();
|
|
run(&mut s, "edit.copy", json!({}));
|
|
// The clipboard holds the member as an ordinary path with the compound's paint, and the
|
|
// piece extracted from the standalone path.
|
|
let clip_ids: Vec<NodeId> = s.clipboard.nodes.iter().map(|n| n.id).collect();
|
|
assert_eq!(clip_ids.len(), 2, "the member plus the extracted standalone piece");
|
|
assert!(clip_ids.contains(&outer), "the member is copied itself: {clip_ids:?}");
|
|
assert!(!clip_ids.contains(&compound), "the compound is not copied");
|
|
let member = s.clipboard.nodes.iter().find(|n| n.id == outer).unwrap();
|
|
assert!(matches!(member.kind, NodeKind::Path { .. }));
|
|
assert_eq!(member.appearance, s.doc().unwrap().doc.node(compound).unwrap().appearance);
|
|
// Copy leaves the document and its history as they were.
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
|
|
assert_eq!(s.doc().unwrap().doc.node(compound).unwrap().children().unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn cut_whole_compound_member_with_direct_selection_cuts_member_and_extracted_piece() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
|
|
let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
|
|
let standalone = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
|
|
|
let outer_anchors = (0..4).map(|i| vec![0, i]).collect::<Vec<_>>();
|
|
run(
|
|
&mut s,
|
|
"select.anchorsMany",
|
|
json!({
|
|
"items": [
|
|
{"id": outer.0, "anchors": outer_anchors},
|
|
{"id": standalone.0, "anchors": [[0, 0]]}
|
|
]
|
|
}),
|
|
);
|
|
|
|
let undo_before = s.doc().unwrap().history.undo.len();
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "Cut is one undo step");
|
|
let clip_ids: Vec<NodeId> = s.clipboard.nodes.iter().map(|n| n.id).collect();
|
|
assert_eq!(clip_ids.len(), 2, "the member plus the extracted standalone piece");
|
|
assert!(clip_ids.contains(&outer) && !clip_ids.contains(&compound), "{clip_ids:?}");
|
|
|
|
// The member left; the other member stays in the compound.
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert!(doc.node(outer).is_none(), "the cut member is removed");
|
|
assert_eq!(doc.node(compound).and_then(|n| n.children()).map(|c| c.iter().map(|m| m.id).collect::<Vec<_>>()), Some(vec![inner]));
|
|
// Standalone has cut pieces remaining
|
|
assert!(doc.node(standalone).is_some(), "partially cut standalone path remains");
|
|
check_session(&s).unwrap();
|
|
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(s.doc().unwrap().doc.node(compound).unwrap().children().unwrap().len(), 2, "Undo puts the member back");
|
|
run(&mut s, "edit.redo", json!({}));
|
|
assert!(s.doc().unwrap().doc.node(outer).is_none());
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
/// A compound path of `n` rectangles side by side (x = 10, 60, 110, …), filled red, with its id
|
|
/// and its members' ids.
|
|
fn compound_of(s: &mut Session, n: usize) -> (NodeId, Vec<NodeId>) {
|
|
let members: Vec<NodeId> = (0..n).map(|i| rect(s, 10.0 + 50.0 * i as f64, 10.0, 40.0, 40.0)).collect();
|
|
run(s, "select.set", json!({"ids": members.iter().map(|m| m.0).collect::<Vec<_>>()}));
|
|
let compound = NodeId(run(s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
|
(compound, members)
|
|
}
|
|
|
|
/// Every anchor of the four-anchor rectangle `id` picked with Direct Selection, which selects it
|
|
/// whole.
|
|
fn pick_all_anchors(s: &mut Session, id: NodeId) {
|
|
run(s, "select.anchorsMany", json!({"items": [{"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2], [0, 3]]}]}));
|
|
assert!(s.doc().unwrap().selection.contains(id) && s.doc().unwrap().selection.partial(id).is_none(), "the member is selected whole");
|
|
}
|
|
|
|
fn members_of(s: &Session, compound: NodeId) -> Option<Vec<NodeId>> {
|
|
s.doc().unwrap().doc.node(compound).and_then(|n| n.children()).map(|c| c.iter().map(|m| m.id).collect())
|
|
}
|
|
|
|
#[test]
|
|
fn cut_of_one_member_of_two_leaves_a_compound_of_one() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
pick_all_anchors(&mut s, m[0]);
|
|
let undo_before = s.doc().unwrap().history.undo.len();
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "Cut is one undo step");
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "the other member stays a compound of one, as after Clear");
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
assert_eq!(s.clipboard.nodes[0].id, m[0]);
|
|
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { .. }), "the clipboard holds an ordinary path");
|
|
check_session(&s).unwrap();
|
|
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(m.clone()), "Undo puts the member back in its place");
|
|
run(&mut s, "edit.redo", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[1]]));
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cut_of_the_middle_member_of_three_leaves_the_other_two() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 3);
|
|
pick_all_anchors(&mut s, m[1]);
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[0], m[2]]));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[1]]);
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn clear_of_a_whole_member_removes_only_the_member_and_keeps_the_clipboard() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let standalone = rect(&mut s, 10.0, 120.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [standalone.0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.clear", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[1]]));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [standalone]);
|
|
}
|
|
|
|
#[test]
|
|
fn group_selection_of_one_member_copies_and_cuts_only_that_member() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap();
|
|
run(&mut s, "select.set", json!({"ids": [m[1].0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[1]]);
|
|
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { .. }));
|
|
assert_eq!(members_of(&s, compound), Some(m.clone()), "Copy leaves both members");
|
|
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[0]]));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[1]]);
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn every_member_selected_whole_copies_and_cuts_the_compound() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap();
|
|
run(&mut s, "select.set", json!({"ids": [m[0].0, m[1].0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert!(s.doc().unwrap().doc.node(compound).is_none(), "the compound leaves whole");
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_copied_member_keeps_the_compounds_transparency_mask_and_fill_rule() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let mask = rect(&mut s, 0.0, 0.0, 120.0, 60.0);
|
|
run(&mut s, "select.set", json!({"ids": [compound.0, mask.0]}));
|
|
run(&mut s, "transparency.makeOpacityMask", json!({}));
|
|
let mask_art = s.doc().unwrap().doc.node(compound).unwrap().extras().mask.clone();
|
|
assert!(mask_art.is_some(), "the compound has an opacity mask");
|
|
run(&mut s, "select.set", json!({"ids": [compound.0]}));
|
|
run(&mut s, "transparency.set", json!({"opacity": 50, "blend": "Multiply", "isolate": true}));
|
|
run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}));
|
|
assert!(matches!(s.doc().unwrap().doc.node(m[0]).unwrap().kind, NodeKind::Path { rule: FillRule::NonZero, .. }), "the member keeps its own rule");
|
|
let (opacity, blend) = {
|
|
let c = s.doc().unwrap().doc.node(compound).unwrap();
|
|
(c.opacity, c.blend)
|
|
};
|
|
assert_eq!(opacity, 0.5);
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| (n.id, n.opacity, n.blend)).collect::<Vec<_>>(), [(m[0], opacity, blend)]);
|
|
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
|
assert!(s.clipboard.nodes[0].isolate, "the member keeps the compound's isolated blending");
|
|
assert_eq!(s.clipboard.nodes[0].extras().mask, mask_art, "the member keeps the compound's opacity mask");
|
|
}
|
|
|
|
#[test]
|
|
fn a_copied_member_with_its_own_appearance_takes_the_compounds() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
// The member carries an appearance of its own, as an imported member can; the compound's
|
|
// appearance is the one drawn.
|
|
run(&mut s, "paint.setFill", json!({"ids": [compound.0], "color": "#00ff00"}));
|
|
let own_look = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone();
|
|
run(&mut s, "paint.setFill", json!({"ids": [compound.0], "color": "#0000ff"}));
|
|
let compound_look = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone();
|
|
assert_ne!(own_look, compound_look);
|
|
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(m[0]).unwrap().appearance = own_look.clone();
|
|
assert!(!s.doc().unwrap().doc.node(m[0]).unwrap().appearance.items.is_empty(), "the member has its own appearance");
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes[0].appearance, compound_look);
|
|
}
|
|
|
|
/// A member copied or cut on its own, whole or in part, takes the compound's graphic style link
|
|
/// with its look, or no link when the compound has none or the member no longer has the style's
|
|
/// look (a gradient pinned to the compound's bounds); the style the member had before Make Compound
|
|
/// Path does not go with it.
|
|
#[test]
|
|
fn a_copied_member_takes_the_compounds_graphic_style() {
|
|
let style_of = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style;
|
|
let clipboard_styles = |s: &Session| s.clipboard.graphic_styles.iter().map(|g| g.name.clone()).collect::<Vec<_>>();
|
|
for (compound_styled, gradient) in [(false, false), (true, false), (true, true)] {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let m0 = rect(&mut s, 10.0, 10.0, 40.0, 40.0);
|
|
run(&mut s, "paint.setFill", json!({"ids": [m0.0], "color": "#00ff00"}));
|
|
run(&mut s, "select.set", json!({"ids": [m0.0]}));
|
|
run(&mut s, "graphicStyle.new", json!({"name": "Member"}));
|
|
let member_style = style_of(&s, m0);
|
|
assert!(member_style.is_some(), "the path is linked to the style made from it");
|
|
let m1 = rect(&mut s, 60.0, 10.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [m0.0, m1.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
let fill =
|
|
if gradient { json!({"ids": [compound.0], "gradient": {"kind": "linear"}}) } else { json!({"ids": [compound.0], "color": "#ff0000"}) };
|
|
run(&mut s, "paint.setFill", fill);
|
|
assert_eq!(style_of(&s, m0), member_style, "the member keeps its old link inside the compound");
|
|
let compound_style = if compound_styled {
|
|
run(&mut s, "select.set", json!({"ids": [compound.0]}));
|
|
run(&mut s, "graphicStyle.new", json!({"name": "Compound"}));
|
|
style_of(&s, compound)
|
|
} else {
|
|
None
|
|
};
|
|
assert_eq!(compound_style.is_some(), compound_styled);
|
|
// A gradient fitted to the compound is pinned to its bounds on the copy, which is no longer
|
|
// the style's look.
|
|
let link = compound_style.filter(|_| !gradient);
|
|
let expected: Vec<String> = if link.is_some() { vec!["Compound".into()] } else { vec![] };
|
|
let case = format!("compound styled: {compound_styled}, gradient: {gradient}");
|
|
|
|
s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap();
|
|
run(&mut s, "select.set", json!({"ids": [m0.0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes[0].id, m0);
|
|
assert_eq!(s.clipboard.nodes[0].extras().graphic_style, link, "whole member, {case}");
|
|
assert_eq!(clipboard_styles(&s), expected, "whole member, {case}");
|
|
|
|
run(&mut s, "select.segments", json!({"id": m0.0, "segments": [[0, 0]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
assert_eq!(s.clipboard.nodes[0].extras().graphic_style, link, "one segment, {case}");
|
|
assert_eq!(clipboard_styles(&s), expected, "one segment, {case}");
|
|
|
|
run(&mut s, "select.set", json!({"ids": [m0.0]}));
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m1]));
|
|
let pasted = NodeId(run(&mut s, "edit.pasteInPlace", json!({}))["ids"][0].as_u64().unwrap());
|
|
assert_eq!(style_of(&s, pasted), link, "Cut and Paste in Place, {case}");
|
|
check_session(&s).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_segment_of_every_member_picked_copies_and_cuts_the_compound() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let all: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();
|
|
run(&mut s, "select.segments", json!({"id": m[0].0, "segments": all}));
|
|
run(&mut s, "select.segments", json!({"id": m[1].0, "segments": all, "mode": "add"}));
|
|
let sel = &s.doc().unwrap().selection;
|
|
assert!(sel.segments.contains_key(&m[0]) && sel.segments.contains_key(&m[1]), "both members keep their picked segments");
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert!(s.doc().unwrap().doc.node(compound).is_none(), "the compound leaves whole");
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn clear_of_every_segment_or_anchor_of_every_member_leaves_no_empty_compound() {
|
|
let all: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();
|
|
let picks: [(&str, Value, Value); 3] = [
|
|
("every segment", json!({"segments": all}), json!({"segments": all, "mode": "add"})),
|
|
("every anchor", json!({"anchors": all}), json!({"anchors": all})),
|
|
("one member whole, every segment of the other", json!({"anchors": all}), json!({"segments": all, "mode": "add"})),
|
|
];
|
|
for (label, first, second) in picks {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let layer = s.doc().unwrap().doc.parent_of(compound).unwrap();
|
|
if first.get("segments").is_some() {
|
|
run(&mut s, "select.segments", json!({"id": m[0].0, "segments": first["segments"]}));
|
|
} else {
|
|
run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": first["anchors"]}]}));
|
|
}
|
|
if second.get("segments").is_some() {
|
|
run(&mut s, "select.segments", json!({"id": m[1].0, "segments": second["segments"], "mode": "add"}));
|
|
} else {
|
|
run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": all}, {"id": m[1].0, "anchors": all}]}));
|
|
}
|
|
let undo_before = s.doc().unwrap().history.undo.len();
|
|
run(&mut s, "edit.clear", json!({}));
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert!(doc.node(compound).is_none(), "{label}: Clear removes the compound with its members, as Cut does");
|
|
assert!(doc.node(layer).and_then(|l| l.children()).is_some_and(|c| c.is_empty()), "{label}: nothing is left in the layer");
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "{label}: Clear is one undo step");
|
|
check_session(&s).unwrap();
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(m.clone()), "{label}: Undo puts the compound back");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_copied_member_keeps_the_compounds_gradient_where_it_was() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
// Members at x 10..50 and 60..100: the compound's gradient spans x 10..100.
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
run(&mut s, "paint.setFill", json!({"ids": [compound.0], "gradient": {"kind": "linear"}}));
|
|
let (mut expected, bounds) = {
|
|
let c = s.doc().unwrap().doc.node(compound).unwrap();
|
|
(c.appearance.clone(), c.geometric_bounds().unwrap())
|
|
};
|
|
assert!(expected.has_unplaced_gradient(), "the compound's gradient is fitted to its bounds");
|
|
assert_eq!((bounds.x0, bounds.x1), (10.0, 100.0));
|
|
expected.pin_gradients(bounds);
|
|
s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap();
|
|
run(&mut s, "select.set", json!({"ids": [m[0].0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let member = &s.clipboard.nodes[0];
|
|
assert_eq!(member.id, m[0]);
|
|
assert!(!member.appearance.has_unplaced_gradient(), "the member's gradient is not refitted to its own bounds");
|
|
assert_eq!(member.appearance, expected, "the gradient keeps the compound's fit");
|
|
|
|
// The member cut and pasted in place draws the gradient over the compound's stretch.
|
|
run(&mut s, "edit.cut", json!({}));
|
|
let pasted = NodeId(run(&mut s, "edit.pasteInPlace", json!({}))["ids"][0].as_u64().unwrap());
|
|
assert_eq!(s.doc().unwrap().doc.node(pasted).unwrap().appearance, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn the_only_member_of_a_compound_of_one_copies_the_compound() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "Cut leaves a compound of one");
|
|
// Its one member selected whole is every member selected whole.
|
|
s.select_tool("groupSelection", vectorcraft_engine::ViewInfo::default()).unwrap();
|
|
run(&mut s, "select.set", json!({"ids": [m[1].0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [compound]);
|
|
assert!(matches!(s.clipboard.nodes[0].kind, NodeKind::Compound { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn copy_of_a_member_then_paste_in_front_adds_a_member_to_the_compound() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.copy", json!({}));
|
|
// The member is still selected: a path pasted in front of it goes into its compound.
|
|
let pasted = NodeId(run(&mut s, "edit.pasteInFront", json!({}))["ids"][0].as_u64().unwrap());
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert!(matches!(doc.node(pasted).unwrap().kind, NodeKind::Path { .. }));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[0], pasted, m[1]]), "the pasted path sits just above the member");
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cut_of_a_whole_member_with_anchors_of_another_member_takes_each_by_its_own_rule() {
|
|
let after = |command: &str| {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let whole: Vec<Vec<usize>> = (0..4).map(|i| vec![0, i]).collect();
|
|
run(&mut s, "select.anchorsMany", json!({"items": [{"id": m[0].0, "anchors": whole}, {"id": m[1].0, "anchors": [[0, 1], [0, 2]]}]}));
|
|
run(&mut s, command, json!({}));
|
|
check_session(&s).unwrap();
|
|
(s, compound, m)
|
|
};
|
|
let (s, compound, m) = after("edit.cut");
|
|
// The whole member and the segment between the two picked anchors went to the clipboard.
|
|
assert_eq!(s.clipboard.nodes.len(), 2);
|
|
assert!(s.clipboard.nodes.iter().any(|n| n.id == m[0]));
|
|
assert!(s.clipboard.nodes.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
|
// The partly picked member stays in the compound, with what Clear leaves of its outline.
|
|
assert_eq!(members_of(&s, compound).map(|c| c.len()), Some(1));
|
|
assert_eq!(contours(&s), contours(&after("edit.clear").0));
|
|
assert_eq!(contours(&s).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn cut_of_a_member_of_a_compound_inside_a_group_leaves_the_group_and_compound() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let other = rect(&mut s, 10.0, 120.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [compound.0, other.0]}));
|
|
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.cut", json!({}));
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert_eq!(doc.parent_of(compound), Some(group));
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[1]]));
|
|
assert_eq!(s.clipboard.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), [m[0]]);
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn paste_in_place_and_in_front_of_a_cut_member_paste_an_ordinary_path() {
|
|
for command in ["edit.pasteInPlace", "edit.pasteInFront"] {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let (compound, m) = compound_of(&mut s, 2);
|
|
let fill = s.doc().unwrap().doc.node(compound).unwrap().appearance.clone();
|
|
pick_all_anchors(&mut s, m[0]);
|
|
run(&mut s, "edit.cut", json!({}));
|
|
let pasted = run(&mut s, command, json!({}));
|
|
let ids = pasted["ids"].as_array().unwrap();
|
|
assert_eq!(ids.len(), 1, "{command}");
|
|
let id = NodeId(ids[0].as_u64().unwrap());
|
|
let doc = &s.doc().unwrap().doc;
|
|
let node = doc.node(id).unwrap();
|
|
assert!(matches!(node.kind, NodeKind::Path { .. }), "{command}");
|
|
assert_eq!(node.appearance, fill, "{command}: the path keeps the compound's paint");
|
|
assert!(doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| p.is_layer()), "{command}: pasted into the layer");
|
|
let anchors: Vec<[f64; 2]> = node.path_data().unwrap().subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect();
|
|
assert!(anchors.contains(&[10.0, 10.0]) && anchors.contains(&[50.0, 50.0]), "{command}: where it was: {anchors:?}");
|
|
assert_eq!(members_of(&s, compound), Some(vec![m[1]]), "{command}");
|
|
check_session(&s).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cut_direct_selected_only_path_in_layer_preserves_layer() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
// Create a new layer
|
|
let layer_id = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
|
|
// Create a path inside the layer
|
|
let rect = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert_eq!(doc.parent_of(rect), Some(layer_id));
|
|
|
|
// Direct-select all 4 anchors of rect and cut
|
|
let all_anchors = (0..4).map(|i| vec![0, i]).collect::<Vec<_>>();
|
|
run(&mut s, "select.anchors", json!({"id": rect.0, "anchors": all_anchors, "mode": "set"}));
|
|
run(&mut s, "edit.cut", json!({}));
|
|
|
|
// The rect was cut, but the layer must NOT be deleted
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert!(doc.node(rect).is_none(), "rect was cut");
|
|
assert!(doc.node(layer_id).is_some(), "layer must remain even when empty");
|
|
assert_eq!(doc.node(layer_id).unwrap().children().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn copy_and_paste_path_in_compound_path_isolation_adds_member_to_compound() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
|
|
let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
|
|
// Enter isolation mode on the compound path.
|
|
run(&mut s, "object.isolate", json!({"id": compound.0}));
|
|
assert_eq!(s.doc().unwrap().isolation, Some(compound));
|
|
|
|
// In isolation mode, select member `inner` whole.
|
|
run(&mut s, "select.set", json!({"ids": [inner.0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
|
|
// Clipboard copies the selected member path, NOT the entire compound path.
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
assert_eq!(s.clipboard.nodes[0].id, inner);
|
|
|
|
// Paste in isolation mode: the pasted path must be inserted into the compound path,
|
|
// not as a new path beside the compound path.
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
let pasted_id = NodeId(ids[0]);
|
|
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert_eq!(doc.parent_of(pasted_id), Some(compound), "pasted path must be added to the compound path");
|
|
let compound_node = doc.node(compound).unwrap();
|
|
assert_eq!(compound_node.children().unwrap().len(), 3, "compound path now has 3 member paths");
|
|
}
|
|
|
|
#[test]
|
|
fn cut_and_paste_direct_selected_anchors_in_compound_path_isolation() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let outer = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 80}))["id"].as_u64().unwrap());
|
|
let inner = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
|
|
// Enter isolation mode on the compound path.
|
|
run(&mut s, "object.isolate", json!({"id": compound.0}));
|
|
|
|
// Direct-select anchor 0 of `inner` and cut.
|
|
run(&mut s, "select.anchors", json!({"id": inner.0, "anchors": [[0, 0]], "mode": "set"}));
|
|
run(&mut s, "edit.cut", json!({}));
|
|
|
|
// Clipboard holds the cut segment piece.
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
|
|
// Paste inside isolation mode:
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
let pasted_id = NodeId(ids[0]);
|
|
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert_eq!(doc.parent_of(pasted_id), Some(compound), "cut and pasted anchors must be inserted into compound path");
|
|
let compound_node = doc.node(compound).unwrap();
|
|
assert_eq!(compound_node.children().unwrap().len(), 3);
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn paste_non_path_in_compound_path_isolation_inserts_into_layer() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let a = rect(&mut s, 10.0, 10.0, 40.0, 40.0);
|
|
let b = rect(&mut s, 60.0, 10.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
|
let _group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
|
|
|
// Copy the group to the clipboard.
|
|
run(&mut s, "edit.copy", json!({}));
|
|
|
|
// Create a compound path on the layer.
|
|
let c1 = rect(&mut s, 10.0, 100.0, 80.0, 80.0);
|
|
let c2 = rect(&mut s, 30.0, 120.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [c1.0, c2.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
let layer_id = s.doc().unwrap().doc.parent_of(compound).unwrap();
|
|
|
|
// Isolate the compound path.
|
|
run(&mut s, "object.isolate", json!({"id": compound.0}));
|
|
assert_eq!(s.doc().unwrap().isolation, Some(compound));
|
|
|
|
// Generic insertion parent inside compound isolation resolves to the layer, not the compound.
|
|
assert_eq!(s.doc().unwrap().insertion_parent(), Some(layer_id));
|
|
|
|
// Paste the group while in compound path isolation.
|
|
// The group cannot be inserted into the compound path; it must be pasted into the layer beside the compound.
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
let pasted_group = NodeId(ids[0]);
|
|
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert_eq!(doc.parent_of(pasted_group), Some(layer_id), "pasted group must be added to layer, not compound");
|
|
let compound_node = doc.node(compound).unwrap();
|
|
assert_eq!(compound_node.children().unwrap().len(), 2, "compound path must keep only its 2 path children");
|
|
|
|
// All document invariants (including compound children being paths only) must hold.
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn shape_creation_in_compound_path_isolation_inserts_into_layer() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let outer = rect(&mut s, 10.0, 10.0, 80.0, 80.0);
|
|
let inner = rect(&mut s, 30.0, 30.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [outer.0, inner.0]}));
|
|
let compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
|
|
// Isolate the compound path.
|
|
run(&mut s, "object.isolate", json!({"id": compound.0}));
|
|
|
|
// Draw a new rectangle while isolating the compound path.
|
|
let new_rect = rect(&mut s, 40.0, 40.0, 20.0, 20.0);
|
|
|
|
let doc = &s.doc().unwrap().doc;
|
|
assert_ne!(doc.parent_of(new_rect), Some(compound), "new shape path is placed outside compound path in layer");
|
|
let compound_node = doc.node(compound).unwrap();
|
|
assert_eq!(compound_node.children().unwrap().len(), 2, "compound path maintains original member paths");
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn paste_compound_into_isolated_compound_preserves_child_order() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
|
let r1 = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
|
let r2 = rect(&mut s, 30.0, 10.0, 10.0, 10.0);
|
|
let r3 = rect(&mut s, 50.0, 10.0, 10.0, 10.0);
|
|
run(&mut s, "select.set", json!({"ids": [r1.0, r2.0, r3.0]}));
|
|
let src_compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
|
|
// Copy src_compound (contains [r1, r2, r3] in order)
|
|
run(&mut s, "select.set", json!({"ids": [src_compound.0]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
|
|
// Create target compound
|
|
let t1 = rect(&mut s, 100.0, 100.0, 80.0, 80.0);
|
|
let t2 = rect(&mut s, 120.0, 120.0, 40.0, 40.0);
|
|
run(&mut s, "select.set", json!({"ids": [t1.0, t2.0]}));
|
|
let target_compound = NodeId(run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap());
|
|
|
|
// Isolate target compound and select member t1 so paste-in-front targets index 1
|
|
run(&mut s, "object.isolate", json!({"id": target_compound.0}));
|
|
run(&mut s, "select.set", json!({"ids": [t1.0]}));
|
|
|
|
// Paste in front of t1
|
|
let pasted = run(&mut s, "edit.pasteInFront", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 3, "unpacked all 3 children");
|
|
|
|
let doc = &s.doc().unwrap().doc;
|
|
let target_node = doc.node(target_compound).unwrap();
|
|
let children = target_node.children().unwrap();
|
|
assert_eq!(children.len(), 5);
|
|
|
|
// Verify the 3 pasted children appear in children in exact forward order [r1_copy, r2_copy, r3_copy]
|
|
let child_ids: Vec<u64> = children.iter().map(|c| c.id.0).collect();
|
|
let pos0 = child_ids.iter().position(|&id| id == ids[0]).unwrap();
|
|
let pos1 = child_ids.iter().position(|&id| id == ids[1]).unwrap();
|
|
let pos2 = child_ids.iter().position(|&id| id == ids[2]).unwrap();
|
|
assert_eq!(pos1, pos0 + 1, "child 1 must immediately follow child 0");
|
|
assert_eq!(pos2, pos1 + 1, "child 2 must immediately follow child 1");
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn copy_and_paste_direct_selected_segment_of_open_path() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80}));
|
|
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
|
run(&mut s, "paint.setStroke", json!({"color": "#0000ff"}));
|
|
run(&mut s, "stroke.set", json!({"weight": 4.0}));
|
|
|
|
// Select segment 0 of the open path (from (10, 20) to (60, 20)).
|
|
run(&mut s, "select.segments", json!({"id": id.0, "segments": [[0, 0]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
|
|
// Paste in place: position and appearance must be preserved.
|
|
let pasted = run(&mut s, "edit.pasteInPlace", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let src_node = s.doc().unwrap().doc.node(id).unwrap();
|
|
assert_eq!(pasted_node.appearance, src_node.appearance, "appearance (stroke & fill) must match source");
|
|
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
let sp = &path.subpaths[0];
|
|
assert!(!sp.closed);
|
|
assert_eq!(sp.segment_count(), 1);
|
|
assert_eq!(sp.anchors.len(), 2);
|
|
assert_eq!(sp.anchors[0].p.x, 10.0);
|
|
assert_eq!(sp.anchors[0].p.y, 20.0);
|
|
assert_eq!(sp.anchors[1].p.x, 60.0);
|
|
assert_eq!(sp.anchors[1].p.y, 20.0);
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn copy_two_non_adjacent_segments_of_open_path_pastes_two_paths() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 300, "height": 200}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 40, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 80, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 120, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 160, "y": 10}));
|
|
|
|
// Path has 5 anchors (0..4) and 4 segments (0..3).
|
|
// Select disjoint/non-adjacent segments 0 and 2.
|
|
run(&mut s, "select.segments", json!({"id": id.0, "segments": [[0, 0], [0, 2]]}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 2, "two non-adjacent segments paste as two independent paths");
|
|
|
|
for node_id in ids {
|
|
let node = s.doc().unwrap().doc.node(NodeId(node_id)).unwrap();
|
|
let path = node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
assert!(!path.subpaths[0].closed);
|
|
assert_eq!(path.subpaths[0].segment_count(), 1);
|
|
assert_eq!(path.subpaths[0].anchors.len(), 2);
|
|
}
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn copy_all_anchors_of_open_path_copies_whole_path() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 80, "y": 40}));
|
|
|
|
// Select all 3 anchors.
|
|
run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted = run(&mut s, "edit.paste", json!({}));
|
|
let ids: Vec<u64> = pasted["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids.len(), 1);
|
|
|
|
let pasted_node = s.doc().unwrap().doc.node(NodeId(ids[0])).unwrap();
|
|
let path = pasted_node.path_data().unwrap();
|
|
assert_eq!(path.subpaths.len(), 1);
|
|
assert!(!path.subpaths[0].closed);
|
|
assert_eq!(path.subpaths[0].segment_count(), 2);
|
|
assert_eq!(path.subpaths[0].anchors.len(), 3);
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn copy_single_anchor_on_open_path_and_lone_point_states_what_is_pasted() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
|
|
// 1. Single interior anchor of an open path:
|
|
// Path: (10, 10) -> (50, 10) -> (90, 50).
|
|
// Selecting interior anchor 1 selects both incident segments (0 and 1).
|
|
// Stated behavior: pasting pastes an open path containing the anchor and both its incident segments (3 anchors, 2 segments).
|
|
let p1 = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": p1.0, "x": 50, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": p1.0, "x": 90, "y": 50}));
|
|
run(&mut s, "select.anchors", json!({"id": p1.0, "anchors": [[0, 1]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted1 = run(&mut s, "edit.paste", json!({}));
|
|
let ids1: Vec<u64> = pasted1["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids1.len(), 1);
|
|
let path1 = s.doc().unwrap().doc.node(NodeId(ids1[0])).unwrap().path_data().unwrap();
|
|
assert!(!path1.subpaths[0].closed);
|
|
assert_eq!(path1.subpaths[0].anchors.len(), 3);
|
|
assert_eq!(path1.subpaths[0].segment_count(), 2);
|
|
|
|
// 2. Single endpoint anchor of an open path:
|
|
// Selecting endpoint anchor 0 selects its single incident segment (segment 0).
|
|
// Stated behavior: pasting pastes an open path for that segment (2 anchors, 1 segment).
|
|
run(&mut s, "select.anchors", json!({"id": p1.0, "anchors": [[0, 0]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted2 = run(&mut s, "edit.paste", json!({}));
|
|
let ids2: Vec<u64> = pasted2["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids2.len(), 1);
|
|
let path2 = s.doc().unwrap().doc.node(NodeId(ids2[0])).unwrap().path_data().unwrap();
|
|
assert!(!path2.subpaths[0].closed);
|
|
assert_eq!(path2.subpaths[0].anchors.len(), 2);
|
|
assert_eq!(path2.subpaths[0].segment_count(), 1);
|
|
|
|
// 3. A lone point path (a path with only 1 anchor and 0 segments):
|
|
// Selecting the lone anchor on a single-point path copies the object whole.
|
|
// Stated behavior: pasting pastes a standalone lone-point path (1 anchor, 0 segments).
|
|
let p_lone = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 100, "y": 100}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "select.anchors", json!({"id": p_lone.0, "anchors": [[0, 0]], "mode": "set"}));
|
|
run(&mut s, "edit.copy", json!({}));
|
|
let pasted_lone = run(&mut s, "edit.paste", json!({}));
|
|
let ids_lone: Vec<u64> = pasted_lone["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
|
assert_eq!(ids_lone.len(), 1);
|
|
let path_lone = s.doc().unwrap().doc.node(NodeId(ids_lone[0])).unwrap().path_data().unwrap();
|
|
assert_eq!(path_lone.subpaths[0].anchors.len(), 1);
|
|
assert_eq!(path_lone.subpaths[0].segment_count(), 0);
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cut_anchor_of_closed_path_opens_it_with_single_undo_step() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
|
|
|
// Direct-select anchor 1 of the closed 4-anchor rectangle.
|
|
run(&mut s, "select.anchors", json!({"id": r.0, "anchors": [[0, 1]], "mode": "set"}));
|
|
let undo_before = s.doc().unwrap().history.undo.len();
|
|
|
|
// Cut the anchor.
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "cut is recorded as one undo step");
|
|
|
|
// Source rectangle opens: 1 open subpath with 3 anchors remaining.
|
|
let src_node = s.doc().unwrap().doc.node(r).unwrap();
|
|
let src_path = src_node.path_data().unwrap();
|
|
assert_eq!(src_path.subpaths.len(), 1);
|
|
assert!(!src_path.subpaths[0].closed, "cutting anchor of closed path opens the path");
|
|
assert_eq!(src_path.subpaths[0].anchors.len(), 3);
|
|
assert_eq!(src_path.subpaths[0].segment_count(), 2);
|
|
|
|
// Clipboard holds the cut portion (the anchor and its two incident segments: 3 anchors).
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
let clip_path = s.clipboard.nodes[0].path_data().unwrap();
|
|
assert!(!clip_path.subpaths[0].closed);
|
|
assert_eq!(clip_path.subpaths[0].anchors.len(), 3);
|
|
assert_eq!(clip_path.subpaths[0].segment_count(), 2);
|
|
|
|
// Verify undo restores the closed path in one step.
|
|
run(&mut s, "edit.undo", json!({}));
|
|
let restored_node = s.doc().unwrap().doc.node(r).unwrap();
|
|
let restored_path = restored_node.path_data().unwrap();
|
|
assert_eq!(restored_path.subpaths.len(), 1);
|
|
assert!(restored_path.subpaths[0].closed, "undo restores closed path");
|
|
assert_eq!(restored_path.subpaths[0].anchors.len(), 4);
|
|
assert_eq!(restored_path.subpaths[0].segment_count(), 4);
|
|
|
|
// Verify redo re-applies the cut.
|
|
run(&mut s, "edit.redo", json!({}));
|
|
let redone_node = s.doc().unwrap().doc.node(r).unwrap();
|
|
let redone_path = redone_node.path_data().unwrap();
|
|
assert!(!redone_path.subpaths[0].closed);
|
|
assert_eq!(redone_path.subpaths[0].anchors.len(), 3);
|
|
|
|
check_session(&s).unwrap();
|
|
}
|
|
|
|
/// Cutting an interior connection copies that segment and leaves two selected sibling objects.
|
|
#[test]
|
|
fn cut_middle_segment_of_open_path_splits_into_two_open_paths_with_single_undo_step() {
|
|
let mut s = Session::new();
|
|
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
|
let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 50, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 90, "y": 10}));
|
|
run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 130, "y": 10}));
|
|
|
|
// Open path has 4 anchors (0, 1, 2, 3) and 3 segments (0, 1, 2).
|
|
// Direct-select middle segment 1 (between anchor 1 and 2).
|
|
run(&mut s, "select.segments", json!({"id": id.0, "segments": [[0, 1]]}));
|
|
let undo_before = s.doc().unwrap().history.undo.len();
|
|
|
|
// Cut the middle segment.
|
|
run(&mut s, "edit.cut", json!({}));
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1, "cut is recorded as one undo step");
|
|
|
|
// Source path should now be split into two surviving open runs.
|
|
let src_node = s.doc().unwrap().doc.node(id).unwrap();
|
|
let src_path = src_node.path_data().unwrap();
|
|
assert_eq!(src_path.subpaths.len(), 1);
|
|
assert!(!src_path.subpaths[0].closed);
|
|
assert_eq!(src_path.subpaths[0].anchors.len(), 2);
|
|
assert_eq!(src_path.subpaths[0].anchors[0].p.x, 10.0);
|
|
assert_eq!(src_path.subpaths[0].anchors[1].p.x, 50.0);
|
|
|
|
let pieces = s.doc().unwrap().selection.objects.clone();
|
|
assert_eq!(pieces.len(), 2, "two independent path objects remain after cutting the middle segment");
|
|
assert_eq!(pieces[0], id);
|
|
let second = &s.doc().unwrap().doc.node(pieces[1]).unwrap().path_data().unwrap().subpaths[0];
|
|
assert!(!second.closed);
|
|
assert_eq!(second.anchors.len(), 2);
|
|
assert_eq!(second.anchors[0].p.x, 90.0);
|
|
assert_eq!(second.anchors[1].p.x, 130.0);
|
|
|
|
// Clipboard holds the cut middle segment:
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
let clip_path = s.clipboard.nodes[0].path_data().unwrap();
|
|
assert_eq!(clip_path.subpaths[0].anchors.len(), 2);
|
|
assert_eq!(clip_path.subpaths[0].anchors[0].p.x, 50.0);
|
|
assert_eq!(clip_path.subpaths[0].anchors[1].p.x, 90.0);
|
|
|
|
// Verify undo restores the original intact path in one step:
|
|
run(&mut s, "edit.undo", json!({}));
|
|
let restored_node = s.doc().unwrap().doc.node(id).unwrap();
|
|
let restored_path = restored_node.path_data().unwrap();
|
|
assert_eq!(restored_path.subpaths.len(), 1, "undo restores single subpath");
|
|
assert_eq!(restored_path.subpaths[0].anchors.len(), 4, "undo restores all 4 anchors");
|
|
|
|
// Redo re-applies the split:
|
|
run(&mut s, "edit.redo", json!({}));
|
|
let redone_path = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap();
|
|
assert_eq!(redone_path.subpaths.len(), 1);
|
|
assert_eq!(s.doc().unwrap().selection.objects, pieces);
|
|
assert!(s.doc().unwrap().doc.node(pieces[1]).is_some());
|
|
|
|
check_session(&s).unwrap();
|
|
}
|