Effects: placed gradients move with Transform and Free Distort effects

A gradient placed with the Gradient tool stayed where it was when the
object's Transform or Free Distort effect moved, turned or scaled the
outline, so the shape slid across its own gradient.

The Transform effect now maps placed gradients by its own transform,
and each of its copies takes them as many times as it takes the
transform. Under Free Distort, each path's placed gradients move and
scale from the path's bounds before the effect to its bounds after it.
This change does not alter gradients fitted to the object.

The canvas, Expand Appearance and export, Pathfinder and compound
shapes, groups, a fill's or stroke's own effects, and Object › Expand
of a gradient fill apply the same rule. In a group, the own fills and
strokes of the group, of the groups inside it and of a symbol's art
follow it too, and images and symbol instances that a group's
Transform effect moves take the placed gradients of their own fills
and strokes along. A group whose path keeps its shape because the
effect would pass the geometry budget keeps its own gradients in
place.

This changes how existing documents draw: a gradient placed on a path,
or on an image's or symbol instance's own fill, inside a group with a
Transform effect now moves with it. Before, only the gradients of type
moved (#2214).

How gradients follow each effect is VectorCraft's own rule. The
Distort & Transform help page describes what the effects do to the
object:
https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html
This commit is contained in:
dotCooCoo
2026-11-07 21:11:07 -08:00
parent e89c036079
commit 142366d9ee
18 changed files with 928 additions and 91 deletions
+23 -12
View File
@@ -407,8 +407,9 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
let mut expanded = false;
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let art = gradient_fill(item).and_then(|(f, g)| {
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
let art = gradient_fill(item).and_then(|_| {
let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?;
let (f, g) = gradient_fill(&moved)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
let Some(art) = art else {
@@ -470,29 +471,39 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// visible geometry effect.
fn reshaped(l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
}
let (path, _) = node_path(l)?;
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
let mut m = with_path(l, g);
// Its gradients move with the outline, as the canvas paints them.
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
m.appearance.effects.retain(|e| !is_geometry(&e.id));
Some(m)
}
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
/// `path` as a plain path, and that outline's box.
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they
/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along
/// with the outline.
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry};
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds));
return Some((l.clone(), path.clone(), bounds, item.clone()));
}
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let b = outline.bounds()?;
Some((with_path(l, outline.clone()), outline, b))
let mut one = Appearance { items: vec![item.clone()], ..Default::default() };
if carry.moves(&one) {
carry.apply(&mut one);
}
let moved = one.items.pop().unwrap_or_else(|| item.clone());
Some((with_path(l, outline.clone()), outline, b, moved))
}
/// Path or compound path `l` with `path` as its path, a plain path.
@@ -809,7 +820,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &
later -= usize::from(fill.is_some());
let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left };
let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| {
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?;
let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else {
full = true;
return None;
+31
View File
@@ -133,6 +133,37 @@ fn white_black() -> Value {
json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]})
}
#[test]
fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it() {
// The object's own effect, and the fill's (the first appearance item).
let slant = json!({"corners": [[0.25, 0.0], [1.25, 0.0], [1.0, 1.0], [0.0, 1.0]]});
for (effect, params, item) in [
("distort.transform", json!({"rotate": 90}), json!(null)),
("distort.transform", json!({"rotate": 30, "scaleH": 60}), json!(null)),
("distort.transform", json!({"rotate": 90}), json!(0)),
("distort.freeDistort", slant.clone(), json!(null)),
("distort.freeDistort", slant, json!(0)),
] {
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(40.0, 70.0, 160.0, 130.0), white_black(), false);
// Placed from a quarter of the way across to the right edge.
run(&mut s, "paint.setGradientGeom", json!({"start": [70, 100], "end": [160, 100], "stroke": false}));
let placed = s.doc().unwrap().doc.node(id).and_then(|n| match n.appearance.fill_paint() {
vectorcraft_color::Paint::Gradient(g) => g.geom,
_ => None,
});
assert!(placed.is_some(), "the fill's gradient is placed");
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
let before = render(&s);
// Fill alone: the gradient becomes strips inside the outline the effect makes.
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
let after = render(&s);
// The gradient moves with the rectangle on the canvas, and the strips follow it.
let diff = mean_diff(&before, &after);
assert!(diff < 1.0, "{effect} {params} on item {item}: mean difference {diff}");
}
}
#[test]
fn eight_strips_match_the_gradient_samples() {
let mut s = session();