Effects: a Transform effect's copies of a filled and stroked path stack one above another (#5920)

A path whose fills and strokes stack (a fill and a stroke, several of
either, an even-odd fill, or a partly transparent or blended one) drew
its Transform effect's copies as one path, so every copy's stroke lay on
top of every copy's fill. It now draws each copy as an object of its own
above the one before, as Expand Appearance makes them.

Fixes #5920
This commit is contained in:
Don Omondi
2026-11-06 23:04:56 +07:00
committed by bflatastic
parent 7d465d4fa3
commit 6537396ee0
3 changed files with 79 additions and 5 deletions
+33 -3
View File
@@ -42,6 +42,31 @@ pub fn has_gradient_copies(n: &Node) -> bool {
(n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) && has_transform_copies(n)
}
/// 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).
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)
}
/// Do the fills and strokes `a` of a path with fill rule `rule` paint a Transform effect's copies
/// differently when the copies are one path than when each is an object of its own? They do
/// unless they paint once, in full and in Normal mode, with a stroke or a nonzero fill: then the
/// one path paints what the copies paint together. A fill and a stroke paint each copy's stroke
/// under the fills of the copies above it, a second fill or stroke likewise, the even-odd rule
/// leaves holes where copies overlap, and a partly transparent or blended fill or stroke paints
/// overlapping copies twice (#5920).
fn paints_copies_apart(a: &Appearance, rule: FillRule) -> bool {
let mut painting = a.items.iter().filter(|i| crate::paints(i));
let (Some(one), None) = (painting.next(), painting.next()) else {
// None paints (nothing to draw either way), or several do.
return a.items.iter().any(crate::paints);
};
one.opacity() < 1.0 || one.blend() != Default::default() || (one.is_fill() && rule != FillRule::NonZero)
}
/// Is `n` a path or compound path with a visible Transform effect that makes copies? Expand
/// Appearance makes each copy an object of its own ([`expand_outlined`]).
pub fn has_transform_copies(n: &Node) -> bool {
@@ -77,10 +102,10 @@ fn reaches_appearance(e: &Effect) -> bool {
}
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
/// ([`has_gradient_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
/// that art ([`reshape`]).
pub fn has_transform_art(n: &Node) -> bool {
has_gradient_copies(n) || has_transform_options(n)
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n)
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
@@ -636,7 +661,12 @@ 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.
let separate = copies > 0 && (apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
// Fills and strokes that stack paint each copy above the one before (#5920).
let separate = copies > 0
&& (apart
|| leaf.appearance.has_unplaced_gradient()
|| leaf.appearance.has_placed_gradient()
|| paints_copies_apart(&leaf.appearance, rule));
if options.reach_appearance() {
let made = transform_leaf(leaf, &path, map, copies, options, room);
used = used.minus(before).plus(made);
+43
View File
@@ -384,3 +384,46 @@ fn a_strokes_own_transform_leaves_room_for_a_fill_above_it() {
assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}");
assert!(Budget::EFFECTS.fits(cost), "{cost:?}");
}
/// A 100 pt square at the origin painted with `fill` and a 1 pt white stroke, with the Transform
/// effect `params`.
fn filled_and_stroked(fill: Paint, params: Value) -> Node {
let mut n = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, Paint::solid(Color::WHITE), 1.0));
n.appearance.effects.push(transform(params));
n
}
#[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.
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));
let art = reshape(None, &n, None).unwrap();
let paths = leaves(&art);
assert_eq!(paths.len(), 26, "the original and 25 copies");
for p in &paths {
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:?}");
// Drawn as Expand Appearance makes it.
assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26);
// One opaque stroke, or one nonzero fill, paints the copies alike as one path: no art.
assert!(!has_transform_art(&filled_and_stroked(Paint::None, params.clone())));
let mut fill_only = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
fill_only.appearance.items.retain(AppearanceItem::is_fill);
assert!(!has_transform_art(&fill_only));
// A partly transparent fill paints where copies overlap twice.
if let Some(AppearanceItem::Fill(f)) = fill_only.appearance.items.first_mut() {
f.opacity = 0.5;
}
assert!(has_transform_art(&fill_only));
// No copies: nothing to stack.
assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10}))));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); previously: Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -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) |
| 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) |
| 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 | — |
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. |
| 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. |
| 2026-10-30 | minor | Pixelate › Color Halftone: Max Radius is in pixels of the Document Raster Effects resolution, 4–127 px, as the [filter effects reference](https://helpx.adobe.com/photoshop/using/filter-effects-reference.html) gives it, instead of points (#3260): fine screens are possible at print resolutions (4 px at 300 ppi ≈ 0.96 pt). The dialog and the Appearance panel show it in px whatever the document units; documents saved before (document versions 1 and 2) open with each radius converted, so their dots stay the same size, and a file saved for an older app version (native versions before today's) holds them in points again. Scores unchanged. Tests: `vectorcraft_effects` `color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution` and `color_halftones_radius_is_in_raster_pixels_not_a_distance`, `vectorcraft_doc` `halftone_radii::tests`, `vectorcraft_format` `halftone_radii_of_older_documents_open_in_pixels`, `vectorcraft_engine` `a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels`, `dialogs::tests::color_halftones_max_radius_stays_in_pixels_whatever_the_units`. |
| 2026-10-30 | minor | Effect dialogs: bounded length fields (Color Halftone Max Radius) clamp on commit and visibly reject invalid input (#4638); scores unchanged. |