Files
vectorcraft/crates/engine/src/tests_live.rs
T
dotCooCoo 88bd4812e0 Part of #1429: path, Pathfinder and live-object commands add and remove many objects in one pass
Offset Path, Outline Stroke, the Pathfinder panel, Clean Up, Make
Blend, Rasterize and the other commands that #1429's table marks 3,
except Split Into Grid, added, removed or replaced their objects one at
a time, and so did Remove Anchor Points, Redefine Symbol and the
removal of Shaper groups whose source art is gone. Each insertion or
removal moved the later objects of the layer or group. The lookups by
id that followed walked the tree, and every fourth walk rebuilt the
lookup cache. On 25,000 rectangles a release build of vectorcraft-cli
took 29.1 s for Offset Path.

These commands now make their edits with Document::wrap,
Document::insert_beside and Document::remove_many, in one pass over the
children of each layer or group, and Outline Stroke and Expand write
each new object into the place of the object it replaces. A list that
holds an object twice, or an object and another inside it, and that was
edited without an error before, is still edited one object at a time.
The documents, selections and undo steps are the same. On the same
rectangles Offset Path now takes 1.3 s.

Docs: docs/target-app-parity.md.
2026-10-13 05:21:31 +07:00

735 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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", &params).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
}
// ---------- 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);
}
#[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]);
}
#[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");
}
#[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);
}