//! Pathfinder panel › compound shapes (`object.compoundShape.*`): Make Compound Shape in each //! shape mode, a member's mode, Release, Expand, Undo, saving and exporting. use serde_json::{Value, json}; use vectorcraft_color::Color; use vectorcraft_doc::{Node, ShapeMode}; use vectorcraft_render::effects; use crate::*; fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).cloned().unwrap() } fn area(path: &vectorcraft_geom::PathData) -> f64 { vectorcraft_pathops::area(path, vectorcraft_geom::FillRule::NonZero).abs() } /// The area a path or compound path fills. fn node_area(n: &Node) -> f64 { area(&crate::cmd::pathops::node_path(n).unwrap().0) } fn square(s: &mut Session, x: f64, color: &str) -> NodeId { let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"].as_u64().unwrap()); run(s, "paint.setFill", json!({"color": color})); id } /// A red square (0–100) behind a blue one (50–150), both 100 high and selected → (session, back, front). fn two_squares() -> (Session, NodeId, NodeId) { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 400, "height": 300})); let back = square(&mut s, 0.0, "#ff0000"); let front = square(&mut s, 50.0, "#0000ff"); run(&mut s, "select.set", json!({"ids": [back.0, front.0]})); (s, back, front) } fn selected(s: &Session) -> Vec { s.doc().unwrap().selection.objects.clone() } /// Make Compound Shape (Alt-clicking a Shape Mode gives its mode) keeps both squares as members in /// the mode, takes the paint of the topmost member not subtracting, draws the area the modes make, /// and Expand turns it into that area as one path; Undo brings each step back. #[test] fn make_and_expand_each_shape_mode() { for (mode, want, fill, kind) in [ ("add", 15_000.0, Color::rgb(0.0, 0.0, 1.0), "Path"), ("subtract", 5_000.0, Color::rgb(1.0, 0.0, 0.0), "Path"), ("intersect", 5_000.0, Color::rgb(0.0, 0.0, 1.0), "Path"), ("exclude", 10_000.0, Color::rgb(0.0, 0.0, 1.0), "Compound Path"), ] { let (mut s, back, front) = two_squares(); let before = s.doc().unwrap().doc.clone(); let id = NodeId(run(&mut s, "object.compoundShape.make", json!({"mode": mode}))["id"].as_u64().unwrap()); assert_eq!(selected(&s), vec![id]); let shape = node(&s, id); assert!(shape.is_compound_shape(), "{mode}"); assert_eq!(shape.kind_label(), "Compound Shape"); let members: Vec = shape.children().unwrap().iter().map(|m| m.id).collect(); assert_eq!(members, vec![back, front], "the members, in their stacking order"); assert!(shape.children().unwrap().iter().all(|m| m.extras().shape_mode.name() == mode)); assert_eq!(shape.appearance.fill_paint().color(), Some(fill), "{mode}"); let drawn = effects::compound_shape_area(&shape, None).unwrap(); assert!((area(&drawn) - want).abs() < 0.5, "{mode}: {}", area(&drawn)); // Expand: the path, or compound path, of the area, painted as the compound shape. let made = run(&mut s, "object.compoundShape.expand", json!({})); let path = NodeId(made["ids"][0].as_u64().unwrap()); let n = node(&s, path); assert_eq!(n.kind_label(), kind, "{mode}"); assert!((node_area(&n) - want).abs() < 0.5, "{mode}"); assert_eq!(n.appearance.fill_paint().color(), Some(fill)); assert_eq!(selected(&s), vec![path]); assert!(s.doc().unwrap().doc.node(back).is_none() && s.doc().unwrap().doc.node(id).is_none()); run(&mut s, "edit.undo", json!({})); assert!(node(&s, id).is_compound_shape()); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{mode}: Undo leaves the squares as they were"); } } /// Make Compound Shape from the panel menu gives every member Add; Release gives the members back /// as they were, selected, in one undo step. #[test] fn release_returns_the_members_as_they_were() { let (mut s, back, front) = two_squares(); let before = s.doc().unwrap().doc.clone(); let id = NodeId(run(&mut s, "object.compoundShape.make", json!({}))["id"].as_u64().unwrap()); assert!(node(&s, id).children().unwrap().iter().all(|m| m.extras().shape_mode == ShapeMode::Add)); run(&mut s, "object.compoundShape.setMode", json!({"mode": "subtract", "ids": [front.0]})); run(&mut s, "select.set", json!({"ids": [id.0]})); let undo = s.doc().unwrap().history.undo.len(); let out = run(&mut s, "object.compoundShape.release", json!({})); assert_eq!(out["ids"], json!([back.0, front.0])); assert_eq!(selected(&s), vec![back, front]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); assert_eq!(s.doc().unwrap().doc.layers, before.layers, "the members' paint and place, no shape modes"); // Nothing selected is in a compound shape: Release and Expand are off. assert!(s.execute("object.compoundShape.release", &json!({})).is_err()); assert!(s.execute("object.compoundShape.expand", &json!({})).is_err()); assert!(s.execute("object.compoundShape.setMode", &json!({"mode": "add"})).is_err()); } /// A member selected (Direct Selection) takes the mode of the Shape Mode clicked; the area follows, /// and Undo puts the mode back. Release and Expand act on the compound shape it is in. #[test] fn a_selected_member_takes_a_shape_mode() { let (mut s, _, front) = two_squares(); let id = NodeId(run(&mut s, "object.compoundShape.make", json!({}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [front.0]})); assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Add]); run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"})); assert_eq!(cmd::selected_member_modes(&s), vec![ShapeMode::Intersect]); assert!((area(&effects::compound_shape_area(&node(&s, id), None).unwrap()) - 5_000.0).abs() < 0.5); // The same mode again is no edit. let undo = s.doc().unwrap().history.undo.len(); run(&mut s, "object.compoundShape.setMode", json!({"mode": "intersect"})); assert_eq!(s.doc().unwrap().history.undo.len(), undo); assert!(s.execute("object.compoundShape.setMode", &json!({"mode": "divide"})).is_err()); run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, front).extras().shape_mode, ShapeMode::Add); run(&mut s, "select.set", json!({"ids": [front.0]})); let made = run(&mut s, "object.compoundShape.expand", json!({})); assert!((node_area(&node(&s, NodeId(made["ids"][0].as_u64().unwrap()))) - 15_000.0).abs() < 0.5); } /// A compound shape holds its paint: a fill given to it (or to a member) paints the compound shape, /// whose members keep theirs. #[test] fn paint_goes_to_the_compound_shape() { let (mut s, back, front) = two_squares(); let id = NodeId(run(&mut s, "object.compoundShape.make", json!({}))["id"].as_u64().unwrap()); let out = run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); assert_eq!(out["objects"], 1); assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0))); run(&mut s, "paint.setFill", json!({"color": "#ffff00", "ids": [front.0]})); assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::rgb(1.0, 1.0, 0.0))); assert_eq!(node(&s, back).appearance.fill_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); assert_eq!(node(&s, front).appearance.fill_paint().color(), Some(Color::rgb(0.0, 0.0, 1.0))); } /// Make Compound Shape needs two or more objects it can take. #[test] fn make_needs_two_objects_it_can_take() { let (mut s, back, _) = two_squares(); run(&mut s, "select.set", json!({"ids": [back.0]})); assert!(s.execute("object.compoundShape.make", &json!({})).is_err()); let (mut s, back, front) = two_squares(); assert!(s.execute("object.compoundShape.make", &json!({"mode": "divide"})).is_err()); assert_eq!(selected(&s), vec![back, front], "nothing changed"); } /// Saved and opened again, a compound shape keeps its members and their modes; a mode a file names /// that isn't one reads as Add. #[test] fn compound_shapes_survive_a_save() { let (mut s, _, front) = two_squares(); let id = NodeId(run(&mut s, "object.compoundShape.make", json!({"mode": "subtract"}))["id"].as_u64().unwrap()); let doc = s.doc().unwrap().doc.clone(); let bytes = vectorcraft_format::save(&doc, false); let back = vectorcraft_format::load(&bytes).unwrap(); assert_eq!(back.node(id), doc.node(id)); assert!(back.node(id).unwrap().is_compound_shape()); assert_eq!(back.node(front).unwrap().extras().shape_mode, ShapeMode::Subtract); let text = String::from_utf8(bytes).unwrap().replace("\"shape_mode\":\"subtract\"", "\"shape_mode\":\"divide\""); let odd = vectorcraft_format::load(text.as_bytes()).unwrap(); assert_eq!(odd.node(front).unwrap().extras().shape_mode, ShapeMode::Add); } /// SVG export draws the area, painted as the compound shape, not the members. #[test] fn svg_export_draws_the_area() { let (mut s, _, _) = two_squares(); run(&mut s, "object.compoundShape.make", json!({"mode": "subtract"})); let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); assert!(svg.contains("#ff0000"), "the back square's red paints the area"); assert!(!svg.contains("#0000ff"), "the front square isn't drawn: {svg}"); } /// The Shape Modes and Pathfinders take a compound shape as the area it draws. #[test] fn pathfinder_operations_take_the_area() { let (mut s, _, _) = two_squares(); let id = NodeId(run(&mut s, "object.compoundShape.make", json!({"mode": "subtract"}))["id"].as_u64().unwrap()); let r = NodeId(run(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 100}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [id.0, r.0]})); let out = run(&mut s, "object.pathfinder.unite", json!({})); let n = node(&s, NodeId(out["ids"][0].as_u64().unwrap())); assert!((node_area(&n) - 10_000.0).abs() < 0.5, "the 50 × 100 area and the 50 × 100 rectangle: {}", node_area(&n)); }