Merge pull request #6363 from dotCooCoo/fix/issue-6316

Fix #6316: a Transform effect's copies of a group are whole groups below the original
This commit is contained in:
BFlat
2026-11-10 08:53:49 +07:00
committed by GitHub
9 changed files with 457 additions and 53 deletions
+2 -2
View File
@@ -603,7 +603,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
let m = bake_appearance(None, &n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 2, "{w:?}");
assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{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");
}
@@ -619,7 +619,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() {
p => panic!("not a pattern: {p:?}"),
})
.collect();
assert_eq!(xs, vec![0.0, 30.0]);
assert_eq!(xs, vec![30.0, 0.0], "the original on top");
}
#[test]
+1 -1
View File
@@ -247,7 +247,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
),
g(
+3 -2
View File
@@ -594,7 +594,7 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
};
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})), [0.0, 30.0, 60.0], "{:?}", kind());
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [60.0, 30.0, 0.0], "{:?}", kind());
}
}
@@ -677,7 +677,8 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
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 (i, c) in copies.iter().enumerate() {
// 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() {
+183 -29
View File
@@ -46,8 +46,8 @@ pub fn has_gradient_copies(n: &Node) -> bool {
/// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and
/// strokes that paint the copies differently as one path than as separate objects
/// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy
/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it
/// (#5920).
/// an object of its own below the one before ([`copy_leaf`]), as Expand Appearance makes it
/// (#5920, #6316).
pub(crate) fn has_layered_copies(n: &Node) -> bool {
leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n)
}
@@ -698,6 +698,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
let seed = art.id.0;
let mut used = art_cost(art);
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
let copy_whole = copies_whole(art);
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
@@ -738,6 +740,14 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
let options = crate::distort::TransformOptions::of(&params);
(m, copies, options, crate::distort::transform_affine(bounds, &params, seed))
});
// Art of several objects is copied whole, so the copies don't interleave member by member.
if let Some((m, copies, options, _)) = transform
&& copies > 0
&& copy_whole
{
used = copy_art(art, m, copies, copy_scaling(options), max);
continue;
}
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
@@ -747,7 +757,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
// strokes; a gradient paints each copy by itself, not the one piece the copies
// make together; and Expand Appearance (`apart`) makes each copy an object.
// Fills and strokes that stack paint each copy above the one before (#5920).
// Fills and strokes that stack paint each copy below the one before (#5920, #6316).
let separate = copies > 0
&& (apart
|| leaf.appearance.has_unplaced_gradient()
@@ -813,6 +823,19 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
/// doesn't grow with them (a horizontal line stretched vertically).
const MAX_STROKE_SCALE: f64 = 1.0e6;
/// How a Transform effect with `options` scales what it maps: strokes, effects and patterns as
/// its options say.
fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::Scaling {
vectorcraft_doc::Scaling {
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
keep_fit_when_flat: true,
..Default::default()
}
}
/// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with
/// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object
/// › Transform with those options transforms an object (#885): the strokes' weights and dashes
@@ -829,14 +852,7 @@ fn transform_leaf(
options: crate::distort::TransformOptions,
budget: Budget,
) -> (Budget, bool) {
let scaling = vectorcraft_doc::Scaling {
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
keep_fit_when_flat: true,
..Default::default()
};
let scaling = copy_scaling(options);
if !options.objects {
if options.strokes
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
@@ -861,36 +877,172 @@ fn transform_leaf(
(copy_leaf(leaf, path, m, copies, scaling, budget), true)
}
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies
/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves
/// with it) rather than spanning every copy, and its strokes and patterns can differ from the
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
/// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the
/// copies cost, the original included.
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
let mut made = Budget::of(path);
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
/// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a
/// group or layer of several objects, at least one of which paints, with no type left in it and no
/// fill or stroke of its own that paints. Type that stays type is copied in its place by the baking
/// that keeps it, a clipping group's first member is its clip, and the art's own fills and strokes
/// paint the shape of everything under it, copies included. A member that [`makes_art`] is copied
/// in its place as well: each copy would make that art again, past what [`copy_art`] counts.
fn copies_whole(art: &Node) -> bool {
fn parts(n: &Node, count: &mut usize, painted: &mut bool, text: &mut bool) {
match &n.kind {
NodeKind::Text(_) => *text = true,
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
*count += 1;
*painted |= n.appearance.items.iter().any(crate::paints);
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
*count += 1;
*painted = true;
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
*painted |= n.appearance.items.iter().any(crate::paints);
children.iter().for_each(|c| parts(c, count, painted, text));
}
_ => {}
}
}
if art.clips() || !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || art.appearance.items.iter().any(crate::paints) {
return false;
}
let (mut count, mut painted, mut text) = (0, false, false);
parts(art, &mut count, &mut painted, &mut text);
count > 1 && painted && !text && !art.children().into_iter().flatten().any(|c| makes_art(c))
}
/// Does `n` or anything visible under it, the art of its opacity masks included, carry a visible
/// effect that makes art when it is drawn (its own or that of a fill or stroke that paints), or is
/// any of them a compound shape? Raster effects, color adjustments and unknown effects make none.
fn makes_art(n: &Node) -> bool {
let makes = |e: &Effect| {
e.visible
&& (is_geometry(&e.id)
|| crate::is_pathfinder(&e.id)
|| crate::is_three_d(&e.id)
|| e.id == crate::CROP_MARKS
|| e.id == crate::addons::halftone::HALFTONE)
};
n.visible
&& (n.is_compound_shape()
|| n.appearance.effects.iter().any(makes)
|| n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(makes))
|| n.extras().mask.as_deref().is_some_and(|m| makes_art(&m.art))
|| n.children().into_iter().flatten().any(|c| makes_art(c)))
}
/// What the paths under `n` cost together, those of its opacity masks included.
fn copy_cost(n: &Node) -> Budget {
let mut cost = Budget { anchors: 0, lines: 0 };
vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| {
if let NodeKind::Path { guide: false, path, .. } = &c.kind {
cost = cost.plus(Budget::of(path));
}
});
cost
}
/// What the geometry effects of `leaf` (with geometry `path`) and of its fills and strokes add to
/// what `path` costs when it is drawn.
fn effects_cost(leaf: &Node, path: &PathData) -> Budget {
let none = Budget { anchors: 0, lines: 0 };
let items: Vec<&AppearanceItem> = leaf.appearance.items.iter().filter(|i| crate::paints(i) && crate::has_geometry(i.effects())).collect();
if items.is_empty() && !crate::has_geometry(&leaf.appearance.effects) {
return none;
}
let Some(bounds) = path.bounds() else { return none };
let ctx = GeomContext { seed: leaf.id.0, ..GeomContext::of(leaf) };
let own = crate::apply_geometry_with(&leaf.appearance.effects, path, bounds, &ctx);
let item_cost = |i: &&AppearanceItem| {
let b = own.bounds()?;
Some(Budget::of(&crate::apply_geometry_with(i.effects(), &own, b, &ctx.item(i))))
};
// The object's geometry is drawn as it is only by what has no geometry effect of its own.
let plain = items.is_empty() || leaf.appearance.items.iter().any(|i| crate::paints(i) && !crate::has_geometry(i.effects()));
let base = if plain { Budget::of(&own) } else { none };
base.plus(items.iter().filter_map(item_cost).fold(none, Budget::plus)).minus(Budget::of(path))
}
/// Remove the guides anywhere under `n`.
fn without_guides(n: &mut Node) {
let Some(children) = n.children_mut() else { return };
children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. }));
children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c)));
}
/// Replace the members of `art` by groups of them: the original on top and `copies` more below it,
/// each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies a
/// group, without its guides. The copies stop where [`copy_leaf`]'s would, before they pass `budget` or
/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what
/// the art costs after.
fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
let Some(members) = art.children().cloned() else { return art_cost(art) };
let original = Node::group(art.id, members);
let mut made = copy_cost(&original);
let mut drawn = original.clone();
without_guides(&mut drawn);
let mut nodes = original.count_with_masks();
let one = drawn.count_with_masks();
let mut placements: Vec<Arc<Node>> = vec![];
let mut xf = Affine::IDENTITY;
for _ in 0..copies {
xf *= m;
// A copy scaled up is drawn with more lines.
let cost = Budget::of(&path.transformed(xf));
if !budget.fits(made.plus(cost)) {
break;
}
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
break;
}
let mut copy = leaf.clone();
nodes = nodes.saturating_add(one);
if nodes > crate::distort::MAX_COPY_OBJECTS {
break;
}
let mut copy = drawn.clone();
copy.transform(xf, scaling);
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
let cost = copy_cost(&copy);
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
made = made.plus(cost);
}
// The copies become nodes of their own under a group with the object's id and name.
// The last copy is the lowest.
placements.reverse();
placements.push(Arc::new(original));
if let Some(children) = art.children_mut() {
*children = placements;
}
made
}
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
/// more below it, each mapped once more by `m` than the last (with `scaling`), as a Transform effect
/// copies an object. Each copy is an object of its own, so its gradients fit it (a placed gradient
/// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
/// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's
/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original
/// included.
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
let mut made = Budget::of(path).plus(effects_cost(leaf, path));
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
let mut xf = Affine::IDENTITY;
for _ in 0..copies {
xf *= m;
// A copy scaled up is drawn with more lines.
let moved = path.transformed(xf);
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
break;
}
let mut copy = leaf.clone();
copy.transform(xf, scaling);
// Each copy's own geometry effects make a different amount, as the copy and its effects scale.
let cost = Budget::of(&moved).plus(effects_cost(&copy, &moved));
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
made = made.plus(cost);
}
// The copies become nodes of their own under a group with the object's id and name, the object
// on top.
placements.reverse();
let mut g = Node::group(leaf.id, placements);
g.name = leaf.name.clone();
*leaf = g;
@@ -1463,6 +1615,8 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
placements.push(Arc::new(copy));
}
*room -= placements.len() - 1;
// The object stays on top of its copies, as a path does.
placements.reverse();
let mut g = Node::group(src.id, placements);
g.name = src.name.clone();
*c = Arc::new(g);
+261 -12
View File
@@ -105,7 +105,7 @@ fn each_copy_scales_its_stroke_once_more() {
let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
let art = reshape(None, &n, None).unwrap();
let widths: Vec<f64> = leaves(&art).iter().map(|p| stroke_of(p).0).collect();
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top");
// Without the option the copies keep the original's weight.
let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2}));
assert!(!has_transform_art(&plain));
@@ -127,7 +127,7 @@ fn transform_patterns_moves_each_copys_pattern() {
assert!(has_transform_options(&n));
let art = reshape(None, &n, None).unwrap();
let xs: Vec<f64> = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top");
// Without it, every copy's pattern stays put (the tiles don't follow the copies).
let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true}));
assert!(!has_transform_art(&n));
@@ -218,7 +218,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
let art = reshape(None, &n, None).unwrap();
let widths: Vec<f64> = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect();
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top");
}
#[test]
@@ -268,7 +268,7 @@ fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() {
assert!(has_transform_options(&n));
let art = reshape(None, &n, None).unwrap();
let xs: Vec<f64> = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top");
// Off, the pattern stays put.
assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2}))));
}
@@ -294,7 +294,7 @@ fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let drawn = reshape(None, &n, None).unwrap();
let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::<Vec<f64>>();
assert_eq!(widths(&drawn), vec![2.0, 4.0]);
assert_eq!(widths(&drawn), vec![4.0, 2.0], "the original on top");
// Expand Appearance.
assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn));
// Export.
@@ -394,9 +394,9 @@ fn filled_and_stroked(fill: Paint, params: Value) -> Node {
}
#[test]
fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
// Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its
// own above the one before, so its fill covers the strokes of the copies below.
fn copies_with_a_fill_and_a_stroke_are_drawn_one_below_another() {
// Black fill, white stroke, each copy scaled and turned (#5920, #6316): each copy is an object
// of its own below the one before, so the fills above cover the strokes of the copies below.
let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25});
let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
assert!(has_transform_art(&n));
@@ -407,10 +407,10 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy");
assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some());
}
// The original is at the bottom, the smallest copy on top.
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
let top = paths[25].geometric_bounds().unwrap();
assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}");
// The smallest copy is at the bottom, the original on top.
assert_eq!(paths[25].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
let bottom = paths[0].geometric_bounds().unwrap();
assert!(bottom.width() < 100.0 && bottom.height() < 100.0, "{bottom:?}");
// Drawn as Expand Appearance makes it.
assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26);
@@ -427,3 +427,252 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
// No copies: nothing to stack.
assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10}))));
}
/// A group of two squares side by side, each with a black fill and a white stroke, with the
/// Transform effect `params`.
fn two_squares(params: Value) -> Node {
let square = |id, x| {
let a = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0);
std::sync::Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), a))
};
let mut g = Node::group(NodeId(20), vec![square(21, 0.0), square(22, 120.0)]);
g.appearance.effects.push(transform(params));
g
}
#[test]
fn a_groups_copies_are_whole_groups_below_the_original() {
let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 95, "scaleV": 95, "copies": 10}))).unwrap();
let copies = art.children().unwrap();
assert_eq!(copies.len(), 11, "the original and 10 copies");
for c in copies {
assert_eq!(c.children().map(Vec::len), Some(2), "each copy holds both squares");
}
// The smallest copy is at the bottom, the original on top with its members in their order.
let width = |i: usize| copies[i].geometric_bounds().unwrap().width();
assert!((0..10).all(|i| width(i) < width(i + 1)), "{:?}", (0..11).map(width).collect::<Vec<_>>());
assert_eq!(copies[10].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 220.0, 100.0));
let members = copies[10].children().unwrap();
assert_eq!(members[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
assert_eq!(members[1].geometric_bounds().unwrap(), Rect::new(120.0, 0.0, 220.0, 100.0));
// Without copies the members stay members of the art.
let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 50, "scaleV": 50}))).unwrap();
assert_eq!(art.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [NodeId(21), NodeId(22)]);
// A group's own fill paints the shape of all its copies, so each member keeps its place.
let mut own_fill = two_squares(json!({}));
own_fill.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
let copies = transform(json!({"moveH": 300, "copies": 2}));
crate::reshape::reshape_leaves_within(&mut own_fill, &[&copies], crate::geometry::Budget::EFFECTS, None);
assert_eq!(own_fill.children().map(Vec::len), Some(2), "a stack of copies per member");
// A guide in the group stays one guide.
let mut guided = two_squares(json!({}));
let mut guide = Node::path(NodeId(23), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
if let NodeKind::Path { guide: on, .. } = &mut guide.kind {
*on = true;
}
if let Some(members) = guided.children_mut() {
members.push(std::sync::Arc::new(guide));
}
guided.appearance.effects.clear();
crate::reshape::reshape_leaves_within(&mut guided, &[&copies], crate::geometry::Budget::EFFECTS, None);
assert_eq!(guided.children().map(Vec::len), Some(3), "the original and 2 copies");
let mut guides = 0;
guided.walk(&mut |n| guides += usize::from(matches!(n.kind, NodeKind::Path { guide: true, .. })));
assert_eq!(guides, 1);
// A clipping group's first member stays its clip.
let mut clipping = two_squares(json!({}));
if let NodeKind::Group { clip, .. } = &mut clipping.kind {
*clip = true;
}
clipping.appearance.effects.clear();
crate::reshape::reshape_leaves_within(&mut clipping, &[&copies], crate::geometry::Budget::EFFECTS, None);
assert_eq!(clipping.children().map(Vec::len), Some(2), "the clip and the clipped member");
}
#[test]
fn a_groups_copies_stop_at_the_node_limit() {
// Empty groups cost no anchors, so only the count of nodes stops their copies.
let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![])));
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
members.extend(empties);
}
g.appearance.effects.clear();
let copies = transform(json!({"moveH": 1, "copies": 1000}));
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
let mut nodes = 0usize;
g.walk(&mut |_| nodes += 1);
assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}");
assert!(g.children().unwrap().len() < 1001, "the limit stopped the copies");
}
#[test]
fn a_groups_copies_count_the_art_of_opacity_masks() {
let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![]))).collect();
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
let mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(99), empties), true);
std::sync::Arc::make_mut(&mut members[0]).extras_mut().mask = Some(Box::new(mask));
}
g.appearance.effects.clear();
let copies = transform(json!({"moveH": 1, "copies": 1000}));
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
let nodes = g.count_with_masks();
assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}");
}
/// The anchors `n` draws with every effect inside it evaluated, group by group and member by
/// member as drawing and baking evaluate them. The count stops once it passes `limit`.
fn drawn_anchors(n: &Node, limit: usize) -> usize {
fn count(n: &Node, limit: usize, total: &mut usize) {
if *total > limit {
return;
}
if n.children().is_some() && !matches!(n.kind, NodeKind::Compound { .. }) {
let art = crate::evaluate_container(None, n).unwrap_or_else(|| n.clone());
art.children().into_iter().flatten().for_each(|c| count(c, limit, total));
return;
}
if let Some(art) = reshape(None, n, None) {
count(&art, limit, total);
return;
}
let Some((path, _)) = crate::reshape::leaf_geometry(n) else { return };
let Some(bounds) = path.bounds() else { return };
let own = crate::apply_geometry(&n.appearance.effects, &path, bounds);
let items: Vec<usize> = n
.appearance
.items
.iter()
.filter(|i| crate::has_geometry(i.effects()))
.filter_map(|i| Some(crate::apply_geometry(i.effects(), &own, own.bounds()?).anchor_count()))
.collect();
*total += if items.is_empty() { own.anchor_count() } else { items.iter().sum() };
}
let mut total = 0;
count(n, limit, &mut total);
total
}
#[test]
fn a_groups_copies_keep_what_the_effects_inside_them_make_to_the_budget() {
let most = crate::geometry::MAX_EFFECT_ANCHORS;
let copies = |key: &str| transform(json!({key: 1, "copies": 1000}));
// Copies of a group whose member group makes copies of its own.
let inner = two_squares(json!({"moveH": 1, "copies": 1000}));
let mut outer = Node::group(NodeId(30), vec![std::sync::Arc::new(inner)]);
outer.appearance.effects.push(copies("moveV"));
let drawn = drawn_anchors(&outer, 2 * most);
assert!(drawn <= most, "a group inside: {drawn}");
// Copies of a group whose member path makes copies of its own.
let mut own = two_squares(json!({"moveH": 1, "copies": 1000}));
if let Some(members) = own.children_mut() {
std::sync::Arc::make_mut(&mut members[0]).appearance.effects.push(copies("moveV"));
}
let drawn = drawn_anchors(&own, 2 * most);
assert!(drawn <= most, "a path's own effect: {drawn}");
// Copies of a group whose member's fill makes copies of its own.
let mut item = two_squares(json!({"moveH": 1, "copies": 1000}));
if let Some(members) = item.children_mut() {
std::sync::Arc::make_mut(&mut members[0]).appearance.items[0].effects_mut().push(copies("moveV"));
}
let drawn = drawn_anchors(&item, 2 * most);
assert!(drawn <= most, "a fill's own effect: {drawn}");
}
#[test]
fn copies_with_scaled_effects_keep_what_each_one_makes_to_the_budget() {
// Each copy's Roughen is twice as large as the last's, and the rough edges take more lines.
let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 40, "relative": false, "detail": 20}), visible: true };
let lines = |budget: crate::geometry::Budget| {
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
let m = std::sync::Arc::make_mut(&mut members[0]);
m.appearance.items.retain(AppearanceItem::is_fill);
m.appearance.effects.push(roughen.clone());
members.truncate(1);
}
g.appearance.effects.clear();
let copies = transform(json!({"scaleH": 200, "scaleV": 200, "copies": 5, "strokes": true}));
crate::reshape::reshape_leaves_within(&mut g, &[&copies], budget, None);
let (mut count, mut lines) = (0, 0);
for leaf in leaves(&g) {
count += 1;
let Some((path, _)) = crate::reshape::leaf_geometry(leaf) else { continue };
let drawn = crate::apply_geometry(&leaf.appearance.effects, &path, path.bounds().unwrap());
lines += crate::geometry::Budget::of(&drawn).lines;
}
(count, lines)
};
let (all, most) = lines(crate::geometry::Budget::EFFECTS);
assert_eq!(all, 6, "the original and 5 copies");
let cap = most / 2;
let budget = crate::geometry::Budget { anchors: crate::geometry::MAX_EFFECT_ANCHORS, lines: cap };
let (count, drawn) = lines(budget);
assert!(count < all, "the budget stopped the copies");
assert!(drawn <= cap, "{drawn} > {cap}");
}
#[test]
fn a_groups_copies_are_whole_only_when_nothing_inside_makes_more_art() {
let copies = transform(json!({"moveH": 300, "copies": 2}));
let effect = |id: &str| Effect { id: id.into(), params: json!({}), visible: true };
let roughen = effect("distort.roughen");
// Two squares under a group with `change` made to the first member.
let whole = |change: &dyn Fn(&mut Node)| {
let mut g = two_squares(json!({}));
if let Some(members) = g.children_mut() {
change(std::sync::Arc::make_mut(&mut members[0]));
}
g.appearance.effects.clear();
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
g.children().unwrap().len() == 3
};
// A group of one square with effect `e`, in place of the member.
let in_group = |m: &mut Node, e: &Effect| {
let square = Node::path(NodeId(40), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
let mut g = Node::group(NodeId(41), vec![std::sync::Arc::new(square), std::sync::Arc::new(m.clone())]);
g.appearance.effects.push(e.clone());
*m = g;
};
assert!(whole(&|_| {}));
// Effects that only change pixels, and effects turned off, are copied with the art.
assert!(whole(&|m| m.appearance.effects.push(effect("stylize.dropShadow"))));
assert!(whole(&|m| m.appearance.effects.push(Effect { visible: false, ..roughen.clone() })));
// Effects that draw nothing are copied with the art as well.
assert!(
whole(&|m| {
m.visible = false;
m.appearance.effects.push(roughen.clone());
}),
"a hidden member's effect"
);
assert!(
whole(&|m| {
*m.appearance.items[0].paint_mut() = Paint::None;
m.appearance.items[0].effects_mut().push(roughen.clone());
}),
"the effect of a fill that paints nothing"
);
assert!(whole(&|m| m.appearance.effects.push(effect("no.such.effect"))), "an unknown effect");
// Effects that make more art than a copy holds keep each member's copies in its place.
assert!(!whole(&|m| m.appearance.effects.push(roughen.clone())), "a member's own effect");
assert!(!whole(&|m| m.appearance.items[0].effects_mut().push(roughen.clone())), "a fill's own effect");
assert!(!whole(&|m| in_group(m, &roughen)), "a group's own effect");
assert!(!whole(&|m| in_group(m, &effect("pathfinder.add"))), "a Pathfinder effect");
assert!(
!whole(&|m| {
let mut art = Node::group(NodeId(43), vec![]);
in_group(&mut art, &roughen);
m.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
}),
"an effect in an opacity mask"
);
}
+3 -3
View File
@@ -581,7 +581,7 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
let copies = n.children().unwrap();
assert_eq!(copies.len(), 3, "the path and its two copies");
let xs: Vec<f64> = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect();
assert_eq!(xs, vec![100.0, 250.0, 400.0]);
assert_eq!(xs, vec![400.0, 250.0, 100.0], "the original on top");
for c in copies {
assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label());
assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0)));
@@ -600,10 +600,10 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
let columns = node(&s, id).children().unwrap().to_vec();
assert_eq!(columns.len(), 3);
for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) {
for (col, x) in columns.iter().zip([400.0, 250.0, 100.0]) {
let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label()));
let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect();
assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]);
assert_eq!(ys, vec![(x, 250.0), (x, 100.0)]);
assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. })));
}
}
+2 -2
View File
@@ -749,7 +749,7 @@ fn an_image_whose_pixels_cant_be_decoded_is_left_out_with_one_note() {
}
/// #2023: a group's Transform effect copies an image among its members as it does the paths: the
/// PDF draws the image three times, where the effect put it.
/// PDF draws the image three times, where the effect put it, the original on top.
#[test]
fn a_group_transform_effect_exports_its_image_copies() {
let mut d = doc(200.0, 100.0);
@@ -776,7 +776,7 @@ fn a_group_transform_effect_exports_its_image_copies() {
let out = roundtrip(&d);
let lefts: Vec<f64> =
leaves(&out).iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect();
assert_eq!(lefts, [20.0, 70.0, 120.0]);
assert_eq!(lefts, [120.0, 70.0, 20.0]);
}
/// Build a PDF with a correct xref table from object bodies.
+1 -1
View File
@@ -92,5 +92,5 @@ fn a_group_transform_copies_its_images() {
.filter(|l| l.starts_with("<image") || l.starts_with("<use"))
.filter_map(|l| l.split("transform=\"").nth(1)?.split('"').next())
.collect();
assert_eq!(placed, ["matrix(1 0 0 1 20 20)", "matrix(1 0 0 1 60 20)", "matrix(1 0 0 1 100 20)"], "{svg}");
assert_eq!(placed, ["matrix(1 0 0 1 100 20)", "matrix(1 0 0 1 60 20)", "matrix(1 0 0 1 20 20)"], "the original on top: {svg}");
}
+1 -1
View File
@@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|---|---|---:|---|
| Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — |
| Crop Marks | Crop Marks | 1/1 | — |
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints above the one before, its fill covering the strokes of the copies below, instead of one path of every copy (#5920) |
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612); drawn, each copy of a path with a fill and a stroke (or several, an even-odd fill or a partly transparent one) paints below the one before, the fills above covering the strokes of the copies below, instead of one path of every copy (#5920), and a group's copies each hold all its members except guides, the original group on top (#6316); a clipping group, a group with a fill or stroke of its own, a group whose type is kept as type, and a group with a member that carries a geometry, Pathfinder or 3D effect copy each member in its place |
| Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) |
| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own |
| Rasterize | Rasterize… (as a live effect) | 1/1 | — |