mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
M2.1: copy and cut direct-selected segments and anchors as independent paths (#2122)
Squashed from #3903 onto main.
This commit is contained in:
@@ -202,3 +202,24 @@ fn cut_direct_selected_anchors_retains_anchor_deletion() {
|
||||
assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3);
|
||||
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_explicit_ids_preserves_unrelated_selection() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r1 = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap());
|
||||
let r2 = NodeId(s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r1.0, r2.0]})).unwrap();
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![r1, r2]);
|
||||
|
||||
// Clearing r1 explicitly should remove r1 from doc and from selection, keeping r2 selected.
|
||||
s.execute("edit.clear", &json!({"ids": [r1.0]})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(r1).is_none());
|
||||
assert!(s.active().unwrap().doc.node(r2).is_some());
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![r2]);
|
||||
|
||||
// Parameterless clear clears the rest of the selection.
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.node(r2).is_none());
|
||||
assert!(s.active().unwrap().selection.is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
//! 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_clipboard_plus_remaining_equals_original() {
|
||||
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);
|
||||
|
||||
// Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1.
|
||||
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(), 3);
|
||||
assert_eq!(src_path.subpaths[0].anchors.len(), 4);
|
||||
assert!(!src_path.subpaths[0].closed);
|
||||
|
||||
// Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments).
|
||||
assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4);
|
||||
}
|
||||
|
||||
#[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_promotes_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());
|
||||
|
||||
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());
|
||||
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
// Clipboard should have:
|
||||
// 1. The compound path (outer promoted to compound #786)
|
||||
// 2. The extracted segment piece from standalone
|
||||
let clip_ids: Vec<NodeId> = s.clipboard.nodes.iter().map(|n| n.id).collect();
|
||||
assert_eq!(clip_ids.len(), 2, "promoted compound plus the extracted standalone piece");
|
||||
assert!(clip_ids.contains(&compound), "whole compound member must be promoted to compound parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_whole_compound_member_with_direct_selection_cuts_compound_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]]}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
let clip_ids: Vec<NodeId> = s.clipboard.nodes.iter().map(|n| n.id).collect();
|
||||
assert_eq!(clip_ids.len(), 2, "promoted compound plus the extracted standalone piece");
|
||||
assert!(clip_ids.contains(&compound), "whole compound member must be promoted to compound parent");
|
||||
|
||||
// Compound was removed because member was selected whole
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(compound).is_none(), "compound path must be removed on cut");
|
||||
// Standalone has cut pieces remaining
|
||||
assert!(doc.node(standalone).is_some(), "partially cut standalone path remains");
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
|
||||
#[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(), 2, "two open paths/subpaths remain after cutting middle segment");
|
||||
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);
|
||||
|
||||
assert!(!src_path.subpaths[1].closed);
|
||||
assert_eq!(src_path.subpaths[1].anchors.len(), 2);
|
||||
assert_eq!(src_path.subpaths[1].anchors[0].p.x, 90.0);
|
||||
assert_eq!(src_path.subpaths[1].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(), 2);
|
||||
|
||||
check_session(&s).unwrap();
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod artboard_click;
|
||||
mod beyond_canvas;
|
||||
mod clear_commands;
|
||||
mod compound_attributes;
|
||||
mod copy_direct_selection;
|
||||
mod dimensions;
|
||||
mod eyedropper_sources;
|
||||
mod find_replace_paras;
|
||||
|
||||
Reference in New Issue
Block a user