mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Expand had nothing for Intertwine groups. It reported success but left the group live (it only dropped the members' live shapes), and its Stroke and Fill options could not reach the members, so a second Expand found nothing to do. Ungroup takes apart plain groups only, so it did nothing either. Expand's Object option now turns every Intertwine group in the selection (inside groups too, inner ones first) into a plain group of what it draws: its objects, keeping their ids and order, then each part drawn over the others as a clip group. The group keeps its id, place, transparency and appearance, everything happens in one undo step, and the dialog enables Object for an Intertwine group. Ungroup then takes the result apart, as with an expanded blend or repeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
15 KiB
Rust
332 lines
15 KiB
Rust
//! Object › Intertwine (#1763), as a tutorial does it: two thick rings that overlap twice are
|
||
//! made into an Intertwine group, then one overlap is clicked so the rings pass over and under
|
||
//! each other. Driven through `Session::execute`; the art is checked on screen, in the saved file
|
||
//! and in the SVG and PDF output.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_render::Rendered;
|
||
|
||
use super::tests_clip::{hit, id_of, render, select, session};
|
||
use super::*;
|
||
|
||
/// Ring A (red, centre 35,50) and ring B (blue, centre 65,50), radius 25, 8 pt strokes, no fill:
|
||
/// B is in front. The rings cross at (50, 30) and (50, 70).
|
||
fn rings(s: &mut Session) -> (NodeId, NodeId) {
|
||
let ring = |s: &mut Session, cx: f64, color: &str| {
|
||
let id = id_of(&s.execute("shape.ellipse", &json!({"x": cx - 25.0, "y": 25, "width": 50, "height": 50})).unwrap());
|
||
s.execute("paint.setFill", &json!({"ids": [id.0], "none": true})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"ids": [id.0], "color": color})).unwrap();
|
||
s.execute("stroke.set", &json!({"ids": [id.0], "weight": 8})).unwrap();
|
||
id
|
||
};
|
||
let a = ring(s, 35.0, "#ff0000");
|
||
let b = ring(s, 65.0, "#0000ff");
|
||
(a, b)
|
||
}
|
||
|
||
/// The rings made into an Intertwine group: (group, a, b).
|
||
fn intertwined(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||
let (a, b) = rings(s);
|
||
select(s, &[a, b]);
|
||
let g = id_of(&s.execute("object.intertwine.make", &json!({})).unwrap());
|
||
(g, a, b)
|
||
}
|
||
|
||
fn red(r: &Rendered, x: u32, y: u32) -> bool {
|
||
let p = r.pixel(x, y);
|
||
p[3] > 200 && p[0] > 200 && p[2] < 60
|
||
}
|
||
|
||
fn blue(r: &Rendered, x: u32, y: u32) -> bool {
|
||
let p = r.pixel(x, y);
|
||
p[3] > 200 && p[2] > 200 && p[0] < 60
|
||
}
|
||
|
||
fn arrange(s: &mut Session, p: Value) -> Value {
|
||
s.execute("object.intertwine.arrange", &p).unwrap()
|
||
}
|
||
|
||
fn area(s: &Session, id: NodeId) -> Option<vectorcraft_geom::PathData> {
|
||
s.doc().unwrap().doc.node(id).unwrap().extras().intertwine.as_deref().cloned()
|
||
}
|
||
|
||
#[test]
|
||
fn make_puts_the_objects_in_an_intertwine_group_drawn_as_before() {
|
||
let mut s = session();
|
||
let (g, a, b) = intertwined(&mut s);
|
||
let d = &s.doc().unwrap().doc;
|
||
let n = d.node(g).unwrap();
|
||
assert!(matches!(n.kind, NodeKind::Intertwine { .. }));
|
||
assert_eq!(n.kind_label(), "Intertwine");
|
||
assert_eq!(n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "the stacking order is kept");
|
||
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
|
||
let r = render(&s);
|
||
assert!(blue(&r, 50, 30) && blue(&r, 50, 70), "B is in front at both crossings until an area is arranged");
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_an_overlap_brings_the_object_below_to_the_front_there_only() {
|
||
let mut s = session();
|
||
let (_, a, b) = intertwined(&mut s);
|
||
let v = arrange(&mut s, json!({"at": [50, 30]}));
|
||
assert_eq!(v["count"], 1);
|
||
assert_eq!(v["top"], json!([a.0]));
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30), "A passes over B at the top crossing");
|
||
assert!(blue(&r, 50, 70), "and under it at the bottom one");
|
||
// Elsewhere each ring looks as it did.
|
||
assert!(red(&r, 10, 50) && blue(&r, 90, 50));
|
||
assert!(area(&s, a).is_some() && area(&s, b).is_none());
|
||
// A click there picks the ring drawn on top.
|
||
assert_eq!(hit(&s, 50.0, 30.0), Some(a));
|
||
assert_eq!(hit(&s, 50.0, 70.0), Some(b));
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_the_same_overlap_again_swaps_back() {
|
||
let mut s = session();
|
||
let (_, a, b) = intertwined(&mut s);
|
||
arrange(&mut s, json!({"at": [50, 30]}));
|
||
let v = arrange(&mut s, json!({"at": [50, 30]}));
|
||
assert_eq!(v["top"], json!([b.0]));
|
||
assert!(area(&s, a).is_none() && area(&s, b).is_none(), "no area is left over");
|
||
assert!(blue(&render(&s), 50, 30));
|
||
}
|
||
|
||
#[test]
|
||
fn a_path_drawn_around_overlaps_arranges_every_overlap_inside_it() {
|
||
let mut s = session();
|
||
let (_, a, _) = intertwined(&mut s);
|
||
// Around the bottom crossing only.
|
||
let v = arrange(&mut s, json!({"area": [[40, 60], [60, 60], [60, 80], [40, 80]]}));
|
||
assert_eq!(v["count"], 1);
|
||
let r = render(&s);
|
||
assert!(blue(&r, 50, 30) && red(&r, 50, 70));
|
||
// Around both: each one swaps.
|
||
let v = arrange(&mut s, json!({"area": [[40, 20], [60, 20], [60, 80], [40, 80]]}));
|
||
assert_eq!(v["count"], 2);
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
|
||
assert!(area(&s, a).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn front_and_back_choose_the_member_where_three_objects_overlap() {
|
||
let mut s = session();
|
||
let (a, b) = rings(&mut s);
|
||
// A green bar across the top crossing, in front of both rings.
|
||
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 46, "y": 20, "width": 8, "height": 20})).unwrap());
|
||
s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"ids": [c.0], "none": true})).unwrap();
|
||
select(&mut s, &[a, b, c]);
|
||
s.execute("object.intertwine.make", &json!({})).unwrap();
|
||
let green = |r: &Rendered| {
|
||
let p = r.pixel(50, 30);
|
||
p[1] > 200 && p[0] < 60 && p[2] < 60
|
||
};
|
||
assert!(green(&render(&s)));
|
||
arrange(&mut s, json!({"at": [50, 30], "order": "front", "member": a.0}));
|
||
assert!(red(&render(&s), 50, 30), "A is brought to the front of all three");
|
||
arrange(&mut s, json!({"at": [50, 30], "order": "back", "member": a.0}));
|
||
assert!(green(&render(&s)), "sent to the back, the others are over it again in their own order");
|
||
arrange(&mut s, json!({"at": [50, 30], "order": "back"}));
|
||
assert!(blue(&render(&s), 50, 30), "back without a member sends the top one (C) back: B is on top");
|
||
let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 30], "order": "front", "member": 999})).unwrap_err();
|
||
assert!(e.to_string().contains("999"), "{e}");
|
||
}
|
||
|
||
#[test]
|
||
fn release_gives_the_objects_back_as_they_were() {
|
||
let mut s = session();
|
||
let (g, a, b) = intertwined(&mut s);
|
||
arrange(&mut s, json!({"at": [50, 30]}));
|
||
let v = s.execute("object.intertwine.release", &json!({})).unwrap();
|
||
assert_eq!(v["ids"], json!([a.0, b.0]));
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.node(g).is_none());
|
||
assert!(d.node(a).unwrap().extras().intertwine.is_none());
|
||
assert!(blue(&render(&s), 50, 30));
|
||
// Undo brings the group back, arranged.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(red(&render(&s), 50, 30));
|
||
}
|
||
|
||
/// #2540: Object › Expand turns the group into a plain group of what it draws (the rings, then the
|
||
/// part of A drawn over B as a clip group) in one undo step, looking the same; Ungroup then takes
|
||
/// it apart.
|
||
#[test]
|
||
fn expand_turns_the_group_into_plain_art_that_ungroups() {
|
||
let mut s = session();
|
||
let (g, a, b) = intertwined(&mut s);
|
||
arrange(&mut s, json!({"at": [50, 30]}));
|
||
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
|
||
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
|
||
let NodeKind::Group { children, clip: false } = &n.kind else { panic!("a group: {}", n.kind_label()) };
|
||
assert_eq!(children.len(), 3, "the rings, then the part of A drawn over B");
|
||
assert_eq!((children[0].id, children[1].id), (a, b), "the rings keep their ids and order");
|
||
assert!(matches!(children[2].kind, NodeKind::Group { clip: true, .. }));
|
||
assert!(children.iter().all(|c| c.extras().intertwine.is_none()), "no area is left over");
|
||
let mut ids = vec![];
|
||
n.walk(&mut |c| ids.push(c.id));
|
||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||
assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
|
||
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left");
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
|
||
// Plain art: Ungroup takes it apart.
|
||
s.execute("object.ungroup", &json!({})).unwrap();
|
||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||
assert_eq!(s.doc().unwrap().selection.objects.len(), 3);
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
|
||
// Undo brings the Intertwine group back.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. }));
|
||
}
|
||
|
||
/// Expand reaches an Intertwine group inside a selected group, and with every option on also
|
||
/// outlines the strokes of the rings and of the part drawn over the other one.
|
||
#[test]
|
||
fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() {
|
||
let mut s = session();
|
||
let (g, ..) = intertwined(&mut s);
|
||
arrange(&mut s, json!({"at": [50, 30]}));
|
||
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
|
||
select(&mut s, &[g, c]);
|
||
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
s.execute("object.expand", &json!({})).unwrap();
|
||
let (mut intertwines, mut strokes) = (0, 0);
|
||
s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| {
|
||
intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. }));
|
||
strokes += usize::from(!n.appearance.stroke_paint().is_none());
|
||
});
|
||
assert_eq!((intertwines, strokes), (0, 0));
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
|
||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||
assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand");
|
||
}
|
||
|
||
#[test]
|
||
fn the_arranged_areas_move_with_the_group() {
|
||
let mut s = session();
|
||
let (g, ..) = intertwined(&mut s);
|
||
arrange(&mut s, json!({"at": [50, 30]}));
|
||
select(&mut s, &[g]);
|
||
s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap();
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 40) && blue(&r, 50, 80));
|
||
}
|
||
|
||
#[test]
|
||
fn the_woven_rings_survive_save_and_export() {
|
||
let mut s = session();
|
||
intertwined(&mut s);
|
||
arrange(&mut s, json!({"at": [50, 30]}));
|
||
for format in ["vectorcraft", "svg", "pdf"] {
|
||
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
|
||
let mut o = Session::new();
|
||
o.execute("document.open", &json!({"name": format!("rings.{format}"), "dataBase64": data})).unwrap();
|
||
let r = render(&o);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "{format}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn what_cannot_be_intertwined_is_refused_with_a_reason() {
|
||
let mut s = session();
|
||
let (a, b) = rings(&mut s);
|
||
select(&mut s, &[a]);
|
||
let e = s.execute("object.intertwine.make", &json!({})).unwrap_err();
|
||
assert!(e.to_string().contains("two or more"), "{e}");
|
||
select(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.intertwine.make", &json!({})).unwrap());
|
||
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
|
||
let e = s.execute("object.intertwine.make", &json!({"ids": [g.0, c.0]})).unwrap_err();
|
||
assert!(e.to_string().contains("an Intertwine group can't") && !e.to_string().contains("already"), "{e}");
|
||
let e = s.execute("object.intertwine.make", &json!({"ids": [a.0, c.0]})).unwrap_err();
|
||
assert!(e.to_string().contains("already in an Intertwine group"), "{e}");
|
||
select(&mut s, &[g]);
|
||
let e = s.execute("object.intertwine.arrange", &json!({"at": [5, 50]})).unwrap_err();
|
||
assert!(e.to_string().contains("no overlap"), "{e}");
|
||
let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 30], "area": [[0, 0], [1, 0], [1, 1]]})).unwrap_err();
|
||
assert!(e.to_string().contains("either"), "{e}");
|
||
let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 30], "order": "sideways"})).unwrap_err();
|
||
assert!(e.to_string().contains("sideways"), "{e}");
|
||
let e = s.execute("object.intertwine.arrange", &json!({"area": [[0, 0], [1, 0]]})).unwrap_err();
|
||
assert!(e.to_string().contains("polygon"), "{e}");
|
||
let e = s.execute("object.intertwine.arrange", &json!({"id": c.0, "at": [1, 1]})).unwrap_err();
|
||
assert!(e.to_string().contains("not an Intertwine group"), "{e}");
|
||
}
|
||
|
||
// ---------- on the canvas ----------
|
||
|
||
fn press(s: &mut Session, pts: &[(f64, f64)], shift: bool) -> Vec<UiRequest> {
|
||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||
let view = ViewInfo::default();
|
||
let ev = |kind, (x, y): (f64, f64)| {
|
||
let mut e = PointerEvent::new(kind, x, y);
|
||
e.mods = Mods { shift, ..Mods::default() };
|
||
e
|
||
};
|
||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||
s.pointer(&ev(PointerKind::Move, first), view).unwrap();
|
||
s.pointer(&ev(PointerKind::Down, first), view).unwrap();
|
||
for p in &pts[1..] {
|
||
s.pointer(&ev(PointerKind::Drag, *p), view).unwrap();
|
||
}
|
||
s.pointer(&ev(PointerKind::Up, last), view).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn make_chooses_the_intertwine_tool_and_a_click_on_an_overlap_arranges_it() {
|
||
let mut s = session();
|
||
let view = ViewInfo::default();
|
||
s.select_tool("ellipse", view).unwrap();
|
||
intertwined(&mut s);
|
||
assert_eq!(s.tool_id(), "intertwine");
|
||
// Hovering the overlap highlights it.
|
||
s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 50.0, 30.0), view).unwrap();
|
||
assert_eq!(s.overlays(view).len(), 1);
|
||
press(&mut s, &[(50.0, 30.0)], false);
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
|
||
// A click off any overlap changes nothing (and reports no error).
|
||
press(&mut s, &[(5.0, 5.0)], false);
|
||
assert!(red(&render(&s), 50, 30));
|
||
// Esc goes back to the tool chosen before Make.
|
||
let back = s.tool_key(vectorcraft_tools::ToolKey::Escape, Default::default(), view).unwrap();
|
||
assert_eq!(back, [UiRequest::SwitchTool("ellipse".into())]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_drag_around_overlaps_arranges_them_and_shift_drags_a_rectangle() {
|
||
let mut s = session();
|
||
intertwined(&mut s);
|
||
// A loop around the bottom crossing.
|
||
press(&mut s, &[(40.0, 60.0), (60.0, 60.0), (60.0, 80.0), (40.0, 80.0), (40.0, 61.0)], false);
|
||
let r = render(&s);
|
||
assert!(blue(&r, 50, 30) && red(&r, 50, 70));
|
||
// A rectangle around both.
|
||
press(&mut s, &[(40.0, 20.0), (50.0, 50.0), (60.0, 80.0)], true);
|
||
let r = render(&s);
|
||
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
|
||
}
|
||
|
||
#[test]
|
||
fn edit_chooses_the_tool_for_the_group_of_a_selected_member() {
|
||
let mut s = session();
|
||
let (g, a, _) = intertwined(&mut s);
|
||
s.select_tool("selection", ViewInfo::default()).unwrap();
|
||
select(&mut s, &[a]);
|
||
let v = s.execute("object.intertwine.edit", &json!({})).unwrap();
|
||
assert_eq!(v["id"], g.0);
|
||
assert_eq!(s.tool_id(), "intertwine");
|
||
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
|
||
}
|