Merge pull request #3726 from storytold/land/3358

G11 #885: Transform effect options (Transform Objects, Transform Patterns, Scale Strokes & Effects)
This commit is contained in:
BFlat
2026-10-23 06:07:51 +07:00
committed by GitHub
15 changed files with 538 additions and 50 deletions
+4 -3
View File
@@ -122,9 +122,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
// its gradient fits it rather than spanning every copy.
if crate::has_gradient_copies(n)
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
// patterns: each copy an object of its own, so its gradient fits it rather than spanning
// every copy and its strokes and patterns can differ from the others'.
if crate::has_transform_art(n)
&& let Some(m) = reshape(n, None)
{
return Some(bake_pieces(d, m));
+50 -8
View File
@@ -114,11 +114,16 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
/// The move and the number of copies of a Transform effect with params `p` that only moves what
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
/// or moves by a random share.
/// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales
/// strokes while leaving the geometry (Scale Strokes & Effects with Transform Objects off).
pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> {
let (m, copies) = transform_map(Rect::ZERO, p, 0);
let [a, b, c, d, x, y] = m.as_coeffs();
(!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
// Transform Patterns moves the patterns along; with Transform Objects off, Scale Strokes &
// Effects still scales the strokes by the map the geometry doesn't take.
let o = TransformOptions::of(p);
let plain = !flag(p, "random", false) && !o.patterns && (o.objects || !o.strokes);
(plain && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
}
/// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the
@@ -170,7 +175,9 @@ pub(crate) fn within_reach(r: Rect) -> bool {
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
/// `random`, each scale goes a random share of the way from 100 % to its value and each move and
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
/// on every redraw while each object varies its own way.
/// on every redraw while each object varies its own way. With Transform Objects off the path stays
/// as it is; Transform Patterns and Scale Strokes & Effects reach the appearance through the
/// object's art ([`crate::has_transform_art`]).
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
}
@@ -201,12 +208,49 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
out
}
/// The Transform effect's options beyond its geometry (#885).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TransformOptions {
/// Transform Objects (default on): the geometry transforms and is copied. Off, it stays where
/// it is and only the two options below apply, once.
pub objects: bool,
/// Transform Patterns (default off): pattern fills and strokes transform along.
pub patterns: bool,
/// Scale Strokes & Effects (default off): stroke weights and dashes, and the distances of the
/// effects on the fills and strokes, scale by the map's mean scale (`√|det|`).
pub strokes: bool,
}
impl TransformOptions {
/// The options of a Transform effect with params `p`. Missing keys take their defaults, so
/// effects saved before the options existed draw as they did.
pub fn of(p: &Value) -> Self {
Self { objects: flag(p, "objects", true), patterns: flag(p, "patterns", false), strokes: flag(p, "strokes", false) }
}
/// Do they reach past the geometry (Transform Patterns or Scale Strokes & Effects)?
pub fn reach_appearance(self) -> bool {
self.patterns || self.strokes
}
}
/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds
/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map
/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies
/// otherwise (see [`transform_within`], and images, whose copies count nodes against
/// [`MAX_COPY_OBJECTS`]).
/// [`MAX_COPY_OBJECTS`]). With Transform Objects off the geometry stays: the identity and no
/// copies ([`transform_affine`] is the map its other options still use).
pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
if !TransformOptions::of(p).objects {
return (Affine::IDENTITY, 0);
}
(transform_affine(b, p, seed), num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize)
}
/// The one transformation of the Transform effect with params `p` on an object of bounds `b`
/// (the reference point lies on them; `seed` for Random), whatever Transform Objects says. Each
/// copy applies it once more to the last.
pub(crate) fn transform_affine(b: Rect, p: &Value, seed: u64) -> Affine {
let random = flag(p, "random", false);
// The share (0..1) of value `k` applied.
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
@@ -215,12 +259,10 @@ pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 };
let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2();
// Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates.
let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o)
Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o)
* Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians())
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
* Affine::translate(-o);
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
(m, copies)
* Affine::translate(-o)
}
/// Tweak: random displacement of anchors and/or control points.
+8 -3
View File
@@ -58,6 +58,8 @@ mod warp;
mod tests;
#[cfg(test)]
mod tests_scale;
#[cfg(test)]
mod tests_transformfx;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
@@ -78,7 +80,10 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, 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, outset, raster_effects};
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape};
pub use reshape::{
expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art,
outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
@@ -281,8 +286,8 @@ 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), copies: 0..1000 (0; each copy transforms the last again), 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)}",
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}),
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), 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(
"distort.tweak",
+104 -22
View File
@@ -36,7 +36,7 @@ pub fn needs_outline(n: &Node) -> bool {
/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
/// paints its gradient by itself ([`copy_leaf`]), as a group's copies do.
pub fn has_gradient_copies(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
@@ -45,6 +45,26 @@ pub fn has_gradient_copies(n: &Node) -> bool {
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
}
/// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or
/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as
/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of
/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]).
pub fn has_transform_options(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& geometry_effects(n)
.iter()
// Both options default to off, so the stored params tell without merging the defaults
// (this runs for every object drawn).
.any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance())
}
/// 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
/// that art ([`reshape`]).
pub fn has_transform_art(n: &Node) -> bool {
has_gradient_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
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
@@ -205,7 +225,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Node::group(n.id, vec![Arc::new(a)])
}
// The object as the one piece the copies of its Transform effect are made from.
_ if has_gradient_copies(n) => {
_ if has_transform_art(n) => {
let mut one = n.clone();
one.opacity = 1.0;
one.blend = Default::default();
@@ -260,19 +280,20 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
/// or has no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) {
return None;
}
as_art(n, symbol_art)
}
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
/// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand
/// Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
if has_gradient_copies(n) {
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
// patterns: each copy an object of its own.
if has_transform_art(n) {
return as_art(n, symbol_art);
}
let own_paint = match n.kind {
@@ -394,26 +415,39 @@ pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budg
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, &params, seed));
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
(m, copies, options, crate::distort::transform_affine(bounds, &params, seed))
});
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
// A gradient paints each copy by itself, not the one piece the copies make together.
if let Some((m, copies)) = transform
&& copies > 0
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
{
let made = copy_with_gradients(leaf, &path, m, copies, room);
used = used.minus(before).plus(made);
return;
if let Some((m, copies, options, map)) = transform {
// 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.
let gradient = copies > 0 && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
if options.reach_appearance() {
let made = transform_leaf(leaf, &path, map, copies, options, room);
used = used.minus(before).plus(made);
return;
}
if gradient {
let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
used = used.minus(before).plus(made);
return;
}
}
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return };
used = used.minus(before).plus(Budget::of(&out));
set_geometry(leaf, out, rule);
});
if let Some((m, copies)) = transform {
if let Some((m, copies, options, _)) = transform
&& options.objects
{
transform_placed(art, m, copies);
}
}
@@ -422,12 +456,57 @@ pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budg
}
}
/// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by
/// (its copies stop before one would take more), so stroke weights stay finite where the geometry
/// doesn't grow with them (a horizontal line stretched vertically).
const MAX_STROKE_SCALE: f64 = 1.0e6;
/// 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
/// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its
/// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them
/// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform
/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after.
fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, options: crate::distort::TransformOptions, budget: Budget) -> Budget {
let scaling = vectorcraft_doc::Scaling {
strokes: options.strokes,
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
patterns: options.patterns,
..Default::default()
};
if !options.objects {
if options.strokes
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
{
leaf.appearance.scale_strokes(k);
leaf.appearance.scale_effects(k, crate::scale_effect);
}
if options.patterns {
vectorcraft_doc::pattern::transform_pattern_paints(leaf, m);
}
return Budget::of(path);
}
if copies == 0 {
// A path scaled up is drawn with more lines; past the budget it keeps its shape.
let moved = path.transformed(m);
if !budget.fits(Budget::of(&moved)) {
return Budget::of(path);
}
leaf.transform(m, scaling);
return Budget::of(&moved);
}
copy_leaf(leaf, path, m, copies, scaling, budget)
}
/// 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, 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. The copies stop where [`crate::distort::transform_within`]'s would,
/// or before they pass `budget`. Returns what the copies cost, the original included.
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, budget: Budget) -> Budget {
/// 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())];
let mut xf = Affine::IDENTITY;
@@ -438,8 +517,11 @@ fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usiz
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();
copy.transform(xf, vectorcraft_doc::Scaling::default());
copy.transform(xf, scaling);
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
+159
View File
@@ -0,0 +1,159 @@
//! The Transform effect's Transform Objects, Transform Patterns and Scale Strokes & Effects
//! options (#885).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Dash, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::distort::{TransformOptions, transform_map, transform_move};
use crate::{has_transform_art, has_transform_options, reshape};
fn transform(params: Value) -> Effect {
Effect { id: "distort.transform".into(), params, visible: true }
}
/// A 100 pt square at the origin with a 2 pt black stroke (dashed 4/2) and the Transform effect
/// `params`.
fn stroked(params: Value) -> Node {
let mut n =
Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
if let Some(s) = n.appearance.stroke_mut() {
s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false });
}
n.appearance.effects.push(transform(params));
n
}
/// A 100 pt square at the origin filled with pattern "p" placed at the identity, with the
/// Transform effect `params`.
fn patterned(params: Value) -> Node {
let paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY };
let mut n = Node::path(NodeId(8), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(paint, Paint::None, 0.0));
n.appearance.effects.push(transform(params));
n
}
/// The paths under `n`, in document order.
fn leaves(n: &Node) -> Vec<&Node> {
let mut out = vec![];
fn walk<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
match n.children() {
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().for_each(|c| walk(c, out)),
_ => out.push(n),
}
}
walk(n, &mut out);
out
}
fn stroke_of(n: &Node) -> (f64, Vec<f64>) {
let s = n.appearance.stroke().unwrap();
(s.width, s.dash.as_ref().map(|d| d.pattern.clone()).unwrap_or_default())
}
fn pattern_xf(n: &Node) -> Affine {
match n.appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf,
p => panic!("not a pattern: {p:?}"),
}
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn the_options_default_to_what_the_effect_did_before() {
let o = TransformOptions::of(&crate::merged_params("distort.transform", &json!({})));
assert_eq!(o, TransformOptions { objects: true, patterns: false, strokes: false });
assert!(!o.reach_appearance());
// An old effect, with no options stored, still draws as geometry alone.
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "copies": 2}));
assert!(!has_transform_options(&n));
assert!(!has_transform_art(&n));
}
#[test]
fn transform_objects_off_maps_nothing_and_makes_no_copies() {
let b = Rect::new(0.0, 0.0, 100.0, 100.0);
let (m, copies) = transform_map(b, &json!({"scaleH": 50, "moveH": 30, "copies": 3, "objects": false}), 0);
assert_eq!((m, copies), (Affine::IDENTITY, 0));
// A plain move with Transform Patterns on isn't a plain move any more: the pattern moves too.
assert!(transform_move(&json!({"moveH": 30})).is_some());
assert!(transform_move(&json!({"moveH": 30, "patterns": true})).is_none());
assert!(transform_move(&json!({"moveH": 30, "strokes": true})).is_some(), "a move scales nothing");
assert!(transform_move(&json!({"scaleH": 50, "objects": false, "strokes": true})).is_none());
}
#[test]
fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() {
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "strokes": true}));
assert!(has_transform_art(&n));
let art = reshape(&n, None).unwrap();
let paths = leaves(&art);
assert_eq!(paths.len(), 1);
let (w, dash) = stroke_of(paths[0]);
assert!(close(w, 1.0), "{w}");
assert_eq!(dash, vec![2.0, 1.0]);
let b = paths[0].geometric_bounds().unwrap();
assert!(close(b.width(), 50.0), "{b:?}");
}
#[test]
fn each_copy_scales_its_stroke_once_more() {
let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
let art = reshape(&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]);
// 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));
}
#[test]
fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() {
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true}));
let art = reshape(&n, None).unwrap();
let paths = leaves(&art);
assert_eq!(paths.len(), 1, "no copies");
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(close(stroke_of(paths[0]).0, 1.0));
}
#[test]
fn transform_patterns_moves_each_copys_pattern() {
let n = patterned(json!({"moveH": 30, "copies": 2, "patterns": true}));
assert!(has_transform_options(&n));
let art = reshape(&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]);
// 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));
}
#[test]
fn transform_patterns_with_objects_off_moves_only_the_pattern() {
let n = patterned(json!({"rotate": 45, "objects": false, "patterns": true}));
let art = reshape(&n, None).unwrap();
let paths = leaves(&art);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
assert_ne!(pattern_xf(paths[0]), Affine::IDENTITY);
}
#[test]
fn stroke_scaling_across_copies_stays_finite() {
// A horizontal line stretched vertically barely grows, but its stroke would: the copies stop
// before a stroke weight runs away.
let mut n =
Node::path(NodeId(9), shapes::line((0.0, 0.0).into(), (100.0, 0.0).into()), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
n.appearance.effects.push(transform(json!({"scaleH": 4000, "scaleV": 4000, "copies": 1000, "strokes": true})));
if let Some(art) = reshape(&n, None) {
for p in leaves(&art) {
let w = stroke_of(p).0;
assert!(w.is_finite() && w <= 1.0e6, "{w}");
}
}
}
+2 -2
View File
@@ -630,11 +630,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
m
})
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_transform_art(&v) {
effects::expand_leaf(d, &v, &mut stroke_art)
} else {
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
// them with a gradient): through their outlines.
// them with a gradient or changes their fills and strokes): through their outlines.
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
+2
View File
@@ -2575,6 +2575,8 @@ mod tests_tints;
#[cfg(test)]
mod tests_toolsettings;
#[cfg(test)]
mod tests_transformfx;
#[cfg(test)]
mod tests_transparencygrid;
#[cfg(test)]
mod tests_typearea;
+169
View File
@@ -0,0 +1,169 @@
//! The Transform effect's Transform Objects and Scale Strokes & Effects options (#885) through
//! commands: as the renderer draws them and as Expand Appearance bakes them, and files saved
//! before the options existed looking the same.
use serde_json::{Value, json};
use vectorcraft_doc::Node;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// A selected 100 pt square at (50, 50) with no fill and a 4 pt black stroke.
fn stroked_square(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"none": true}));
run(s, "paint.setStroke", json!({"color": "#000000"}));
run(s, "stroke.set", json!({"weight": 4}));
id
}
fn render(s: &Session) -> vectorcraft_render::Rendered {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
}
fn dark(px: [u8; 4]) -> bool {
px[0] < 128 && px[3] > 128
}
/// Scale Strokes & Effects: the stroke of a square halved by the effect is drawn 2 pt wide with
/// the option, 4 pt without. The halved square's left edge is at x = 75: a 4 pt stroke covers the
/// pixel column at 73, a 2 pt one doesn't.
#[test]
fn scale_strokes_and_effects_scales_the_drawn_stroke() {
for strokes in [false, true] {
let mut s = session();
stroked_square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50, "strokes": strokes}}));
let img = render(&s);
assert!(dark(img.pixel(75, 100)), "strokes={strokes}: the edge itself is stroked");
assert_eq!(dark(img.pixel(73, 100)), !strokes, "strokes={strokes}: a 4 pt stroke reaches x = 73, a 2 pt one doesn't");
assert!(!dark(img.pixel(50, 100)), "strokes={strokes}: the original edge is gone");
}
}
/// Expand Appearance bakes the option: the outlined stroke is 2 pt wide (bounds 74..126) with
/// it, 4 pt (73..127) without.
#[test]
fn expand_appearance_bakes_the_scaled_stroke() {
for strokes in [false, true] {
let mut s = session();
let id = stroked_square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50, "strokes": strokes}}));
run(&mut s, "effect.expandAppearance", json!({}));
let b = node(&s, id).visual_bounds().unwrap();
let want = if strokes { 52.0 } else { 54.0 };
assert!((b.width() - want).abs() < 1e-6 && (b.x0 - (100.0 - want / 2.0)).abs() < 1e-6, "strokes={strokes}: {b:?}");
}
}
/// Transform Objects off: the geometry stays where it is (the effect then only reaches patterns
/// and strokes).
#[test]
fn transform_objects_off_keeps_the_geometry_in_place() {
for objects in [true, false] {
let mut s = session();
let id = stroked_square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 60, "objects": objects}}));
let img = render(&s);
assert_eq!(dark(img.pixel(50, 100)), !objects, "objects={objects}: the original left edge");
assert_eq!(dark(img.pixel(110, 100)), objects, "objects={objects}: the moved left edge");
// The object itself never moves: the effect is live.
assert_eq!(node(&s, id).geometric_bounds().unwrap().x0, 50.0);
}
}
/// An effect saved before the options existed (no `objects`, `patterns`, `strokes` keys) draws
/// exactly as one with the defaults written out, and the dialog defaults carry them.
#[test]
fn old_transform_effects_draw_as_before() {
let mut old = session();
stroked_square(&mut old);
run(&mut old, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 20, "scaleH": 150, "rotate": 15, "copies": 2}}));
let mut explicit = session();
stroked_square(&mut explicit);
run(
&mut explicit,
"effect.apply",
json!({"effect": "distort.transform", "params": {"moveH": 20, "scaleH": 150, "rotate": 15, "copies": 2, "objects": true, "patterns": false, "strokes": false}}),
);
assert_eq!(render(&old).pixels, render(&explicit).pixels);
let list = run(&mut old, "effect.list", json!({}));
let entry = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == "distort.transform").unwrap();
assert_eq!(
(&entry["defaults"]["objects"], &entry["defaults"]["patterns"], &entry["defaults"]["strokes"]),
(&json!(true), &json!(false), &json!(false))
);
assert!(entry["params"].as_str().unwrap().contains("Scale Strokes & Effects"));
}
/// A group's Transform effect with Scale Strokes & Effects scales its members' strokes (#885
/// review): two 40 pt squares with 4 pt strokes halved as one piece about the group's centre;
/// the first square's left edge lands at x = 75, where a 4 pt stroke covers x = 73 and a 2 pt one
/// doesn't.
#[test]
fn a_group_transform_scales_its_members_strokes() {
for strokes in [false, true] {
let mut s = session();
for x in [50, 110] {
run(&mut s, "shape.rectangle", json!({"x": x, "y": 50, "width": 40, "height": 40}));
run(&mut s, "paint.setFill", json!({"none": true}));
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
run(&mut s, "stroke.set", json!({"weight": 4}));
}
run(&mut s, "select.all", json!({}));
run(&mut s, "object.group", json!({}));
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50, "strokes": strokes}}));
let img = render(&s);
assert!(dark(img.pixel(75, 70)), "strokes={strokes}: the moved edge is stroked");
assert_eq!(dark(img.pixel(73, 70)), !strokes, "strokes={strokes}: a 4 pt stroke reaches x = 73, a 2 pt one doesn't");
assert!(!dark(img.pixel(50, 70)), "strokes={strokes}: the original edge is gone");
}
}
/// The canvas and Expand Appearance (what export bakes) paint copies with scaled strokes alike
/// (#885 review): a 2 pt stroke, 150 % per copy, two copies.
#[test]
fn live_copies_and_their_expanded_art_paint_alike() {
let mut s = session();
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 30, "height": 30}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"none": true}));
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
run(&mut s, "stroke.set", json!({"weight": 2}));
run(
&mut s,
"effect.apply",
json!({"effect": "distort.transform", "params": {"scaleH": 150, "scaleV": 150, "moveH": 30, "moveV": 30, "copies": 2, "reference": 0, "strokes": true}}),
);
let live = render(&s);
// The copies' strokes are 2, 3 and 4.5 pt: the last copy's edge at (20 + 30·2.5, …) is wider.
run(&mut s, "effect.expandAppearance", json!({}));
let expanded = render(&s);
let differ = live.pixels.chunks(4).zip(expanded.pixels.chunks(4)).filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 96)).count();
let inked = live.pixels.chunks(4).filter(|p| p[0] < 128).count();
assert!(inked > 500, "{inked} dark pixels");
assert!(differ * 50 < inked, "{differ} of {inked} pixels differ");
// Each copy's outlined stroke is an object of its own (a compound path), whatever groups hold
// them.
fn pieces(n: &Node) -> usize {
match n.children() {
Some(c) if !matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => c.iter().map(|c| pieces(c)).sum(),
_ => 1,
}
}
assert_eq!(pieces(&node(&s, id)), 3, "one piece per copy");
}
+3 -3
View File
@@ -81,12 +81,12 @@ pub(crate) fn has_fx(n: &Node) -> bool {
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with
/// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own
/// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies
/// it with a gradient ([`effects::has_gradient_copies`]), or is it a group or layer with an
/// appearance of its own (see [`Renderer::draw_object_fx`])?
/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a
/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|| effects::has_item_geometry(n)
|| effects::has_gradient_copies(n)
|| effects::has_transform_art(n)
|| effects::has_container_appearance(n)
|| effects::has_crop_marks(n)
|| effects::has_adjustment(n)
+6 -2
View File
@@ -477,8 +477,9 @@ fn values_changed(d: &mut Dialog) -> bool {
const TRANSFORM: &str = "distort.transform";
/// The Transform effect's dialog: Transform Each's Scale and Move sliders and Rotate dial, then
/// Copies, the reflections, the reference point and Random. Returns whether the values differ from
/// those last previewed.
/// the options Transform Objects, Transform Patterns and Scale Strokes & Effects (#885), Copies,
/// the reflections, the reference point and Random. Returns whether the values differ from those
/// last previewed.
fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| transform_each::sections(ui, d, unit));
@@ -486,6 +487,9 @@ fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Options"));
ui.add_space(4.0);
form::check(ui, d, "objects", tl!("Transform Objects"));
form::check(ui, d, "patterns", tl!("Transform Patterns"));
form::check(ui, d, "strokes", tl!("Scale Strokes & Effects"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Copies:"));
if let Some(n) = widgets::range_field(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) {
+11
View File
@@ -927,11 +927,17 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga
"Reflect Y",
"Reference Point",
"Random",
"Transform Objects",
"Transform Patterns",
"Scale Strokes & Effects",
"Preview",
] {
assert!(text.lines().any(|l| l == s), "{s} in {text}");
}
assert!(app.session.in_interaction(), "the preview runs");
// The options start at the catalog defaults (#885).
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.bool("objects"), d.bool("patterns"), d.bool("strokes")), (true, false, false));
let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone();
// The reference point and Random update the preview.
let d = app.ui.dialog.as_mut().unwrap();
@@ -943,6 +949,10 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga
app.ui.dialog.as_mut().unwrap().fields.insert("random".into(), json!(true));
frame(&mut app, Default::default());
assert_eq!(effect(&app).params["random"], json!(true));
// So does Scale Strokes & Effects.
app.ui.dialog.as_mut().unwrap().fields.insert("strokes".into(), json!(true));
frame(&mut app, Default::default());
assert_eq!(effect(&app).params["strokes"], json!(true));
confirm(&mut app).unwrap();
assert!(!app.session.in_interaction());
assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.len(), 1);
@@ -950,6 +960,7 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga
app.run("effect.dialog", json!({"effect": "distort.transform", "index": 0})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("reference", 4.0), d.f64("copies", 0.0), d.f64("moveH", 0.0), d.bool("random")), (8.0, 3.0, 30.0, true));
assert_eq!((d.bool("objects"), d.bool("strokes")), (true, true));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
assert!(text.lines().any(|l| l == "Reference Point"), "{text}");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously:; stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw; Roughen, Zig Zag, Free Distort, Twist and the warps build within it, #3578; scores unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G2: Effect › Sketch › Note Paper; Halftone Pattern and Note Paper defaults documented as unverified VectorCraft choices; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 46/57, 11 remaining (8–14 h); G2: Sketch › Chrome; Sketch › Chalk & Charcoal, Graphic Pen and Reticulation and Artistic › Plastic Wrap, #2972, #2967, #2976, #3034: 37/57 Photoshop effects; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 46/57, 11 remaining (8–14 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Transform effect: Transform Objects, Transform Patterns and Scale Strokes & Effects, #885; G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously:; stacked geometry effects make at most 2 million anchors of a path and about 8 million lines to draw; Roughen, Zig Zag, Free Distort, Twist and the warps build within it, #3578; scores unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G2: Effect › Sketch › Note Paper; Halftone Pattern and Note Paper defaults documented as unverified VectorCraft choices; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 46/57, 11 remaining (8–14 h); G2: Sketch › Chrome; Sketch › Chalk & Charcoal, Graphic Pen and Reticulation and Artistic › Plastic Wrap, #2972, #2967, #2976, #3034: 37/57 Photoshop effects; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 46/57, 11 remaining (8–14 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **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; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); 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) |
| 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; Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect (on the object, Transform has all three options and visible checkboxes, #885); 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) |
| 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 | 11/13 | Hard Mix, Trap |
| 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-10-22 | minor | The Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, with visible checkboxes (#885); on a fill's or stroke's own Transform effect the last two are still missing. Counts unchanged. |
| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. |
| 2026-10-22 | minor | Stacked geometry effects make at most 2 million anchors of a path (of a type, symbol or image art's paths together) and about 8 million lines to draw: Roughen spaces its points further apart, Zig Zag makes fewer ridges a segment, Free Distort, Twist and the warps split into longer pieces, a result drawn with too many lines is built again with fewer points, and an effect that would still pass either is left out (#3578). Scores unchanged (estimated) |
| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Sketch 14/14. |
+8 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long