Effects: Object › Expand, Outline Stroke and 3D keep the raster effects a Transform effect moves

A raster effect listed before a Transform effect follows that Transform
effect on the canvas. Several commands apply and remove the geometry
effects, and they dropped the Transform effect while the raster effect
kept its old values: Object › Expand of a gradient or pattern fill,
Outline Stroke (also Expand's Stroke option and Flatten Transparency's
Convert All Strokes to Outlines) and the 3D effects. For a square with a
Drop Shadow and a 200% Transform effect, the canvas drew a 20 pt offset
and these commands gave a 10 pt one.

- These commands now keep the raster effects at the values the canvas
  uses (without_geometry).
- A fill's or stroke's own Transform effect with Transform Patterns or
  Scale Strokes & Effects on now transforms the raster effects listed
  before it, as one without these options does. Before, Scale Strokes &
  Effects only scaled them, and Transform Patterns left them as they
  were.
- In a fill's or stroke's own effects, a Transform effect that makes
  copies or moves at random keeps the raster effects before it from
  following the Transform effects after it, as in an object's effects.
- The params text and docs/target-app-parity.md say that pixel filters
  keep their angles and light directions.
This commit is contained in:
dotCooCoo
2026-11-12 06:24:48 -08:00
parent 6129853555
commit ccc962300c
12 changed files with 298 additions and 70 deletions
+1 -1
View File
@@ -265,7 +265,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 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; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales), 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; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), 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(
+1 -1
View File
@@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
+49 -22
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Color};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::merged_params;
use crate::pixel::{self, PixelFx, PixelSpace};
@@ -437,7 +437,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
/// random follows stays as it is, as does one after the last Transform effect. Borrowed when
/// nothing changes.
pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> {
if !effects.iter().any(|e| e.visible && e.id == TRANSFORM) {
if !effects.iter().any(|e| e.visible && e.id == "distort.transform") {
return Cow::Borrowed(effects);
}
let mut out: Option<Vec<Effect>> = None;
@@ -445,16 +445,8 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> {
// `None` once one of them makes copies or moves at random.
let mut after = Some(Affine::IDENTITY);
for (i, e) in effects.iter().enumerate().rev() {
if e.id == TRANSFORM {
if !e.visible {
continue;
}
let p = merged_params(&e.id, &e.params);
// With Transform Objects off the art stays where it is.
if flag(&p, "objects", true) {
let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0);
after = after.filter(|_| copies == 0 && !flag(&p, "random", false)).map(|a| a * linear(map));
}
if e.id == "distort.transform" {
after = after.and_then(|a| transform_step(e).map(|s| a * s));
continue;
}
let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue };
@@ -467,13 +459,54 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> {
}
/// `effects` without their geometry effects, each raster effect following the Transform effects
/// it loses ([`followed`]): what an object keeps once its geometry effects are applied to its art.
pub(crate) fn without_geometry(effects: &[Effect]) -> Vec<Effect> {
/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects
/// are applied to its art.
pub fn without_geometry(effects: &[Effect]) -> Vec<Effect> {
followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect()
}
/// The Transform effect's id.
const TRANSFORM: &str = "distort.transform";
/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as
/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false`
/// when `t` leaves them as they are.
pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool {
let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false };
let mut changed = false;
for e in effects.iter_mut().filter(|e| follows(&e.id)) {
follow_transform(e, a);
changed = true;
}
changed
}
/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the
/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform
/// effect among them that makes copies or moves at random.
pub(crate) fn held_back(effects: &[Effect]) -> usize {
let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 };
effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count()
}
/// Does `e` stop the raster effects listed before it from following the Transform effects after
/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random?
pub(crate) fn holds_back(e: &Effect) -> bool {
transform_step(e).is_none()
}
/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map
/// when it is a visible Transform effect that moves the art once, the identity when it is no
/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None`
/// when it makes copies or moves at random.
fn transform_step(e: &Effect) -> Option<Affine> {
if e.id != "distort.transform" || !e.visible {
return Some(Affine::IDENTITY);
}
let p = merged_params(&e.id, &e.params);
if !flag(&p, "objects", true) {
return Some(Affine::IDENTITY);
}
let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0);
(copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO))
}
/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG
/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room
@@ -482,12 +515,6 @@ fn follows(id: &str) -> bool {
crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE
}
/// `a` without its translation.
fn linear(a: Affine) -> Affine {
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
Affine::new([a0, a1, a2, a3, 0.0, 0.0])
}
/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as
/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean
/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with
+55 -13
View File
@@ -1263,9 +1263,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
let params = merged_params(&fx[at].id, &fx[at].params);
// Where each effect the piece keeps sits among the item's effects.
let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect();
// The effects before the Transform effect that it keeps, each raster effect following the
// geometry effects before it ([`crate::raster::without_geometry`]).
let kept_before = crate::without_geometry(before);
if let Some(plugin) = painted_plugin(&fx[at]) {
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect();
*it.effects_mut() = rest;
let mut one = Appearance { items: vec![it.clone()], ..Default::default() };
if carry.moves(&one) {
@@ -1287,7 +1290,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
group.push(Arc::new(p));
}
let mut p = Node::group(leaf.id, group);
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc);
pieces.push(Arc::new(p));
continue;
}
@@ -1295,16 +1298,20 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(tb, &params, ctx.seed);
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect();
*it.effects_mut() = rest;
let mut p = piece(vec![it]);
if carry.moves(&p.appearance) {
carry.apply(&mut p.appearance);
}
set_geometry(&mut p, g.clone(), rule);
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room);
*used = used.plus(made);
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
let from = crate::raster::held_back(before);
if moved {
follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]);
}
transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
@@ -1324,24 +1331,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source,
/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the
/// piece was made from, and the index among that item's effects of each effect the piece kept,
/// which a deferred live-effect plug-in is listed by.
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) {
/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect
/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before
/// the piece's effects that makes copies or moves at random holds back. The geometry effects then
/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with
/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in
/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the
/// fill or stroke the piece was made from, and the index among that item's effects of each effect
/// the piece kept, which a deferred live-effect plug-in is listed by.
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) {
let mut count = 0;
for_each_leaf(p, &mut |l| {
if let Some(item) = l.appearance.items.first() {
count = count.max(item.effects().len());
}
});
let mut held = held;
for j in 0..count {
let Some(bounds) = p.geometric_bounds() else { break };
let from = held.min(j);
let mut holds = false;
for_each_leaf(p, &mut |leaf| {
let Some(item) = leaf.appearance.items.first().cloned() else { return };
let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return };
holds |= crate::raster::holds_back(&e);
// The effects before this one as they were before it moved the path.
let was = item.effects().get(from..j).unwrap_or_default();
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
@@ -1358,8 +1373,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m
let (_, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(bounds, &params, seed);
let (made, _) = transform_leaf(leaf, &path, map, copies, options, room);
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
*used = used.minus(before).plus(made);
if moved {
follow_piece(leaf, was, from, &e);
}
return;
}
let appearance = leaf.appearance.clone();
@@ -1376,7 +1394,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m
carry.apply(&mut leaf.appearance);
}
set_geometry(leaf, out, rule);
follow_piece(leaf, was, from, &e);
});
if holds {
held = j;
}
}
for_each_leaf(p, &mut |leaf| {
for item in &mut leaf.appearance.items {
@@ -1385,6 +1407,26 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m
});
}
/// Make the raster effects of `leaf`'s first fill or stroke from index `from` on follow Transform
/// effect `t`, listed after them, once `t` has moved the leaf ([`crate::raster::follow_one`]).
/// `was` holds those effects as they were before `t` was applied: each one that follows takes the
/// value [`crate::raster::followed`] gives it, in place of what Scale Strokes & Effects made of it,
/// and the others keep theirs.
fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) {
let mut follow = was.to_vec();
if !crate::raster::follow_one(&mut follow, t) {
return;
}
let Some(item) = leaf.appearance.items.first_mut() else { return };
for (k, (new, old)) in follow.into_iter().zip(was).enumerate() {
if new != *old
&& let Some(e) = item.effects_mut().get_mut(from + k)
{
*e = new;
}
}
}
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
+7 -12
View File
@@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
use vectorcraft_three_d::{Edge, Revolve};
use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds};
use crate::solids::{
Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds,
};
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
@@ -558,17 +560,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
}
children.push(Arc::new(surface));
// Later geometry acts on the projected art as a whole through container evaluation.
remaining = n
.appearance
.effects
.iter()
.enumerate()
.filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
remaining = past_effect(&n.appearance.effects, index);
remaining.retain(|e| !is_revolve(&e.id));
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining.retain(|e| !is_geometry(&e.id));
remaining = crate::without_geometry(&remaining);
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
@@ -830,8 +826,7 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
// One shadow for every member's surface, behind them all.
let mut children = children;
children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?);
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let remaining = past_effect(&n.appearance.effects, index);
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
+11 -11
View File
@@ -913,17 +913,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?;
children.extend(shadow.into_iter().chain(surfaces));
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
.effects
.iter()
.enumerate()
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
remaining = past_effect(&n.appearance.effects, index);
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
remaining = crate::without_geometry(&n.appearance.effects);
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in &paints {
@@ -959,14 +952,21 @@ fn group_solid_art(n: &Node) -> Option<Node> {
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let (shadow, surfaces) = art_for(n, fx, &shapes)?;
let children = shadow.into_iter().chain(surfaces).collect();
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let remaining = past_effect(&n.appearance.effects, index);
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the
/// art it projects. Each raster effect before it follows the Transform effects among those
/// ([`crate::without_geometry`]); the effects after it stay as they are.
pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec<Effect> {
let (before, after) = effects.split_at(index.min(effects.len()));
crate::without_geometry(before).into_iter().chain(after.iter().skip(1).cloned()).collect()
}
/// One solid of a 3D object as a mesh, with the color it is painted.
#[derive(Clone, Debug, PartialEq)]
pub struct SolidMesh {
+116
View File
@@ -125,6 +125,122 @@ fn rasterize_before_a_transform_keeps_its_options() {
assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]);
}
/// An SVG filter keeps its options: it paints in its own user space.
#[test]
fn an_svg_filter_before_a_transform_keeps_its_options() {
let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]}));
let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))];
assert_eq!(crate::raster::followed(&effects)[0], filter);
assert_eq!(crate::raster::without_geometry(&effects), vec![filter]);
}
/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects
/// and from each fill's and stroke's.
fn shadows_in(n: &Node) -> Vec<(f64, f64)> {
let mut out = vec![];
n.walk(&mut |m| {
let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects()));
for list in lists {
for r in raster_effects(list) {
if let RasterFx::DropShadow { dx, dy, .. } = r {
out.push((dx, dy));
}
}
}
});
out
}
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9)
}
/// A square whose fill carries `effects` of its own.
fn fill_square(id: u64, effects: Vec<Effect>) -> Node {
let mut n = square(id, vec![]);
if let Some(item) = n.appearance.items.first_mut() {
*item.effects_mut() = effects;
}
n
}
/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are
/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option
/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the
/// Transform effect a second time.
#[test]
fn a_fills_shadow_follows_the_fills_own_transform() {
let cases = [
(json!({"rotate": 90}), (0.0, -10.0)),
(json!({"rotate": 90, "strokes": true}), (0.0, -10.0)),
(json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)),
(json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)),
(json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)),
];
for (params, want) in cases {
let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]);
// Drawn and expanded through its art when an option makes some.
if crate::has_transform_art(&n) {
let art = crate::reshape(None, &n, None).unwrap();
assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art));
let expanded = expand_outlined(None, &n, None).unwrap();
assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded));
} else {
assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n));
}
let mut d = Document::new(800.0, 800.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
let baked = bake_document(&d).unwrap();
let b = baked.node(NodeId(11)).unwrap();
assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b));
}
}
/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the
/// shadow between them, which the first one scaled; a Transform effect that moves at random holds
/// the shadow before it back from the Transform effects after it.
#[test]
fn a_fills_later_transform_turns_the_shadow_its_options_scaled() {
let n = fill_square(
12,
vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))],
);
let art = crate::reshape(None, &n, None).unwrap();
assert!(near(&shadows_in(&art), &[(0.0, -20.0)]), "{:?}", shadows_in(&art));
let held = fill_square(
13,
vec![
shadow(10.0, 0.0, 0.0),
transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})),
transform(json!({"rotate": 90})),
],
);
let art = crate::reshape(None, &held, None).unwrap();
let drawn = shadows_in(&art);
assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}");
}
/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the
/// object's and its fill's 3D effect alike.
#[test]
fn a_shadow_before_a_transform_follows_it_into_3d_art() {
let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))];
for three_d in [crate::EXTRUDE, crate::REVOLVE] {
let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) };
let mut on_object = before.clone();
on_object.push(fx(three_d, json!({})));
let projected = art(&square(14, on_object)).unwrap();
assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected));
let mut on_fill = square(15, before.clone());
if let Some(item) = on_fill.appearance.items.first_mut() {
*item.effects_mut() = vec![fx(three_d, json!({}))];
}
let projected = art(&on_fill).unwrap();
assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected));
}
}
#[test]
fn glows_blurs_and_feathers_scale_with_the_transform_after_them() {
let effects = [
+8 -5
View File
@@ -543,12 +543,14 @@ fn expand_piece_leaves(
}
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// path (a plain path then), and every geometry effect of its own removed, with each raster effect
/// following the Transform effects after it
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
/// path that one paints goes through [`expand_pieces`] instead.
pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
}
@@ -559,7 +561,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
m.appearance.effects.retain(|e| !is_geometry(&e.id));
m.appearance.effects = without_geometry(&m.appearance.effects);
Some(m)
}
@@ -601,9 +603,10 @@ fn with_path(l: &Node, path: PathData) -> Node {
}
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
/// shape the outline it paints ([`fill_shape`]).
/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform
/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)).
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
vectorcraft_render::effects::without_geometry(&f.effects)
}
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
+4 -4
View File
@@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke(
};
let art = d.reid(&art);
let mut art = with_transparency(d, art, st.opacity, st.blend);
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
let raster = vectorcraft_render::effects::without_geometry(&st.effects);
if !raster.is_empty() {
if !art.appearance.effects.is_empty() {
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
@@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke(
/// geometry effects leave nothing to paint, an empty group takes the path's place.
/// `None` when `l` has no stroke to outline.
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape};
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry};
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
return None;
}
@@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
}
let mut plain = moved;
plain.effects_mut().retain(|e| !is_geometry(&e.id));
*plain.effects_mut() = without_geometry(plain.effects());
parts.push(styled_copy(d, &shape, vec![plain]));
}
AppearanceItem::Stroke(st) if outlinable(st) => {
@@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
}
let mut plain = st.clone();
plain.effects.retain(|e| !is_geometry(&e.id));
plain.effects = without_geometry(&st.effects);
let stroked = match path.bounds() {
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
_ => path.clone(),
+21
View File
@@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
}
}
/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the
/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew.
#[test]
fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() {
for item in [json!(null), json!(0)] {
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false);
for (effect, params) in [
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})),
] {
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
}
let before = render(&s);
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
let after = render(&s);
let diff = differing(&before, &after, 64);
assert_eq!(diff, 0, "item {item}: {diff} pixels differ");
}
}
#[test]
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
// The object's own effect, and the fill's (the first appearance item).
+24
View File
@@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() {
assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did");
}
/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns
/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew.
#[test]
fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() {
let effects = [
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})),
];
for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] {
// Flatten Transparency draws a brushed stroke's drop shadow differently with or without a
// Transform effect, so the brushed stroke is checked with the other two commands.
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
let mut s = session();
square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects);
if brush {
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}");
}
}
}
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
#[test]
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
+1 -1
View File
@@ -589,7 +589,7 @@ since 2026-10-01.
- **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged.
- **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged.
- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged.
- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)), so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect is transformed with the art, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Before, it was drawn at its own distances around the transformed art. The canvas, export and Expand Appearance agree (effects `tests::rasterfollow`, render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are, and a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. Scores and hours unchanged.
- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged.
- **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim.
- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.