//! What envelopes distort: the envelope's own frame under transforms, images (a raster mesh warp), //! symbols, linear gradients, pattern fills, strokes and effects (Distort Appearance). use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; use vectorcraft_doc::live::EnvelopeOptions; use vectorcraft_doc::{AppearanceItem, Node, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_render::effects; 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 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(); } /// A 200×100 rectangle at (100, 100), filled red, no stroke. fn rect(s: &mut Session) -> NodeId { let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()); s.edit("red", |d, _| { d.node_mut(a).unwrap().appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); Ok(()) }) .unwrap(); a } /// Envelope the selection with an arch warp (`bend` %) and `options` → the envelope. fn arch(s: &mut Session, bend: f64, options: Value) -> NodeId { let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": bend})).unwrap()); s.execute("object.envelope.options", &options).unwrap(); e } /// The envelope evaluated as the canvas and the exporters do. fn evaluated(s: &Session, e: NodeId) -> Vec { effects::expand_live(Some(&s.doc().unwrap().doc), &node(s, e)) } fn anchors(nodes: &[Node]) -> Vec { let mut v = vec![]; for n in nodes { n.walk(&mut |c| v.extend(c.path_data().into_iter().flat_map(|p| p.anchors().map(|(_, _, a)| a.p)))); } v } fn frame(s: &Session, e: NodeId) -> Affine { match node(s, e).kind { NodeKind::Envelope { frame, .. } => *frame, k => panic!("{k:?}"), } } /// Regression: rotating a warp envelope rotated its content and re-warped it along the page axes. #[test] fn a_rotated_warp_envelope_turns_with_its_warp() { let mut s = session(); let a = rect(&mut s); sel(&mut s, &[a]); let e = arch(&mut s, 50.0, json!({})); let before = anchors(&evaluated(&s, e)); let c = Point::new(200.0, 150.0); s.execute("object.rotate", &json!({"angle": 90, "origin": [c.x, c.y]})).unwrap(); let after = anchors(&evaluated(&s, e)); let turn = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c); assert_eq!(before.len(), after.len()); for (p, q) in before.iter().zip(&after) { assert!((turn * *p).distance(*q) < 1e-6, "{:?} vs {q:?}", turn * *p); } assert_ne!(frame(&s, e), Affine::IDENTITY); // Turning back squares the frame with the page again; a plain move or scale never tilts it. s.execute("object.rotate", &json!({"angle": -90, "origin": [c.x, c.y]})).unwrap(); assert_eq!(frame(&s, e), Affine::IDENTITY); s.execute("object.scale", &json!({"sx": 150, "sy": 50})).unwrap(); assert_eq!(frame(&s, e), Affine::IDENTITY); // A reflection keeps a frame: the arch flips with the art. s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); let flipped = anchors(&evaluated(&s, e)); let b = node(&s, e).geometric_bounds().unwrap(); assert!(flipped.iter().any(|p| p.y > b.y1 - 1.0) && frame(&s, e) != Affine::IDENTITY); } #[test] fn the_frame_round_trips_natively() { let mut s = session(); let a = rect(&mut s); sel(&mut s, &[a]); let e = arch(&mut s, 50.0, json!({})); s.execute("object.rotate", &json!({"angle": 30})).unwrap(); let d = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); assert_eq!(back.node(e), d.node(e)); let mut old = serde_json::to_value(node(&s, e)).unwrap(); old["kind"].as_object_mut().unwrap().remove("frame"); let n: Node = serde_json::from_value(old).unwrap(); assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Box::new(Affine::IDENTITY)), "older envelopes stand square to the page"); } /// A selected embedded 40×40 pt red image at (100, 100). fn red_image(s: &mut Session) -> NodeId { let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])); 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 = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, 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 images_bend_as_a_raster_mesh_warp() { let mut s = session(); let im = red_image(&mut s); sel(&mut s, &[im]); let e = arch(&mut s, 80.0, json!({})); let out = evaluated(&s, e); let mut pieces = 0; out[0].walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize); assert!(pieces >= 32, "{pieces} pieces"); // The arch lifts the image's middle above its top edge, not its corners. let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); let b = node(&s, e).geometric_bounds().unwrap(); assert!(b.y0 < 95.0, "{b:?}"); let mid = img.pixel(120, 97); assert!(mid[0] > 200 && mid[1] < 60, "the middle rose: {mid:?}"); assert!(img.pixel(101, 95)[1] > 200, "the corner stayed: {:?}", img.pixel(101, 95)); // The pieces export as clipped images, bent in the SVG too. let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string(); let svg = String::from_utf8(vectorcraft_format::base64_decode(&svg).unwrap()).unwrap(); assert!(svg.matches("= 32 && svg.contains("clip"), "{}", &svg[..svg.len().min(400)]); assert_eq!(svg.matches(" 200); } fn strokes(nodes: &[Node]) -> usize { let mut n = 0; for c in nodes { c.walk(&mut |c| n += c.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count()); } n } #[test] fn distort_appearance_bends_strokes_and_effects_with_the_art() { let mut s = session(); let a = rect(&mut s); s.execute("paint.setStroke", &json!({"ids": [a.0], "color": "#000000"})).unwrap(); s.execute("stroke.set", &json!({"ids": [a.0], "weight": 8})).unwrap(); sel(&mut s, &[a]); let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); let out = evaluated(&s, e); assert_eq!(strokes(&out), 0, "the stroke became its bent outline"); // Off: the stroke paints along the bent path at its own weight. s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap(); assert_eq!(strokes(&evaluated(&s, e)), 1); // Geometry effects bake into the art first too. s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); s.execute("effect.apply", &json!({"effect": "distort.zigZag", "params": {"size": 5, "ridges": 10}})).unwrap(); s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); s.execute("object.envelope.options", &json!({"distortAppearance": true})).unwrap(); let out = evaluated(&s, e); let mut effects_left = 0; out[0].walk(&mut |c| effects_left += c.appearance.effects.len()); assert_eq!(effects_left, 0); assert!(anchors(&out).len() > anchors(&[node(&s, a)]).len()); } fn gradient_geom(nodes: &[Node]) -> Option { let mut g = None; for n in nodes { n.walk(&mut |c| { if let Paint::Gradient(gp) = c.appearance.fill_paint() { g = g.or(gp.geom); } }); } g } #[test] fn linear_gradients_bend_when_asked() { let mut s = session(); let a = rect(&mut s); s.edit("gradient", |d, _| { d.node_mut(a).unwrap().appearance.set_fill(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); Ok(()) }) .unwrap(); sel(&mut s, &[a]); let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); assert!(gradient_geom(&evaluated(&s, e)).is_none(), "off: the gradient fits the bent shape"); s.execute("object.envelope.options", &json!({"distortLinearGradients": true})).unwrap(); let g = gradient_geom(&evaluated(&s, e)).expect("on: the gradient is placed and follows the warp"); // Arch lifts the middle: the vector (left to right through the middle) moved up. assert!(g.start.midpoint(g.end).y < 150.0 - 1.0, "{g:?}"); // It needs Distort Appearance. s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap(); assert!(gradient_geom(&evaluated(&s, e)).is_none()); } #[test] fn pattern_fills_bend_when_asked() { let mut s = session(); let a = rect(&mut s); s.edit("pattern", |d, _| { let dot = Node::path( NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); let mut def = PatternDef::new("Dots", vec![Arc::new(dot)]); def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); d.patterns.push(def); d.node_mut(a).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Dots")); Ok(()) }) .unwrap(); sel(&mut s, &[a]); let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); let clip_groups = |out: &[Node]| { let mut n = 0; out[0].walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize); n }; assert_eq!(clip_groups(&evaluated(&s, e)), 0, "off: the pattern fills the bent shape unbent"); s.execute("object.envelope.options", &json!({"distortPatternFills": true})).unwrap(); let out = evaluated(&s, e); assert_eq!(clip_groups(&out), 1); assert!(anchors(&out).len() > 50 * 4, "the tiles are art"); assert!(!matches!(out[0].appearance.fill_paint(), Paint::Pattern { .. })); } #[test] fn new_envelopes_distort_appearance_and_old_ones_keep_their_look() { let mut s = session(); let a = rect(&mut s); sel(&mut s, &[a]); let e = arch(&mut s, 50.0, json!({})); assert!(matches!(node(&s, e).kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::NEW)); }