Files
vectorcraft/crates/engine/src/tests/envelope_distort.rs
T
dotCooCoo ab69d8f6d9 Envelopes: Anti-Alias and Preserve Shape Using change how distorted images draw
Envelope Options stored Anti-Alias and Preserve Shape Using, but nothing that
draws an image read them: a distorted image was always smoothed and always
cut into clip groups.

- With Anti-Alias off, each piece of a distorted image is marked pixelated
  (saved as `pixelated`). The renderer draws it with nearest-neighbor
  sampling and without mips, SVG export writes `image-rendering: pixelated`,
  and PDF export writes it without /Interpolate.
- With Preserve Shape Using Transparency, each piece is the image with a
  white opacity mask in the piece's shape instead of a clip group. The
  renderer paints an image whose mask is one plain white path straight into
  that path, as it does for an image in a clip group, when the path lies
  inside the image's frame.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-29 11:07:57 +07:00

408 lines
17 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 crate::*;
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(), pixels: None };
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");
}
/// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one.
fn two_color_image(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 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("two".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(40.0);
let im = vectorcraft_doc::ImageObject { key: "two".into(), width: 2, height: 2, xf, link: None, placement: Default::default(), pixels: None };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
/// The pixels well inside the image's frame (the clip edges left out) that are a mix of its red
/// and blue rather than one of them.
fn mixed_pixels(s: &Session) -> usize {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(110.0, 110.0, 170.0, 170.0), 2.0, true);
let mut mixed = 0;
for y in 0..img.height {
for x in 0..img.width {
let p = img.pixel(x, y);
mixed += (p[0] > 40 && p[2] > 40) as usize;
}
}
mixed
}
#[test]
fn anti_alias_off_draws_distorted_images_without_smoothing() {
let mut s = session();
let im = two_color_image(&mut s);
sel(&mut s, &[im]);
let e = arch(&mut s, 2.0, json!({}));
let smooth = mixed_pixels(&s);
assert!(smooth > 400, "with Anti-Alias on the colors blend where the pixels meet: {smooth}");
s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap();
let mut pieces = 0;
for n in evaluated(&s, e) {
n.walk(&mut |c| pieces += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize);
}
assert!(pieces >= 32, "{pieces} pixelated pieces");
let hard = mixed_pixels(&s);
assert!(hard * 50 < smooth, "with Anti-Alias off each pixel is red or blue: {hard} mixed of {smooth}");
// The SVG asks viewers not to smooth the pieces either.
let svg = |s: &mut Session| {
let b64 = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
String::from_utf8(vectorcraft_format::base64_decode(&b64).unwrap()).unwrap()
};
assert!(svg(&mut s).matches("image-rendering").count() >= 32);
s.execute("object.envelope.options", &json!({"antiAlias": true})).unwrap();
assert!(!svg(&mut s).contains("image-rendering"));
s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap();
// Expanded, the pieces keep it through a native save.
s.execute("object.envelope.expand", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
let mut kept = 0;
back.walk(|c| kept += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize);
assert_eq!(kept, pieces);
}
fn clip_groups_in(nodes: &[Node]) -> usize {
let mut n = 0;
for c in nodes {
c.walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize);
}
n
}
#[test]
fn transparency_masks_distorted_images_instead_of_clipping_them() {
let mut s = session();
let im = red_image(&mut s);
sel(&mut s, &[im]);
let e = arch(&mut s, 80.0, json!({}));
let region = Rect::new(80.0, 60.0, 160.0, 160.0);
let clipped = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true);
assert!(clip_groups_in(&evaluated(&s, e)) >= 32);
s.execute("object.envelope.options", &json!({"preserveShape": "transparency"})).unwrap();
let out = evaluated(&s, e);
assert_eq!(clip_groups_in(&out), 0, "no clipping masks");
let (mut images, mut masked) = (0, 0);
for n in &out {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Image(_)) {
images += 1;
masked += c.extras().mask.as_ref().is_some_and(|m| m.clip && !m.invert) as usize;
}
});
}
assert!(images >= 32 && masked == images, "{masked} of {images} pieces masked");
// It draws as the clipping masks did.
let alpha = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true);
let (mut sum, mut n) = (0u64, 0u64);
for y in 0..clipped.height {
for x in 0..clipped.width {
let (a, b) = (clipped.pixel(x, y), alpha.pixel(x, y));
sum += (0..4).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs() as u64).sum::<u64>();
n += 4;
}
}
assert!(sum < n, "mean difference {} per channel", sum as f64 / n as f64);
assert!(alpha.pixel(80, 74)[0] > 200 && alpha.pixel(80, 74)[1] < 60, "the bent image shows: {:?}", alpha.pixel(80, 74));
}
#[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));
}