mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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, ¶ms, seed));
|
||||
let transform = (e.id == "distort.transform").then(|| {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
(m, copies, options, crate::distort::transform_affine(bounds, ¶ms, 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, ¶ms, &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;
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user