Files
vectorcraft/crates/effects/src/group/tests.rs
T
BFlat 035d22b3e2 Merge pull request #7476 from dotCooCoo/fix/svg-clip-path-effects
Effects: a clipping path's geometry effects shape the clip on the canvas
2026-11-14 20:38:37 +07:00

1797 lines
100 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.
use super::*;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
fn skip_without_craft_fonts() -> bool {
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
if skip {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
skip
}
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(Paint::solid(color), Paint::None, 0.0)))
}
fn group_with(effect: &str) -> Node {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))]);
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
g
}
fn area(n: &Node) -> f64 {
po::area(n.path_data().unwrap(), FillRule::NonZero).abs()
}
fn overlap_cmyk(g: &Node) -> [f32; 4] {
let faces = pathfinder_children(None, g, None).unwrap();
assert_eq!(faces.len(), 3);
let overlap = faces
.iter()
.find(|n| {
let b = n.geometric_bounds().unwrap();
b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9
})
.expect("overlap face");
assert!(overlap.appearance.stroke_paint().is_none());
overlap.appearance.fill_paint().color().unwrap().to_cmyk()
}
fn two_overlapping_cmyk_squares() -> Node {
let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0);
let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0);
Node::group(
NodeId(10),
vec![
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)),
Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)),
],
)
}
/// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter
/// colour inside the darker one along the edge where they meet.
#[test]
fn trap_effect_adds_an_overprinting_strip_above_the_members() {
let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0);
let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0);
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]);
let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100});
g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true });
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 3, "both squares and one trap");
assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2)));
let b = out[2].geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}");
let fill = out[2].appearance.fill().unwrap();
assert!(fill.overprint);
assert_eq!(fill.paint.color(), Some(yellow));
}
#[test]
fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() {
let mut g = two_overlapping_cmyk_squares();
g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true });
let [c, m, y, k] = overlap_cmyk(&g);
assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}");
}
#[test]
fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() {
let mut g = two_overlapping_cmyk_squares();
g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true });
let [c, m, y, k] = overlap_cmyk(&g);
assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}");
}
#[test]
fn shape_modes_follow_the_pathfinder_panel() {
let add = pathfinder_children(None, &group_with("pathfinder.add"), None).unwrap();
assert_eq!(add.len(), 1);
assert!((area(&add[0]) - 150.0).abs() < 1e-3);
// Add keeps the front object's paint, Subtract the back one's.
assert_eq!(add[0].id, NodeId(2));
let sub = pathfinder_children(None, &group_with("pathfinder.subtract"), None).unwrap();
assert_eq!(sub[0].id, NodeId(1));
assert!((area(&sub[0]) - 50.0).abs() < 1e-3);
let int = pathfinder_children(None, &group_with("pathfinder.intersect"), None).unwrap();
assert!((area(&int[0]) - 50.0).abs() < 1e-3);
let div = pathfinder_children(None, &group_with("pathfinder.divide"), None).unwrap();
assert_eq!(div.len(), 3);
}
/// Divide and Outline draw no piece of a member without fill or stroke, the same as when such
/// pieces are left out.
#[test]
fn divide_and_outline_draw_nothing_for_unpainted_members() {
let unpainted =
Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0)));
for effect in ["pathfinder.divide", "pathfinder.outline"] {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]);
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(None, &g, None).unwrap();
assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}");
for n in out.iter().filter(|n| n.id == NodeId(2)) {
assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}");
}
}
}
/// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either
/// side of x = 100.
fn type_h() -> Node {
let style = CharStyle { size: 100.0, fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), ..CharStyle::default() };
Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::new(70.0, 80.0), "H", style))))
}
/// Returns a group with `effect` that holds a red 100 x 100 square and `front` in front of it.
fn square_and(front: Option<Node>, effect: &str) -> Node {
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let square = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), red);
let mut g = Node::group(NodeId(10), [Some(square), front].into_iter().flatten().map(Arc::new).collect());
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
g
}
/// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it.
fn results(g: &Node) -> Vec<Arc<Node>> {
pathfinder_children(None, g, crate::text_outliner()).unwrap()
}
#[test]
fn type_changes_the_result_of_every_pathfinder_effect() {
if skip_without_craft_fonts() {
return;
}
let glyphs = crate::outline_text(&type_h()).unwrap();
let b = glyphs.geometric_bounds().unwrap();
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
// `measure` gives the number of results and their total area.
let measure = |front: Option<Node>, effect: &str| {
let r = results(&square_and(front, effect));
(r.len(), r.iter().map(|n| area(n)).sum::<f64>())
};
let unchanged: Vec<_> = PATHFINDER_EFFECTS
.iter()
.map(|(id, label, _)| (label, measure(Some(type_h()), id), measure(None, id)))
.filter(|(_, with, without)| with.0 == without.0 && (with.1 - without.1).abs() <= 1.0)
.collect();
assert!(unchanged.is_empty(), "(label, with the type, without): {unchanged:?}");
// The shape modes take the glyph's whole area: the part inside the square and the part outside.
let glyph_area: f64 = glyphs.children().unwrap().iter().map(|n| area(n)).sum();
let with_type = |id: &str| measure(Some(type_h()), id).1;
assert!((with_type("pathfinder.add") - with_type("pathfinder.subtract") - glyph_area).abs() < 0.5, "{glyph_area}");
assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}");
// Outline art nested one group deeper takes part as well.
let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)]));
let sub = pathfinder_children(None, &square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap();
assert!((sub.iter().map(|n| area(n)).sum::<f64>() - with_type("pathfinder.subtract")).abs() < 1e-6);
}
#[test]
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
if skip_without_craft_fonts() {
return;
}
let mut t = type_h();
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
let square = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0, BlendMode::Normal);
let glyph = (Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 0.5, BlendMode::Multiply);
let looks: Vec<_> = results(&square_and(Some(t.clone()), "pathfinder.divide")).iter().map(look).collect();
assert!(looks.contains(&square) && looks.contains(&glyph) && looks.iter().all(|l| *l == square || *l == glyph), "{looks:?}");
// In the shape modes a group with the type in front is one shape, styled like the type.
let green = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0);
let behind = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), green);
let both = Node::group(NodeId(4), vec![Arc::new(behind), Arc::new(t)]);
let added: Vec<_> = results(&square_and(Some(both), "pathfinder.add")).iter().map(look).collect();
assert_eq!(added, [glyph]);
}
#[test]
fn a_random_transform_on_a_group_moves_its_members_together() {
let mut g = group_with("distort.transform");
g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true});
let art = evaluate_container(None, &g).unwrap();
let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap();
let (a, b) = (corner(0), corner(1));
assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}");
assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}");
}
/// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`,
/// in a group with a square and Offset Path 0, which leaves every path as it is.
fn type_in_group(style: impl FnOnce(&mut Node)) -> Node {
let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts);
let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text)));
style(&mut t);
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]);
g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true });
let art = evaluate_container(None, &g).unwrap();
(*art.children().unwrap()[1]).clone()
}
#[test]
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
if skip_without_craft_fonts() {
return;
}
use vectorcraft_color::BlendMode;
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply));
// Its opacity mask, its own shadow, its knockout shape option and its visibility stay.
let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
let mask = vectorcraft_doc::OpacityMask::new(mask_art, true);
assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask));
let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true };
assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]);
assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape);
assert!(!type_in_group(|t| t.visible = false).visible);
// Its own Transform moves its outlines 30 pt before the group's effect reshapes them.
let x0 = |n: Node| n.geometric_bounds().unwrap().x0;
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true };
let moved = x0(type_in_group(|t| t.appearance.effects.push(transform)));
assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}");
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
g.appearance.effects[0].visible = false;
assert!(pathfinder_children(None, &g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
#[test]
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_those_placed_on_its_paths() {
if skip_without_craft_fonts() {
return;
}
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
// The type's characters fit the gradient to its layout; the square has it placed across itself.
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text))));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0)));
let start = |n: &Node| match n.appearance.fill_paint() {
Paint::Gradient(g) => g.geom.unwrap().start.x,
p => panic!("a gradient: {p:?}"),
};
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
let mut g = Node::group(NodeId(10), vec![square, t]);
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let art = evaluate_container(None, &g).unwrap();
let members = art.children().unwrap();
let after = start(&members[1].children().unwrap()[0]);
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
assert!((start(&members[0]) - 30.0).abs() < 1e-6, "the square's gradient moves with it");
}
#[test]
fn a_groups_transform_moves_the_gradient_of_its_own_fill() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (15.0, 5.0).into(), aspect: 1.0, focal: None };
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]);
g.appearance
.items
.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })))));
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut starts = vec![];
art.walk(&mut |c: &Node| {
if let Paint::Gradient(p) = c.appearance.fill_paint() {
starts.push(p.geom.unwrap().start.x);
}
});
assert!(!starts.is_empty() && starts.iter().all(|s| (s - 30.0).abs() < 1e-6), "{starts:?}");
}
/// The art Pathfinder and compound shapes take (`shape_art`) paints as the canvas does: a path's
/// gradient and a group's own fill move with a Transform effect.
#[test]
fn shape_art_moves_gradients_with_a_transform_effect() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true };
let starts = |art: &Node| {
let mut out = vec![];
art.walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let Paint::Gradient(p) = item.paint() {
out.push(p.geom.unwrap().start.x);
}
}
});
out
};
let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0));
path.appearance.effects.push(moved());
let got = starts(&shape_art(None, &path, None).unwrap());
assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}");
let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]);
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed)));
g.appearance.effects.push(moved());
let got = starts(&shape_art(None, &g, None).unwrap());
assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}");
}
/// A symbol instance's Transform effect moves the gradient placed on its art's own fill in the
/// art Pathfinder takes, as it moves its members.
#[test]
fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]);
art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed)));
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let out = shape_art(None, &instance, Some(&hook)).unwrap();
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) };
assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom);
assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves");
}
/// Under a Warp effect, the gradient placed on a symbol instance's art's own fill follows the art's
/// bounds, and the gradient placed on a compound shape follows its area's bounds, each from before
/// the warp to after it, in the art Pathfinder takes.
#[test]
fn shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (2.0, 3.0).into(), end: (9.0, 3.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let follow = |p: Point, from: Rect, to: Rect| {
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
};
let check = |out: &Node, from: Rect, what: &str| {
let to = out.geometric_bounds().unwrap();
assert_ne!(from, to, "{what}: the warp changes the bounds");
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("{what}: a gradient: {:?}", out.appearance) };
let got = g.geom.unwrap();
let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
assert!(got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6, "{what}: {got:?}, want {s:?} {e:?}");
};
for style in ["arc", "rise", "bulge"] {
let warp = || Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true };
let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]);
art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
let hook = move |_: &Node| Some(art.clone());
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
instance.appearance.effects.push(warp());
check(&shape_art(None, &instance, Some(&hook)).unwrap(), Rect::new(0.0, 0.0, 10.0, 10.0), &format!("warp.{style} on a symbol instance"));
let mut shape = compound_shape(ShapeMode::Add);
shape.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
shape.appearance.effects.push(warp());
check(&shape_art(None, &shape, None).unwrap(), Rect::new(0.0, 0.0, 15.0, 10.0), &format!("warp.{style} on a compound shape"));
}
}
#[test]
fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let ends = |e: Effect| {
let square =
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0)));
let mut g = Node::group(NodeId(10), vec![square, sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e);
let art = evaluate_container(None, &g).unwrap();
match art.children().unwrap()[0].appearance.fill_paint() {
Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(),
p => panic!("a gradient: {p:?}"),
}
};
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
// A quarter turn about the group's center (15, 5) takes (0, 5) to (15, 20) and (10, 5) to (15, 10).
let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true });
assert!(near(s, Point::new(15.0, 20.0)) && near(e, Point::new(15.0, 10.0)), "{s:?} {e:?}");
// Free Distort moving the group's top corners 10 pt right slants the square to 0..20 across:
// its gradient follows its own bounds (0..10 to 0..20), not the group's (0..30 to 0..40).
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true });
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
}
/// A group's Warp effect moves and scales the gradient placed on each of its paths from that path's
/// bounds before the warp to its bounds after it, not by the group's bounds.
#[test]
fn a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = |x: f64| GradientGeom { start: (x + 2.0, 3.0).into(), end: (x + 8.0, 3.0).into(), aspect: 1.0, focal: None };
let placed = |x: f64| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom(x)), ..ramp.clone() }));
let square = |id: u64, x: f64| {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(placed(x), Paint::None, 0.0)))
};
for style in ["arc", "bulge", "rise", "twist"] {
let mut g = Node::group(NodeId(10), vec![square(1, 0.0), square(2, 20.0)]);
g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut moved = false;
for (i, x) in [0.0, 20.0].into_iter().enumerate() {
let m = &art.children().unwrap()[i];
let b = m.geometric_bounds().unwrap();
let follow = |p: Point| Point::new(b.x0 + (p.x - x) / 10.0 * b.width(), b.y0 + p.y / 10.0 * b.height());
let Paint::Gradient(gp) = m.appearance.fill_paint() else { panic!("a gradient") };
let got = gp.geom.unwrap();
let want = geom(x);
assert!(
got.start.distance(follow(want.start)) < 1e-6 && got.end.distance(follow(want.end)) < 1e-6,
"warp.{style} path {i}: {got:?} in {b:?}"
);
moved |= got.start.distance(want.start) > 0.1;
}
assert!(moved, "warp.{style} moves a gradient");
}
}
/// Under a group's Warp effect, the group's own placed gradients follow the group's bounds, a
/// nested group's own placed gradients follow the nested group's bounds, and a compound path's
/// placed gradients follow the compound path's bounds, each from before the warp to after it (a
/// stroke's: those bounds inflated by the stroke).
#[test]
fn a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (12.0, 4.0).into(), end: (26.0, 4.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let own_fill = |n: &mut Node| {
n.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
n.appearance.items.push(AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(placed.clone(), 3.0)));
};
let follow = |p: Point, from: Rect, to: Rect| {
Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height())
};
for style in ["arc", "bulge", "rise", "flag"] {
let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]);
own_fill(&mut inner);
let ring = vec![
Arc::new(Node::path(NodeId(6), shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 14.0)), Appearance::default())),
Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(23.0, 3.0, 27.0, 7.0)), Appearance::default())),
];
let mut compound = Node::new(NodeId(3), NodeKind::Compound { children: ring, rule: FillRule::EvenOdd });
compound.appearance = Appearance::basic(placed.clone(), placed.clone(), 2.0).into();
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(compound), sq(2, 40.0, Color::BLACK)]);
own_fill(&mut g);
let mut before = std::collections::HashMap::new();
g.walk(&mut |c: &Node| {
before.insert(c.id, c.geometric_bounds().unwrap());
});
g.appearance.effects.push(Effect { id: format!("warp.{style}"), params: serde_json::json!({"bend": 40.0}), visible: true });
let art = evaluate_container(None, &g).unwrap();
// The bounds after the warp of the group, the nested group and the compound path (the paths
// a group's own fill paints carry the group's id).
let mut after = std::collections::HashMap::new();
art.walk(&mut |c: &Node| {
if matches!(c.kind, NodeKind::Group { .. } | NodeKind::Compound { .. }) {
after.entry(c.id).or_insert_with(|| c.geometric_bounds().unwrap());
}
});
let mut checked = vec![];
art.walk(&mut |c: &Node| {
if ![NodeId(3), NodeId(5), NodeId(10)].contains(&c.id) {
return;
}
for item in &c.appearance.items {
let Paint::Gradient(gp) = item.paint() else { continue };
let (from, to) = (before[&c.id], after[&c.id]);
assert_ne!(from, to, "warp.{style} {:?}: the warp changes the bounds", c.id);
let stroke = match item {
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let (from, to) = match stroke {
Some(s) => (s.paint_bounds(from), s.paint_bounds(to)),
None => (from, to),
};
let got = gp.geom.unwrap();
let (s, e) = (follow(geom.start, from, to), follow(geom.end, from, to));
assert!(
got.start.distance(s) < 1e-6 && got.end.distance(e) < 1e-6,
"warp.{style} {:?} (stroke: {}): {got:?}, want {s:?} {e:?} ({from:?} to {to:?})",
c.id,
stroke.is_some()
);
checked.push((c.id, stroke.is_some()));
}
});
checked.sort();
checked.dedup();
let want: Vec<_> = [NodeId(3), NodeId(5), NodeId(10)].into_iter().flat_map(|id| [(id, false), (id, true)]).collect();
assert_eq!(checked, want, "warp.{style}");
}
}
/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient
/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the
/// nested group's bounds before the effect to its bounds after it.
#[test]
fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let ends = |e: Effect| {
let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]);
inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e);
let art = evaluate_container(None, &g).unwrap();
match art.children().unwrap()[0].appearance.fill_paint() {
Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(),
p => panic!("a gradient: {p:?}"),
}
};
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
assert!(near(s, Point::new(30.0, 5.0)) && near(e, Point::new(40.0, 5.0)), "{s:?} {e:?}");
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true });
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
}
/// The bounds of what `n` draws, as the renderer draws it: a group or layer with an appearance of
/// its own as the art [`evaluate_container`] makes of it, and each visible path and compound path
/// with the geometry effects still on it applied, then those of each visible fill and stroke that
/// has some.
fn drawn_bounds(n: &Node) -> Option<Rect> {
if !n.visible {
return None;
}
if let Some(m) = evaluate_container(None, n) {
return drawn_bounds(&m);
}
if let Some(ch) = n.children()
&& !matches!(n.kind, NodeKind::Compound { .. })
{
return ch.iter().filter_map(|c| drawn_bounds(c)).reduce(|a, b| a.union(b));
}
let (path, _) = crate::reshape::leaf_geometry(n)?;
let geometry = |fx: &[Effect]| -> Vec<Effect> { fx.iter().filter(|e| e.visible && crate::is_geometry(&e.id)).cloned().collect() };
let apply = |fx: &[Effect], p: &PathData| crate::apply_geometry(&geometry(fx), p, p.bounds().unwrap());
let g = apply(&n.appearance.effects, &path);
let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i) && !geometry(i.effects()).is_empty()).collect();
if items.is_empty() {
return g.bounds();
}
items.iter().filter_map(|i| apply(i.effects(), &g).bounds()).reduce(|a, b| a.union(b))
}
fn black() -> Appearance {
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)
}
/// A black 100 pt square from (100, 100).
fn square_at_100() -> Node {
Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black())
}
fn effect(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
fn offset_10() -> Effect {
effect("path.offsetPath", serde_json::json!({"offset": 10}))
}
fn scaled(s: f64) -> Effect {
effect("distort.transform", serde_json::json!({"scaleH": s, "scaleV": s}))
}
fn with_effect(mut n: Node, e: Effect) -> Node {
n.appearance.effects.push(e);
n
}
/// The width that a group with `e` holding `member` draws at.
fn drawn_width(member: Node, e: Effect) -> f64 {
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), e);
drawn_bounds(&g).unwrap().width()
}
/// A member's own Offset Path runs before its group's Transform: the square offset by 10 pt to
/// 120 pt, then scaled by half, is 60 pt wide (70 pt the other way round).
#[test]
fn a_members_own_offset_runs_before_its_groups_scale() {
let w = drawn_width(with_effect(square_at_100(), offset_10()), scaled(50.0));
assert!((w - 60.0).abs() < 1e-6, "{w}");
}
/// A member's own Transform runs before its group's Offset Path: the square scaled to 200 pt, then
/// offset by 10 pt, is 220 pt wide (240 pt the other way round).
#[test]
fn a_members_own_scale_runs_before_its_groups_offset() {
let w = drawn_width(with_effect(square_at_100(), scaled(200.0)), offset_10());
assert!((w - 220.0).abs() < 1e-6, "{w}");
}
/// A member inside a plain group inside the group takes its own effects first too.
#[test]
fn a_member_of_a_plain_nested_group_takes_its_own_effects_first() {
let w = drawn_width(Node::group(NodeId(5), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0));
assert!((w - 60.0).abs() < 1e-6, "{w}");
}
/// A nested group's own geometry effects run before the outer group's.
#[test]
fn a_nested_groups_own_effects_run_before_its_groups() {
let w = drawn_width(with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), offset_10()), scaled(50.0));
assert!((w - 60.0).abs() < 1e-6, "{w}");
}
/// A nested group's Pathfinder effect runs before the outer group's geometry effects: the back
/// square minus the front one is 50 pt wide, and offset by 10 pt it is 70 pt (offsetting the
/// squares first leaves 50 pt).
#[test]
fn a_nested_groups_pathfinder_runs_before_its_groups_effects() {
let back = square_at_100();
let front = Node::path(NodeId(2), shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)), black());
let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(back), Arc::new(front)]), effect("pathfinder.subtract", serde_json::json!({})));
let w = drawn_width(nested, offset_10());
assert!((w - 70.0).abs() < 1e-6, "{w}");
}
/// The geometry effects of a member's own fill run before its group's: 60 pt, as for the
/// member's own effect.
#[test]
fn a_members_fill_effects_run_before_its_groups() {
let mut member = square_at_100();
member.appearance.items[0].effects_mut().push(offset_10());
let w = drawn_width(member, scaled(50.0));
assert!((w - 60.0).abs() < 1e-6, "{w}");
}
/// A member whose fills have geometry effects stays one element of a knockout group: its fills do
/// not knock each other out.
#[test]
fn a_member_drawn_as_its_fills_stays_one_knockout_element() {
let mut member = square_at_100();
member.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))));
member.appearance.items[0].effects_mut().push(offset_10());
let mut g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), scaled(50.0));
g.knockout = vectorcraft_doc::Knockout::On;
let art = evaluate_container(None, &g).unwrap();
let elements = Node::knockout_elements(art.children().unwrap());
assert_eq!(elements.len(), 1, "{:?}", elements.iter().map(|e| (e.id, &e.kind)).collect::<Vec<_>>());
}
/// The art a Pathfinder operation or compound shape takes from a group with geometry effects has
/// the members' own effects applied first, as the canvas draws it.
#[test]
fn shape_art_of_a_group_applies_its_members_effects_first() {
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0));
let art = shape_art(None, &g, None).unwrap();
let w = drawn_bounds(&art).unwrap().width();
assert!((w - 60.0).abs() < 1e-6, "{w}");
let leaves = member_leaves(None, &g, None);
let w = leaves.iter().filter_map(drawn_bounds).reduce(|a, b| a.union(b)).unwrap().width();
assert!((w - 60.0).abs() < 1e-6, "leaves: {w}");
}
/// A symbol instance's own geometry effects run after those of its symbol's members, as the canvas
/// draws it and in the art a Pathfinder operation takes from it.
#[test]
fn a_symbol_instances_effects_run_after_its_symbols_members_own() {
let art = Node::group(NodeId(20), vec![Arc::new(with_effect(square_at_100(), offset_10()))]);
let instance =
with_effect(Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }), scaled(50.0));
let drawn = crate::reshape(None, &instance, Some(&art)).unwrap();
let w = drawn_bounds(&drawn).unwrap().width();
assert!((w - 60.0).abs() < 1e-6, "drawn: {w}");
let hook = move |_: &Node| Some(art.clone());
let w = drawn_bounds(&shape_art(None, &instance, Some(&hook)).unwrap()).unwrap().width();
assert!((w - 60.0).abs() < 1e-6, "shape art: {w}");
}
/// Export bakes the group as the canvas draws it.
#[test]
fn a_baked_group_applies_its_members_effects_first() {
let mut d = Document::new(800.0, 800.0);
let layer = d.layers[0].id;
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0));
d.insert(Some(layer), 0, g).unwrap();
let baked = crate::bake_document(&d).unwrap();
let w = baked.node(NodeId(10)).unwrap().geometric_bounds().unwrap().width();
assert!((w - 60.0).abs() < 1e-6, "{w}");
}
/// A group whose Transform effect only moves and copies keeps its type when baked; a nested
/// group's own Transform still runs before the copies: the nested square scaled by half to 50 pt
/// and copied 200 pt to the right spans 250 pt (copying first, then scaling both copies about
/// their middle, spans 150 pt).
#[test]
fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() {
if skip_without_craft_fonts() {
return;
}
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = Node::new(NodeId(3), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style))));
let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), scaled(50.0));
let g = with_effect(
Node::group(NodeId(10), vec![Arc::new(text), Arc::new(nested)]),
effect("distort.transform", serde_json::json!({"moveH": 200, "copies": 1})),
);
let mut d = Document::new(800.0, 800.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, g).unwrap();
let baked = crate::bake_document(&d).unwrap();
let mut text = 0;
baked.walk(|n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
assert_eq!(text, 2, "the type stays type, copied");
let w = baked.node(NodeId(5)).unwrap().geometric_bounds().unwrap().width();
assert!((w - 250.0).abs() < 1e-6, "{w}");
}
/// A clip group's clipping path and the clipped members take their own effects first.
#[test]
fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() {
let mut clip = with_effect(square_at_100(), offset_10());
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let clipped =
Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(with_effect(square_at_100(), offset_10()))], clip: true });
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(clipped)]), scaled(50.0));
let art = evaluate_container(None, &g).unwrap();
let clip_group = art.children().unwrap()[0].clone();
let ch = clip_group.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind);
assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied");
let w = ch[0].geometric_bounds().unwrap().width();
assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}");
assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art");
}
/// A member with a linked opacity mask keeps its own effects on it, so its mask moves with it
/// under the group's Transform; so does a member with a 3D effect, whose later geometry effects
/// apply to its 3D art.
#[test]
fn members_with_linked_masks_or_3d_effects_keep_their_own_effects() {
let mut masked = with_effect(square_at_100(), offset_10());
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(
Node::path(NodeId(2), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()),
true,
)));
let mut solid = with_effect(square_at_100(), effect(crate::solids::EXTRUDE, serde_json::json!({})));
solid.id = NodeId(3);
solid.appearance.effects.push(scaled(50.0));
let moved = effect("distort.transform", serde_json::json!({"moveH": 50, "strokes": true}));
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(masked), Arc::new(solid)]), moved);
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(&ch[0]).len() == 1, "{:?}", ch[0].kind);
let mask = ch[0].extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap();
assert!((mask.x0 - 150.0).abs() < 1e-6, "the mask moves with the square: {mask:?}");
assert!(matches!(ch[1].kind, NodeKind::Path { .. }) && ch[1].appearance.effects.len() == 2, "{:?}", ch[1].kind);
}
/// A nested group with a 3D effect stays as it is, with its members: the Transform listed after
/// its Extrude still applies to its 3D art.
#[test]
fn a_nested_group_with_a_3d_effect_keeps_its_own_effects() {
let inner = with_effect(square_at_100(), offset_10());
let mut solid = with_effect(Node::group(NodeId(5), vec![Arc::new(inner)]), effect(crate::solids::EXTRUDE, serde_json::json!({})));
solid.appearance.effects.push(scaled(50.0));
let moved = effect("distort.transform", serde_json::json!({"moveH": 50}));
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(solid)]), moved);
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert!(
matches!(ch[0].kind, NodeKind::Group { .. }) && ch[0].id == NodeId(5) && ch[0].appearance.effects.len() == 2,
"{:?}",
ch[0].appearance.effects
);
let member = &ch[0].children().unwrap()[0];
assert!(matches!(member.kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(member).len() == 1, "{:?}", member.kind);
}
/// Members nested deeper than the depth limit keep their own effects as they are.
#[test]
fn members_past_the_depth_limit_stay_as_they_are() {
let mut n = with_effect(square_at_100(), offset_10());
for i in 0..vectorcraft_doc::depth::MAX_DEPTH {
n = Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]);
}
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0));
let art = evaluate_container(None, &g).unwrap();
let mut deepest = None;
art.walk(&mut |c: &Node| {
if c.id == NodeId(1) {
deepest = Some(c.clone());
}
});
let deepest = deepest.unwrap();
assert!(matches!(deepest.kind, NodeKind::Path { .. }), "{:?}", deepest.kind);
assert_eq!(crate::reshape::geometry_effects(&deepest).len(), 1);
}
/// The depth limit counts nested groups with effects of their own too.
#[test]
fn nested_groups_with_effects_count_toward_the_depth_limit() {
let mut n = with_effect(square_at_100(), offset_10());
for i in 0..vectorcraft_doc::depth::MAX_DEPTH {
n = with_effect(Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]), effect("distort.transform", serde_json::json!({"moveH": 1})));
}
let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0));
let art = evaluate_container(None, &g).unwrap();
let mut deepest = None;
art.walk(&mut |c: &Node| {
if c.id == NodeId(1) && matches!(c.kind, NodeKind::Path { .. }) {
deepest = Some(c.clone());
}
});
assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect");
}
/// An image or a symbol instance that a group's Transform effect moves takes the placed gradients
/// of its own fills along, as a path does, and each copy takes them as many times as it takes the
/// transform.
#[test]
fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::ImageObject;
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let image = || {
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
}))
};
let instance = || NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let starts = |kind: NodeKind, params: serde_json::Value| {
let mut object = Node::new(NodeId(1), kind);
object.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
let mut g = Node::group(NodeId(10), vec![Arc::new(object), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut out = vec![];
art.walk(&mut |c: &Node| {
if let (NodeKind::Image(_) | NodeKind::SymbolInstance { .. }, Paint::Gradient(p)) = (&c.kind, c.appearance.fill_paint()) {
out.push(p.geom.unwrap().start.x);
}
});
out
};
for kind in [image as fn() -> NodeKind, instance] {
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind());
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [60.0, 30.0, 0.0], "{:?}", kind());
}
}
/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the
/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own
/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients
/// fitted.
#[test]
fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::ImageObject;
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let square = |id: u64, x: f64| {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(fitted.clone(), Paint::None, 0.0)))
};
let mut image = Node::new(
NodeId(4),
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::translate((30.0, 0.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
// A symbol instance whose symbol is not in a document is measured as the 20 pt square about
// its origin: here 22.5..27.5 across and 2.5..7.5 down.
let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25);
let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at });
instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into();
// Ends of each gradient by object id; none for a gradient still fitted.
let ends = |params: serde_json::Value| {
let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]);
inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]);
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone())));
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mut out = std::collections::BTreeMap::new();
art.walk(&mut |c: &Node| {
if let Paint::Gradient(p) = c.appearance.fill_paint() {
out.insert(c.id.0, p.geom.map(|g| (g.start, g.end)));
}
});
out
};
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
// A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp
// from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1).
let turned = ends(serde_json::json!({"rotate": 90}));
let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)];
for (id, x0, x1) in want {
let got = turned.get(&id).copied().flatten();
let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1)));
assert!(ok, "object {id}: {got:?} in {turned:?}");
}
// The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70.
let moved = ends(serde_json::json!({"moveH": 30}));
let group = moved.get(&10).copied().flatten();
assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}");
assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}");
}
#[test]
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
for (k, paint) in paints.into_iter().enumerate() {
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)));
let mut g = Node::group(NodeId(10), vec![square]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
// The original is on top, after the copies below it.
for (i, c) in copies.iter().rev().enumerate() {
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
match c.appearance.fill_paint() {
Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"),
Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"),
p => panic!("a gradient: {p:?}"),
}
}
}
}
/// A white 10 pt square with id `id` at the origin.
fn white_square(id: u64) -> Node {
Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0))
}
/// `n` masked by `art`, linked or not.
fn masked_by(mut n: Node, art: Node, linked: bool) -> Node {
let mut mask = vectorcraft_doc::OpacityMask::new(art, true);
mask.linked = linked;
n.extras_mut().mask = Some(Box::new(mask));
n
}
/// The geometry effects that reshape a group's members, one at a time.
fn reshaping_effects() -> Vec<Effect> {
let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true };
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
vec![
fx("distort.transform", serde_json::json!({"moveH": 30})),
fx("distort.transform", serde_json::json!({"rotate": 90})),
fx("distort.twist", serde_json::json!({"angle": 90})),
fx("distort.freeDistort", corners),
fx("warp.arc", serde_json::json!({})),
fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})),
fx("distort.zigZag", serde_json::json!({})),
fx("distort.puckerBloat", serde_json::json!({"amount": 50})),
fx("distort.tweak", serde_json::json!({})),
fx("path.offsetPath", serde_json::json!({"offset": 3})),
fx("stylize.roundCorners", serde_json::json!({"radius": 3})),
]
}
/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the
/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is.
#[test]
fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() {
let square = white_square(1);
let original = square.path_data().cloned();
for e in reshaping_effects() {
for linked in [true, false] {
let member = masked_by(square.clone(), white_square(4), linked);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = member.extras().mask.as_deref().unwrap();
assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id);
let want = if linked { member.path_data().cloned() } else { original.clone() };
assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id);
assert_eq!(mask.linked, linked);
}
}
}
/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and
/// an inverted mask all take the outer group's effect with the paths they mask.
#[test]
fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() {
for e in reshaping_effects() {
let innermost = masked_by(white_square(7), white_square(8), true);
let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true);
let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true);
if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) {
m.invert = true;
}
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let path = &inner.children().unwrap()[0];
let want = path.path_data().cloned();
let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap();
let (outer_mask, path_mask) = (mask_of(inner), mask_of(path));
assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert));
assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id);
assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id);
assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id);
assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id);
}
}
/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the
/// mask moved with it; the original keeps its mask where it was.
#[test]
fn each_transform_copy_of_a_masked_member_carries_its_mask() {
let member = masked_by(white_square(1), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member)]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
for c in copies {
let mask = c.extras().mask.as_deref().unwrap();
assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds());
}
}
/// A Transform effect's copies of the paths of a member group give the group's linked mask the same
/// copies: the mask covers each copy.
#[test]
fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() {
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let want = inner.geometric_bounds();
assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy");
assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want);
}
/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy
/// of the mask's art is a group of its objects, so the copies do not interleave object by object.
#[test]
fn a_masks_art_of_several_objects_is_copied_whole() {
let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
let copies = mask.children().unwrap();
assert_eq!(copies.len(), 2, "the mask's art and its copy");
for c in copies {
let ids: Vec<NodeId> = c.children().into_iter().flatten().map(|m| m.id).collect();
assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects");
}
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)));
}
/// The linked mask of a mask's art that is copied whole takes the same Transform copies.
#[test]
fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() {
let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let mask_art = masked_by(parts, white_square(6), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy");
let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art;
assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too");
}
/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the
/// budget has no room for that mask's copy, the art keeps its shape with its masks.
#[test]
fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true };
let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let mask_art = masked_by(parts, white_square(6), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the four squares and the copies of three of them, not for the copy of the fourth.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let inner = &art.children().unwrap()[0];
let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape");
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask");
}
/// When a member group's linked mask can't take all the Transform copies its paths take (past the
/// budget), the art keeps its shape under the effect, so the mask still covers the paths.
#[test]
fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true };
let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the path, its mask and a copy of the path, not for a copy of the mask.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let member = &art.children().unwrap()[0];
let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(member.geometric_bounds(), square, "the path keeps its shape");
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask");
}
/// The copies of a path with a linked mask count the art of the mask each copy carries against the
/// budget: they stop where the masks would pass it.
#[test]
fn transform_copies_count_the_art_of_their_masks() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true };
let member = masked_by(white_square(1), white_square(4), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the path, its mask and one copy of both, not for a second copy.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own");
assert_eq!(copies.len(), 2);
for c in copies {
assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds());
}
}
/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them.
/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's
/// paths too.
#[test]
fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() {
let mask_art = masked_by(white_square(4), white_square(5), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]);
let params = serde_json::json!({"moveH": 30, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let want = inner.children().unwrap()[0].geometric_bounds();
assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0)));
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), want, "the group's mask");
assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art");
}
/// A group's Transform effect moves an image member's linked opacity mask with the image, and its
/// copies' masks with the copies; an unlinked mask stays.
#[test]
fn a_groups_transform_moves_an_image_members_linked_mask() {
for linked in [true, false] {
for copies in [0, 2] {
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked);
let mut g = Node::group(NodeId(10), vec![Arc::new(image)]);
let params = serde_json::json!({"moveH": 30, "copies": copies});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let first = &art.children().unwrap()[0];
let images: Vec<&Node> = match first.children() {
Some(ch) => ch.iter().map(|c| &**c).collect(),
None => vec![first],
};
assert_eq!(images.len(), copies + 1);
for im in images {
let mask = im.extras().mask.as_deref().unwrap();
let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) };
assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}");
}
}
}
}
/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it
/// was, rather than part of its art moving with the member, and the member keeps its shape with it.
#[test]
fn a_mask_whose_art_passes_the_budget_stays_whole() {
// Bloat bends a path's sides into curves drawn with more lines the larger they are: many more
// on the mask's large square.
let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true };
let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default());
let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]);
let member = masked_by(white_square(1), mask_art.clone(), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
// Room for the member and the mask's small square to take the effect, not for the large one.
let bounds = art.geometric_bounds().unwrap();
let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) };
let square = white_square(1).path_data().cloned().unwrap();
let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines;
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines };
let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines;
assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines");
crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None);
let member = &art.children().unwrap()[0];
assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask");
assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole");
}
/// Live objects in a member's linked mask are evaluated before the group's geometry effects run
/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with
/// the member.
#[test]
fn a_groups_effects_reshape_a_live_object_in_a_members_mask() {
let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0);
if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind {
(*rows, *cols) = (1, 1);
}
let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid));
let drawn: Vec<Node> = crate::expand_live(None, &repeat);
let leaf = |n: &Node| {
let mut found = None;
n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned()));
found
};
assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member");
for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] {
let member = masked_by(white_square(1), repeat.clone(), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = &member.extras().mask.as_deref().unwrap().art;
assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id);
assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id);
}
}
/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's
/// art with the member.
#[test]
fn a_groups_transform_moves_type_in_a_members_mask() {
if skip_without_craft_fonts() {
return;
}
let member = masked_by(white_square(1), type_h(), true);
let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap();
let mut g = Node::group(NodeId(10), vec![Arc::new(member)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
let mut text = false;
mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
assert!(!text, "the type is outlined");
let after = mask.geometric_bounds().unwrap();
assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}");
}
/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry
/// effects with the art it masks.
#[test]
fn a_compound_paths_and_a_layers_members_masks_take_the_effects() {
let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p);
for e in reshaping_effects() {
let square = white_square(1);
let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero });
let compound = masked_by(compound, white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id);
assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id);
let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]);
}
layer.appearance.effects.push(e.clone());
let art = evaluate_container(None, &layer).unwrap();
let member = &art.children().unwrap()[0];
assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id);
assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id);
}
}
/// A group's Transform effect moves a symbol instance member's linked mask with the instance.
#[test]
fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() {
let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let instance = &art.children().unwrap()[0];
let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") };
assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}");
let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap();
assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}");
}
/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image
/// member's linked mask as it scales that of a path member's mask.
#[test]
fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() {
let stroked =
Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0));
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true);
let path = masked_by(white_square(2), stroked, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]);
let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width);
let ch = art.children().unwrap();
assert_eq!(width(&ch[1]), Some(4.0), "the path's mask");
assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask");
}
/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the
/// strokes of a member's linked mask as it scales the member's, and moves neither.
#[test]
fn a_transform_of_strokes_only_scales_a_members_mask_strokes() {
let stroked = |id: u64| {
Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0))
};
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]);
let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = &member.extras().mask.as_deref().unwrap().art;
assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke");
assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke");
assert_eq!(mask.geometric_bounds(), member.geometric_bounds());
}
/// The art of a member group's mask is copied as it was before the first effect: a mask of one
/// path takes a second Transform effect's copies path by path, as the path it masks does, though
/// the first effect (with Scale Strokes & Effects) made it a group of copies.
#[test]
fn a_masks_art_is_copied_as_it_was_before_the_first_effect() {
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
for strokes in [true, false] {
let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
}
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), inner.geometric_bounds());
let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::<Vec<_>>();
let paths = &inner.children().unwrap()[0];
assert_eq!(kinds(paths).len(), 3, "the first effect's three paths");
assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies");
assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do");
}
/// A 10 × 10 image with id `id` at the origin.
fn image_node(id: u64) -> Node {
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
Node::new(NodeId(id), NodeKind::Image(Box::new(im)))
}
/// A linked mask whose art is an image moves with the path it masks under a group's Transform
/// effect, and a member group's image mask takes the copies its paths take.
#[test]
fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() {
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0)));
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds());
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0)));
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies");
}
/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect
/// the art keeps its shape with its mask.
#[test]
fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() {
let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[4].clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape");
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
}
/// A Warp effect bends an image's pixels but not the art of its linked mask, so art with such an
/// image keeps its shape under the warp, with the image and its mask.
#[test]
fn art_with_a_masked_image_keeps_its_shape_under_a_warp() {
let image = masked_by(image_node(1), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(image.clone()), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[4].clone());
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(*ch[0], image, "the image and its mask");
assert_eq!(ch[1].path_data(), sq(2, 20.0, Color::BLACK).path_data(), "the path keeps its shape");
}
/// A symbol instance in perspective projects the linked masks of the objects in its art with them.
#[test]
fn perspective_projects_the_masks_in_a_symbols_art() {
let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]);
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0);
at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]);
instance.extras_mut().perspective = Some(Box::new(at));
let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap();
let mut masked = vec![];
out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned()))));
let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") };
assert!(path != &white_square(21).path_data().cloned(), "the path is projected");
assert_eq!(mask, path, "and so is its mask");
}
/// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit
/// pitch, each crossing every bar of the other direction: n² crossings.
fn crossing_bars(n: usize, effect: &str) -> Node {
let len = 4.0 * n as f64;
let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let mut children = vec![];
for i in 0..n {
let at = 4.0 * i as f64;
children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone())));
}
for i in 0..n {
let at = 4.0 * i as f64;
let id = NodeId(100 + (n + i) as u64);
children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone())));
}
let mut g = Node::group(NodeId(10), children);
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
g
}
#[test]
fn pathfinder_past_its_budget_draws_the_source_art() {
// n² crossings past the cap: no evaluation (#3591), the source art as it is.
for n in [130, 800] {
let g = crossing_bars(n, "pathfinder.divide");
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 2 * n, "{n} x {n} bars");
let ids: Vec<NodeId> = out.iter().map(|c| c.id).collect();
let src: Vec<NodeId> = g.children().unwrap().iter().map(|c| c.id).collect();
assert_eq!(ids, src, "{n} x {n} bars");
assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data()));
}
}
#[test]
fn pathfinder_within_its_budget_still_divides() {
// Each bar is cut into one piece per crossing plus the n pieces between and past them, so
// n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly
// `PATHFINDER_MAX_OVERLAPS` pairs, must still divide.
for n in [8, 128] {
let out = pathfinder_children(None, &crossing_bars(n, "pathfinder.divide"), None).unwrap();
assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars");
}
}
#[test]
fn pathfinder_on_many_stacked_bars_still_runs() {
// 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999
// overlapping pairs are within the budget, so Add still merges them into one path.
let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let children = (0..3000)
.map(|i| {
let at = 1.5 * i as f64;
Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone()))
})
.collect();
let mut g = Node::group(NodeId(10), children);
g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 1);
}
/// Two overlapping squares (0–10 and 5–15 by 10 high) as a compound shape, the front one in `mode`.
fn compound_shape(mode: ShapeMode) -> Node {
let mut front = (*sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))).clone();
front.extras_mut().shape_mode = mode;
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(front)]);
g.extras_mut().compound_shape = true;
g.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0).into();
g
}
/// A compound shape's Transform effect turns its fill's drop shadow with the area it turns.
#[test]
fn a_compound_shapes_transform_turns_its_fills_shadow() {
let mut shape = compound_shape(ShapeMode::Add);
if let Some(item) = shape.appearance.items.first_mut() {
*item.effects_mut() =
vec![Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 10, "y": 0, "blur": 0}), visible: true }];
}
shape.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true });
let art = shape_art(None, &shape, None).unwrap();
let item = art.appearance.items.first().unwrap();
match crate::raster_effects(item.effects()).first() {
Some(crate::RasterFx::DropShadow { dx, dy, .. }) => assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9, "{dx} {dy}"),
other => panic!("expected a drop shadow, got {other:?}"),
}
}
/// A compound shape draws the area its members' modes make, one unpainted path that its own
/// appearance paints; evaluated, it is plain art painted with that appearance.
#[test]
fn compound_shapes_draw_the_area_their_shape_modes_make() {
for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] {
let g = compound_shape(mode);
assert!(has_pathfinder(&g), "{mode:?}");
let area_path = compound_shape_area(None, &g, None).unwrap();
assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}");
let out = pathfinder_children(None, &g, None).unwrap();
assert_eq!(out.len(), 1, "{mode:?}");
assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it");
let art = evaluate_container(None, &g).unwrap();
assert!(!art.is_compound_shape(), "evaluated, it is art");
let painted: Vec<&Arc<Node>> = art.children().unwrap().iter().filter(|c| !c.appearance.items.is_empty()).collect();
assert_eq!(painted.len(), 1);
assert_eq!(painted[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "{mode:?}");
assert!((area(painted[0]) - want).abs() < 0.01, "{mode:?}");
}
// The backmost member's mode doesn't change the area.
let mut g = compound_shape(ShapeMode::Add);
if let Some(ch) = g.children_mut() {
let mut back = (*ch[0]).clone();
back.extras_mut().shape_mode = ShapeMode::Subtract;
ch[0] = Arc::new(back);
}
assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
// Hidden members take no part; the mark on a clip group is ignored.
let mut g = compound_shape(ShapeMode::Subtract);
if let Some(ch) = g.children_mut() {
let mut front = (*ch[1]).clone();
front.visible = false;
ch[1] = Arc::new(front);
}
assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
let mut clip = compound_shape(ShapeMode::Add);
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
assert!(!clip.is_compound_shape() && compound_shape_area(None, &clip, None).is_none());
}
/// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area,
/// styled like it.
#[test]
fn nested_compound_shapes_take_part_as_their_area() {
let inner = compound_shape(ShapeMode::Subtract);
let mut cutter = (*sq(3, 2.0, Color::BLACK)).clone();
cutter.extras_mut().shape_mode = ShapeMode::Intersect;
let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]);
outer.extras_mut().compound_shape = true;
// (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high.
assert!((po::area(&compound_shape_area(None, &outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]);
effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let out = pathfinder_children(None, &effect, None).unwrap();
assert_eq!(out.len(), 1);
assert!((area(&out[0]) - 50.0).abs() < 0.01);
assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape");
}
/// The ruler guides of a layer with a Pathfinder effect, a Transform effect that copies its art, or
/// fills and strokes of its own take no part: they stay last in the layer, once and unchanged.
#[test]
fn a_layers_ruler_guides_stay_last_through_its_effects() {
use vectorcraft_doc::LayerColor;
let guide = |id, pos| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos, artboard: None }));
let layer = |effect: &str, params: serde_json::Value| {
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
if let Some(ch) = l.children_mut() {
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), guide(20, 3.0), guide(21, 8.0)];
}
l.appearance.effects.push(Effect { id: effect.into(), params, visible: true });
l
};
let tail = |n: &Node| {
let ch = n.children().unwrap();
assert_eq!(ch.iter().filter(|c| c.is_ruler_guide()).count(), 2, "each guide once");
ch.iter().rev().take(2).map(|c| c.kind.clone()).collect::<Vec<_>>()
};
let guides = vec![guide(21, 8.0).kind.clone(), guide(20, 3.0).kind.clone()];
let add = layer("pathfinder.add", serde_json::json!({}));
let out = pathfinder_children(None, &add, None).unwrap();
assert_eq!(out.len(), 3, "the united area, then the two guides");
assert!((area(&out[0]) - 150.0).abs() < 0.01);
assert!(out[1].is_ruler_guide() && out[2].is_ruler_guide());
assert_eq!(tail(&evaluate_container(None, &add).unwrap()), guides);
let copies = layer("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}));
let art = evaluate_container(None, &copies).unwrap();
assert_eq!(tail(&art), guides);
assert_eq!(art.children().unwrap().len(), 5, "three groups of the art, then the two guides");
let mut art = copies.clone();
crate::reshape::reshape_leaves(&mut art, &copies.appearance.effects.iter().collect::<Vec<_>>(), &copies.appearance.effects, None);
assert_eq!(tail(&art), guides);
assert_eq!(art.children().unwrap().len(), 5, "reshaped as members too");
let mut painted = layer("distort.transform", serde_json::json!({"moveH": 30}));
painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items;
assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides);
}
/// The ruler guides of a sublayer in a layer with a Pathfinder effect, or a Transform effect that
/// copies its art, stay on a layer of the evaluated art, once, hidden and locked as the sublayer is.
#[test]
fn a_sublayers_ruler_guides_survive_its_layers_effects() {
use vectorcraft_doc::LayerColor;
let layer = |effect: &str, params: serde_json::Value, shown: bool| {
let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1));
(sub.visible, sub.locked) = (shown, !shown);
if let Some(ch) = sub.children_mut() {
*ch = vec![
sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)),
Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })),
];
}
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
if let Some(ch) = l.children_mut() {
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(sub)];
}
l.appearance.effects.push(Effect { id: effect.into(), params, visible: true });
l
};
// The guides found where ruler guides are looked for: directly in a layer or sublayer.
fn listed(n: &Node, out: &mut Vec<(NodeId, bool, bool)>) {
for c in n.children().into_iter().flatten() {
if c.is_ruler_guide() {
out.push((c.id, c.visible, c.locked));
} else if c.is_layer() {
listed(c, out);
}
}
}
for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] {
for shown in [true, false] {
let art = evaluate_container(None, &layer(effect, params.clone(), shown)).unwrap();
let mut found = vec![];
listed(&art, &mut found);
assert_eq!(found, [(NodeId(20), shown, !shown)], "{effect}: the sublayer's guide, once, on a layer");
}
}
}
/// A sublayer's ruler guide inside a hidden or locked group stays hidden or locked when its layer's
/// Pathfinder effect moves it onto the layer, as the group made it.
#[test]
fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() {
use vectorcraft_doc::LayerColor;
for (hidden, locked) in [(true, false), (false, true), (false, false)] {
let mut sub = Node::layer(NodeId(11), "Sub", LayerColor::Preset(1));
if let Some(ch) = sub.children_mut() {
*ch = vec![
sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)),
Arc::new(Node::new(NodeId(20), NodeKind::RulerGuide { vertical: true, pos: 3.0, artboard: None })),
];
}
let mut group = Node::group(NodeId(12), vec![Arc::new(sub)]);
(group.visible, group.locked) = (!hidden, locked);
let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0));
if let Some(ch) = l.children_mut() {
*ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(group)];
}
l.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
let art = evaluate_container(None, &l).unwrap();
let guides: Vec<_> = art.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).map(|c| (c.id, c.visible, c.locked)).collect();
assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}");
}
}
/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among
/// its members, its clipping path, not the clipped art it hides outside that path (#7289).
#[test]
fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() {
let mut clip = square_at_100();
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
// The clipped art reaches 100 pt past the clipping path on either side.
let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black());
let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true });
let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]);
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(offset_10());
g.appearance.items = vec![AppearanceItem::Fill(fill)];
g.appearance.set_contents_at(1);
let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5;
let expected = Rect::new(90.0, 90.0, 210.0, 210.0);
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(ch.len(), 2, "the fill's art below the clip group");
assert_eq!(ch[0].id, NodeId(10));
let b = drawn_bounds(&ch[0]).unwrap();
assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}");
// The clip group itself with that fill: the same outline.
let mut own = clipped;
own.appearance = g.appearance.clone();
assert_eq!(member_shapes(None, &own).len(), 1);
let b = member_shapes(None, &own)[0].0.bounds().unwrap();
assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}");
}