Files
vectorcraft/crates/effects/src/bake/tests.rs
T
dotCooCoo 59304f9964 Fix #6316: a Transform effect's copies of a group are whole groups below the original
A Transform effect with copies on a group copied each member on its own,
so one member's copies lay over the fills of the next member. Each copy
is now a whole group, the original on top and each copy below the one
before. A path's copies follow the same order. Guides are not copied,
and the copies stop at the node limit. A group with its own fill or
stroke, a clipping group and live type keep their previous copies.
2026-11-09 14:27:25 -08:00

1007 lines
52 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 serde_json::json;
use vectorcraft_color::{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 fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// Asserts that placed gradient `got` has `want`'s start, end, aspect and focal point, within 1e-6.
fn assert_geom_near(got: vectorcraft_color::GradientGeom, want: vectorcraft_color::GradientGeom, what: &str) {
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
let focal = match (got.focal, want.focal) {
(None, None) => true,
(Some(a), Some(b)) => near(a, b),
_ => false,
};
assert!(
near(got.start, want.start) && near(got.end, want.end) && (got.aspect - want.aspect).abs() < 1e-6 && focal,
"{what}: got {got:?}, want {want:?}"
);
}
fn doc_with(n: Node) -> Document {
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
#[test]
fn plain_documents_are_not_copied() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()));
assert!(bake_document(&d).is_none());
}
#[test]
fn object_effects_become_geometry_and_raster_effects_stay() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0})));
n.appearance.effects.push(fx("stylize.dropShadow", json!({})));
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let b = m.geometric_bounds().unwrap();
assert!((b.width() - 120.0).abs() < 0.5, "{b:?}");
assert_eq!(m.appearance.effects.len(), 1);
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
}
#[test]
fn a_baked_transform_moves_a_placed_gradient_with_the_path() {
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: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let placed_geom = |n: &Node| match n.appearance.fill_paint() {
Paint::Gradient(g) => g.geom.unwrap(),
p => panic!("a gradient: {p:?}"),
};
// A move 30 pt right, and a turn of 90° with the width halved about the center (50, 50): the
// square becomes 100 pt wide and 50 pt tall, and the gradient runs from the bottom up.
let moves = [
(json!({"moveH": 30.0}), Point::new(30.0, 0.0), GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom }),
(
json!({"rotate": 90.0, "scaleH": 50.0}),
Point::new(0.0, 25.0),
GradientGeom { start: Point::new(50.0, 75.0), end: Point::new(50.0, 25.0), ..geom },
),
];
// Alone, with a stroke whose own effect makes one path for each fill and stroke, and as the
// fill's own effect.
for (params, corner, want) in moves {
for (split, own) in [(false, false), (true, false), (true, true)] {
let mut ap = Appearance::basic(placed.clone(), Paint::solid(Color::BLACK), if split { 1.0 } else { 0.0 });
if split && let AppearanceItem::Stroke(s) = &mut ap.items[1] {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
}
let transform = fx("distort.transform", params.clone());
if own {
ap.items[0].effects_mut().push(transform.clone());
}
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
if !own {
n.appearance.effects.push(transform);
}
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let fill: &Node = if split { &m.children().unwrap()[0] } else { m };
let b = fill.geometric_bounds().unwrap();
assert!(b.origin().distance(corner) < 1e-6, "{params}, split {split}, own {own}: {b:?}");
assert_geom_near(placed_geom(fill), want, &format!("{params}, split {split}, own {own}"));
}
}
}
/// A stroke's own Transform effect or Free Distort moves the gradient placed on that stroke.
#[test]
fn a_strokes_own_transform_and_free_distort_move_its_placed_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: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
// Moved 30 pt right by Transform, and one width (100 pt) right by Free Distort.
let moves = [
(fx("distort.transform", json!({"moveH": 30.0})), 30.0),
(fx("distort.freeDistort", json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]})), 100.0),
];
for (e, moved) in moves {
let mut ap = Appearance::basic(Paint::None, placed.clone(), 4.0);
let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
stroke.effects_mut().push(e.clone());
let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
let d = bake_document(&doc_with(n)).unwrap();
let mut geoms = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let AppearanceItem::Stroke(s) = item
&& let Paint::Gradient(g) = &s.paint
{
geoms.push(g.geom.unwrap());
}
}
});
assert!(!geoms.is_empty(), "{}: the stroke keeps its gradient", e.id);
let want = GradientGeom { start: Point::new(moved, 50.0), end: Point::new(100.0 + moved, 50.0), ..geom };
for g in geoms {
assert_geom_near(g, want, &e.id);
}
}
}
#[test]
fn a_fills_gradient_follows_the_effects_around_its_transform_with_options() {
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: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let moved = fx("distort.transform", json!({"moveH": 30.0}));
let scales_strokes = fx("distort.transform", json!({"strokes": true}));
// A move before and a move after the fill's Transform effect with Scale Strokes & Effects on.
for fill_fx in [vec![moved.clone(), scales_strokes.clone()], vec![scales_strokes, moved]] {
let mut ap = Appearance::basic(placed.clone(), Paint::None, 0.0);
*ap.items[0].effects_mut() = fill_fx.clone();
let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
if let Paint::Gradient(g) = c.appearance.fill_paint() {
found.push((c.geometric_bounds().unwrap(), g.geom.unwrap()));
}
});
assert!(!found.is_empty(), "{fill_fx:?}: the fill keeps its gradient");
let want = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom };
for (b, g) in found {
assert!(b.origin().distance(Point::new(30.0, 0.0)) < 1e-6, "{fill_fx:?}: {b:?}");
assert_geom_near(g, want, &format!("{fill_fx:?}"));
}
}
}
#[test]
fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_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 = |kind| GradientPaint::new(Gradient::new(kind, stops.clone()));
let linear = GradientGeom { start: Point::new(20.0, 50.0), end: Point::new(80.0, 50.0), aspect: 1.0, focal: None };
let radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: Some(Point::new(45.0, 32.0)) };
let placed_linear = Paint::Gradient(Box::new(GradientPaint { geom: Some(linear), ..ramp(GradientKind::Linear) }));
let placed_radial = Paint::Gradient(Box::new(GradientPaint { geom: Some(radial), ..ramp(GradientKind::Radial) }));
let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear)));
let baked = |fill: &Paint, e: Effect| {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill.clone(), Paint::None, 0.0));
n.appearance.effects.push(e);
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
match m.appearance.fill_paint() {
Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()),
p => panic!("a gradient: {p:?}"),
}
};
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
// The bottom right corner moves 50 pt right, so the bounds grow from 0..100 to 0..150 across.
let corners = json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.5, 1.0], [0.0, 1.0]]});
let distort = || fx("distort.freeDistort", corners.clone());
let (b, g) = baked(&placed_linear, distort());
assert!((b.x1 - 150.0).abs() < 1e-6, "{b:?}");
let geom = g.geom.unwrap();
assert!(near(geom.start, Point::new(30.0, 50.0)) && near(geom.end, Point::new(120.0, 50.0)), "{geom:?}");
// A radial gradient's center, focal point and ellipse take the same stretch: the radius along
// the vector grows from 20 to 30 pt and the 10 pt radius across it stays.
let (_, g) = baked(&placed_radial, distort());
let want = GradientGeom { start: Point::new(60.0, 30.0), end: Point::new(90.0, 30.0), aspect: 1.0 / 3.0, focal: Some(Point::new(67.5, 32.0)) };
assert_geom_near(g.geom.unwrap(), want, "radial");
// A fitted gradient stays fitted, and fits the distorted outline.
assert_eq!(baked(&fitted, distort()).1.geom, None);
}
/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a
/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped.
/// Moves and even scales leave it fitted.
#[test]
fn a_fitted_gradient_turns_reflects_and_stretches_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 fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone()))));
let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None };
// The geometry of the baked fill (`own`: the Transform effect is the fill's own effect).
let baked = |kind, effects: &[Effect], own: bool| {
let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0);
if own {
*ap.items[0].effects_mut() = effects.to_vec();
}
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
if !own {
n.appearance.effects = effects.to_vec();
}
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
if let Paint::Gradient(g) = c.appearance.fill_paint() {
found.push(g.geom);
}
});
assert_eq!(found.len(), 1, "{effects:?}, own {own}");
found[0]
};
let t = |params| fx("distort.transform", params);
let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"}));
let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]});
let linear = GradientKind::Linear;
let cases = [
// A quarter turn about the center: the left to right ramp runs from the bottom up.
(linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))),
// Reflected left to right: the ramp runs from right to left.
(linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))),
// With Scale Strokes & Effects on, the same.
(linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))),
// Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt.
(linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))),
// Stretched to twice the width: the circle of radius 50 about the center becomes an
// ellipse 100 pt across and 50 pt tall.
(GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }),
// Turned, then moved one width right by Free Distort: the fixed gradient moves with it.
(linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))),
];
for (kind, effects, want) in cases {
for own in [false, true] {
let what = format!("{kind:?} {effects:?}, own {own}");
let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
assert_geom_near(got, want, &what);
}
}
// A move and an even scale are what a refit gives: the gradient stays fitted.
for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] {
for own in [false, true] {
assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}");
}
}
// A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102,
// with Scale Strokes & Effects off or on.
let stroked = |effects: &[Effect], own: bool| {
let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0);
let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
if own {
*stroke.effects_mut() = effects.to_vec();
}
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
if !own {
n.appearance.effects = effects.to_vec();
}
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let AppearanceItem::Stroke(s) = item
&& let Paint::Gradient(g) = &s.paint
{
found.push(g.geom);
}
}
});
assert_eq!(found.len(), 1, "{effects:?}, own {own}");
found[0]
};
for strokes in [false, true] {
for own in [false, true] {
let what = format!("stroke, strokes {strokes}, own {own}");
let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit"));
assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what);
}
}
}
/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted:
/// mapping it with the effect would flatten it to one color.
#[test]
fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() {
use vectorcraft_color::{Gradient, 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 fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
// With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them.
for (options, strokes) in [
(json!({}), 1),
(json!({"strokes": true}), 1),
(json!({"patterns": true}), 1),
(json!({"copies": 2}), 3),
(json!({"strokes": true, "copies": 2}), 3),
(json!({"patterns": true, "copies": 2}), 3),
] {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0));
let mut p = json!({"scaleH": 0.0, "rotate": 30.0});
if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) {
p.extend(o.clone());
}
n.appearance.effects = vec![fx("distort.transform", p)];
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let AppearanceItem::Stroke(s) = item
&& let Paint::Gradient(g) = &s.paint
{
found.push(g.geom);
}
}
});
assert_eq!(found, vec![None; strokes], "{options}");
}
}
#[test]
fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_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 = |kind| GradientPaint::new(Gradient::new(kind, stops.clone()));
// A ramp across the square and one down it, so both axes of the box map show.
let across = GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
let down = GradientGeom { start: Point::new(60.0, 10.0), end: Point::new(60.0, 75.0), aspect: 1.0, focal: None };
let placed = |geom| Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp(GradientKind::Linear) }));
let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear)));
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
let baked = |fill: &Paint, e: Effect| {
let mut n = Node::path(NodeId(50), shapes::rectangle(square), Appearance::basic(fill.clone(), Paint::None, 0.0));
n.appearance.effects.push(e);
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
match m.appearance.fill_paint() {
Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()),
p => panic!("a gradient: {p:?}"),
}
};
// Where the box map from the square to `to` takes `p`.
let follow = |p: Point, to: Rect| Point::new(to.x0 + p.x / 100.0 * to.width(), to.y0 + p.y / 100.0 * to.height());
let styles = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
// The square's warped bounds, from `warp_point` on 401 points along each side: the square's
// corners are (-1, -1) and (1, 1) in the warp's box coordinates, and a vertical warp swaps
// the axes.
let warped_bounds = |style: &str, p: &serde_json::Value| {
let Some(style) = crate::WarpStyle::from_id(style) else { panic!("warp.{style}: a style") };
let share = |key: &str, default: f64| p.get(key).and_then(serde_json::Value::as_f64).unwrap_or(default) / 100.0;
let vertical = p.get("orientation").and_then(serde_json::Value::as_str) == Some("vertical");
let (bend, dh, dv) = (share("bend", 50.0), share("horizontal", 0.0), share("vertical", 0.0));
let mut r: Option<Rect> = None;
for k in 0..=400 {
let s = f64::from(k) / 200.0 - 1.0;
for (x, y) in [(s, -1.0), (s, 1.0), (-1.0, s), (1.0, s)] {
let (x2, y2) = if vertical {
let (a, b) = crate::warp_point(style, bend, dh, dv, 1.0, y, x);
(b, a)
} else {
crate::warp_point(style, bend, dh, dv, 1.0, x, y)
};
let q = Point::new(50.0 + x2 * 50.0, 50.0 + y2 * 50.0);
r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
}
}
r.unwrap_or(square)
};
let near =
|a: Rect, b: Rect, tol: f64| (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol;
let mut moved = 0;
for style in styles {
for params in
[json!({"bend": 30.0}), json!({"bend": -40.0, "orientation": "vertical"}), json!({"bend": 20.0, "horizontal": 30.0, "vertical": -20.0})]
{
// A warp that bends the square's sides inward keeps its bounds (within 0.5 pt), and the
// gradient stays with them.
let expected = warped_bounds(style, &params);
let moves = !near(expected, square, 0.5);
moved += usize::from(moves);
for linear in [across, down] {
let what = format!("warp.{style} {params} {linear:?}");
let (b, g) = baked(&placed(linear), fx(&format!("warp.{style}"), params.clone()));
assert!(near(b, expected, 0.1), "{what}: bounds {b:?}, want {expected:?}");
let geom = g.geom.unwrap();
let shifted = geom.start.distance(linear.start).max(geom.end.distance(linear.end));
assert_eq!(shifted > 0.1, moves, "{what}: the gradient moves {shifted} with bounds {b:?}");
let want = GradientGeom { start: follow(linear.start, b), end: follow(linear.end, b), ..linear };
assert_geom_near(geom, want, &what);
}
let what = format!("warp.{style} {params}");
// A fitted gradient stays fitted, and fits the warped outline.
assert_eq!(baked(&fitted, fx(&format!("warp.{style}"), params)).1.geom, None, "{what}");
}
}
assert_eq!(moved, 39, "the warps that move the bounds, of 45");
// With no bend and no distortion the gradient stays where it is.
let (_, g) = baked(&placed(across), fx("warp.arc", json!({"bend": 0.0})));
assert_geom_near(g.geom.unwrap(), across, "no bend");
}
/// A warp with no distortion and no bend (or a bend Arc takes as none) leaves a placed gradient
/// exactly where it was, even where warping the outline moves its bounds by a rounding error.
#[test]
fn a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place() {
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 geom = GradientGeom { start: Point::new(13.7, 31.3), end: Point::new(91.1, 47.9), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut drift = 0;
let cases = crate::WARP_STYLES.map(|(s, _)| (s, 0.0));
for (k, (style, bend)) in cases.into_iter().chain([("arc", 1e-5), ("arc", -1e-5)]).enumerate() {
let k = k as f64;
let oval = Rect::new(10.1 + k * 0.37, 20.3 - k * 1.13, 97.7 + k * 3.31, 71.9 + k * 0.71);
let mut n = Node::path(NodeId(50), shapes::ellipse(oval), Appearance::basic(placed.clone(), Paint::None, 0.0));
n.appearance.effects.push(fx(&format!("warp.{style}"), json!({ "bend": bend })));
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
drift += usize::from(m.geometric_bounds() != shapes::ellipse(oval).bounds());
let Paint::Gradient(g) = m.appearance.fill_paint() else { panic!("warp.{style} {bend}: a gradient") };
assert_eq!(g.geom, Some(geom), "warp.{style} {bend}");
}
assert!(drift > 0, "the warped outlines' bounds move by a rounding error");
// Another style with as small a bend still moves the outline, and the gradient follows it.
let mut n = Node::path(NodeId(50), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(placed, Paint::None, 0.0));
n.appearance.effects.push(fx("warp.rise", json!({"bend": 1e-5})));
let d = bake_document(&doc_with(n)).unwrap();
let Paint::Gradient(g) = d.node(NodeId(50)).unwrap().appearance.fill_paint() else { panic!("warp.rise: a gradient") };
assert_ne!(g.geom, Some(geom), "warp.rise 1e-5");
}
/// A Warp effect on a fill or a stroke itself moves and scales its placed gradient from the
/// path's bounds before the warp to its bounds after it (a stroke: its stroke-inflated bounds).
#[test]
fn a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_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: Point::new(20.0, 60.0), end: Point::new(80.0, 60.0), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
let width = 4.0;
// Where the box map from `from` to `to` takes `p`.
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 pad = |r: Rect| r.inflate(width / 2.0, width / 2.0);
for style in ["arc", "rise", "inflate"] {
for stroke in [false, true] {
let what = format!("warp.{style} on the {}", if stroke { "stroke" } else { "fill" });
let mut ap =
if stroke { Appearance::basic(Paint::None, placed.clone(), width) } else { Appearance::basic(placed.clone(), Paint::None, 0.0) };
let Some(item) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_)) == stroke) else { panic!("{what}: the item") };
item.effects_mut().push(fx(&format!("warp.{style}"), json!({"bend": 40.0})));
let n = Node::path(NodeId(50), shapes::rectangle(square), ap);
let d = bake_document(&doc_with(n)).unwrap();
let mut found = vec![];
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
for item in &c.appearance.items {
if let Paint::Gradient(g) = item.paint() {
found.push((c.geometric_bounds().unwrap(), g.geom.unwrap()));
}
}
});
assert!(!found.is_empty(), "{what}: the gradient is kept");
for (b, g) in found {
assert_ne!(b, square, "{what}: the warp changes the bounds");
let (from, to) = if stroke { (pad(square), pad(b)) } else { (square, b) };
let want = GradientGeom { start: follow(geom.start, from, to), end: follow(geom.end, from, to), ..geom };
assert_geom_near(g, want, &what);
}
}
}
}
#[test]
fn item_effects_split_into_one_path_per_item() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
}
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let ch = m.children().unwrap();
assert_eq!(ch.len(), 2);
assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6);
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
}
#[test]
fn a_baked_stroke_keeps_its_raster_effects() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
s.effects.push(fx("stylize.dropShadow", json!({})));
}
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
}
#[test]
fn a_baked_stroke_leaves_dither_behind() {
// Strokes ignore dither, so the filled outline goes without it and looks as the stroke did.
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
g.gradient.dither = true;
s.paint = Paint::Gradient(Box::new(g));
}
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") };
assert!(!g.gradient.dither);
}
#[test]
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.width = 10.0;
s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
s.opacity = 0.5;
}
let m = bake_appearance(None, &n).unwrap();
let ch = m.children().unwrap();
let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes");
assert_eq!(ch[1].opacity, 0.5);
}
/// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform
/// effect `params`.
fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, stroke, width));
n.appearance.effects.push(fx("distort.transform", params));
n
}
/// The paths under `n`, in document order.
fn baked_paths(n: &Node) -> Vec<&Node> {
match n.children() {
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().flat_map(|c| baked_paths(c)).collect(),
_ => vec![n],
}
}
fn widths(n: &Node) -> Vec<f64> {
baked_paths(n).iter().map(|p| p.geometric_bounds().unwrap().width()).collect()
}
#[test]
fn a_baked_transform_copy_keeps_its_scaled_stroke() {
// Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline
// is 204 pt wide.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 2, "{w:?}");
assert!((w[1] - 102.0).abs() < 0.5 && (w[0] - 204.0).abs() < 0.5, "the original on top: {w:?}");
assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined");
}
#[test]
fn a_baked_transform_copy_keeps_its_moved_pattern() {
let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true}));
let m = bake_appearance(None, &n).unwrap();
let xs: Vec<f64> = baked_paths(&m)
.iter()
.map(|p| match p.appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs()[4],
p => panic!("not a pattern: {p:?}"),
})
.collect();
assert_eq!(xs, vec![30.0, 0.0], "the original on top");
}
#[test]
fn a_baked_transform_without_its_options_outlines_the_copies_together() {
// Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1}));
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 1, "{w:?}");
assert!((w[0] - 202.0).abs() < 0.5, "{w:?}");
}
/// Black 24 pt "Type" with its baseline at (20, 60).
fn type_node(id: u64) -> Node {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style))))
}
/// The origins of the type objects in `d`, in paint order.
fn type_origins(d: &Document) -> Vec<(f64, f64)> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let c = t.xf.as_coeffs();
out.push((c[4], c[5]));
}
});
out
}
/// A group of the type and a 10 pt square with `effects`.
fn type_group(effects: Vec<Effect>) -> Node {
let square = Node::path(NodeId(51), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut g = Node::group(NodeId(52), vec![Arc::new(type_node(50)), Arc::new(square)]);
g.appearance.effects = effects;
g
}
#[test]
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
if skip_without_craft_fonts() {
return;
}
for (params, origins, square) in [
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
(json!({"moveV": 50, "copies": 2}), vec![(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)], Rect::new(0.0, 0.0, 10.0, 110.0)),
] {
let g = type_group(vec![fx("distort.transform", params.clone()), fx("stylize.dropShadow", json!({}))]);
let d = bake_document(&doc_with(g.clone())).unwrap();
assert_eq!(type_origins(&d), origins, "{params}");
let baked = d.node(NodeId(52)).unwrap();
assert_eq!(baked.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
// The square moves with the type, as one path holding its copies.
let b = baked.children().unwrap().last().and_then(|s| s.geometric_bounds()).unwrap();
assert!([b.x0 - square.x0, b.y0 - square.y0, b.x1 - square.x1, b.y1 - square.y1].iter().all(|v| v.abs() < 1e-9), "{params}: {b:?}");
let mut ids = std::collections::HashSet::new();
baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id));
// The renderer and Expand Appearance still draw it as outlines.
let mut text = 0;
evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_))));
assert_eq!(text, 0, "{params}");
}
// Stacked effects that only move: every copy of a copy.
let d = bake_document(&doc_with(type_group(vec![
fx("distort.transform", json!({"moveH": 100, "copies": 1})),
fx("distort.transform", json!({"moveV": 100, "copies": 1})),
])))
.unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (120.0, 60.0), (20.0, 160.0), (120.0, 160.0)]);
}
#[test]
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut hidden = type_node(55);
hidden.visible = false;
g.children_mut().unwrap().push(Arc::new(hidden));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
assert!(!d.node(NodeId(55)).unwrap().visible);
}
#[test]
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut shadowed = type_node(55);
shadowed.opacity = 0.5;
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
// Type that clips a group is outlined as the plain outlines it clips by.
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(type_node(56)), Arc::new(square)], clip: true });
g.children_mut().unwrap().extend([Arc::new(shadowed), Arc::new(clip)]);
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
let o = d.node(NodeId(55)).unwrap();
assert!(matches!(o.kind, NodeKind::Group { .. }) && o.opacity == 0.5, "{o:?}");
assert_eq!(o.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
// The outlines are copied with the type.
let b = o.geometric_bounds().unwrap();
assert!(b.x0 < 30.0 && b.x1 > 220.0, "{b:?}");
let clipped = d.node(NodeId(58)).and_then(|n| n.children()).and_then(|c| c.first()).unwrap();
assert!(matches!(clipped.kind, NodeKind::Group { .. }) && clipped.opacity == 1.0, "{clipped:?}");
}
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
let mut moved = type_node(60);
moved.appearance.effects.push(fx("distort.transform", json!({"moveV": 300})));
let mut path = Node::group(NodeId(59), vec![Arc::new(moved)]);
path.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))));
let square = Node::path(NodeId(57), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
let clip = Node::new(NodeId(58), NodeKind::Group { children: vec![Arc::new(path), Arc::new(square)], clip: true });
g.children_mut().unwrap().push(Arc::new(clip));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
}
#[test]
fn type_with_its_own_transform_effect_copies_stays_type() {
if skip_without_craft_fonts() {
return;
}
let mut n = type_node(50);
n.opacity = 0.5;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
let d = bake_document(&doc_with(n)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (20.0, 110.0), (20.0, 160.0)]);
// One group keeps the object's transparency and its other effects; the copies are plain.
let g = d.node(NodeId(50)).unwrap();
assert!(matches!(g.kind, NodeKind::Group { .. }) && g.opacity == 0.5);
assert_eq!(g.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
for c in g.children().unwrap() {
assert!(c.opacity == 1.0 && c.appearance.effects.is_empty() && c.id != g.id, "{c:?}");
}
// Hidden type stays hidden.
let mut n = type_node(50);
n.visible = false;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 1}))];
let d = bake_document(&doc_with(n)).unwrap();
assert_eq!(type_origins(&d).len(), 2);
assert!(!d.node(NodeId(50)).unwrap().visible);
}
#[test]
fn type_under_other_geometry_effects_is_still_outlined() {
for effects in [
vec![fx("distort.transform", json!({"moveH": 30, "random": true}))],
vec![fx("distort.transform", json!({"moveH": 30, "scaleH": 50}))],
vec![fx("distort.transform", json!({"moveH": 30, "rotate": 10, "copies": 1}))],
vec![fx("distort.transform", json!({"moveH": 30, "reflectX": true}))],
vec![fx("distort.transform", json!({"moveH": 30})), fx("distort.roughen", json!({"size": 4}))],
// Copies that would reach past the effect's limit, which stops them early.
vec![fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))],
// Copies that overlap, or land on each other.
vec![fx("distort.transform", json!({"moveH": 5, "copies": 1}))],
vec![fx("distort.transform", json!({"moveH": 100, "copies": 1})), fx("distort.transform", json!({"moveH": -100, "copies": 1}))],
] {
let mut own = type_node(50);
own.appearance.effects = effects.clone();
for n in [type_group(effects.clone()), own] {
let d = bake_document(&doc_with(n)).unwrap();
assert!(type_origins(&d).is_empty(), "{effects:?}");
}
}
}
#[test]
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
// A 20 pt stroke on the characters reaches the copy below.
let mut stroked = type_node(50);
if let NodeKind::Text(t) = &mut stroked.kind {
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
t.runs[0].style.stroke_width = 20.0;
}
let mut g = type_group(moves);
g.children_mut().unwrap()[0] = Arc::new(stroked);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
#[test]
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
let mut translucent = type_node(50);
translucent.opacity = 0.5;
let mut underlined = type_node(50);
if let NodeKind::Text(t) = &mut underlined.kind {
t.runs[0].style.underline = true;
}
let mut shadowed = type_node(50);
shadowed.appearance.effects.push(fx("stylize.dropShadow", json!({})));
let mut gradient = type_node(50);
if let NodeKind::Text(t) = &mut gradient.kind {
t.runs[0].style.fill = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
}
let mut reshaped = type_node(50);
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
fill.effects.push(fx("path.offsetPath", json!({"offset": 2})));
reshaped.appearance.items.push(AppearanceItem::Fill(fill));
// A stroke of the type object's own (beyond its characters').
let mut stroked = type_node(50);
stroked.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
// Character strokes scale with type scaled by its transform, where the outlines keep theirs.
let mut scaled = type_node(50);
if let NodeKind::Text(t) = &mut scaled.kind {
t.runs[0].style.stroke = Paint::solid(Color::BLACK);
t.runs[0].style.stroke_width = 1.0;
t.xf *= Affine::scale(2.0);
}
let mut turned = scaled.clone();
if let NodeKind::Text(t) = &mut turned.kind {
t.xf = Affine::translate((20.0, 60.0)) * Affine::rotate(0.5);
}
let far = vec![fx("distort.transform", json!({"moveH": 300, "copies": 1}))];
for (t, kept) in [(turned, 2), (scaled, 0)] {
let mut g = type_group(far.clone());
g.children_mut().unwrap()[0] = Arc::new(t);
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept);
}
for t in [translucent, underlined, shadowed, gradient, reshaped, stroked] {
let mut g = type_group(moves.clone());
g.children_mut().unwrap()[0] = Arc::new(t);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
}
#[test]
fn type_in_a_moved_group_with_a_fill_of_its_own_is_still_outlined() {
let moves = vec![fx("distort.transform", json!({"moveV": 100, "copies": 1}))];
let fill = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)));
let mut g = type_group(moves.clone());
g.appearance.items.push(fill.clone());
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
// A group with a fill of its own inside the moved group.
let mut inner = type_group(vec![]);
inner.id = NodeId(53);
inner.appearance.items.push(fill);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects = moves;
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
}
#[test]
fn type_in_a_moved_group_inside_a_moved_group_is_still_outlined() {
// The inner copy lands back on the original.
let mut inner = type_group(vec![fx("distort.transform", json!({"moveH": -200, "copies": 1}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveH": 200, "copies": 1})));
assert!(type_origins(&bake_document(&doc_with(outer)).unwrap()).is_empty());
}
#[test]
fn type_whose_copies_hold_too_much_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let spaced = |spaces: usize| {
let mut n = type_node(50);
if let NodeKind::Text(t) = &mut n.kind {
t.runs[0].text = format!("a{}", " ".repeat(spaces));
}
Arc::new(n)
};
// 1001 copies of about 2,000 bytes stay type; of about 21,000 bytes each, past 20,000,000
// bytes together, they are outlined.
let copies = vec![fx("distort.transform", json!({"moveH": 100, "copies": 1000}))];
for (spaces, kept) in [(1000, 1001), (20_000, 0)] {
let mut g = type_group(copies.clone());
g.children_mut().unwrap()[0] = spaced(spaces);
assert_eq!(type_origins(&bake_document(&doc_with(g)).unwrap()).len(), kept, "{spaces} spaces");
}
// 1001 × 21 copies, past 10,000.
let mut g = type_group(vec![
fx("distort.transform", json!({"moveH": 100, "copies": 1000})),
fx("distort.transform", json!({"moveV": 100, "copies": 20})),
]);
g.children_mut().unwrap()[0] = spaced(0);
assert!(type_origins(&bake_document(&doc_with(g)).unwrap()).is_empty());
}
#[test]
fn copies_are_apart_when_their_boxes_only_touch() {
let b = Rect::new(0.0, 0.0, 10.0, 5.0);
assert!(apart(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(-10.0, -5.0)], b));
assert!(!apart(&[Vec2::ZERO, Vec2::new(9.0, 4.0)], b));
assert!(!apart(&[Vec2::new(100.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(100.0, 0.0)], b));
assert!(!apart(&[Vec2::new(-1.0, -1.0), Vec2::new(8.5, 3.5)], b));
// Offsets too far for the cells' range count as overlapping.
assert!(!apart(&[Vec2::ZERO, Vec2::new(1.0e9, 0.0)], Rect::new(0.0, 0.0, 1.0e-9, 1.0)));
}
#[test]
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 100, "copies": 1})));
let d = bake_document(&doc_with(outer)).unwrap();
assert!(type_origins(&d).is_empty());
// The glyphs take part in Add: the result reaches past the 10 pt square.
let b = d.node(NodeId(54)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.width() > 30.0, "{b:?}");
}
#[test]
fn type_is_copied_only_when_the_effect_makes_every_copy() {
// A 10 pt line with `n` anchors.
let line = |n: usize| {
let pts: Vec<Point> = (0..n).map(|i| Point::new(i as f64 * 10.0 / n as f64, 0.0)).collect();
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)])
};
let right = json!({"moveH": 10, "copies": 1000});
let down = json!({"moveV": 10, "copies": 1});
// 1000 copies of a path stay within 2,000,000 anchors up to 1998 anchors.
assert_eq!(transform_offsets(std::slice::from_ref(&right), &line(1998)).map(|o| o.len()), Some(1001));
assert!(transform_offsets(std::slice::from_ref(&right), &line(1999)).is_none());
// A second effect copies every copy.
assert_eq!(transform_offsets(&[right.clone(), down.clone()], &line(999)).map(|o| o.len()), Some(2002));
assert!(transform_offsets(&[right, down], &line(1000)).is_none());
// Copies stop before one reaches 1,000,000,000 pt from the origin.
let square = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 99})], &square).map(|o| o.len()), Some(100));
assert!(transform_offsets(&[json!({"moveH": 1.0e7, "copies": 100})], &square).is_none());
// An effect that also scales.
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
}
/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies.
fn gradient_copies(id: u64) -> Node {
use vectorcraft_color::{Gradient, 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 paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
n
}
/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right.
fn gradient_pieces(n: &Node) -> Vec<f64> {
let mut out = vec![];
let mut walk = vec![n];
while let Some(n) = walk.pop() {
match n.children() {
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)),
_ => {
assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}");
out.extend(n.geometric_bounds().map(|b| b.width()));
}
}
}
out
}
#[test]
fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
let d = bake_document(&doc_with(gradient_copies(50))).unwrap();
// Three squares of their own, not one path spanning all three.
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
}