Files
vectorcraft/crates/engine/src/tests_envelope_distort.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

299 lines
12 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.
//! 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::<Vec<_>>()})).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<Node> {
effects::expand_live(Some(&s.doc().unwrap().doc), &node(s, e))
}
fn anchors(nodes: &[Node]) -> Vec<Point> {
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("<use").count() >= 32 && svg.contains("clip"), "{}", &svg[..svg.len().min(400)]);
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
}
#[test]
fn symbols_bend_with_the_envelope() {
let mut s = session();
let inst = s
.edit("symbol", |d, sel| {
// Symbol art is normalised to ±10 (its instance's transform sizes it).
let art = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let id = d.alloc_id();
let xf = Affine::translate((200.0, 150.0)) * Affine::scale_non_uniform(10.0, 5.0);
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf }))?;
sel.set([id]);
Ok(id)
})
.unwrap();
sel(&mut s, &[inst]);
let e = arch(&mut s, 60.0, json!({}));
let pts = anchors(&evaluated(&s, e));
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
let mut r = vectorcraft_render::Renderer::new();
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
s.edit("edit symbol", |d, _| {
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
d.symbols[0].art = Arc::new(art);
Ok(())
})
.unwrap();
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 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<vectorcraft_color::GradientGeom> {
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));
}