//! 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::>()})).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 { 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 { 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 = 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()); } #[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: EnvelopeKind::Warp { bend, style, .. }, .. } => { 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, outline: false, path_only: false }); 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("= 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); }