//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool: //! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18). use serde_json::{Value, json}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); s } fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) } fn rect(s: &mut Session, x: f64, y: f64, w: f64) -> NodeId { id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()) } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).unwrap().clone() } fn spec(s: &Session, id: NodeId) -> (Vec, BlendSpec) { match node(s, id).kind { NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec), _ => panic!("not a blend"), } } fn sel(s: &mut Session, ids: &[NodeId]) { s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); } #[test] fn options_with_nothing_selected_set_what_new_blends_start_with() { let mut s = session(); let info = s.execute("object.blend.info", &json!({})).unwrap(); assert_eq!((info["target"].as_str(), info["spacing"].as_str()), (Some("defaults"), Some("smooth"))); let depth = s.doc().unwrap().history.undo.len(); let r = s.execute("object.blend.options", &json!({"spacing": "distance", "value": 20, "orientation": "path"})).unwrap(); assert_eq!(r["defaults"], json!(true)); assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step"); let info = s.execute("object.blend.info", &json!({})).unwrap(); assert_eq!((info["spacing"].as_str(), info["distance"].as_f64(), info["orientation"].as_str()), (Some("distance"), Some(20.0), Some("path"))); // New blends start with them; explicit params still win. let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); let (_, sp) = spec(&s, g); assert_eq!((sp.spacing, sp.orientation), (BlendSpacing::Distance(20.0), BlendOrientation::AlignToPath)); s.execute("edit.undo", &json!({})).unwrap(); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); assert_eq!(spec(&s, g).1.spacing, BlendSpacing::Steps(2)); // The preference survives a round trip through the preferences file. let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); assert_eq!(back.blend_options, s.prefs.blend_options); assert!(Prefs::default().to_json().get("blendOptions").is_none()); } #[test] fn info_reads_the_selected_blend() { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap()); let info = s.execute("object.blend.info", &json!({})).unwrap(); assert_eq!(info["target"], "blend"); assert_eq!(info["id"], json!(g.0)); assert_eq!((info["spacing"].as_str(), info["steps"].as_u64()), (Some("steps"), Some(4))); assert_eq!(info["keys"], json!([a.0, b.0])); } #[test] fn start_points_pair_the_clicked_anchors() { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); // a starts at its top left, b at its bottom right: corners meet their opposites. let g = id_of(&s.execute("object.blend.make", &json!({"ids": [b.0, a.0], "starts": [2, 0], "steps": 1})).unwrap()); let (keys, sp) = spec(&s, g); assert_eq!(keys, vec![a, b], "keys in paint order"); assert_eq!(sp.starts, vec![Some(0), Some(2)], "each start stays with its object"); let steps = vectorcraft_doc::live::expand_live(&node(&s, g)); let mid = steps[1].geometric_bounds().unwrap(); assert!(mid.height() < 1e-6, "the middle step collapses where opposite corners meet: {mid:?}"); assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": "x"})).is_err()); assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [-1, 0]})).is_err()); // Reverse Front to Back keeps each key's start. sel(&mut s, &[g]); s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap(); assert_eq!(spec(&s, g).1.starts, vec![Some(2), Some(0)]); } #[test] fn a_blend_and_an_object_make_one_blend_with_more_keys() { let mut s = session(); let (a, b, c) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0), rect(&mut s, 200.0, 0.0, 10.0)); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); s.edit("name", |d, _| { d.node_mut(g).unwrap().name = Some("Ribbon".into()); Ok(()) }) .unwrap(); let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap()); let n = node(&s, g2); let (keys, sp) = spec(&s, g2); assert_eq!(keys, vec![a, b, c], "no blend inside the blend"); assert_eq!(sp.spacing, BlendSpacing::Steps(3), "the blend's options are kept"); assert_eq!(n.name.as_deref(), Some("Ribbon")); assert!(s.doc().unwrap().doc.node(g).is_none()); assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3); } #[test] fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() { let mut s = session(); let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); s.select_tool("blend", ViewInfo::default()).unwrap(); let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); // a's top right anchor, then b's interior. click(&mut s, 40.0, 0.0); click(&mut s, 220.0, 20.0); let g = s.doc().unwrap().selection.objects[0]; let (keys, sp) = spec(&s, g); assert_eq!(keys, vec![a, b]); assert_eq!(sp.starts, vec![Some(1), None]); // The next object joins the same blend. click(&mut s, 420.0, 20.0); let g2 = s.doc().unwrap().selection.objects[0]; assert_eq!(spec(&s, g2).0, vec![a, b, c]); // Alt-click asks for Blend Options. let r = s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 500.0).with_mods(Mods { alt: true, ..Default::default() }), ViewInfo::default()); assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions")); } #[test] fn start_points_round_trip_and_old_blends_load() { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [null, 3]})).unwrap(); let d = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); assert_eq!(&back.layers, &d.layers); // A blend saved before start points has none. let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}, "orientation": "alignToPage"})).unwrap(); assert!(old.starts.is_empty() && old.start(0).is_none()); assert!(serde_json::to_value(&old).unwrap().get("starts").is_none()); }