//! Brushes and symbols: commands, tools, rendering and persistence. use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); s } fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); NodeId(r["id"].as_u64().unwrap()) } fn line(s: &mut Session) -> NodeId { let r = s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 250, "y2": 100})); match r { Ok(v) if v["id"].is_u64() => NodeId(v["id"].as_u64().unwrap()), _ => { let r = s.execute("path.freehand", &json!({"points": [[50, 100], [150, 100], [250, 100]]})).unwrap(); NodeId(r["id"].as_u64().unwrap()) } } } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).cloned().unwrap() } fn brush_of(s: &Session, id: NodeId) -> Option { node(s, id).appearance.stroke().and_then(|st| st.brush.clone()) } fn select(s: &mut Session, ids: &[NodeId]) { let ids = ids.to_vec(); s.select(|_, sel| sel.set(ids)).unwrap(); } fn close(a: Rect, b: Rect, tol: f64) -> bool { (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol } fn bounds(s: &Session, id: NodeId) -> Rect { node(s, id).geometric_bounds().unwrap() } // ---------- brushes ---------- #[test] fn brush_list_has_the_default_library() { let mut s = session(); let l = s.execute("brush.list", &json!({})).unwrap(); let names: Vec<&str> = l["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect(); for n in ["3 pt. Round", "6 pt. Flat", "Charcoal", "Arrow", "Dots", "Chain", "Bristle Round"] { assert!(names.contains(&n), "{n} in {names:?}"); } let def = s.execute("brush.get", &json!({"name": "6 pt. Flat"})).unwrap(); assert_eq!(def["type"], "calligraphic"); assert_eq!(def["angle"], json!(45.0)); } #[test] fn apply_and_remove_brush_with_undo() { let mut s = session(); let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); s.execute("brush.apply", &json!({"name": "Charcoal"})).unwrap(); assert_eq!(brush_of(&s, a).as_deref(), Some("Charcoal")); assert_eq!(s.execute("brush.list", &json!({})).unwrap()["current"], "Charcoal"); s.execute("brush.remove", &json!({})).unwrap(); assert_eq!(brush_of(&s, a), None); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(brush_of(&s, a).as_deref(), Some("Charcoal")); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(brush_of(&s, a), None); assert!(s.execute("brush.apply", &json!({"name": "Nope"})).is_err()); } #[test] fn apply_adds_a_stroke_when_missing_and_reaches_into_groups() { let mut s = session(); let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); let b = rect(&mut s, 80.0, 10.0, 50.0, 50.0); s.execute("paint.setStroke", &json!({"color": null, "ids": [a.0]})).ok(); select(&mut s, &[a, b]); s.execute("object.group", &json!({})).unwrap(); s.execute("brush.apply", &json!({"name": "Dots"})).unwrap(); assert_eq!(brush_of(&s, a).as_deref(), Some("Dots")); assert_eq!(brush_of(&s, b).as_deref(), Some("Dots")); assert!(!node(&s, a).appearance.stroke_paint().is_none()); } #[test] fn new_brushes_from_params_and_selection() { let mut s = session(); let r = s.execute("brush.new", &json!({"type": "calligraphic", "name": "Nib", "params": {"angle": 20, "roundness": 30, "size": 8}})).unwrap(); assert_eq!(r["name"], "Nib"); assert_eq!(s.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(8.0)); // Art brushes need art. s.select(|_, sel| sel.clear()).unwrap(); assert!(s.execute("brush.new", &json!({"type": "art"})).is_err()); let a = rect(&mut s, 0.0, 0.0, 40.0, 8.0); let r = s.execute("brush.new", &json!({"type": "art", "name": "Bar"})).unwrap(); assert_eq!(r["name"], "Bar"); let r = s.execute("brush.new", &json!({"type": "pattern"})).unwrap(); assert_eq!(r["name"], "New Pattern Brush"); let def = s.execute("brush.get", &json!({"name": "New Pattern Brush"})).unwrap(); assert!(def["side"].is_object()); // The new art brush draws the rectangle along another path. let l = line(&mut s); s.execute("brush.apply", &json!({"name": "Bar", "ids": [l.0]})).unwrap(); let g = s.execute("object.expandBrush", &json!({"ids": [l.0]})).unwrap(); let gid = NodeId(g["ids"][0].as_u64().unwrap()); let b = bounds(&s, gid); assert!((b.width() - 200.0).abs() < 0.5 && (b.height() - 8.0).abs() < 0.5, "{b:?}"); let _ = a; // Undo removes the brush again; the library was not there before. assert!(s.execute("brush.new", &json!({"type": "nope"})).is_err()); } /// The brush of every stroke in symbol "Mark", graph design "Bar", pattern `pattern`, graphic style /// "Linked", the opacity mask in the layers and the art of brush "Holder", each as ": /// " ("-" for a stroke without one). fn brushes_outside_the_layers(s: &Session, pattern: &str) -> Vec { let d = &s.doc().unwrap().doc; let mut out = vec![]; let mut items = |place: &str, items: &[vectorcraft_doc::AppearanceItem]| { for it in items { if let vectorcraft_doc::AppearanceItem::Stroke(st) = it { out.push(format!("{place}: {}", st.brush.as_deref().unwrap_or("-"))); } } }; let mut tree = |place: &str, n: &Node| n.walk(&mut |m| items(place, &m.appearance.items)); tree("symbol", &d.symbols.iter().find(|x| x.name == "Mark").unwrap().art); tree("design", &d.graph_designs.iter().find(|x| x.name == "Bar").unwrap().art); d.pattern(pattern).unwrap().art.iter().for_each(|n| tree("pattern", n)); let mut masks = vec![]; d.walk(|n| masks.extend(n.extras().mask.as_deref().map(|m| m.art.clone()))); masks.iter().for_each(|n| tree("mask", n)); let holder = vectorcraft_brush::find(d, "Holder").unwrap(); holder.art().into_iter().for_each(|n| tree("brush art", n)); items("style", &d.graphic_styles.iter().find(|x| x.name == "Linked").unwrap().appearance.items); out } /// Renaming a brush in Brush Options and deleting it reach every stroke that paints with it, not /// only those in the layers: strokes in symbols, graph designs, pattern tiles, graphic styles, /// opacity masks and the art of other brushes are renamed, or become plain strokes, in the same /// undo step. #[test] fn brush_rename_and_delete_reach_strokes_outside_the_layers() { let mut s = session(); let brushed = |s: &mut Session| { let id = rect(s, 10.0, 10.0, 40.0, 20.0); s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id.0]})).unwrap(); id }; let clear = |s: &mut Session, id: NodeId| { if s.doc().unwrap().doc.node(id).is_some() { s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); } }; brushed(&mut s); let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); let id = brushed(&mut s); s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); clear(&mut s, id); let id = brushed(&mut s); let pattern = s.execute("object.pattern.make", &json!({"edit": false})).unwrap()["name"].as_str().unwrap().to_string(); clear(&mut s, id); let id = brushed(&mut s); s.execute("graphicStyle.new", &json!({"name": "Linked", "id": id.0})).unwrap(); clear(&mut s, id); let id = brushed(&mut s); s.execute("brush.new", &json!({"type": "art", "name": "Holder"})).unwrap(); clear(&mut s, id); let masked = rect(&mut s, 100.0, 100.0, 60.0, 60.0); let mask = brushed(&mut s); select(&mut s, &[masked, mask]); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); let a = brushed(&mut s); let plain = rect(&mut s, 200.0, 10.0, 40.0, 20.0); let shared = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|c| c.id == plain).unwrap().clone(); let plain_before = shared(&s); let before = brushes_outside_the_layers(&s, &pattern); for place in ["symbol", "design", "pattern", "mask", "brush art", "style"] { assert!(before.contains(&format!("{place}: Charcoal")), "{place} in {before:?}"); } let with = |to: &str| -> Vec { before.iter().map(|e| e.replace(": Charcoal", &format!(": {to}"))).collect() }; let undo = s.doc().unwrap().history.undo.len(); s.execute("brush.options", &json!({"name": "Charcoal", "newName": "Chalk"})).unwrap(); assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Chalk"), with("Chalk"))); assert!(std::sync::Arc::ptr_eq(&shared(&s), &plain_before), "an object without the brush is not copied"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Charcoal"), before.clone())); s.execute("brush.delete", &json!({"name": "Charcoal"})).unwrap(); assert_eq!((brush_of(&s, a), brushes_outside_the_layers(&s, &pattern)), (None, with("-"))); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); // A brush made later with the deleted brush's name does not paint the strokes that used it. s.execute("brush.new", &json!({"type": "calligraphic", "name": "Charcoal"})).unwrap(); assert_eq!(brushes_outside_the_layers(&s, &pattern), with("-")); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Charcoal"), before)); } /// A brush renamed while a pattern is being edited keeps its new name in the tile that pattern /// editing's Cancel puts back. #[test] fn brush_rename_reaches_the_tile_that_pattern_cancel_puts_back() { let mut s = session(); let id = rect(&mut s, 10.0, 10.0, 40.0, 20.0); s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id.0]})).unwrap(); let pattern = s.execute("object.pattern.make", &json!({"edit": false})).unwrap()["name"].as_str().unwrap().to_string(); s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); s.execute("brush.options", &json!({"name": "Charcoal", "newName": "Chalk"})).unwrap(); s.execute("object.pattern.cancel", &json!({})).unwrap(); let mut brushes = vec![]; for n in &s.doc().unwrap().doc.pattern(&pattern).unwrap().art { n.walk(&mut |m| brushes.extend(m.appearance.stroke().and_then(|st| st.brush.clone()))); } assert_eq!(brushes, ["Chalk"]); } /// `brush.selectionArt` gives the art `brush.new` takes: the selected object, a group of the selected /// objects, the object of `ids`, or null when nothing is selected. It changes nothing. #[test] fn selection_art_is_the_art_new_brush_takes() { let mut s = session(); let art = |s: &mut Session, p: Value| s.execute("brush.selectionArt", &p).unwrap()["art"].clone(); s.select(|_, sel| sel.clear()).unwrap(); assert_eq!(art(&mut s, json!({})), Value::Null); let a = rect(&mut s, 0.0, 0.0, 40.0, 8.0); let b = rect(&mut s, 0.0, 20.0, 40.0, 8.0); let steps = s.doc().unwrap().history.undo.len(); select(&mut s, &[a, b]); let group: Node = serde_json::from_value(art(&mut s, json!({}))).unwrap(); assert!(matches!(&group.kind, NodeKind::Group { children, .. } if children.len() == 2), "{group:?}"); let one: Node = serde_json::from_value(art(&mut s, json!({"ids": [a.0]}))).unwrap(); assert_eq!(one.geometric_bounds(), Some(bounds(&s, a))); // The art brush `brush.new` makes from the same selection holds the same art. s.execute("brush.new", &json!({"type": "art", "name": "Two"})).unwrap(); let made: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Two"})).unwrap()["art"].clone()).unwrap(); assert_eq!(made.geometric_bounds(), group.geometric_bounds()); assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step"); } #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); s.execute("brush.apply", &json!({"name": "3 pt. Round"})).unwrap(); s.execute("brush.options", &json!({"name": "3 pt. Round", "params": {"size": 5}, "newName": "5 pt. Round"})).unwrap(); assert_eq!(brush_of(&s, a).as_deref(), Some("5 pt. Round")); assert_eq!(s.execute("brush.get", &json!({"name": "5 pt. Round"})).unwrap()["size"], json!(5.0)); assert_eq!(s.execute("brush.list", &json!({})).unwrap()["current"], "5 pt. Round"); let d = s.execute("brush.duplicate", &json!({"name": "5 pt. Round"})).unwrap(); assert_eq!(d["name"], "5 pt. Round copy"); s.execute("brush.delete", &json!({"name": "5 pt. Round"})).unwrap(); assert_eq!(brush_of(&s, a), None); assert!(s.execute("brush.get", &json!({"name": "5 pt. Round"})).is_err()); assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err()); } /// `brush.move`, which the Brushes panel runs when a brush is dragged, reorders brushes among those of /// their type in one undo step and refuses to move one into another type. #[test] fn brush_move_reorders_brushes_within_their_type() { let mut s = session(); let names = |s: &mut Session, ty: &str| -> Vec { let list = s.execute("brush.list", &json!({})).unwrap(); let of_type = list["brushes"].as_array().unwrap().iter().filter(|b| ty.is_empty() || b["type"] == ty); of_type.map(|b| b["name"].as_str().unwrap().to_string()).collect() }; let undo = |s: &Session| s.doc().unwrap().history.undo.len(); let before = names(&mut s, ""); assert_eq!(names(&mut s, "calligraphic"), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]); let steps = undo(&s); // To index 0 of the calligraphic brushes: before the first; the other types stay where they are. let r = s.execute("brush.move", &json!({"name": "10 pt. Oval", "to": 0})).unwrap(); assert_eq!(r["moved"], json!(1)); assert_eq!(names(&mut s, "")[..3], ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); assert_eq!(names(&mut s, "")[3..], before[3..]); assert_eq!(undo(&s), steps + 1); // Several go in the order given, as one step. s.execute("brush.move", &json!({"names": ["6 pt. Flat", "3 pt. Round"], "to": 0, "type": "calligraphic"})).unwrap(); assert_eq!(names(&mut s, "calligraphic"), ["6 pt. Flat", "3 pt. Round", "10 pt. Oval"]); assert_eq!(undo(&s), steps + 2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(names(&mut s, "calligraphic"), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(names(&mut s, ""), before); // Into the scatter brushes, with a scatter brush, or with a `to` that isn't a whole number: an // error, and nothing moves. for (p, why) in [ (json!({"name": "3 pt. Round", "type": "scatter"}), "brushes move only within their type"), (json!({"names": ["3 pt. Round", "Dots"], "to": 0}), "brushes move only within their type"), (json!({"name": "3 pt. Round", "to": "1"}), "`to` must be a number, not \"1\""), (json!({"name": "3 pt. Round", "to": 1.5}), "`to` must be a whole number"), (json!({"name": "Nope", "to": 0}), "no brush named `Nope`"), ] { let r = s.execute("brush.move", &p); assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == why), "{p}: {r:?}"); } assert_eq!((names(&mut s, ""), undo(&s)), (before.clone(), steps)); // A move that keeps the order records no step. s.execute("brush.move", &json!({"name": "3 pt. Round", "to": 0})).unwrap(); assert_eq!((names(&mut s, ""), undo(&s)), (before.clone(), steps)); // Without `to`, or past the last one: after the last brush of the type, even one listed after // brushes of other types. s.execute("brush.new", &json!({"type": "calligraphic", "name": "Quill"})).unwrap(); s.execute("brush.move", &json!({"name": "3 pt. Round"})).unwrap(); assert_eq!(names(&mut s, "calligraphic"), ["6 pt. Flat", "10 pt. Oval", "Quill", "3 pt. Round"]); assert_eq!(names(&mut s, "").last().map(String::as_str), Some("3 pt. Round")); s.execute("brush.move", &json!({"name": "6 pt. Flat", "to": 99})).unwrap(); assert_eq!(names(&mut s, "calligraphic"), ["10 pt. Oval", "Quill", "3 pt. Round", "6 pt. Flat"]); s.execute("brush.move", &json!({"name": "Leaves", "to": 1})).unwrap(); assert_eq!(names(&mut s, "scatter"), ["Dots", "Leaves", "Confetti"]); } /// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options /// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name /// gives the name the command gives when there is none. #[test] fn new_duplicate_and_renamed_brushes_trim_their_names() { let mut s = session(); let new = |s: &mut Session, name: &str| s.execute("brush.new", &json!({"type": "calligraphic", "name": name})).unwrap()["name"].clone(); assert_eq!(new(&mut s, " Nib \t"), "Nib"); assert_eq!(new(&mut s, " Nib"), "Nib 2"); assert_eq!(new(&mut s, " \t "), "New Calligraphic Brush"); assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " Quill\t"})).unwrap()["name"], "Quill"); assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " "})).unwrap()["name"], "Nib copy"); assert_eq!(s.execute("brush.options", &json!({"name": "Nib 2", "newName": " Pen "})).unwrap()["name"], "Pen"); assert_eq!(s.execute("brush.options", &json!({"name": "Pen", "newName": " "})).unwrap()["name"], "Pen"); let e = s.execute("brush.options", &json!({"name": "Pen", "newName": " Quill"})).unwrap_err().to_string(); assert!(e.contains("a brush named `Quill` exists"), "{e}"); // Duplicate Brush names the copy of a brush whose saved name has white space around it, as a // saved file can hold, from the trimmed name (" Old " gives "Old copy"). let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc); lib.iter_mut().filter(|b| b.name == "Pen").for_each(|b| b.name = " Old ".into()); vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib); assert_eq!(s.execute("brush.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy"); let names: Vec = vectorcraft_brush::library(&s.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]); } /// New Brush and Bristle Brush Options take a bristle brush name of at most 31 characters, counted /// in Unicode characters (31 é's fit). A longer name that a document already holds is kept, and the /// other brush types take a name of any length. #[test] fn bristle_brush_names_have_at_most_31_characters() { let mut s = session(); let library = |s: &Session| vectorcraft_brush::library(&s.doc().unwrap().doc); let refused = |s: &mut Session, cmd: &str, p: Value| s.execute(cmd, &p).unwrap_err().to_string(); let (long, fits) = ("b".repeat(32), "é".repeat(31)); let e = refused(&mut s, "brush.new", json!({"type": "bristle", "name": long})); assert!(e.contains("a bristle brush name can have at most 31 characters: `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb` has 32"), "{e}"); assert!(!library(&s).iter().any(|b| b.name == long)); assert_eq!(s.execute("brush.new", &json!({"type": "bristle", "name": fits})).unwrap()["name"], fits.as_str()); // A taken name is numbered, and the numbered name is the one counted. let e = refused(&mut s, "brush.new", json!({"type": "bristle", "name": fits})); assert!(e.contains(&format!("`{fits} 2` has 33")), "{e}"); let e = refused(&mut s, "brush.options", json!({"name": "Bristle Round", "newName": long})); assert!(e.contains("at most 31 characters"), "{e}"); let short = "c".repeat(31); assert_eq!(s.execute("brush.options", &json!({"name": "Bristle Round", "newName": short})).unwrap()["name"], short.as_str()); // Calligraphic brushes take a longer name. assert_eq!(s.execute("brush.new", &json!({"type": "calligraphic", "name": long})).unwrap()["name"], long.as_str()); let longer = format!("{long}x"); assert_eq!(s.execute("brush.options", &json!({"name": long, "newName": longer})).unwrap()["name"], longer.as_str()); // Bristle brushes that already have longer names, one of them with white space around it, as a // saved file can hold. let (old, spaced) = ("d".repeat(40), format!(" {} ", "e".repeat(40))); let mut lib = library(&s); lib.iter_mut().filter(|b| b.name == short).for_each(|b| b.name = old.clone()); lib.iter_mut().filter(|b| b.name == fits).for_each(|b| b.name = spaced.clone()); vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib); let l = line(&mut s); s.execute("brush.apply", &json!({"name": old, "ids": [l.0]})).unwrap(); // Brush Options without a new name, with the same name, or with the name only trimmed keep them. s.execute("brush.options", &json!({"name": old, "params": {"size": 9}})).unwrap(); assert_eq!(s.execute("brush.options", &json!({"name": old, "newName": old, "params": {"density": 20}})).unwrap()["name"], old.as_str()); assert_eq!(s.execute("brush.options", &json!({"name": spaced, "newName": spaced})).unwrap()["name"], spaced.trim()); assert_eq!(s.execute("brush.get", &json!({"name": old})).unwrap()["size"], json!(9.0)); assert_eq!(s.execute("brush.duplicate", &json!({"name": old})).unwrap()["name"], format!("{old} copy")); // Saved and opened again, the brush and the stroke painted with it keep the name. let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); assert!(vectorcraft_brush::find(&back, &old).is_some_and(|b| b.kind.type_id() == "bristle")); assert_eq!(back.node(l).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(old.as_str())); let mut s2 = Session::new(); s2.add_document(back, None); assert_eq!(s2.execute("brush.options", &json!({"name": old, "params": {"size": 4}})).unwrap()["name"], old.as_str()); } /// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo /// step, and an unknown name changes nothing. #[test] fn brushes_delete_and_duplicate_by_names() { let mut s = session(); let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); s.execute("brush.apply", &json!({"name": "3 pt. Round"})).unwrap(); let b = rect(&mut s, 100.0, 10.0, 50.0, 50.0); s.execute("brush.apply", &json!({"name": "6 pt. Flat"})).unwrap(); let names = |s: &mut Session| -> Vec { let list = s.execute("brush.list", &json!({})).unwrap(); list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap().to_string()).collect() }; let before = names(&mut s); let undo = s.doc().unwrap().history.undo.len(); // Copies of both, after the last brush. let d = s.execute("brush.duplicate", &json!({"names": ["3 pt. Round", "6 pt. Flat"]})).unwrap(); assert_eq!(d["names"], json!(["3 pt. Round copy", "6 pt. Flat copy"])); assert_eq!(names(&mut s)[before.len()..], ["3 pt. Round copy", "6 pt. Flat copy"]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(names(&mut s), before); // A name that isn't a brush: an error, and nothing is deleted. assert!(s.execute("brush.delete", &json!({"names": ["3 pt. Round", "Nope"]})).is_err()); assert!(s.execute("brush.duplicate", &json!({"names": ["3 pt. Round", "Nope"]})).is_err()); assert_eq!((names(&mut s), brush_of(&s, a).as_deref()), (before.clone(), Some("3 pt. Round"))); assert_eq!(s.doc().unwrap().history.undo.len(), undo); // Both go in one step; the strokes that used them become plain strokes. s.execute("brush.delete", &json!({"names": ["3 pt. Round", "6 pt. Flat"]})).unwrap(); assert_eq!((brush_of(&s, a), brush_of(&s, b)), (None, None)); assert!(!names(&mut s).iter().any(|n| n == "3 pt. Round" || n == "6 pt. Flat")); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((names(&mut s), brush_of(&s, b).as_deref()), (before, Some("6 pt. Flat"))); } fn unused(s: &mut Session) -> Vec { serde_json::from_value(s.execute("brush.unused", &json!({})).unwrap()["names"].clone()).unwrap() } /// Select All Unused: `brush.unused` lists the brushes no stroke paints with, in library order, /// without an undo step. #[test] fn brush_unused_lists_the_brushes_no_stroke_paints_with() { let mut s = session(); let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc); lib.retain(|b| ["3 pt. Round", "6 pt. Flat", "Charcoal"].contains(&b.name.as_str())); vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib); let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); s.execute("brush.apply", &json!({"name": "6 pt. Flat"})).unwrap(); let undo = s.doc().unwrap().history.undo.len(); assert_eq!(unused(&mut s), ["3 pt. Round", "Charcoal"]); assert_eq!(s.doc().unwrap().history.undo.len(), undo); s.execute("brush.remove", &json!({"ids": [a.0]})).unwrap(); assert_eq!(unused(&mut s), ["3 pt. Round", "6 pt. Flat", "Charcoal"]); } /// A brush counts as used when a stroke paints with it in an opacity mask, a symbol, a graph /// design, a pattern tile, a graphic style, or the art of a brush that is used. The art of a brush /// that is not used, its own strokes included, does not count. #[test] fn brush_unused_counts_strokes_in_masks_symbols_designs_patterns_styles_and_used_brush_art() { let mut s = session(); // Makes a rectangle painted with `brush` and leaves it selected; the test clears it once it // has been used. let brushed = |s: &mut Session, brush: &str| { let id = rect(s, 10.0, 10.0, 40.0, 20.0); s.execute("brush.apply", &json!({"name": brush, "ids": [id.0]})).unwrap(); id }; let clear = |s: &mut Session, id: NodeId| { s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); }; brushed(&mut s, "Charcoal"); let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); let id2 = brushed(&mut s, "Dots"); s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); clear(&mut s, id2); let id3 = brushed(&mut s, "Arrow"); s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); if s.doc().unwrap().doc.node(id3).is_some() { clear(&mut s, id3); } let id4 = brushed(&mut s, "Chain"); s.execute("graphicStyle.new", &json!({"name": "Linked", "id": id4.0})).unwrap(); clear(&mut s, id4); let masked = rect(&mut s, 100.0, 100.0, 60.0, 60.0); let mask = brushed(&mut s, "6 pt. Flat"); select(&mut s, &[masked, mask]); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); // "Holder" is used, and its art paints with "10 pt. Oval"; "Idle" is not, and its art paints // with "Bristle Round". "Loop" paints its own art with itself and is not used anywhere else. let id5 = brushed(&mut s, "10 pt. Oval"); s.execute("brush.new", &json!({"type": "art", "name": "Holder"})).unwrap(); clear(&mut s, id5); let l = line(&mut s); s.execute("brush.apply", &json!({"name": "Holder", "ids": [l.0]})).unwrap(); let id6 = brushed(&mut s, "Bristle Round"); s.execute("brush.new", &json!({"type": "art", "name": "Idle"})).unwrap(); clear(&mut s, id6); let id7 = rect(&mut s, 10.0, 10.0, 40.0, 20.0); s.execute("brush.new", &json!({"type": "art", "name": "Loop"})).unwrap(); s.execute("brush.apply", &json!({"name": "Loop", "ids": [id7.0]})).unwrap(); let art = serde_json::to_value(node(&s, id7)).unwrap(); s.execute("brush.options", &json!({"name": "Loop", "params": {"art": art}})).unwrap(); clear(&mut s, id7); let left = unused(&mut s); for used in ["Charcoal", "Dots", "Arrow", "Chain", "6 pt. Flat", "Holder", "10 pt. Oval"] { assert!(!left.iter().any(|n| n == used), "{used} in {left:?}"); } for name in ["Idle", "Bristle Round", "Loop", "3 pt. Round"] { assert!(left.iter().any(|n| n == name), "{name} not in {left:?}"); } } #[test] fn expand_brush_replaces_path_with_art_group() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("brush.apply", &json!({"name": "10 pt. Oval"})).unwrap(); let r = s.execute("object.expandBrush", &json!({})).unwrap(); let g = NodeId(r["ids"][0].as_u64().unwrap()); assert!(s.doc().unwrap().doc.node(a).is_none()); let gn = node(&s, g); assert!(matches!(gn.kind, NodeKind::Group { .. })); // White fill kept + the black nib outline. assert_eq!(gn.children().unwrap().len(), 2); let b = bounds(&s, g); assert!(b.width() > 104.0 && b.width() < 112.0, "{b:?}"); assert_eq!(s.doc().unwrap().selection.objects, vec![g]); assert!(s.execute("object.expandBrush", &json!({})).is_err(), "nothing brushed left"); } #[test] fn brush_freehand_paints_with_a_brush_in_one_step() { let mut s = session(); let n = s.doc().unwrap().history.undo.len(); s.begin_interaction("Paintbrush").unwrap(); s.preview("brush.freehand", &json!({"points": [[10, 10], [60, 40], [120, 10]], "style": "brush", "brush": "Charcoal"})).unwrap(); s.commit_interaction().unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); let id = s.doc().unwrap().selection.objects[0]; assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); } #[test] fn paintbrush_tool_uses_the_current_brush() { let mut s = session(); s.execute("brush.setCurrent", &json!({"name": "Arrow"})).unwrap(); let v = ViewInfo::default(); s.select_tool("paintbrush", v).unwrap(); for (k, x, y) in [(PointerKind::Down, 10.0, 10.0), (PointerKind::Drag, 50.0, 30.0), (PointerKind::Drag, 90.0, 20.0), (PointerKind::Up, 130.0, 10.0)] { s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); } let id = s.doc().unwrap().selection.objects[0]; assert_eq!(brush_of(&s, id).as_deref(), Some("Arrow")); // A tool option overrides the document's current brush. s.set_tool_option("brush", &json!("Dots")); for (k, x, y) in [(PointerKind::Down, 10.0, 110.0), (PointerKind::Drag, 50.0, 130.0), (PointerKind::Up, 130.0, 110.0)] { s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); } let id = s.doc().unwrap().selection.objects[0]; assert_eq!(brush_of(&s, id).as_deref(), Some("Dots")); assert!(s.execute("brush.setCurrent", &json!({"name": "Nope"})).is_err()); } /// A Size: Pressure variant of "3 pt. Round" (3 ± 3 pt). fn pressure_round(s: &mut Session) { let params = json!({"variation": [0, 0, 3], "modes": ["fixed", "fixed", "pressure"]}); s.execute("brush.options", &json!({"name": "3 pt. Round", "params": params})).unwrap(); } fn pressure_of(s: &Session, id: NodeId) -> Option { node(s, id).appearance.stroke().and_then(|st| st.pressure.clone()) } /// #852: the Paintbrush records the pen pressure on the stroke, the brush draws it, and it lasts /// through saving and every export; a mouse stroke records none. #[test] fn paintbrush_records_pen_pressure_on_the_stroke() { let mut s = session(); pressure_round(&mut s); s.execute("brush.setCurrent", &json!({"name": "3 pt. Round"})).unwrap(); let v = ViewInfo::default(); s.select_tool("paintbrush", v).unwrap(); let strokes = |s: &mut Session, y: f64, pressure: [f32; 4]| { let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Drag, PointerKind::Up]; for ((k, x), p) in kinds.into_iter().zip([20.0, 120.0, 220.0, 320.0]).zip(pressure) { s.pointer(&PointerEvent { pressure: p, ..PointerEvent::new(k, x, y) }, v).unwrap(); } let id = s.doc().unwrap().selection.objects[0]; // Deselected, so the next stroke starts a path of its own. select(s, &[]); id }; let pen = strokes(&mut s, 60.0, [0.0, 0.4, 0.8, 1.0]); let p = pressure_of(&s, pen).expect("pressure recorded"); assert!(p.at(0.0) < 0.05 && p.at(1.0) > 0.95 && (p.at(0.5) - 0.6).abs() < 0.05, "{p:?}"); let mouse = strokes(&mut s, 200.0, [1.0; 4]); assert_eq!(pressure_of(&s, mouse), None, "a mouse presses fully: nothing to record"); // Drawn: thin where the pen pressed lightly, 6 pt where it pressed fully. let doc: Document = (*s.doc().unwrap().doc).clone(); let art = |d: &Document, id: NodeId| vectorcraft_brush::node_pieces(d, d.node(id).unwrap()).unwrap(); let heavy_end = vectorcraft_brush::pieces_bounds(&art(&doc, pen)).unwrap(); assert!((heavy_end.height() - 6.0).abs() < 0.3, "{heavy_end:?}"); let even = vectorcraft_brush::pieces_bounds(&art(&doc, mouse)).unwrap(); assert!((even.height() - 3.0).abs() < 0.1, "no pressure draws the size itself: {even:?}"); // Saved and reopened as it was. let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.node(pen).unwrap().appearance.stroke().unwrap().pressure, Some(p)); // SVG and PDF draw the brush through the same stroke code: without the pressure they differ. let mut flat = doc.clone(); flat.node_mut(pen).unwrap().appearance.stroke_mut().unwrap().pressure = None; assert_ne!(vectorcraft_svg::export(&doc, &Default::default()), vectorcraft_svg::export(&flat, &Default::default())); let pdf = |d: &Document| { vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap() }; assert_ne!(pdf(&doc), pdf(&flat)); } /// Continuing a pressure stroke keeps its pressure where it was and adds the new stroke's after it. #[test] fn continuing_a_stroke_joins_its_pressure() { let mut s = session(); let r = s.execute("path.freehand", &json!({"points": [[0, 50, 0], [100, 50, 1]], "style": "brush"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); s.execute("path.freehand", &json!({"points": [[100, 50, 1], [200, 50, 0]], "style": "brush", "extend": {"id": id.0, "end": "end"}})).unwrap(); let p = pressure_of(&s, id).unwrap(); assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05 && p.at(1.0) < 0.05, "{p:?}"); // Continued at its start: the new stroke comes first, running backwards from where it joined. let r = s.execute("path.freehand", &json!({"points": [[0, 150], [100, 150]], "style": "brush"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); s.execute("path.freehand", &json!({"points": [[0, 150, 1], [-100, 150, 0]], "style": "brush", "extend": {"id": id.0, "end": "start"}})).unwrap(); let p = pressure_of(&s, id).unwrap(); assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05, "{p:?}"); assert_eq!(p.at(1.0), vectorcraft_doc::PressureProfile::MID, "the old part had no pressure"); } /// Pen pressure belongs to its path: new art, graphic styles and Brush Options' edits leave it. #[test] fn pressure_stays_with_its_own_path() { let mut s = session(); s.execute("appearance.setNewArtBasic", &json!({"on": false})).unwrap(); let r = s.execute("brush.freehand", &json!({"points": [[0, 50, 0.2], [100, 50, 0.9]], "style": "brush", "brush": "3 pt. Round"})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); assert!(pressure_of(&s, id).is_some()); select(&mut s, &[id]); s.execute("graphicStyle.new", &json!({"name": "Inked"})).unwrap(); let style = s.doc().unwrap().doc.graphic_style("Inked").unwrap().appearance.clone(); assert!(style.stroke().is_some_and(|st| st.pressure.is_none())); let other = rect(&mut s, 10.0, 100.0, 50.0, 50.0); assert_eq!(pressure_of(&s, other), None, "new art takes the look, not the pressure"); pressure_round(&mut s); assert!(pressure_of(&s, id).is_some(), "editing the brush keeps the strokes' pressure"); } /// Brush Options sets the variation modes; bad modes are refused, never a panic. #[test] fn brush_options_set_variation_modes() { let mut s = session(); pressure_round(&mut s); let b = s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(); assert_eq!(b["modes"], json!(["fixed", "fixed", "pressure"])); assert_eq!(b["variation"], json!([0.0, 0.0, 3.0])); for bad in [json!({"modes": ["fixed", "fixed", "tilt"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})] { assert!(s.execute("brush.options", &json!({"name": "3 pt. Round", "params": bad})).is_err(), "{bad}"); } assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing"); } /// The bounds of the brush art `id`'s strokes draw. fn brushed_bounds(s: &Session, id: NodeId) -> Rect { let st = s.doc().unwrap(); let pieces = vectorcraft_brush::node_pieces(&st.doc, &node(s, id)).expect("brushed"); vectorcraft_brush::pieces_bounds(&pieces).expect("draws") } /// Scatter, Art and Pattern Brush Options: the params reach the brush and every stroke painted /// with it redraws; values out of range are clamped, unknown choices refused. #[test] fn scatter_art_and_pattern_brush_options_update_strokes() { let mut s = session(); // A rectangle as an art brush (straight edges: no flattening between sizes). let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); let bar = s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap()["name"].as_str().unwrap().to_string(); let id = line(&mut s); select(&mut s, &[id]); let edit = |s: &mut Session, name: &str, params: Value| -> f64 { s.execute("brush.apply", &json!({ "name": name })).unwrap(); let before = brushed_bounds(s, id).height(); s.execute("brush.options", &json!({ "name": name, "params": params })).unwrap(); brushed_bounds(s, id).height() / before }; // Art: Width 300 % draws the art three times as wide across the path. let art = json!({"width": 300, "direction": "rightToLeft", "flip_across": true}); assert!((edit(&mut s, &bar, art) - 3.0).abs() < 1e-6); s.execute("brush.options", &json!({"name": bar, "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap(); let b = s.execute("brush.get", &json!({ "name": bar })).unwrap(); assert_eq!( (&b["direction"], &b["flip_across"], &b["scale"]), (&json!("rightToLeft"), &json!(true), &json!({"mode": "betweenGuides", "start": 0.2, "end": 0.6})) ); // Pattern: Scale 200 % doubles the tiles. assert!((edit(&mut s, "Stitches", json!({"scale": 200, "spacing": 50, "fit": "addSpace", "flip_along": true})) - 2.0).abs() < 0.05); let b = s.execute("brush.get", &json!({"name": "Stitches"})).unwrap(); assert_eq!((&b["spacing"], &b["fit"]), (&json!(50.0), &json!("addSpace"))); // Scatter: a fixed Size of 200 % doubles the dots; the modes and Hue Shift are kept. let key = json!({"model": "rgb", "r": 0.0, "g": 0.0, "b": 0.0}); let scatter = json!({"size": [200, 50], "modes": ["fixed", "fixed", "fixed", "random"], "rotation_relative_to_path": true, "colorization": {"method": "hueShift", "key": key}}); assert!((edit(&mut s, "Dots", scatter) - 2.0).abs() < 0.05); let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap(); assert_eq!( (&b["modes"], &b["colorization"]["method"], &b["colorization"]["key"]), (&json!(["fixed", "fixed", "fixed", "random"]), &json!("hueShift"), &key) ); // Out of range: clamped. Unknown choices: refused, nothing changes. let junk = json!({"size": [1e308, -1e308], "spacing": [0, 1e308], "rotation": [720, -1e9], "scatter": [-1e308, 5]}); s.execute("brush.options", &json!({"name": "Dots", "params": junk})).unwrap(); let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap(); assert_eq!( (&b["size"], &b["spacing"], &b["rotation"], &b["scatter"]), (&json!([10000.0, 1.0]), &json!([1.0, 10000.0]), &json!([180.0, -180.0]), &json!([-1000.0, 5.0])) ); assert!(brushed_bounds(&s, id).is_finite()); for (name, bad) in [ ("Dots", json!({"modes": ["fixed", "tilt", "fixed", "fixed"]})), ("Dots", json!({"modes": ["fixed"]})), ("Dots", json!({"colorization": {"method": "sparkle"}})), ("Arrow", json!({"direction": "diagonal"})), ("Arrow", json!({"width": "wide"})), ("Chain", json!({"fit": "squeeze"})), ] { let before = s.execute("brush.get", &json!({ "name": name })).unwrap(); assert!(s.execute("brush.options", &json!({ "name": name, "params": bad })).is_err(), "{bad}"); assert_eq!(s.execute("brush.get", &json!({ "name": name })).unwrap(), before); } s.execute("brush.options", &json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308}})).unwrap(); let b = s.execute("brush.get", &json!({"name": "Chain"})).unwrap(); assert_eq!((&b["scale"], &b["spacing"]), (&json!(10000.0), &json!(0.0))); } /// Hue Shift given without a key color takes the color the art's fills and strokes use most, in /// New Brush and in Brush Options, and a Brush Options edit that gives only the method keeps the /// brush's key color. #[test] fn hue_shift_without_a_key_takes_the_most_used_art_color() { let mut s = session(); let paint = |s: &mut Session, id: NodeId, fill: &str, stroke: Option<&str>| { s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap(); match stroke { Some(c) => s.execute("paint.setStroke", &json!({"color": c, "ids": [id.0]})).unwrap(), None => s.execute("paint.setStroke", &json!({"none": true, "ids": [id.0]})).unwrap(), }; }; // Blue comes first; red paints two fills and a stroke. let a = rect(&mut s, 10.0, 200.0, 20.0, 8.0); paint(&mut s, a, "#0000ff", None); let b = rect(&mut s, 40.0, 200.0, 20.0, 8.0); paint(&mut s, b, "#ff0000", Some("#ff0000")); let c = rect(&mut s, 70.0, 200.0, 20.0, 8.0); paint(&mut s, c, "#ff0000", None); let color = |r: f32, g: f32, b: f32| serde_json::to_value(vectorcraft_color::Color::rgb(r, g, b)).unwrap(); let (red, green) = (color(1.0, 0.0, 0.0), color(0.0, 1.0, 0.0)); let hue_shift = json!({"colorization": {"method": "hueShift"}}); let r = s.execute("brush.new", &json!({"type": "scatter", "name": "Key", "ids": [a.0, b.0, c.0], "params": hue_shift})).unwrap(); assert_eq!(r["name"], "Key"); let colorization = |s: &mut Session| s.execute("brush.get", &json!({"name": "Key"})).unwrap()["colorization"].clone(); assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": red})); let options = |s: &mut Session, params: Value| s.execute("brush.options", &json!({"name": "Key", "params": params})).unwrap(); // Brush Options switches from Tints to Hue Shift without a key. options(&mut s, json!({"colorization": {"method": "tints"}})); assert_eq!(colorization(&mut s), json!({"method": "tints"})); options(&mut s, hue_shift.clone()); assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": red})); // A key color that was set stays when an edit gives only the method. options(&mut s, json!({"colorization": {"method": "hueShift", "key": green}})); options(&mut s, hue_shift.clone()); assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": green})); // Art with no solid color takes black. let d = rect(&mut s, 100.0, 200.0, 20.0, 8.0); s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [d.0]})).unwrap(); s.execute("paint.setStroke", &json!({"none": true, "ids": [d.0]})).unwrap(); let r = s.execute("brush.new", &json!({"type": "art", "name": "Plain", "ids": [d.0], "params": hue_shift})).unwrap(); assert_eq!(r["name"], "Plain"); let b = s.execute("brush.get", &json!({"name": "Plain"})).unwrap(); assert_eq!(b["colorization"], json!({"method": "hueShift", "key": color(0.0, 0.0, 0.0)})); } /// Brush Options merges an art brush's `scale` field by field: an edit that gives only the start /// guide keeps the end guide. #[test] fn art_brush_options_keep_the_scale_fields_an_edit_leaves_out() { let mut s = session(); let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap(); s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "betweenGuides", "start": 0.3}}})).unwrap(); let b = s.execute("brush.get", &json!({"name": "Bar"})).unwrap(); assert_eq!(b["scale"], json!({"mode": "betweenGuides", "start": 0.3, "end": 0.6})); // A scale that becomes a plain mode drops the guides. s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "stretch"}}})).unwrap(); assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap()["scale"], json!({"mode": "stretch"})); } // ---------- symbols ---------- fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) { let a = rect(s, 100.0, 50.0, 80.0, 40.0); let b = bounds(s, a); let r = s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); (NodeId(r["id"].as_u64().unwrap()), r["name"].as_str().unwrap().to_string(), b) } #[test] fn new_symbol_replaces_selection_with_instance_of_same_bounds() { let mut s = session(); let (inst, name, b) = make_symbol(&mut s); assert_eq!(name, "Box"); let n = node(&s, inst); assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Box")); assert!(close(n.geometric_bounds().unwrap(), b, 1e-9), "{:?} vs {b:?}", n.geometric_bounds()); let d = &s.doc().unwrap().doc; assert_eq!(d.symbols.len(), 1); assert_eq!(d.layers[0].children().unwrap().len(), 1); let l = s.execute("symbol.list", &json!({})).unwrap(); assert_eq!(l["symbols"][0]["instances"], 1); assert_eq!(l["symbols"][0]["size"], json!([80.0, 40.0])); // A second symbol gets a unique name. rect(&mut s, 0.0, 0.0, 10.0, 10.0); assert_eq!(s.execute("symbol.new", &json!({"name": "Box"})).unwrap()["name"], "Box 2"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.symbols.len(), 1); } /// New Symbol given `ids` that list an object and then the group that holds it puts the instance /// where the group was and removes both, as removing one object at a time does. #[test] fn new_symbol_of_an_object_and_the_group_that_holds_it_keeps_order_and_ids() { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); select(&mut s, &[a, b]); let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0); let before = s.doc().unwrap().doc.clone(); let r = s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, group.0]})).unwrap(); let inst = NodeId(r["id"].as_u64().unwrap()); let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::>(); assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [inst, c]); assert_eq!(s.doc().unwrap().selection.objects, [inst]); s.execute("edit.undo", &json!({})).unwrap(); assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document"); } /// New Symbol given `ids` that list an object and a group that holds it (directly, or inside /// another group) makes the symbol of the outer group, whose art holds the object once, as it does /// with those objects selected. #[test] fn new_symbol_of_an_object_and_a_group_that_holds_it_has_the_object_once() { let nested = || { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); select(&mut s, &[a, b]); let inner = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0); select(&mut s, &[inner, c]); let outer = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); s.execute("select.none", &json!({})).unwrap(); (s, [a, inner, outer]) }; for (picks, paths) in [(&[0, 1][..], 2), (&[1, 0, 2], 3)] { let (mut given, ids) = nested(); let list: Vec = picks.iter().map(|i| ids[*i]).collect(); given.execute("symbol.new", &json!({"name": "Pair", "ids": list.iter().map(|i| i.0).collect::>()})).unwrap(); let d = &given.doc().unwrap().doc; let mut n = 0; d.symbols[0].art.walk(&mut |c| n += usize::from(matches!(c.kind, NodeKind::Path { .. }))); assert_eq!(n, paths, "ids {list:?}"); let (mut selected, _) = nested(); select(&mut selected, &list); selected.execute("symbol.new", &json!({"name": "Pair"})).unwrap(); let e = &selected.doc().unwrap().doc; assert!(d.symbols == e.symbols && d.layers == e.layers, "ids {list:?}"); } } #[test] fn place_and_break_link_round_trip_geometry() { let mut s = session(); let (_, name, _) = make_symbol(&mut s); let r = s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 200})).unwrap(); let p = NodeId(r["id"].as_u64().unwrap()); assert!(close(bounds(&s, p), Rect::new(260.0, 180.0, 340.0, 220.0), 1e-9), "{:?}", bounds(&s, p)); let r = s.execute("symbol.breakLink", &json!({"ids": [p.0]})).unwrap(); let art = NodeId(r["ids"][0].as_u64().unwrap()); let n = node(&s, art); assert!(matches!(n.kind, NodeKind::Path { .. })); assert!(close(n.geometric_bounds().unwrap(), Rect::new(260.0, 180.0, 340.0, 220.0), 1e-6)); assert_eq!(n.appearance.stroke_width(), 1.0, "strokes are not scaled by normalisation"); } #[test] fn edit_then_update_redefines_all_instances() { let mut s = session(); let (inst, name, _) = make_symbol(&mut s); let other = NodeId(s.execute("symbol.place", &json!({"x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); select(&mut s, &[inst]); let r = s.execute("symbol.edit", &json!({})).unwrap(); assert_eq!(r["name"], name); let art = NodeId(r["ids"][0].as_u64().unwrap()); // Make the art twice as wide, then redefine. s.execute("object.transform", &json!({"ids": [art.0], "matrix": [2, 0, 0, 1, -140, 0]})).ok(); let w = bounds(&s, art).width(); let r = s.execute("symbol.update", &json!({"name": name})).unwrap(); let new_inst = NodeId(r["id"].as_u64().unwrap()); assert!((bounds(&s, new_inst).width() - w).abs() < 1e-6); assert!((bounds(&s, other).width() - w).abs() < 1e-6, "other instances pick up the new size"); assert!((bounds(&s, other).center().x - 300.0).abs() < 1e-6); } #[test] fn delete_duplicate_and_replace_symbols() { let mut s = session(); let (inst, name, _) = make_symbol(&mut s); let d = s.execute("symbol.duplicate", &json!({"name": name})).unwrap(); assert_eq!(d["name"], "Box copy"); rect(&mut s, 0.0, 0.0, 20.0, 20.0); s.execute("symbol.new", &json!({"name": "Small"})).unwrap(); select(&mut s, &[inst]); s.execute("symbol.replace", &json!({"name": "Small"})).unwrap(); assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Small")); assert!(close(bounds(&s, inst), Rect::new(130.0, 60.0, 150.0, 80.0), 1e-6), "{:?}", bounds(&s, inst)); s.execute("symbol.delete", &json!({"name": "Small"})).unwrap(); let doc = &s.doc().unwrap().doc; assert!(doc.symbols.iter().all(|x| x.name != "Small")); assert!(doc.node(inst).is_none()); let mut plain = 0; doc.walk(|n| plain += matches!(n.kind, NodeKind::Path { .. }) as i32); assert_eq!(plain, 2, "both instances of Small were expanded"); assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err()); } /// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims /// `newName`; the trimmed name is the one compared with the symbols there are. #[test] fn new_and_duplicate_symbols_trim_their_names_as_a_rename_does() { let mut s = session(); rect(&mut s, 0.0, 0.0, 10.0, 10.0); assert_eq!(s.execute("symbol.new", &json!({"name": " Box \t"})).unwrap()["name"], "Box"); rect(&mut s, 20.0, 0.0, 10.0, 10.0); assert_eq!(s.execute("symbol.new", &json!({"name": " Box"})).unwrap()["name"], "Box 2"); rect(&mut s, 40.0, 0.0, 10.0, 10.0); assert_eq!(s.execute("symbol.new", &json!({"name": " "})).unwrap()["name"], "New Symbol"); assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " Logo\t"})).unwrap()["name"], "Logo"); assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " "})).unwrap()["name"], "Box copy"); let names: Vec = s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect(); assert_eq!(names, ["Box", "Box 2", "New Symbol", "Logo", "Box copy"]); } #[test] fn symbol_export_type_defaults_to_movie_clip_and_round_trips() { let mut s = session(); let (_, name, _) = make_symbol(&mut s); let list = s.execute("symbol.list", &json!({})).unwrap(); assert_eq!(list["symbols"][0]["exportType"], "movieClip"); assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).is_none()); s.execute("symbol.options", &json!({"name": name, "exportType": "graphic"})).unwrap(); assert_eq!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["exportType"], "graphic"); assert_eq!(s.doc().unwrap().doc.unknown[vectorcraft_doc::SYMBOL_EXPORT_TYPE][&name], json!("graphic")); let copy = s.execute("symbol.duplicate", &json!({"name": name})).unwrap(); assert_eq!(copy["exportType"], "graphic"); s.execute("symbol.options", &json!({"name": name, "newName": "Renamed", "exportType": "movieClip"})).unwrap(); let list = s.execute("symbol.list", &json!({})).unwrap(); let renamed = list["symbols"].as_array().unwrap().iter().find(|x| x["name"] == "Renamed").unwrap(); assert_eq!(renamed["exportType"], "movieClip"); assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).and_then(|m| m.get("Renamed")).is_none()); assert_eq!(s.doc().unwrap().doc.unknown[vectorcraft_doc::SYMBOL_EXPORT_TYPE]["Box copy"], json!("graphic")); s.execute("symbol.delete", &json!({"name": "Box copy"})).unwrap(); assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_EXPORT_TYPE).is_none()); rect(&mut s, 0.0, 0.0, 10.0, 10.0); let r = s.execute("symbol.new", &json!({"name": "Icon", "exportType": "graphic"})).unwrap(); assert_eq!(r["exportType"], "graphic"); assert!(s.execute("symbol.new", &json!({"exportType": "bogus"})).is_err()); assert!(s.execute("symbol.options", &json!({"name": "Renamed"})).is_err()); } #[test] fn sprayer_command_creates_a_symbol_set_and_alt_removes() { let mut s = session(); make_symbol(&mut s); s.select(|_, sel| sel.clear()).unwrap(); let pts: Vec = (0..20).map(|i| json!([20.0 + i as f64 * 15.0, 150.0])).collect(); let r = s.execute("symbol.spray", &json!({"points": pts, "radius": 10, "density": 8})).unwrap(); let set = NodeId(r["id"].as_u64().unwrap()); let n = r["count"].as_u64().unwrap() as usize; assert!(n >= 3, "{n}"); let g = node(&s, set); assert_eq!(g.name.as_deref(), Some("Symbol Set")); assert_eq!(g.children().unwrap().len(), n); // Spraying again with the set selected adds to it. s.execute("symbol.spray", &json!({"points": [[200, 250]]})).unwrap(); assert_eq!(node(&s, set).children().unwrap().len(), n + 1); let r = s.execute("symbol.spray", &json!({"points": [[200, 250]], "radius": 60, "alt": true})).unwrap(); assert!(r["count"].as_u64().unwrap() >= 1); assert_eq!(node(&s, set).children().unwrap().len(), n + 1 - r["count"].as_u64().unwrap() as usize); } #[test] fn symbolism_adjustments() { let mut s = session(); let (inst, _, b) = make_symbol(&mut s); let c = b.center(); let at = json!([[c.x, c.y]]); s.execute("symbol.adjust", &json!({"tool": "size", "points": at, "radius": 50, "intensity": 10})).unwrap(); assert!(bounds(&s, inst).width() > b.width() + 1.0); s.execute("symbol.adjust", &json!({"tool": "screen", "points": at, "intensity": 10})).unwrap(); assert!(node(&s, inst).opacity < 1.0); s.execute("symbol.adjust", &json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10})).unwrap(); let f = node(&s, inst).appearance.fill().cloned().unwrap(); assert!(f.opacity > 0.0 && f.paint.color().unwrap().to_hex() == "#ff0000"); s.execute("symbol.adjust", &json!({"tool": "shift", "points": [[c.x, c.y], [c.x + 20.0, c.y]], "intensity": 10})).unwrap(); assert!(bounds(&s, inst).center().x > c.x + 5.0); let before = bounds(&s, inst); s.execute("symbol.adjust", &json!({"tool": "spin", "points": at, "intensity": 10})).unwrap(); assert!(!close(bounds(&s, inst), before, 1e-6), "spin rotates"); let far = s.execute("symbol.adjust", &json!({"tool": "size", "points": [[0, 290]], "radius": 5})).unwrap(); assert_eq!(far["count"], 0); assert!(s.execute("symbol.adjust", &json!({"tool": "nope", "points": at})).is_err()); } /// In a CMYK document the Symbol Stainer's RGB `color` separates through the color settings, as /// `paint.setFill` stores it. #[test] fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_settings() { use vectorcraft_color::{Color, Paint, cms}; let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap(); let (inst, _, b) = make_symbol(&mut s); let c = b.center(); s.execute("symbol.adjust", &json!({"tool": "stain", "points": [[c.x, c.y]], "color": "#ff0000", "intensity": 10})).unwrap(); let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent); assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red))); } /// In a CMYK document the Symbol Stainer refuses a `keepModel` given as text or as another value that /// is not true or false, and the instance and the undo history stay as they were. Null is read as /// `keepModel` left out. #[test] fn symbol_stainer_refuses_a_keep_model_given_as_text() { use vectorcraft_color::{Color, Paint, cms}; let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap(); let (inst, _, b) = make_symbol(&mut s); let at = json!([[b.center().x, b.center().y]]); let state = |s: &Session| (node(s, inst), s.doc().unwrap().history.undo.len()); let before = state(&s); for keep in [json!("true"), json!(1), json!("false"), json!([true])] { let p = json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": keep}); let r = s.execute("symbol.adjust", &p); assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{p}: {r:?}"); assert_eq!(state(&s), before, "{p}"); } s.execute("symbol.adjust", &json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": null})).unwrap(); let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent); assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red))); } /// Place Symbol Instance, Delete Symbol, the Symbol Sprayer and the symbolism tools refuse a number /// or an on/off option given as text or as another value of the wrong kind, naming the key; the /// document and the undo history stay as they were. Numbers, true, false and null still work. #[test] fn symbol_commands_refuse_a_number_or_boolean_given_as_text() { use super::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let (_, name, b) = make_symbol(&mut s); let at = json!([[b.center().x, b.center().y]]); let mut calls: Vec<_> = [ ("symbol.place", json!({"name": name, "x": "200"}), "x"), ("symbol.place", json!({"name": name, "x": 200, "y": [100]}), "y"), ("symbol.spray", json!({"name": name, "points": at, "radius": "40"}), "radius"), ("symbol.spray", json!({"name": name, "points": at, "density": "5"}), "density"), ("symbol.spray", json!({"name": name, "points": at, "scale": true}), "scale"), ("symbol.adjust", json!({"tool": "size", "points": at, "radius": "40"}), "radius"), ("symbol.adjust", json!({"tool": "size", "points": at, "intensity": "10"}), "intensity"), ] .into_iter() .map(|(cmd, p, key)| not_a_number(cmd, p, key)) .collect(); calls.extend( [ ("symbol.spray", json!({"name": name, "points": at, "alt": "true"}), "alt"), ("symbol.adjust", json!({"tool": "size", "points": at, "alt": 1}), "alt"), ("symbol.delete", json!({"name": name, "expandInstances": "false"}), "expandInstances"), ] .map(|(cmd, p, key)| not_a_switch(cmd, p, key)), ); let wrong = not_refused(&mut s, &calls); assert!(wrong.is_empty(), "not refused: {wrong:#?}"); s.execute("symbol.place", &json!({"name": name, "x": 200, "y": null})).unwrap(); s.execute("symbol.spray", &json!({"name": name, "points": at, "radius": 40, "alt": false})).unwrap(); s.execute("symbol.delete", &json!({"name": name, "expandInstances": true})).unwrap(); assert!(s.doc().unwrap().doc.symbols.is_empty()); } #[test] fn symbol_sprayer_tool_gesture_is_one_undo_step() { let mut s = session(); make_symbol(&mut s); s.select(|_, sel| sel.clear()).unwrap(); let n = s.doc().unwrap().history.undo.len(); let v = ViewInfo::default(); s.select_tool("symbolSprayer", v).unwrap(); let mut evs = vec![PointerEvent::new(PointerKind::Down, 50.0, 200.0)]; for i in 1..15 { evs.push(PointerEvent::new(PointerKind::Drag, 50.0 + i as f64 * 20.0, 200.0)); } evs.push(PointerEvent::new(PointerKind::Up, 350.0, 200.0).with_mods(Mods::default())); for e in evs { s.pointer(&e, v).unwrap(); } assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); let set = s.doc().unwrap().selection.objects[0]; assert!(node(&s, set).children().unwrap().len() >= 3); // The sizer tool grows the sprayed instances. let first = node(&s, set).children().unwrap()[0].clone(); let c = first.geometric_bounds().unwrap().center(); s.select_tool("symbolSizer", v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap(); let after = s.doc().unwrap().doc.node(first.id).unwrap().geometric_bounds().unwrap(); assert!(after.width() > first.geometric_bounds().unwrap().width()); } // ---------- rendering & persistence ---------- #[test] fn brushed_strokes_and_instances_render() { let mut s = session(); make_symbol(&mut s); let l = line(&mut s); s.execute("brush.apply", &json!({"name": "Chain", "ids": [l.0]})).unwrap(); let doc = s.doc().unwrap().doc.clone(); let img = vectorcraft_render::Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true); let dark = |x: u32, y: u32| { let p = img.pixel(x, y); (p[0] as u32 + p[1] as u32 + p[2] as u32) < 300 }; // The instance's outline (the rectangle's black stroke) is drawn. assert!((45..=55).any(|y| dark(100, y)), "instance stroke rendered"); // Chain links are thicker than the 1 pt stroke, with gaps. let hits = (50..250).filter(|x| (95..=105).any(|y| dark(*x, y))).count(); assert!(hits > 100, "chain covers most of the line: {hits}"); } #[test] fn brushes_and_symbols_survive_vectorcraft_round_trip() { let mut s = session(); make_symbol(&mut s); s.execute("brush.new", &json!({"type": "calligraphic", "name": "Mine", "params": {"size": 7}})).unwrap(); let l = line(&mut s); s.execute("brush.apply", &json!({"name": "Mine", "ids": [l.0]})).unwrap(); let doc = s.doc().unwrap().doc.clone(); let bytes = vectorcraft_format::save(&doc, false); let back = vectorcraft_format::load(&bytes).unwrap(); assert_eq!(vectorcraft_brush::library(&back), vectorcraft_brush::library(&doc)); assert_eq!(vectorcraft_brush::find(&back, "Mine").map(|b| b.kind.type_id()), Some("calligraphic")); assert_eq!(back.symbols, doc.symbols); assert_eq!(back.unknown.get("symbolSizes"), doc.unknown.get("symbolSizes")); assert_eq!(back.unknown.get(vectorcraft_doc::SYMBOL_REGISTRATION), doc.unknown.get(vectorcraft_doc::SYMBOL_REGISTRATION)); let bl = back.node(l).unwrap(); assert_eq!(bl.appearance.stroke().unwrap().brush.as_deref(), Some("Mine")); // A reloaded document places instances at the natural size. let mut s2 = Session::new(); s2.add_document(back, None); let r = s2.execute("symbol.place", &json!({"name": "Box", "x": 100, "y": 100})).unwrap(); let b = s2.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).unwrap().geometric_bounds().unwrap(); assert!(close(b, Rect::new(60.0, 80.0, 140.0, 120.0), 1e-9)); } /// Symbol Options › Registration: place puts the chosen 9-point grid point on (x, y). #[test] fn symbol_registration_places_the_chosen_point_on_xy() { let mut s = session(); rect(&mut s, 0.0, 0.0, 80.0, 40.0); let r = s.execute("symbol.new", &json!({"name": "Corner", "registration": 0})).unwrap(); assert_eq!(r["registration"], 0); assert_eq!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["registration"], 0); // Creating kept the art where it was (top-left still at the origin of the selection). let inst = NodeId(r["id"].as_u64().unwrap()); assert!(close(bounds(&s, inst), Rect::new(0.0, 0.0, 80.0, 40.0), 1e-9), "{:?}", bounds(&s, inst)); let p = NodeId(s.execute("symbol.place", &json!({"name": "Corner", "x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); assert!(close(bounds(&s, p), Rect::new(300.0, 200.0, 380.0, 240.0), 1e-9), "{:?}", bounds(&s, p)); assert!(s.execute("symbol.new", &json!({"registration": 9})).is_err()); } /// Symbol Options › 9-slice guides: off by default, stored per symbol, follow rename/duplicate, validated. #[test] fn symbol_nine_slice_guides_round_trip() { let mut s = session(); let (_, name, _) = make_symbol(&mut s); assert!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["nineSlice"].is_null()); assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none()); assert!(s.execute("symbol.options", &json!({"name": name, "nineSliceGuides": [0.6, 0.2, 0.6, 0.2]})).is_err()); assert!(s.execute("symbol.options", &json!({"name": name, "nineSlice": "yes"})).is_err()); let r = s.execute("symbol.options", &json!({"name": name, "nineSlice": true})).unwrap(); assert_eq!(r["nineSlice"].as_array().unwrap().len(), 4); s.execute("symbol.options", &json!({"name": name, "nineSliceGuides": [0.25, 0.1, 0.25, 0.1]})).unwrap(); let copy = s.execute("symbol.duplicate", &json!({"name": name})).unwrap(); assert_eq!(copy["nineSlice"], json!([0.25, 0.1, 0.25, 0.1])); s.execute("symbol.options", &json!({"name": name, "newName": "Panel"})).unwrap(); let g = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap(); assert_eq!((g.left, g.top), (0.25, 0.1)); assert!(s.doc().unwrap().doc.symbol_nine_slice(&name).is_none()); s.execute("symbol.options", &json!({"name": "Panel", "nineSlice": false})).unwrap(); s.execute("symbol.delete", &json!({"name": "Box copy"})).unwrap(); assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none()); }