mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
object.transformEach refused reflectX, reflectY, random and copy given
as text, but read strokes, corners, objects and patterns given as text
as the key left out. With Scale Strokes & Effects on,
{"scaleH": 200, "scaleV": 200, "strokes": "false"} scaled the stroke,
recorded an undo step and kept the params for Transform Again, which
repeated the call. artboard.rearrange, artboard.move and
artboard.duplicate read their patterns option the same way, and
artboard.setProps reads its patterns, strokes and corners options the
same way.
These commands now refuse such a value with "`<key>` must be true or
false" before they change anything. So do the other options and
numbers of artboard.setProps and artboard.move, the art of Duplicate,
Copy and Cut Artboard, the shape commands and path.create, path.close,
path.setHandle, path.moveAnchors, the Pencil, Curvature, Reshape
Segment and Blob Brush options, text.create, text.createInPath,
text.setRangeStyle, text.editRange, text.getRange, text.touchType,
text.areaOptions, type.step, the font size steps,
type.opticalMarginAlignment, type.systemLayout, type.fractionalWidths,
type.ruby, the symbol and swatch commands, the Appearance panel's
commands, the gradient and freeform gradient commands, Shape Builder,
Live Paint, Image Trace, the opacity of object.setProps, the radius,
sides and relativeRadii of object.setLiveShape, the reference point of
object.setBounds and the artboard of Align, Distribute and Distribute
Spacing. They refuse a number given as text with "`<key>` must be a
number, not <value>", and a gradient stop's tint with "stop <n>:
`tint` must be a number". A null still reads as the key left out.
Docs: docs/mcp.md, docs/target-app-parity.md.
1222 lines
67 KiB
Rust
1222 lines
67 KiB
Rust
//! 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<String> {
|
||
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 "<place>:
|
||
/// <brush>" ("-" for a stroke without one).
|
||
fn brushes_outside_the_layers(s: &Session, pattern: &str) -> Vec<String> {
|
||
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<String> { 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<String> {
|
||
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<String> = 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<String> {
|
||
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<String> {
|
||
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<vectorcraft_doc::PressureProfile> {
|
||
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::<Vec<_>>();
|
||
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<NodeId> = picks.iter().map(|i| ids[*i]).collect();
|
||
given.execute("symbol.new", &json!({"name": "Pair", "ids": list.iter().map(|i| i.0).collect::<Vec<_>>()})).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<String> = 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<Value> = (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());
|
||
}
|