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
+13
View File
@@ -32,6 +32,19 @@ pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &GeomContext::of(n))
}
/// [`apply`], and how gradients follow the effects ([`effects::Carry`]).
pub(super) fn apply_carrying(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> (BezPath, effects::Carry) {
if !effects::has_geometry(effects) || bp.elements().is_empty() {
return (bp.clone(), effects::Carry::default());
}
effects::apply_geometry_bez_carrying(effects, bp, bp.bounding_box(), ctx)
}
/// [`effected_path`], and how `n`'s gradients follow its effects.
pub(crate) fn effected_path_carrying(n: &Node, bp: &BezPath) -> (BezPath, effects::Carry) {
apply_carrying(&n.appearance.effects, bp, &GeomContext::of(n))
}
pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] {
match item {
AppearanceItem::Fill(f) => &f.effects,
+6 -1
View File
@@ -360,6 +360,11 @@ pub(super) fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize {
pub(crate) struct FxGeom {
pub(crate) node: Arc<Node>,
pub(super) g: Arc<BezPath>,
pub(super) items: Vec<Option<Arc<BezPath>>>,
/// The node with its gradients moved along with its effects, when they move.
pub(super) carried: Option<Arc<Node>>,
pub(super) items: Vec<Option<ItemGeom>>,
pub(crate) stamp: u64,
}
/// One appearance item's own effected geometry, and how its gradient follows those effects.
pub(super) type ItemGeom = (Arc<BezPath>, Arc<effects::Carry>);
+18 -7
View File
@@ -1,5 +1,5 @@
use super::bounds::{apply, item_effects};
use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, KeptImage, Made, Reads, feather_outline};
use super::bounds::{apply_carrying, item_effects};
use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, ItemGeom, KeptImage, Made, Reads, feather_outline};
#[cfg(test)]
use super::raster::RASTERIZED;
use super::raster::{
@@ -624,7 +624,7 @@ impl Renderer {
g: &BezPath,
rule: FillRule,
gctx: &GeomContext,
mut cache: Option<&mut Vec<Option<Arc<BezPath>>>>,
mut cache: Option<&mut Vec<Option<ItemGeom>>>,
) {
let bounds = g.bounding_box();
for (j, item) in n.appearance.items.iter().enumerate() {
@@ -633,12 +633,23 @@ impl Renderer {
}
// The item's own geometry effects on `g`, from the cache when there is one.
let slot = cache.as_deref_mut().and_then(|c| c.get_mut(j));
let ig: Arc<BezPath> = match slot {
Some(Some(ig)) => ig.clone(),
Some(s) => s.insert(Arc::new(apply(item_effects(item), g, &gctx.item(item)))).clone(),
None => Arc::new(apply(item_effects(item), g, &gctx.item(item))),
let made = || {
let (ig, carry) = apply_carrying(item_effects(item), g, &gctx.item(item));
(Arc::new(ig), Arc::new(carry))
};
let (ig, carry): ItemGeom = match slot {
Some(Some(e)) => e.clone(),
Some(s) => s.insert(made()).clone(),
None => made(),
};
let ig = &*ig;
// The item with its gradient moved along with its own effects.
let carried = (!carry.is_empty() && matches!(item.paint(), vectorcraft_color::Paint::Gradient(_))).then(|| {
let mut ap = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() };
carry.apply(&mut ap);
ap.items.pop()
});
let item = carried.as_ref().and_then(Option::as_ref).unwrap_or(item);
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
let rfx = effects::raster_effects(item_effects(item));
if rfx.is_empty() {
+31 -11
View File
@@ -1,5 +1,5 @@
use super::bounds::{cull_bounds, effected_path};
use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, Kept, Made, Reads, Shadow, ShadowEntry};
use super::bounds::{cull_bounds, effected_path_carrying};
use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry};
use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted};
use super::raster::{
DRAG_PIXEL_BUDGET, MAX_RASTERIZE, PIXEL_BUDGET, ViewCrop, content_changing, doc_raster_ppi, image_size, linear_key, pan_margin, pcolor,
@@ -314,29 +314,39 @@ impl Renderer {
let stamp = self.stamp;
let e = match self.fx_geom.remove(&key) {
Some(e) if Arc::ptr_eq(&e.node, &o) => e,
_ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp },
_ => {
let (g, carry) = effected_path_carrying(n, bp);
FxGeom { g: Arc::new(g), carried: carried(n, &carry), items: vec![None; n.appearance.items.len()], node: o, stamp }
}
};
(key, FxGeom { stamp, ..e })
});
let g = match &cached {
Some((_, e)) => e.g.clone(),
None => Arc::new(effected_path(n, bp)),
let (g, carried) = match &cached {
Some((_, e)) => (e.g.clone(), e.carried.clone()),
None => {
let (g, carry) = effected_path_carrying(n, bp);
(Arc::new(g), carried(n, &carry))
}
};
self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items));
let paints = carried.as_deref().unwrap_or(n);
self.draw_shape_fx_geom(ctx, f, n, paints, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items));
if let Some((key, e)) = cached {
self.fx_geom.insert(key, e);
}
}
/// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry.
/// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry:
/// `paints` is `n` with the fills and strokes it paints.
#[allow(clippy::too_many_arguments)]
fn draw_shape_fx_geom(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
n: &Node,
paints: &Node,
g: &BezPath,
rule: FillRule,
items: Option<&mut Vec<Option<Arc<BezPath>>>>,
items: Option<&mut Vec<Option<ItemGeom>>>,
) {
if g.elements().is_empty() {
return;
@@ -345,7 +355,7 @@ impl Renderer {
self.hairline(ctx, f, g, [0, 0, 0, 255]);
return;
}
let gctx = GeomContext::of(n);
let gctx = GeomContext::of(paints);
let rfx = effects::raster_effects(&n.appearance.effects);
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
@@ -355,7 +365,7 @@ impl Renderer {
let reach = bounds.map_or(reach, |b| reach.union(b));
let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None };
let mut items = items;
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut()));
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, paints, g, rule, &gctx, items.as_deref_mut()));
ctx.set_transform(Affine::IDENTITY);
}
@@ -772,6 +782,16 @@ impl Renderer {
}
}
/// `n` with the gradients of its fills and strokes moved along with its effects by `carry`, or
/// `None` when they stay where they are.
fn carried(n: &Node, carry: &effects::Carry) -> Option<Arc<Node>> {
carry.moves(&n.appearance).then(|| {
let mut m = n.clone();
carry.apply(&mut m.appearance);
Arc::new(m)
})
}
#[cfg(test)]
mod tests {
use super::*;
+46
View File
@@ -811,6 +811,52 @@ fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() {
}
}
#[test]
fn a_paths_gradient_moves_with_its_transform_and_free_distort() {
use vectorcraft_color::GradientGeom;
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));
// Placed across the square from its left edge (red) to its right edge (blue).
let geom = GradientGeom { start: Point::new(10.0, 50.0), end: Point::new(40.0, 50.0), aspect: 1.0, focal: None };
let draw = |paint: &GradientPaint, e: Option<Effect>| {
let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0));
let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint.clone())), Paint::None, 0.0));
n.appearance.effects = e.into_iter().collect();
render(&doc_with(Document::new(100.0, 100.0), n))
};
let placed = GradientPaint { geom: Some(geom), ..ramp.clone() };
let rb = |img: &Rendered, x: u32, y: u32| {
let p = img.pixel(x, y);
(p[0], p[2])
};
let red = |(r, b): (u8, u8)| r > 200 && b < 60;
let blue = |(r, b): (u8, u8)| b > 200 && r < 60;
// Moved 40 pt right by Transform, and 30 pt (one width) right by a Free Distort that moves
// every corner.
let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]});
for e in [fx("distort.transform", json!({"moveH": 40})), fx("distort.freeDistort", corners)] {
let img = draw(&placed, Some(e.clone()));
let moved = if e.id == "distort.transform" { 40 } else { 30 };
let (left, right) = (11 + moved, 38 + moved);
assert!(red(rb(&img, left, 50)) && blue(rb(&img, right, 50)), "{}: {:?} {:?}", e.id, rb(&img, left, 50), rb(&img, right, 50));
}
// Turned a quarter: the gradient runs from top to bottom (or bottom to top).
let turn = fx("distort.transform", json!({"rotate": 90}));
let img = draw(&placed, Some(turn));
let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63));
assert!((red(top) && blue(bottom)) || (blue(top) && red(bottom)), "{top:?} {bottom:?}");
assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "the same color across");
// Scaled to half its width about its center: the gradient spans the narrower square.
let img = draw(&placed, Some(fx("distort.transform", json!({"scaleH": 50}))));
assert!(red(rb(&img, 18, 50)) && blue(rb(&img, 31, 50)), "{:?} {:?}", rb(&img, 18, 50), rb(&img, 31, 50));
// The fill's own Transform moves its gradient too.
let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0));
let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(placed.clone())), Paint::None, 0.0));
n.appearance.items[0].effects_mut().push(fx("distort.transform", json!({"moveH": 40})));
let img = render(&doc_with(Document::new(100.0, 100.0), n));
assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50));
}
#[test]
fn darkness_subtracts_from_rgb_before_shadow_opacity() {
for opacity in [50.0, 100.0] {