mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
The blend, envelope and gradient mesh commands read their optional
numbers with f64_or or as_f64, which fall back to a default for a value
that is not a JSON number. object.blend.make {"steps": "8"} made a
Smooth Color blend and reported success. Blend Options did the same
with steps, distance and value. Make with Warp, Reset with Warp and
Envelope Options did the same with bend, h and v (and their long
names). Make with Mesh, Reset with Mesh and Create Gradient Mesh did
the same with rows, cols and columns. Envelope Options did the same
with fidelity, Create Gradient Mesh did the same with highlight, and
Set Mesh Point Color did the same with opacity.
These commands now fail with "`<key>` must be a number, not <value>"
for a value that is not a number or null, as the transform commands
do, and change nothing. Envelope Options refuses the warp keys whether
or not a warp envelope is selected. Create Gradient Mesh refuses an
`at` that is not exactly two numbers with "`at` must be [x, y]".
Before, it took the first two entries of a longer array, and for any
other value it made a mesh with no lines through the point.
Docs: docs/mcp.md, docs/target-app-parity.md.
1034 lines
43 KiB
Rust
1034 lines
43 KiB
Rust
//! Live blends, envelopes and gradient meshes, driven through `Session::execute`.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind};
|
||
use vectorcraft_doc::{Node, NodeKind};
|
||
use vectorcraft_geom::{Point, Rect};
|
||
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn id_of(v: &Value) -> NodeId {
|
||
NodeId(v["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||
}
|
||
|
||
fn fill(s: &mut Session, id: NodeId, hex: &str) {
|
||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap();
|
||
}
|
||
|
||
fn close(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 1e-3
|
||
}
|
||
|
||
fn undo_len(s: &Session) -> usize {
|
||
s.doc().unwrap().history.undo.len()
|
||
}
|
||
|
||
/// Two 10×10 rects (black at x=0, white at x=100), blended with `params`.
|
||
fn blend(s: &mut Session, params: Value) -> (NodeId, NodeId, NodeId) {
|
||
let a = rect(s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(s, 100.0, 0.0, 10.0, 10.0);
|
||
fill(s, a, "#000000");
|
||
fill(s, b, "#ffffff");
|
||
sel(s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", ¶ms).unwrap());
|
||
(a, b, g)
|
||
}
|
||
|
||
fn steps_of(s: &Session, id: NodeId) -> Vec<Node> {
|
||
live::expand_live(&node(s, id))
|
||
}
|
||
|
||
fn render(s: &Session, region: Rect) -> vectorcraft_render::Rendered {
|
||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 1.0, true)
|
||
}
|
||
|
||
fn all_anchors(nodes: &[Node]) -> Vec<Point> {
|
||
let mut v = vec![];
|
||
for n in nodes {
|
||
n.walk(&mut |c| {
|
||
if let Some(p) = c.path_data() {
|
||
v.extend(p.anchors().map(|(_, _, a)| a.p));
|
||
}
|
||
});
|
||
}
|
||
v
|
||
}
|
||
|
||
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
|
||
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
|
||
let r = s.execute(cmd, p);
|
||
let want = format!("`{key}` must be a number, not {}", p[key]);
|
||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
|
||
}
|
||
|
||
// ---------- Blend ----------
|
||
|
||
#[test]
|
||
fn blend_make_is_live_with_keys_as_children() {
|
||
let mut s = session();
|
||
let (a, b, g) = blend(&mut s, json!({"steps": 3}));
|
||
let n = node(&s, g);
|
||
assert!(matches!(n.kind, NodeKind::Blend { .. }));
|
||
let ch: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
|
||
assert_eq!(ch, vec![a, b]);
|
||
assert_eq!(n.kind_label(), "Blend");
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_specified_steps_count() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
|
||
assert_eq!(steps_of(&s, g).len(), 5);
|
||
}
|
||
|
||
/// Specified Steps that are not a whole number are an error, in Make and in Blend Options, and
|
||
/// nothing changes; 4.0 is a whole number of steps.
|
||
#[test]
|
||
fn blend_steps_that_are_not_a_whole_number_are_refused() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
|
||
let (a, b) = (rect(&mut s, 0.0, 50.0, 10.0, 10.0), rect(&mut s, 100.0, 50.0, 10.0, 10.0));
|
||
let n = undo_len(&s);
|
||
for p in [json!({"ids": [a.0, b.0], "steps": 2.5}), json!({"ids": [a.0, b.0], "spacing": "steps", "value": 2.5})] {
|
||
let key = if p.get("value").is_some() { "value" } else { "steps" };
|
||
let r = s.execute("object.blend.make", &p);
|
||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{p}: {r:?}");
|
||
}
|
||
sel(&mut s, &[g]);
|
||
for p in [json!({"steps": 2.5}), json!({"spacing": "steps", "value": 1.5})] {
|
||
assert!(s.execute("object.blend.options", &p).is_err(), "{p}");
|
||
}
|
||
assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "nothing changed");
|
||
s.execute("object.blend.options", &json!({"steps": 4.0})).unwrap();
|
||
assert_eq!(steps_of(&s, g).len(), 6);
|
||
}
|
||
|
||
/// Make refuses Specified Steps or Distance given as text or as another value that is not a number,
|
||
/// and makes no blend; `steps: 8` as a number makes 8 steps.
|
||
#[test]
|
||
fn blend_make_refuses_a_number_given_as_text() {
|
||
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));
|
||
sel(&mut s, &[a, b]);
|
||
let n = undo_len(&s);
|
||
for (key, p) in [
|
||
("steps", json!({"steps": "8"})),
|
||
("distance", json!({"distance": "10"})),
|
||
("value", json!({"spacing": "steps", "value": "3"})),
|
||
("value", json!({"spacing": "distance", "value": [5]})),
|
||
("distance", json!({"spacing": "distance", "distance": true})),
|
||
] {
|
||
refuses_non_number(&mut s, "object.blend.make", &p, key);
|
||
}
|
||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not blended");
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 8})).unwrap());
|
||
assert_eq!(steps_of(&s, g).len(), 10);
|
||
}
|
||
|
||
/// Blend Options refuses Specified Steps or Distance given as text, for the selected blends and for
|
||
/// the options new blends start with, and changes neither.
|
||
#[test]
|
||
fn blend_options_refuse_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
|
||
let n = undo_len(&s);
|
||
let calls = [
|
||
("steps", json!({"steps": "4"})),
|
||
("value", json!({"spacing": "steps", "value": "4"})),
|
||
("distance", json!({"distance": "20"})),
|
||
("distance", json!({"spacing": "distance", "value": null, "distance": "20"})),
|
||
];
|
||
for (key, p) in &calls {
|
||
refuses_non_number(&mut s, "object.blend.options", p, key);
|
||
}
|
||
assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options");
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
let defaults = s.prefs.blend_options;
|
||
for (key, p) in &calls {
|
||
refuses_non_number(&mut s, "object.blend.options", p, key);
|
||
}
|
||
assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_mid_step_interpolates_geometry_and_colour() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 30.0, 30.0);
|
||
fill(&mut s, a, "#000000");
|
||
fill(&mut s, b, "#ffffff");
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
|
||
let st = steps_of(&s, g);
|
||
let mb = st[2].geometric_bounds().unwrap();
|
||
assert!(close(mb.x0, 50.0) && close(mb.width(), 20.0), "{mb:?}");
|
||
let c = st[2].appearance.fill_paint().color().unwrap().to_rgb();
|
||
assert!((c[0] - 0.5).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_smooth_colour_steps_from_colour_distance() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({}));
|
||
let n = steps_of(&s, g).len() - 2;
|
||
assert_eq!(n, 128, "black→white: one step per two levels");
|
||
// Consecutive steps differ by at most ~2 levels.
|
||
let st = steps_of(&s, g);
|
||
for w in st.windows(2) {
|
||
let x = w[0].appearance.fill_paint().color().unwrap().to_rgb()[0];
|
||
let y = w[1].appearance.fill_paint().color().unwrap().to_rgb()[0];
|
||
assert!((x - y).abs() <= 2.5 / 255.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn blend_smooth_colour_same_colours_uses_distance() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"smooth": true})).unwrap());
|
||
assert_eq!(steps_of(&s, g).len() - 2, 20);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_specified_distance() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"distance": 10}));
|
||
assert_eq!(steps_of(&s, g).len() - 2, 9);
|
||
let st = steps_of(&s, g);
|
||
let d = st[1].geometric_bounds().unwrap().x0 - st[0].geometric_bounds().unwrap().x0;
|
||
assert!(close(d, 10.0), "{d}");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_interpolates_opacity_and_stroke_weight() {
|
||
let mut s = session();
|
||
let (a, b, g) = blend(&mut s, json!({"steps": 1}));
|
||
s.edit("t", |d, _| {
|
||
d.node_mut(a).unwrap().opacity = 0.2;
|
||
d.node_mut(b).unwrap().appearance.stroke_mut().unwrap().width = 9.0;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
// Keys live inside the blend now; edit them there.
|
||
let _ = g;
|
||
let st = steps_of(&s, g);
|
||
assert!((st[1].opacity - 0.6).abs() < 1e-5);
|
||
assert!(close(st[1].appearance.stroke_width(), 5.0));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_resamples_different_anchor_counts() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||
let b = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 25, "radius1": 25, "radius2": 10, "points": 5})).unwrap());
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
|
||
let st = steps_of(&s, g);
|
||
let n = st[1].path_data().unwrap().subpaths[0].anchors.len();
|
||
assert_eq!(n, 10, "rect resampled to the star's 10 anchors");
|
||
let b1 = st[1].geometric_bounds().unwrap();
|
||
assert!(b1.x0 > 50.0 && b1.x1 < 325.0, "{b1:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_three_keys() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||
let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b, c]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap());
|
||
let st = steps_of(&s, g);
|
||
assert_eq!(st.len(), 3 + 2 * 4);
|
||
assert_eq!(st[5].id, b);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_options_change_spacing_and_orientation() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
|
||
s.execute("object.blend.options", &json!({"spacing": "steps", "value": 1, "orientation": "path"})).unwrap();
|
||
assert_eq!(steps_of(&s, g).len(), 3);
|
||
match &node(&s, g).kind {
|
||
NodeKind::Blend { spec, .. } => {
|
||
assert_eq!(spec.spacing, BlendSpacing::Steps(1));
|
||
assert_eq!(spec.orientation, BlendOrientation::AlignToPath);
|
||
}
|
||
_ => panic!(),
|
||
}
|
||
assert!(s.execute("object.blend.options", &json!({"steps": 0})).is_err());
|
||
assert!(s.execute("object.blend.options", &json!({"spacing": "bogus"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn blend_release_restores_keys() {
|
||
let mut s = session();
|
||
let (a, b, g) = blend(&mut s, json!({"steps": 3}));
|
||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.node(g).is_none() && d.node(a).is_some() && d.node(b).is_some());
|
||
assert!(s.execute("object.blend.release", &json!({})).is_ok() || s.execute("object.blend.release", &json!({})).is_err());
|
||
}
|
||
|
||
/// Release Blend of a blend and of a blend inside a group that is a key object of the first puts
|
||
/// the outer blend's spine and keys where it was, then the inner blend's spine and keys where it
|
||
/// was inside that group, as releasing one blend at a time does.
|
||
#[test]
|
||
fn blend_release_of_a_blend_and_a_blend_inside_it_keeps_order_and_ids() {
|
||
let mut s = session();
|
||
let (r1, r2) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||
sel(&mut s, &[r1, r2]);
|
||
let inner = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||
let r3 = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[inner, r3]);
|
||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||
let r4 = rect(&mut s, 400.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[group, r4]);
|
||
let outer = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||
sel(&mut s, &[outer, inner]);
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let n = before.peek_next_id();
|
||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||
assert_eq!(r["ids"], json!([group.0, r4.0, r1.0, r2.0]));
|
||
assert_eq!(r["spines"], json!([n, n + 1]));
|
||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [NodeId(n), group, r4]);
|
||
assert_eq!(ids(&node(&s, group)), [NodeId(n + 1), r1, r2, r3]);
|
||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(n), NodeId(n + 1), group, r4, r1, r2]);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_expand_makes_group_with_unique_ids() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
|
||
s.execute("object.blend.expand", &json!({})).unwrap();
|
||
let n = node(&s, g);
|
||
assert!(matches!(n.kind, NodeKind::Group { .. }));
|
||
assert_eq!(n.children().unwrap().len(), 5);
|
||
let mut ids = vec![];
|
||
s.doc().unwrap().doc.walk(|n| ids.push(n.id));
|
||
let len = ids.len();
|
||
ids.sort();
|
||
ids.dedup();
|
||
assert_eq!(ids.len(), len);
|
||
assert!(!ids.contains(&NodeId(0)));
|
||
assert!(s.execute("object.blend.release", &json!({})).is_err(), "no longer a blend");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_make_undo_is_one_step() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a, b]);
|
||
let n = undo_len(&s);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.node(g).is_none());
|
||
assert_eq!(d.parent_of(a), d.parent_of(b));
|
||
assert!(d.node(a).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn blend_replace_spine_follows_path() {
|
||
let mut s = session();
|
||
let (_, _, g) = blend(&mut s, json!({"steps": 5}));
|
||
// Half circle of radius 100 around (200, 300).
|
||
let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap());
|
||
sel(&mut s, &[g, arc]);
|
||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||
assert!(s.doc().unwrap().doc.node(arc).is_none(), "the path is consumed");
|
||
let st = steps_of(&s, g);
|
||
assert_eq!(st.len(), 7);
|
||
for n in &st {
|
||
let c = n.geometric_bounds().unwrap().center();
|
||
let r = c.distance(Point::new(200.0, 300.0));
|
||
assert!((r - 100.0).abs() < 1.5, "{c:?} r={r}");
|
||
}
|
||
let first = st[0].geometric_bounds().unwrap().center();
|
||
assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn blend_align_to_path_rotates_steps() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 40.0, 4.0);
|
||
let b = rect(&mut s, 100.0, 0.0, 40.0, 4.0);
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 1, "orientation": "path"})).unwrap());
|
||
let spine = id_of(&s.execute("path.create", &json!({"d": "M300 100 L300 300"})).unwrap());
|
||
sel(&mut s, &[g, spine]);
|
||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||
let st = steps_of(&s, g);
|
||
let b1 = st[1].geometric_bounds().unwrap();
|
||
assert!(b1.height() > b1.width(), "vertical spine turns the bars upright: {b1:?}");
|
||
s.execute("object.blend.options", &json!({"orientation": "page"})).unwrap();
|
||
let b1 = steps_of(&s, g)[1].geometric_bounds().unwrap();
|
||
assert!(b1.width() > b1.height());
|
||
}
|
||
|
||
#[test]
|
||
fn blend_reverse_spine_and_front_to_back() {
|
||
let mut s = session();
|
||
let (a, _, g) = blend(&mut s, json!({"steps": 2}));
|
||
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
|
||
assert_eq!(node(&s, g).children().unwrap().last().unwrap().id, a);
|
||
s.execute("object.blend.reverseSpine", &json!({})).unwrap();
|
||
assert!(close(node(&s, a).geometric_bounds().unwrap().x0, 100.0));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_renders_intermediate_steps() {
|
||
let mut s = session();
|
||
let (_, _, _) = blend(&mut s, json!({"steps": 9}));
|
||
let img = render(&s, Rect::new(0.0, 0.0, 120.0, 20.0));
|
||
let p = img.pixel(55, 5);
|
||
assert!(p[0] < 200 && p[0] > 60, "a grey step is drawn: {p:?}");
|
||
}
|
||
|
||
// ---------- Envelope ----------
|
||
|
||
fn text_rect(s: &mut Session) -> NodeId {
|
||
let a = rect(s, 100.0, 100.0, 200.0, 100.0);
|
||
fill(s, a, "#ff0000");
|
||
a
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_warp_make_and_content_stays_inside_bounds() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
|
||
let n = node(&s, e);
|
||
assert!(matches!(n.kind, NodeKind::Envelope { .. }));
|
||
assert_eq!(n.children().unwrap()[0].id, a);
|
||
let b = n.geometric_bounds().unwrap().inflate(0.5, 0.5);
|
||
let out = live::expand_live(&n);
|
||
let pts = all_anchors(&out);
|
||
assert!(pts.len() > 4, "curves are subdivided");
|
||
assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}");
|
||
// It really is bent: the sides moved down.
|
||
assert!(pts.iter().any(|p| p.y > 201.0));
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_warp_zero_bend_is_identity() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 0})).unwrap());
|
||
for p in all_anchors(&live::expand_live(&node(&s, e))) {
|
||
assert!(p.x >= 99.99 && p.x <= 300.01 && p.y >= 99.99 && p.y <= 200.01, "{p:?}");
|
||
}
|
||
assert!(s.execute("object.envelope.makeWithWarp", &json!({"style": "nope"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_mesh_identity_then_point_moves_content() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
let inner = rect(&mut s, 190.0, 140.0, 20.0, 20.0);
|
||
sel(&mut s, &[a, inner]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
|
||
let b0 = Rect::from_points(Point::new(100.0, 100.0), Point::new(300.0, 200.0));
|
||
for p in all_anchors(&live::expand_live(&node(&s, e))) {
|
||
assert!(b0.inflate(1e-6, 1e-6).contains(p), "{p:?}");
|
||
}
|
||
// Centre point of the 3×3 grid is index 4 (200, 150); pull it up by 40.
|
||
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 200, "y": 110})).unwrap();
|
||
let out = live::expand_live(&node(&s, e));
|
||
let pts = all_anchors(&out);
|
||
// Corners stay; content near the centre moves up.
|
||
assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6));
|
||
let inner_top = all_anchors(&out[1..]).iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
|
||
assert!(inner_top < 125.0, "{inner_top}");
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_top_object_maps_corners() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
// A trapezoid on top.
|
||
let t = id_of(&s.execute("path.create", &json!({"d": "M400 100 L500 100 L550 250 L350 250 Z"})).unwrap());
|
||
sel(&mut s, &[a, t]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap());
|
||
assert!(s.doc().unwrap().doc.node(t).is_none(), "top object consumed");
|
||
let pts = all_anchors(&live::expand_live(&node(&s, e)));
|
||
for c in [Point::new(400.0, 100.0), Point::new(500.0, 100.0), Point::new(550.0, 250.0), Point::new(350.0, 250.0)] {
|
||
assert!(pts.iter().any(|p| p.distance(c) < 0.5), "corner {c:?} missing");
|
||
}
|
||
let b = node(&s, e).geometric_bounds().unwrap();
|
||
assert!(close(b.x0, 350.0) && close(b.y1, 250.0));
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_release_and_expand() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 40})).unwrap());
|
||
let r = s.execute("object.envelope.release", &json!({})).unwrap();
|
||
assert_eq!(r["ids"][0].as_u64(), Some(a.0));
|
||
assert!(s.doc().unwrap().doc.node(e).is_none());
|
||
assert_eq!(node(&s, a).geometric_bounds(), Some(Rect::new(100.0, 100.0, 300.0, 200.0)));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
sel(&mut s, &[e]);
|
||
s.execute("object.envelope.expand", &json!({})).unwrap();
|
||
let g = node(&s, e);
|
||
assert!(matches!(g.kind, NodeKind::Group { .. }));
|
||
// (Distort Appearance, on for new envelopes, expands the stroke too: the fill comes first.)
|
||
let mut fill = None;
|
||
g.walk(&mut |c| fill = fill.or_else(|| c.path_data().map(|p| p.anchor_count())));
|
||
assert!(fill.unwrap() > 4);
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_options_and_edit_contents() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
|
||
s.execute("object.envelope.options", &json!({"fidelity": 90, "bend": -30})).unwrap();
|
||
match &node(&s, e).kind {
|
||
NodeKind::Envelope { fidelity, kind, .. } => {
|
||
let EnvelopeKind::Warp { bend, style, .. } = &**kind else { panic!("{kind:?}") };
|
||
assert!(close(*fidelity, 90.0) && close(*bend, -30.0) && style == "arc");
|
||
}
|
||
k => panic!("{k:?}"),
|
||
}
|
||
assert!(s.execute("object.envelope.options", &json!({"fidelity": 101})).is_err());
|
||
let r = s.execute("object.envelope.editContents", &json!({})).unwrap();
|
||
assert_eq!(r["editing"], true);
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||
let r = s.execute("object.envelope.editContents", &json!({})).unwrap();
|
||
assert_eq!(r["editing"], false);
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![e]);
|
||
}
|
||
|
||
/// The warp's `bend`, and `h` and `v` by those keys or by their long names, given as text.
|
||
fn warp_numbers_as_text() -> [(&'static str, Value); 5] {
|
||
[
|
||
("bend", json!({"bend": "40"})),
|
||
("h", json!({"h": "10"})),
|
||
("v", json!({"v": "-10"})),
|
||
("horizontalDistortion", json!({"horizontalDistortion": "10"})),
|
||
("verticalDistortion", json!({"verticalDistortion": "10%"})),
|
||
]
|
||
}
|
||
|
||
/// Make with Warp refuses a bend or distortion given as text, and makes no envelope.
|
||
#[test]
|
||
fn envelope_make_with_warp_refuses_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let n = undo_len(&s);
|
||
for (key, p) in warp_numbers_as_text() {
|
||
refuses_non_number(&mut s, "object.envelope.makeWithWarp", &p, key);
|
||
}
|
||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
|
||
}
|
||
|
||
/// Reset with Warp refuses a bend or distortion given as text, and the envelope keeps its warp.
|
||
#[test]
|
||
fn envelope_reset_with_warp_refuses_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
|
||
let (before, n) = (node(&s, e), undo_len(&s));
|
||
for (key, p) in warp_numbers_as_text() {
|
||
refuses_non_number(&mut s, "object.envelope.resetWithWarp", &p, key);
|
||
}
|
||
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
|
||
}
|
||
|
||
/// Make with Mesh refuses rows or columns given as text, and makes no envelope.
|
||
#[test]
|
||
fn envelope_make_with_mesh_refuses_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let n = undo_len(&s);
|
||
for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"rows": 2, "columns": "3"}))] {
|
||
refuses_non_number(&mut s, "object.envelope.makeWithMesh", &p, key);
|
||
}
|
||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
|
||
}
|
||
|
||
/// Reset with Mesh refuses rows or columns given as text, and the envelope keeps its mesh.
|
||
#[test]
|
||
fn envelope_reset_with_mesh_refuses_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
|
||
let (before, n) = (node(&s, e), undo_len(&s));
|
||
for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"columns": "3", "maintainShape": false}))] {
|
||
refuses_non_number(&mut s, "object.envelope.resetWithMesh", &p, key);
|
||
}
|
||
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
|
||
}
|
||
|
||
/// Envelope Options refuses a fidelity, bend or distortion given as text, for the selected
|
||
/// envelope and for the options new envelopes get, and changes neither.
|
||
#[test]
|
||
fn envelope_options_refuse_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
|
||
let (before, n) = (node(&s, e), undo_len(&s));
|
||
let mut calls = vec![("fidelity", json!({"fidelity": "80", "antiAlias": false}))];
|
||
calls.extend(warp_numbers_as_text());
|
||
for (key, p) in &calls {
|
||
refuses_non_number(&mut s, "object.envelope.options", p, key);
|
||
}
|
||
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
for (key, p) in &calls {
|
||
refuses_non_number(&mut s, "object.envelope.options", p, key);
|
||
}
|
||
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_renders_distorted() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 60})).unwrap();
|
||
let img = render(&s, Rect::new(0.0, 0.0, 400.0, 300.0));
|
||
// Arch lifts the middle: red above the original top edge at the centre, not at the sides.
|
||
let mid = img.pixel(200, 85);
|
||
assert!(mid[0] > 200 && mid[1] < 60, "{mid:?}");
|
||
let side = img.pixel(102, 85);
|
||
assert!(side[1] > 200, "{side:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_transform_moves_mesh_points() {
|
||
let mut s = session();
|
||
let a = text_rect(&mut s);
|
||
sel(&mut s, &[a]);
|
||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap());
|
||
s.edit("move", |d, _| {
|
||
d.node_mut(e).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 20.0)), false);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let b = node(&s, e).geometric_bounds().unwrap();
|
||
assert_eq!(b, Rect::new(110.0, 120.0, 310.0, 220.0));
|
||
let pts = all_anchors(&live::expand_live(&node(&s, e)));
|
||
assert!(pts.iter().any(|p| p.distance(Point::new(110.0, 120.0)) < 1e-6));
|
||
}
|
||
|
||
// ---------- Gradient mesh ----------
|
||
|
||
fn mesh_of(s: &Session, id: NodeId) -> vectorcraft_doc::GradientMesh {
|
||
match &node(s, id).kind {
|
||
NodeKind::Mesh(m) => m.clone(),
|
||
k => panic!("{k:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_create_flat() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
fill(&mut s, a, "#0000ff");
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 3, "cols": 2})).unwrap();
|
||
let m = mesh_of(&s, a);
|
||
assert_eq!((m.rows, m.cols, m.points.len()), (3, 2, 12));
|
||
assert!(m.points.iter().all(|p| p.color.to_hex() == "#0000ff"));
|
||
assert_eq!(node(&s, a).geometric_bounds().map(|b| (b.x0.round(), b.x1.round())), Some((0.0, 100.0)));
|
||
assert_eq!(node(&s, a).kind_label(), "Mesh");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_to_center_highlight() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
fill(&mut s, a, "#ff0000");
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100})).unwrap();
|
||
let m = mesh_of(&s, a);
|
||
assert_eq!(m.points[4].color.to_hex(), "#ffffff");
|
||
assert_eq!(m.points[0].color.to_hex(), "#ff0000");
|
||
assert!(close(m.points[4].p.x, 50.0) && close(m.points[4].p.y, 50.0));
|
||
// Colour inside patch (0,0) at its far corner = the centre.
|
||
let (c, _) = m.color_at(0, 0, 1.0, 1.0);
|
||
assert_eq!(c.to_hex(), "#ffffff");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_renders_corner_and_centre_colours() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
fill(&mut s, a, "#ff0000");
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100})).unwrap();
|
||
let img = render(&s, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||
let c = img.pixel(50, 50);
|
||
assert!(c[0] > 240 && c[1] > 220 && c[2] > 220, "centre near white {c:?}");
|
||
let k = img.pixel(2, 2);
|
||
assert!(k[0] > 240 && k[1] < 40, "corner red {k:?}");
|
||
let e = img.pixel(25, 25);
|
||
assert!(e[1] > 40 && e[1] < 220, "in between {e:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_point_colour_move_and_undo() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 3, "color": "#00ff00", "opacity": 0.5})).unwrap();
|
||
let m = mesh_of(&s, a);
|
||
assert_eq!(m.points[3].color.to_hex(), "#00ff00");
|
||
assert!((m.points[3].opacity - 0.5).abs() < 1e-6);
|
||
s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 3, "x": 120, "y": 130})).unwrap();
|
||
assert_eq!(mesh_of(&s, a).points[3].p, Point::new(120.0, 130.0));
|
||
s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 3, "x": 125, "y": 130, "handle": 1})).unwrap();
|
||
assert_eq!(mesh_of(&s, a).points[3].handles[1], vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(mesh_of(&s, a).points[3].p, Point::new(100.0, 100.0));
|
||
assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_add_line_and_delete_point() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
let r = s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 40, "y": 70, "color": "#123456"})).unwrap();
|
||
let i = r["index"].as_u64().unwrap() as usize;
|
||
let m = mesh_of(&s, a);
|
||
assert_eq!((m.rows, m.cols), (2, 2));
|
||
assert!(m.points[i].p.distance(Point::new(40.0, 70.0)) < 0.5);
|
||
assert_eq!(m.points[i].color.to_hex(), "#123456");
|
||
assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 400, "y": 70})).is_err());
|
||
s.execute("object.mesh.deletePoint", &json!({"id": a.0, "index": i})).unwrap();
|
||
let m = mesh_of(&s, a);
|
||
assert_eq!((m.rows, m.cols), (1, 1));
|
||
assert!(s.execute("object.mesh.deletePoint", &json!({"id": a.0, "index": 0})).is_err(), "corner lines can't go");
|
||
}
|
||
|
||
/// A mesh point `index`, and `rows`, `cols` or `columns` for Create Gradient Mesh, that is not a
|
||
/// whole number is an error that changes nothing; 4.0 names point 4.
|
||
#[test]
|
||
fn mesh_commands_refuse_an_index_or_count_that_is_not_a_whole_number() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||
let (before, n) = (mesh_of(&s, a), undo_len(&s));
|
||
sel(&mut s, &[b]);
|
||
for (key, p) in [("rows", json!({"rows": 2.5})), ("cols", json!({"cols": 1.5})), ("columns", json!({"columns": 3.25}))] {
|
||
let r = s.execute("object.mesh.create", &p);
|
||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{p}: {r:?}");
|
||
}
|
||
assert!(matches!(node(&s, b).kind, NodeKind::Path { .. }), "still a path");
|
||
for (cmd, p) in [
|
||
("object.mesh.setPointColor", json!({"id": a.0, "index": 4.5, "color": "#00ff00"})),
|
||
("object.mesh.movePoint", json!({"id": a.0, "index": 4.5, "x": 10, "y": 10})),
|
||
("object.mesh.movePoint", json!({"id": a.0, "index": 3.5, "x": 10, "y": 10, "handle": 0})),
|
||
("object.mesh.deletePoint", json!({"id": a.0, "index": 4.5})),
|
||
] {
|
||
let r = s.execute(cmd, &p);
|
||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`index` must be a whole number"), "{cmd} {p}: {r:?}");
|
||
}
|
||
assert_eq!((mesh_of(&s, a), undo_len(&s)), (before, n), "nothing changed");
|
||
s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 4.0, "x": 40, "y": 60})).unwrap();
|
||
assert_eq!(mesh_of(&s, a).points[4].p, Point::new(40.0, 60.0));
|
||
}
|
||
|
||
/// Create Gradient Mesh refuses rows, columns or a highlight given as text, and an `at` that is not
|
||
/// two numbers, and makes no mesh; `at: [50, 40]` with a null highlight makes one.
|
||
#[test]
|
||
fn mesh_create_refuses_a_number_given_as_text() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
let n = undo_len(&s);
|
||
for (key, p) in [
|
||
("rows", json!({"rows": "3"})),
|
||
("cols", json!({"cols": "2"})),
|
||
("columns", json!({"columns": "2"})),
|
||
("highlight", json!({"appearance": "center", "highlight": "50"})),
|
||
] {
|
||
refuses_non_number(&mut s, "object.mesh.create", &p, key);
|
||
}
|
||
for at in [json!(["50", "40"]), json!([50]), json!(50), json!({"x": 50, "y": 40})] {
|
||
let r = s.execute("object.mesh.create", &json!({ "at": at }));
|
||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "{at}: {r:?}");
|
||
}
|
||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "still a path");
|
||
s.execute("object.mesh.create", &json!({"at": [50, 40], "highlight": null})).unwrap();
|
||
assert_eq!((mesh_of(&s, a).rows, mesh_of(&s, a).cols), (2, 2));
|
||
}
|
||
|
||
/// Set Mesh Point Color refuses an opacity given as text, and the point keeps its color and opacity.
|
||
#[test]
|
||
fn mesh_set_point_color_refuses_an_opacity_given_as_text() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
let (before, n) = (mesh_of(&s, a), undo_len(&s));
|
||
for p in [json!({"id": a.0, "index": 3, "color": "#00ff00", "opacity": "0.5"}), json!({"id": a.0, "index": 3, "opacity": "50%"})] {
|
||
refuses_non_number(&mut s, "object.mesh.setPointColor", &p, "opacity");
|
||
}
|
||
assert_eq!((mesh_of(&s, a), undo_len(&s)), (before, n), "nothing changed");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_create_at_point_single_undo() {
|
||
let mut s = session();
|
||
let a = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
|
||
sel(&mut s, &[a]);
|
||
let n = undo_len(&s);
|
||
let r = s.execute("object.mesh.create", &json!({"at": [50, 40]})).unwrap();
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
let m = mesh_of(&s, a);
|
||
assert_eq!((m.rows, m.cols), (2, 2));
|
||
let i = r["index"].as_u64().unwrap() as usize;
|
||
assert!(m.points[i].p.distance(Point::new(50.0, 40.0)) < 0.5);
|
||
// Mesh boundary follows the ellipse.
|
||
let b = node(&s, a).geometric_bounds().unwrap();
|
||
assert!(b.x0 > -3.0 && b.x1 < 103.0, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_expand_to_flat_pieces() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap();
|
||
s.execute("object.mesh.expand", &json!({})).unwrap();
|
||
let g = node(&s, a);
|
||
assert!(matches!(g.kind, NodeKind::Group { .. }));
|
||
assert_eq!(g.children().unwrap().len(), 2 * 64);
|
||
assert!(g.children().unwrap().iter().all(|c| c.id != NodeId(0)));
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_hit_test_inside() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
let h = vectorcraft_doc::hit::hit_test(d, Point::new(50.0, 30.0), vectorcraft_doc::hit::HitOptions { tol: 1.0, ..Default::default() });
|
||
assert_eq!(h.map(|h| h.leaf), Some(a));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_of_meshes_interpolates_mesh_points() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||
let b = rect(&mut s, 200.0, 0.0, 50.0, 50.0);
|
||
fill(&mut s, a, "#000000");
|
||
fill(&mut s, b, "#ffffff");
|
||
sel(&mut s, &[a, b]);
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
sel(&mut s, &[a, b]);
|
||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 1})).unwrap());
|
||
let st = steps_of(&s, g);
|
||
match &st[1].kind {
|
||
NodeKind::Mesh(m) => {
|
||
assert!(close(m.points[0].p.x, 100.0));
|
||
assert!((m.points[0].color.to_rgb()[0] - 0.5).abs() < 1e-3);
|
||
}
|
||
k => panic!("{k:?}"),
|
||
}
|
||
let img = render(&s, Rect::new(0.0, 0.0, 250.0, 50.0));
|
||
assert!(img.pixel(125, 25)[3] == 255);
|
||
}
|
||
|
||
// ---------- Persistence and export ----------
|
||
|
||
fn live_doc() -> Session {
|
||
let mut s = session();
|
||
let (_, _, _) = blend(&mut s, json!({"steps": 2}));
|
||
let a = rect(&mut s, 100.0, 300.0, 100.0, 50.0);
|
||
sel(&mut s, &[a]);
|
||
s.execute("object.envelope.makeWithWarp", &json!({"style": "wave", "bend": 30})).unwrap();
|
||
let m = rect(&mut s, 400.0, 300.0, 100.0, 100.0);
|
||
sel(&mut s, &[m]);
|
||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2, "appearance": "edge", "highlight": 50})).unwrap();
|
||
s
|
||
}
|
||
|
||
#[test]
|
||
fn live_objects_round_trip_through_native_format() {
|
||
let s = live_doc();
|
||
let d = &s.doc().unwrap().doc;
|
||
let bytes = vectorcraft_format::save(d, false);
|
||
let back = vectorcraft_format::load(&bytes).unwrap();
|
||
assert_eq!(&back.layers, &d.layers);
|
||
let mut kinds = vec![];
|
||
back.walk(|n| kinds.push(n.kind_label()));
|
||
for k in ["Blend", "Envelope", "Mesh"] {
|
||
assert!(kinds.contains(&k), "{k}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn svg_export_expands_live_objects() {
|
||
let s = live_doc();
|
||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||
// 4 blend objects + envelope content + 4 patches × 64 mesh pieces.
|
||
let paths = svg.matches("<path").count();
|
||
assert!(paths >= 4 + 1 + 256, "{paths}");
|
||
assert!(vectorcraft_svg::import(&svg).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_expands_live_objects() {
|
||
let s = live_doc();
|
||
let pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &Default::default()).unwrap();
|
||
assert!(pdf.starts_with(b"%PDF"));
|
||
}
|
||
|
||
#[test]
|
||
fn old_documents_still_load() {
|
||
let s = session();
|
||
let d = &s.doc().unwrap().doc;
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, true)).unwrap();
|
||
assert_eq!(back.layers.len(), d.layers.len());
|
||
}
|
||
|
||
#[test]
|
||
fn live_commands_have_menus_and_params() {
|
||
for id in [
|
||
"object.blend.make",
|
||
"object.blend.replaceSpine",
|
||
"object.envelope.makeWithWarp",
|
||
"object.envelope.makeWithMesh",
|
||
"object.envelope.makeWithTopObject",
|
||
"object.envelope.release",
|
||
"object.envelope.expand",
|
||
"object.envelope.editContents",
|
||
"object.envelope.options",
|
||
"object.mesh.create",
|
||
] {
|
||
let c = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
|
||
}
|
||
for id in ["object.mesh.setPointColor", "object.mesh.movePoint", "object.mesh.addLine", "object.mesh.expand"] {
|
||
assert!(crate::cmd::find_command(id).is_some(), "{id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_without_live_selection() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||
sel(&mut s, &[a]);
|
||
assert!(s.execute("object.blend.release", &json!({})).is_err());
|
||
assert!(s.execute("object.envelope.release", &json!({})).is_err());
|
||
assert!(s.execute("object.mesh.expand", &json!({})).is_err());
|
||
assert!(s.execute("object.blend.make", &json!({})).is_err(), "needs two objects");
|
||
let _ = Paint::solid(Color::BLACK);
|
||
}
|
||
|
||
/// A selected embedded 8×4 image at (100, 50), 2 pt per pixel: red on the left half, blue on the
|
||
/// right half (which is transparent when `clear_right`). `link` makes it a linked image.
|
||
fn two_colour_image(s: &mut Session, clear_right: bool, linked: bool) -> NodeId {
|
||
let right = image::Rgba([0, 0, 255, if clear_right { 0 } else { 255 }]);
|
||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([255, 0, 0, 255]) } else { right });
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
s.edit("Place", |d, sel| {
|
||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
|
||
let id = d.alloc_id();
|
||
let xf = vectorcraft_geom::Affine::translate((100.0, 50.0)) * vectorcraft_geom::Affine::scale(2.0);
|
||
let link = linked.then(|| vectorcraft_doc::LinkInfo { path: "/tmp/pic.png".into(), ..Default::default() });
|
||
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default() };
|
||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||
sel.set([id]);
|
||
Ok(id)
|
||
})
|
||
.unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_create_on_an_embedded_image_takes_its_colours_and_placement() {
|
||
let mut s = session();
|
||
let id = two_colour_image(&mut s, false, false);
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||
let m = mesh_of(&s, id);
|
||
assert_eq!((m.rows, m.cols, m.points.len()), (1, 2, 6));
|
||
// Row 0 is the image's top: red at the left edge, the average of both halves in the middle
|
||
// (an area, not one pixel), blue at the right edge.
|
||
let hex = |i: usize| m.points[i].color.to_hex();
|
||
assert_eq!((hex(0).as_str(), hex(1).as_str(), hex(2).as_str()), ("#ff0000", "#800080", "#0000ff"));
|
||
assert_eq!((hex(3).as_str(), hex(5).as_str()), ("#ff0000", "#0000ff"));
|
||
assert!(m.points.iter().all(|p| p.opacity == 1.0));
|
||
// The mesh keeps the image's transform: (100, 50) to (116, 58).
|
||
let near = |p: Point, x: f64, y: f64| close(p.x, x) && close(p.y, y);
|
||
assert!(near(m.points[0].p, 100.0, 50.0) && near(m.points[2].p, 116.0, 50.0) && near(m.points[5].p, 116.0, 58.0));
|
||
assert_eq!(node(&s, id).kind_label(), "Mesh");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(node(&s, id).kind_label(), "Image");
|
||
}
|
||
|
||
#[test]
|
||
fn mesh_create_on_an_image_keeps_alpha_and_refuses_linked_and_big_grids() {
|
||
let mut s = session();
|
||
let id = two_colour_image(&mut s, true, false);
|
||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
let m = mesh_of(&s, id);
|
||
assert_eq!(m.points.iter().map(|p| p.opacity).collect::<Vec<_>>(), [1.0, 0.0, 1.0, 0.0]);
|
||
assert_eq!(m.points[0].color.to_hex(), "#ff0000");
|
||
|
||
let mut s = session();
|
||
two_colour_image(&mut s, false, false);
|
||
assert!(s.execute("object.mesh.create", &json!({"rows": 51, "cols": 4})).is_err(), "grid capped");
|
||
|
||
let mut s = session();
|
||
let id = two_colour_image(&mut s, false, true);
|
||
let e = s.execute("object.mesh.create", &json!({})).unwrap_err().to_string();
|
||
assert!(e.contains("linked"), "{e}");
|
||
assert_eq!(node(&s, id).kind_label(), "Image", "left as it was");
|
||
}
|