mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
the_reply_names_each_expanded_copy_by_its_own_id checked that no id in the effect.expandAppearance reply repeats and that each one names a node in the document. Only for group copies did it check which ids the reply holds. In the other four cases a reply that left out the expanded object, or held no ids at all, passed. The test now checks in every case that the reply starts with the id of the selected object, and keeps the exact check of the group and the two copies of the square for group copies.
334 lines
16 KiB
Rust
334 lines
16 KiB
Rust
//! Object → Expand Appearance: fills and strokes as objects, baked geometry effects, raster
|
||
//! effects as images, brushes as art, and the enable rule.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::BlendMode;
|
||
use vectorcraft_doc::{Node, NodeKind};
|
||
|
||
use super::tests_menucmds::{place_undecodable_png, warns_undecoded};
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
/// A selected 100×100 square at (50, 50) with a red fill and a 4 pt blue stroke.
|
||
fn square(s: &mut Session) -> NodeId {
|
||
let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||
run(s, "stroke.set", json!({"weight": 4}));
|
||
id
|
||
}
|
||
|
||
fn render(s: &Session) -> vectorcraft_render::Rendered {
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
|
||
}
|
||
|
||
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
|
||
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
|
||
a.pixels
|
||
.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.zip(b.pixels.as_chunks::<4>().0)
|
||
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
|
||
.count()
|
||
}
|
||
|
||
fn expandable(s: &Session) -> bool {
|
||
s.commands().iter().find(|c| c.id == "effect.expandAppearance").unwrap().enabled
|
||
}
|
||
|
||
#[test]
|
||
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
assert!(!expandable(&s), "a basic appearance has nothing to expand");
|
||
// A second fill on top (half-transparent green) and an outset stroke.
|
||
run(&mut s, "appearance.addFill", json!({}));
|
||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50}));
|
||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1}));
|
||
assert!(expandable(&s));
|
||
let before = render(&s);
|
||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||
assert_eq!(r["ids"], json!([id.0]));
|
||
let n = node(&s, id);
|
||
let ch = n.children().expect("a group of the fills and the stroke");
|
||
assert_eq!(ch.len(), 3);
|
||
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
|
||
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
|
||
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
|
||
let ring = ch[1].geometric_bounds().unwrap();
|
||
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
|
||
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
|
||
assert!(ch[2].appearance.is_basic());
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||
assert!(!expandable(&s));
|
||
// One undo step.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(matches!(node(&s, id).kind, NodeKind::Path { .. }));
|
||
assert_eq!(node(&s, id).appearance.items.len(), 3);
|
||
}
|
||
|
||
/// The objects [`random_transforms`] makes.
|
||
pub(super) const RANDOM_CASES: [&str; 5] = ["copies", "group copies", "nested group copies", "crop marks", "group crop marks"];
|
||
|
||
/// Selected art with Random in Transform effects, from 50 pt squares at y 60 with an 8 pt black
|
||
/// stroke, as `case` says. "copies": a square at x 20 filled with a gradient, a random Transform
|
||
/// effect on its stroke and a Transform effect that copies it 80 pt right. "group copies": the
|
||
/// same square without the second effect, in a group that has it. "nested group copies": the same
|
||
/// square in a group with a random Transform effect, in a group with the second effect. "crop
|
||
/// marks": a red square at x 20 with a random Transform effect and crop marks. "group crop marks":
|
||
/// red squares at x 20 and x 110 in a group with a random Transform effect and crop marks. Returns
|
||
/// the object's id.
|
||
pub(super) fn random_transforms(s: &mut Session, case: &str) -> NodeId {
|
||
let random = json!({"moveH": 15, "moveV": 15, "rotate": 40, "random": true});
|
||
let square = |s: &mut Session, x: f64, fill: Value| {
|
||
let id = run(s, "shape.rectangle", json!({"x": x, "y": 60, "width": 50, "height": 50}))["id"].as_u64().unwrap();
|
||
run(s, "paint.setFill", fill);
|
||
run(s, "paint.setStroke", json!({"color": "#000000"}));
|
||
run(s, "stroke.set", json!({"weight": 8}));
|
||
id
|
||
};
|
||
let red = json!({"color": "#ff0000"});
|
||
let id = match case {
|
||
"copies" | "group copies" | "nested group copies" => {
|
||
let a = square(s, 20.0, json!({"gradient": {"kind": "linear"}}));
|
||
run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": 1}));
|
||
if case == "nested group copies" {
|
||
run(s, "object.group", json!({}));
|
||
run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null}));
|
||
}
|
||
if case == "copies" { a } else { run(s, "object.group", json!({}))["id"].as_u64().unwrap() }
|
||
}
|
||
"crop marks" => square(s, 20.0, red),
|
||
_ => {
|
||
let a = square(s, 20.0, red.clone());
|
||
let b = square(s, 110.0, red);
|
||
run(s, "select.set", json!({"ids": [a, b]}));
|
||
run(s, "object.group", json!({}))["id"].as_u64().unwrap()
|
||
}
|
||
};
|
||
if case.ends_with("copies") {
|
||
run(s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 80, "copies": 1}, "item": null}));
|
||
} else {
|
||
run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null}));
|
||
run(s, "effect.apply", json!({"effect": "cropMarks", "item": null}));
|
||
}
|
||
NodeId(id)
|
||
}
|
||
|
||
/// Random in a Transform effect takes its seed from the object's id. The expanded art draws as the
|
||
/// canvas drew each of [`random_transforms`]' objects.
|
||
#[test]
|
||
fn expanded_art_keeps_the_random_transforms_the_canvas_drew() {
|
||
let mut differ = vec![];
|
||
for case in RANDOM_CASES {
|
||
let mut s = session();
|
||
let id = random_transforms(&mut s, case);
|
||
let before = render(&s);
|
||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||
differ.push((case, differing(&before, &render(&s), 64)));
|
||
let mut art = node(&s, id);
|
||
// Crop marks: the reply names the group of the object and its marks, then the object.
|
||
if case.ends_with("crop marks") {
|
||
art = (*art.children().unwrap()[0]).clone();
|
||
assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}");
|
||
}
|
||
// No object keeps an effect, and in the copies cases no object keeps a stroke: every copy
|
||
// is expanded.
|
||
art.walk(&mut |n| {
|
||
let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none());
|
||
assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}");
|
||
});
|
||
}
|
||
assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0)));
|
||
}
|
||
|
||
/// The reply lists each object whose appearance expanded once, by the id it has in the document,
|
||
/// starting with the selected object. In "group copies" the two copies of the square share an id
|
||
/// until Expand Appearance gives them ids of their own, and the reply names the group, then each
|
||
/// copy by its new id.
|
||
#[test]
|
||
fn the_reply_names_each_expanded_copy_by_its_own_id() {
|
||
for case in RANDOM_CASES {
|
||
let mut s = session();
|
||
let id = random_transforms(&mut s, case);
|
||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||
let ids: Vec<u64> = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||
assert_eq!(ids.first(), Some(&id.0), "{case}: {ids:?}");
|
||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||
assert_eq!(unique.len(), ids.len(), "{case}: {ids:?}");
|
||
assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}");
|
||
if case == "group copies" {
|
||
let art = node(&s, id);
|
||
let copies: Vec<u64> = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect();
|
||
assert_eq!(ids, [vec![id.0], copies].concat(), "{case}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A square with a color adjustment and crop marks: the reply names the group of the square and its
|
||
/// marks once. The adjusted square has a basic appearance, so the reply does not name it.
|
||
#[test]
|
||
fn the_reply_names_an_adjusted_object_with_crop_marks_once() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
run(&mut s, "effect.apply", json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}}));
|
||
run(&mut s, "effect.apply", json!({"effect": "cropMarks", "item": null}));
|
||
let r = run(&mut s, "effect.expandAppearance", json!({}));
|
||
assert!(node(&s, id).children().is_some_and(|c| c.len() > 1));
|
||
assert_eq!(r["ids"], json!([id.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 8, "y": 8, "blur": 2, "opacity": 100}}));
|
||
run(&mut s, "transparency.set", json!({"opacity": 60, "blend": "multiply"}));
|
||
let before = render(&s);
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, id);
|
||
assert!((n.opacity - 0.6).abs() < 1e-6 && n.blend == BlendMode::Multiply);
|
||
assert!(n.appearance.effects.is_empty() && n.appearance.items.is_empty());
|
||
let ch = n.children().unwrap();
|
||
assert_eq!(ch.len(), 3, "the shadow image, the fill, the outlined stroke");
|
||
let NodeKind::Image(im) = &ch[0].kind else { panic!("the shadow is an image: {}", ch[0].kind_label()) };
|
||
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
|
||
assert!(ch[1..].iter().all(|c| c.opacity == 1.0 && c.appearance.effects.is_empty()));
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 24) < 400, "{} pixels differ", differing(&before, &after, 24));
|
||
// The shadow is still there, below and right of the square.
|
||
let p = after.pixel(155, 155);
|
||
assert!(p[0] < 200, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_blur_turns_the_object_into_an_image() {
|
||
let mut s = session();
|
||
let id = square(&mut s);
|
||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Image(_)), "{}", n.kind_label());
|
||
assert!(n.appearance.effects.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn expanding_a_blur_says_when_an_image_could_not_be_decoded() {
|
||
let mut s = session();
|
||
let cut = place_undecodable_png(&mut s);
|
||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [cut.0]}));
|
||
let r = run(&mut s, "effect.expandAppearance", json!({"ids": [cut.0]}));
|
||
assert!(warns_undecoded(&r), "{r}");
|
||
assert!(matches!(node(&s, cut).kind, NodeKind::Image(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn an_art_brush_becomes_a_group_of_its_art() {
|
||
let mut s = session();
|
||
let id = NodeId(run(&mut s, "shape.line", json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
run(&mut s, "paint.setFill", json!({"none": true}));
|
||
run(&mut s, "brush.apply", json!({"name": "Charcoal"}));
|
||
assert!(expandable(&s));
|
||
let before = render(&s);
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, id);
|
||
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
|
||
let mut brushed = false;
|
||
n.walk(&mut |c| brushed |= vectorcraft_brush::has_brush(c));
|
||
assert!(!brushed, "no brush strokes are left");
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 24) < 200, "{} pixels differ", differing(&before, &after, 24));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(vectorcraft_brush::has_brush(&node(&s, id)));
|
||
}
|
||
|
||
#[test]
|
||
fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
|
||
let mut s = session();
|
||
let a = square(&mut s);
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "stroke.set", json!({"item": 0, "weight": 6}));
|
||
assert!(expandable(&s));
|
||
let before = render(&s);
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let l = node(&s, layer);
|
||
assert!(l.appearance.items.is_empty());
|
||
let ch = l.children().unwrap();
|
||
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
|
||
assert_eq!(ch[0].id, a);
|
||
// The layer's stroke is outlined: a filled path.
|
||
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
|
||
let after = render(&s);
|
||
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
|
||
}
|
||
|
||
#[test]
|
||
fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() {
|
||
let mut s = session();
|
||
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, t);
|
||
let ch = n.children().unwrap();
|
||
assert!(matches!(ch[0].kind, NodeKind::Image(_)) && matches!(ch[1].kind, NodeKind::Text(_)));
|
||
// A stroke of its own: outlined glyphs.
|
||
let u = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Yo", "size": 48}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [u.0]}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let mut text = false;
|
||
node(&s, u).walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
|
||
assert!(!text, "the type is outlined");
|
||
assert!(!expandable(&s));
|
||
}
|
||
|
||
#[test]
|
||
fn type_with_its_own_stroke_expands_with_its_underline() {
|
||
let mut s = session();
|
||
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
|
||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||
run(&mut s, "text.setFormat", json!({"underline": true}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
// "Hi" has no ink below its baseline (y 160): the pieces reach the bar under it.
|
||
let b = node(&s, t).geometric_bounds().unwrap();
|
||
assert!(b.y1 > 162.0, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_blurred_layer_keeps_its_image_as_its_only_member() {
|
||
let mut s = session();
|
||
square(&mut s);
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0}));
|
||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||
run(&mut s, "layer.target", json!({"id": layer.0}));
|
||
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian"}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let l = node(&s, layer);
|
||
assert!(l.is_layer() && !l.clips() && l.appearance.effects.is_empty());
|
||
let ch = l.children().unwrap();
|
||
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
|
||
}
|