Merge pull request #6963 from dotCooCoo/fix/transform-effect-moves-member-raster-effects

Effects: a group's or object's Transform effect moves the raster effects inside it
This commit is contained in:
BFlat
2026-11-13 06:11:24 +07:00
committed by GitHub
17 changed files with 602 additions and 22 deletions
+2
View File
@@ -440,6 +440,8 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
// The raster effects of its fills and strokes follow its Transform effects, as on the canvas.
crate::raster::follow_items(&mut m);
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
Some((g, rule, ctx, m))
}
+1 -1
View File
@@ -267,7 +267,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}",
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are; so do the raster effects of the object's fills and strokes and, on a group, those of its members, of their fills and strokes and of their linked opacity masks' art, after their own Transform effects, including those that a member's own Transform effect with copies or random holds back), 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; raster effects that transform with the art are not scaled again; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}",
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(
+3
View File
@@ -257,6 +257,9 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
// So do the raster effects of its fills and strokes, by `n`'s Transform effects
// ([`crate::follow_items`]).
crate::raster::follow_items_of(&mut m, &n.appearance.effects);
}
without_geometry_effects(&mut m);
m
+17
View File
@@ -1281,6 +1281,23 @@ fn compound_shape(mode: ShapeMode) -> Node {
g
}
/// A compound shape's Transform effect turns its fill's drop shadow with the area it turns.
#[test]
fn a_compound_shapes_transform_turns_its_fills_shadow() {
let mut shape = compound_shape(ShapeMode::Add);
if let Some(item) = shape.appearance.items.first_mut() {
*item.effects_mut() =
vec![Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 10, "y": 0, "blur": 0}), visible: true }];
}
shape.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true });
let art = shape_art(None, &shape, None).unwrap();
let item = art.appearance.items.first().unwrap();
match crate::raster_effects(item.effects()).first() {
Some(crate::RasterFx::DropShadow { dx, dy, .. }) => assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9, "{dx} {dy}"),
other => panic!("expected a drop shadow, got {other:?}"),
}
}
/// A compound shape draws the area its members' modes make, one unpainted path that its own
/// appearance paints; evaluated, it is plain art painted with that appearance.
#[test]
+1 -1
View File
@@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
+100 -2
View File
@@ -5,7 +5,7 @@ use std::borrow::Cow;
use serde_json::{Map, Value, json};
use vectorcraft_doc::color::{BlendMode, Color};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
use vectorcraft_doc::{AppearanceItem, Effect, Node, RasterColorModel, RasterEffectsSettings};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@@ -478,6 +478,104 @@ pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool {
changed
}
/// The linear part of the visible Transform effects of `owner`, an object's or a container's own
/// effects, in order: what they do to the raster effects of everything the object paints (its
/// fills and strokes, and a container's members and theirs), which those effects transform with
/// the rest of the art. `None` when they leave them as they are: no Transform effect moves the
/// art, or one of them makes copies or moves at random ([`followed`]).
pub(crate) fn contents_step(owner: &[Effect]) -> Option<Affine> {
let a = owner.iter().filter(|e| e.visible && e.id == "distort.transform").try_fold(Affine::IDENTITY, |a, e| transform_step(e).map(|s| s * a))?;
(a != Affine::IDENTITY).then_some(a)
}
/// Make the raster effects of `effects`, the own effects of something inside an object (a fill's
/// or stroke's, or a member's), follow `a`, the linear part of the object's Transform effects
/// ([`contents_step`]), which apply after all of them. `outer` is what the Transform effects
/// between them and `a` do to them (those of the fill's or stroke's object and of the groups that
/// hold it inside the object). A raster effect that the Transform effects after it in `effects`
/// move ([`followed`]) is set so that it draws as those effects, then `outer` and then `a`
/// transform it, whichever of them is applied first. One that a Transform effect after it in
/// `effects` holds back keeps its value through those effects, so it is set to draw as `outer` and
/// then `a` transform it. `false` when nothing changed.
pub(crate) fn follow_contents(effects: &mut [Effect], a: Affine, outer: Affine) -> bool {
if a == Affine::IDENTITY || !effects.iter().any(|e| follows(&e.id)) {
return false;
}
let mut changed = false;
// The linear part of the Transform effects after the effect at hand, as `followed` reads it;
// `None` once one of them holds the effect back, which they then leave as it is.
let mut after = Some(Affine::IDENTITY);
for e in effects.iter_mut().rev() {
if e.id == "distort.transform" {
after = after.and_then(|b| transform_step(e).map(|s| b * s));
continue;
}
if !follows(&e.id) {
continue;
}
let b = outer * after.unwrap_or(Affine::IDENTITY);
// The effect is still to be mapped by `b` when it is drawn, so it takes b⁻¹·a·b now:
// drawn, it is a·b of what it was. A `b` that flattens the art takes `a` alone.
let det = b.determinant();
let c = if b == Affine::IDENTITY || !(det.is_finite() && det.abs() > 1e-12) { a } else { b.inverse() * a * b };
if c.is_finite() {
follow_transform(e, c);
changed = true;
}
}
changed
}
/// Make the raster effects of the fills and strokes of `n` follow the Transform effects of its own
/// effect list ([`contents_step`]), as they do while the object is drawn with them: for art that
/// applies those effects and keeps the fills and strokes. `false` when nothing changed.
pub fn follow_items(n: &mut Node) -> bool {
let Some(a) = contents_step(&n.appearance.effects) else { return false };
follow_items_by(n, a)
}
/// [`follow_items`] for `n` holding the fills and strokes of an object whose own effects are
/// `owner` (the area of a compound shape, which carries none of the shape's effects).
pub(crate) fn follow_items_of(n: &mut Node, owner: &[Effect]) -> bool {
let Some(a) = contents_step(owner) else { return false };
follow_items_by(n, a)
}
/// Make the raster effects of the fills and strokes of `n` follow `a` ([`follow_contents`]).
fn follow_items_by(n: &mut Node, a: Affine) -> bool {
if !n.appearance.items.iter().any(|i| i.effects().iter().any(|e| follows(&e.id))) {
return false;
}
let mut changed = false;
for item in &mut n.appearance.items {
changed |= follow_contents(item.effects_mut(), a, Affine::IDENTITY);
}
changed
}
/// Make the raster effects of `items`, the fills and strokes of an object whose own effects are
/// `owner`, follow the Transform effects of `owner` ([`follow_contents`]): for art that applies
/// those effects to the object's path and draws each fill and stroke on it as a node of its own.
pub(crate) fn follow_paints(items: &mut [AppearanceItem], owner: &[Effect]) {
let Some(a) = contents_step(owner) else { return };
for item in items {
follow_contents(item.effects_mut(), a, Affine::IDENTITY);
}
}
/// `effects`, those of a fill or stroke of an object whose own effects are `owner`, with each
/// raster effect following the Transform effects of `owner` as well ([`follow_contents`]): the
/// list to draw that fill's or stroke's raster effects from ([`raster_effects`]). Borrowed when
/// nothing changes.
pub fn item_effects_in<'a>(owner: &[Effect], effects: &'a [Effect]) -> Cow<'a, [Effect]> {
let Some(a) = contents_step(owner) else { return Cow::Borrowed(effects) };
if !effects.iter().any(|e| follows(&e.id)) {
return Cow::Borrowed(effects);
}
let mut out = effects.to_vec();
if follow_contents(&mut out, a, Affine::IDENTITY) { Cow::Owned(out) } else { Cow::Borrowed(effects) }
}
/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the
/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform
/// effect among them that makes copies or moves at random.
@@ -511,7 +609,7 @@ fn transform_step(e: &Effect) -> Option<Affine> {
/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG
/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room
/// around the art.
fn follows(id: &str) -> bool {
pub(crate) fn follows(id: &str) -> bool {
crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE
}
+113 -11
View File
@@ -740,12 +740,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
wholes.entry(id).or_insert_with(|| copies_whole(m));
});
let mut warped = WarpedImages::new();
// The raster effects of what the art holds follow each Transform effect that moves it once,
// whatever its options, unless one of the effects makes copies or moves at random
// ([`crate::raster::contents_step`], [`follow_art`]).
let held = listed.iter().any(|e| crate::raster::holds_back(e));
for e in fx {
let Some(bounds) = warped.bounds(art) else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
let step = Step { e, params: &params, bounds, seed, effect, apart, doc, wholes: &wholes };
let step = Step { e, params: &params, bounds, seed, effect, apart, doc, wholes: &wholes, held };
// Art with linked masks keeps its shape under an effect that a mask in it could not take
// as the art it masks did, so no mask is left apart from its art.
let before = masked.then(|| (art.clone(), used));
@@ -768,8 +772,10 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its
/// merged params, the reference box and seed of the object's art, where a deferred live-effect
/// plug-in is listed, whether each Transform copy becomes an object, the document that the
/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of
/// each linked mask before the first effect, by the id of the object it masks.
/// swatches and patterns of painted pieces are looked up in, [`copies_whole`] of the art of
/// each linked mask before the first effect, by the id of the object it masks, and whether one of
/// the object's effects holds the raster effects in its art back from its Transform effects
/// ([`follow_art`]).
struct Step<'a> {
e: &'a Effect,
params: &'a serde_json::Value,
@@ -779,6 +785,7 @@ struct Step<'a> {
apart: bool,
doc: Option<&'a Document>,
wholes: &'a HashMap<NodeId, bool>,
held: bool,
}
/// What [`Step::run`] did to a piece of art.
@@ -885,6 +892,8 @@ impl Step<'_> {
let options = crate::distort::TransformOptions::of(params);
(m, copies, options, crate::distort::transform_affine(bounds, params, seed))
});
// What the effect does to the raster effects in the art, when they follow it.
let raster_map = if self.held { None } else { crate::raster::contents_step(std::slice::from_ref(e)) };
// In a mask's art, images and symbol instances take only a Transform effect
// ([`transform_placed`]): a mask's art that holds one can't take another effect as the art
// it masks does.
@@ -944,7 +953,7 @@ impl Step<'_> {
// The copies of a path replace it by a group of them, the path included.
let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len);
if options.reach_appearance() {
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None);
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, raster_map.is_some(), None);
*used = used.minus(before).plus(made);
if moved {
// With Transform Objects off the path stays, and its mask takes the
@@ -1014,12 +1023,22 @@ impl Step<'_> {
*art = Node::group(id, vec![Arc::new(one)]);
}
let placed = if watch { masked_placed(art, own) } else { 0 };
transform_placed(art, m, copies, copy_scaling(options));
// Scale Strokes & Effects leaves the raster effects that follow the effect to
// [`follow_art`].
let mut scaling = copy_scaling(options);
if raster_map.is_some() {
scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64));
}
transform_placed(art, m, copies, scaling);
short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1));
if root && let Some([one]) = art.children().map(Vec::as_slice) {
*art = (**one).clone();
}
}
// A mask's art takes them as a group inside the art: its own raster effects follow too.
if let Some(a) = raster_map {
follow_art(art, a, &kept_ids, Some(&moved_ids), own.then_some(Affine::IDENTITY), depth);
}
if outlines.iter().any(Option::is_some) {
rebase_type_outlines(art, &mut outlines.into_iter());
}
@@ -1239,15 +1258,94 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S
}
}
/// [`crate::scale_effect`] for the effects that do not follow a Transform effect as raster effects
/// do ([`crate::raster::follows`]): Scale Strokes & Effects scales them, and [`follow_art`] moves
/// the others.
fn scale_unfollowed(e: &mut Effect, s: f64) {
if !crate::raster::follows(&e.id) {
crate::scale_effect(e, s);
}
}
/// Make the raster effects under `art` follow `a`, the linear part of a Transform effect that moved
/// it ([`crate::raster::follow_contents`]): those of every object in it (its own and its fills' and
/// strokes'), the art's own effects excepted (`up` is `None` for the art itself). A path that kept
/// its shape (in `kept`), and a group with such a path under it, keep their own and their fills'
/// and strokes'. `up` is what the Transform effects of the groups between an object and the art do
/// to the object's effects. A Transform effect of an object's own that makes copies or moves at
/// random leaves the effects it holds back as they are, so those follow `a` and the groups' maps
/// alone. The linked opacity mask of an object that took the effect as an object (in `moved`, and
/// every image and symbol instance) moved with it, so the raster effects in the mask's art follow
/// `a` as those of a mask that takes the effect as art do ([`Step::reshape_masks`]); `moved` is
/// `None` inside such a mask's art, where every linked mask moved. The walk reaches the groups and
/// layers that [`for_each_leaf`] made the art's own, so it copies nothing. Returns whether a path in
/// `kept` is under `n`.
fn follow_art(n: &mut Node, a: Affine, kept: &HashSet<NodeId>, moved: Option<&HashSet<NodeId>>, up: Option<Affine>, depth: usize) -> bool {
if n.is_guide() {
return false;
}
if moved.is_none_or(|moved| moved.contains(&n.id)) || is_placed(n) {
follow_moved_mask(n, a, depth);
}
// What the object's own Transform effects then do to its fills' and strokes' effects and its
// members' (nothing when one of them holds those back); the art's own effects are the ones
// being applied.
let below = up.map_or(Affine::IDENTITY, |up| crate::raster::contents_step(&n.appearance.effects).map_or(up, |s| up * s));
let keeps = match &mut n.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => kept.contains(&n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
let mut keeps = false;
for c in children.iter_mut() {
keeps |= follow_art(Arc::make_mut(c), a, kept, moved, Some(below), depth);
}
keeps
}
_ => false,
};
if !keeps {
if let Some(up) = up
&& contents_raster(&n.appearance.effects)
{
crate::raster::follow_contents(&mut n.appearance.effects, a, up);
}
if n.appearance.items.iter().any(|i| contents_raster(i.effects())) {
for item in &mut n.appearance.items {
crate::raster::follow_contents(item.effects_mut(), a, below);
}
}
}
keeps
}
/// Make the raster effects in the art of the linked opacity mask of `n`, which moved with `n`,
/// follow `a` ([`follow_art`]), with those of the linked masks inside that art, which moved too. A
/// mask past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks stays as it is.
fn follow_moved_mask(n: &mut Node, a: Affine, depth: usize) {
if depth >= vectorcraft_doc::depth::MAX_DEPTH {
return;
}
let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return };
follow_art(Arc::make_mut(&mut m.art), a, &HashSet::new(), None, Some(Affine::IDENTITY), depth + 1);
}
/// Does any of `effects` follow a Transform effect as a raster effect ([`crate::raster::follows`])?
fn contents_raster(effects: &[Effect]) -> bool {
effects.iter().any(|e| crate::raster::follows(&e.id))
}
/// 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. The effects `kept` names keep their
/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and
/// `false` when the path kept its shape because the moved path would pass `budget`.
/// Objects off the geometry stays and the options apply once. With `follows`, Scale Strokes &
/// Effects leaves the raster effects that follow the Transform effect ([`follow_art`]) to the
/// caller. The effects `kept` names keep their values on the leaf and on each copy
/// ([`keep_listed`]), whether Scale Strokes & Effects scaled them or left them to the caller.
/// Returns what the leaf costs after, and `false` when the path kept its shape because the moved
/// path would pass `budget`.
#[allow(clippy::too_many_arguments)]
fn transform_leaf(
leaf: &mut Node,
path: &PathData,
@@ -1255,9 +1353,13 @@ fn transform_leaf(
copies: usize,
options: crate::distort::TransformOptions,
budget: Budget,
follows: bool,
kept: Kept,
) -> (Budget, bool) {
let scaling = copy_scaling(options);
let mut scaling = copy_scaling(options);
if follows && copies == 0 {
scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64));
}
if !options.objects {
if options.strokes
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
@@ -1716,7 +1818,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
set_geometry(&mut p, g.clone(), rule);
let after = kept_before.len();
let keep = |k: usize| k >= after || !options.objects;
let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep)));
let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, false, Some((&rest, &keep)));
*used = used.plus(made);
let from = crate::raster::held_back(before);
if moved && !held_later {
@@ -1795,7 +1897,7 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(bounds, &params, seed);
let keep = |k: usize| k > j || (k < j && !options.objects);
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep)));
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false, Some((item.effects(), &keep)));
*used = used.minus(before).plus(made);
if moved {
follow_piece(leaf, was, from, &e);
+5 -1
View File
@@ -619,9 +619,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = crate::without_geometry(&remaining);
// The object's Transform effects, applied to the path above, transform the items' raster
// effects too.
let mut items: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
crate::raster::follow_paints(&mut items, &n.appearance.effects);
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
for item in &items {
if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) {
let fx = item.effects().get(index)?;
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
+4
View File
@@ -985,6 +985,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = crate::without_geometry(&n.appearance.effects);
// The object's Transform effects, applied to the path above, transform the items' raster
// effects too.
let mut paints = paints;
crate::raster::follow_paints(&mut paints, &n.appearance.effects);
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in &paints {
+231
View File
@@ -325,6 +325,23 @@ fn a_shadow_before_a_transform_follows_it_into_3d_art() {
}
}
/// An object's Transform effect transforms the raster effects of its fills when only a fill has a
/// 3D effect: the shadow after that fill's 3D effect and another fill's shadow, as it does without
/// the 3D effect.
#[test]
fn an_objects_transform_moves_its_fills_shadows_beside_a_fills_3d_effect() {
for three_d in [crate::EXTRUDE, crate::REVOLVE] {
let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) };
let mut n = fill_square(18, vec![fx(three_d, json!({})), shadow(10.0, 10.0, 0.0)]);
let mut other = n.appearance.items[0].clone();
*other.effects_mut() = vec![shadow(10.0, 10.0, 0.0)];
n.appearance.items.push(other);
n.appearance.effects = vec![transform(json!({"scaleH": 200, "scaleV": 100}))];
let drawn = shadows_in(&art(&n).unwrap());
assert!(drawn.len() > 1 && near(&drawn, &vec![(20.0, 10.0); drawn.len()]), "{three_d}: {drawn:?}");
}
}
#[test]
fn glows_blurs_and_feathers_scale_with_the_transform_after_them() {
let effects = [
@@ -419,3 +436,217 @@ fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() {
assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects);
}
}
/// A group of `members` with `effects` of its own.
fn group(id: u64, members: Vec<Node>, effects: Vec<Effect>) -> Node {
let mut g = Node::group(NodeId(id), members.into_iter().map(std::sync::Arc::new).collect());
g.appearance.effects = effects;
g
}
/// The node with `id` under `n`.
fn node_in(n: &Node, id: u64) -> Node {
let mut found = None;
n.walk(&mut |m| {
if m.id == NodeId(id) && found.is_none() {
found = Some(m.clone());
}
});
found.unwrap_or_else(|| panic!("no node {id}"))
}
/// The offset of the Drop Shadow that the first fill of `n` draws, its own effects and `n`'s
/// Transform effects applied ([`crate::item_effects_in`]).
fn fill_shadow(n: &Node) -> (f64, f64) {
let item = n.appearance.items.first().unwrap();
match raster_effects(&crate::item_effects_in(&n.appearance.effects, item.effects())).first() {
Some(RasterFx::DropShadow { dx, dy, .. }) => (*dx, *dy),
other => panic!("expected a drop shadow, got {other:?}"),
}
}
/// A group's Transform effect transforms the raster effects of what the group holds with the art,
/// whatever its options: a member's own shadow, a member's fill's shadow and a nested group's own
/// shadow, drawn (the group's art) and baked.
#[test]
fn a_groups_transform_moves_its_members_shadows() {
let cases = [
(json!({"scaleH": 200, "scaleV": 200}), (20.0, 20.0)),
(json!({"scaleH": 200, "scaleV": 100, "strokes": true}), (20.0, 10.0)),
(json!({"rotate": 90, "patterns": true}), (10.0, -10.0)),
(json!({"rotate": 90, "strokes": true}), (10.0, -10.0)),
];
for (params, want) in cases {
let members = vec![
square(21, vec![shadow(10.0, 10.0, 0.0)]),
fill_square(22, vec![shadow(10.0, 10.0, 0.0)]),
group(23, vec![square(24, vec![])], vec![shadow(10.0, 10.0, 0.0)]),
];
let g = group(20, members, vec![transform(params.clone())]);
let art = crate::evaluate_container(None, &g).unwrap();
assert!(near(&shadows_in(&art), &[want; 3]), "{params}: drawn {:?}", shadows_in(&art));
let mut d = Document::new(800.0, 800.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, g).unwrap();
let baked = bake_document(&d).unwrap();
for id in [21, 22, 23] {
let b = baked.node(NodeId(id)).unwrap();
assert!(near(&shadows_in(b), &[want]), "{params}: {id} baked {:?}", shadows_in(b));
}
}
}
/// A group's Transform effect that makes copies or moves at random leaves its members' shadows as
/// they are, as it leaves the shadows listed before it in its own effects.
#[test]
fn a_groups_copying_transform_leaves_its_members_shadows() {
for params in [json!({"scaleH": 200, "scaleV": 200, "copies": 1}), json!({"scaleH": 200, "scaleV": 200, "random": true})] {
let g = group(25, vec![square(26, vec![shadow(10.0, 10.0, 0.0)]), square(27, vec![])], vec![transform(params.clone())]);
let art = crate::evaluate_container(None, &g).unwrap();
let drawn = shadows_in(&art);
assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 10.0); drawn.len()]), "{params}: {drawn:?}");
}
}
/// The Transform effects inside a group apply before the group's: a member's own turn of 90°
/// applies to its shadow before the group's uneven scale does, for the member's own shadow, its
/// fill's shadow, and the shadow of a member of a nested group that has the turn.
#[test]
fn a_groups_transform_applies_after_its_members_own() {
let turn = || transform(json!({"rotate": 90}));
let uneven = || transform(json!({"scaleH": 200, "scaleV": 100}));
// Turned first, (10, 0) goes to (0, -10), which the scale leaves; scaled first, it would be (0, -20).
let want = (0.0, -10.0);
let own = group(30, vec![square(31, vec![shadow(10.0, 0.0, 0.0), turn()])], vec![uneven()]);
let art = crate::evaluate_container(None, &own).unwrap();
assert!(near(&shadows_in(&art), &[want]), "own: {:?}", shadows_in(&art));
let mut on_fill = fill_square(33, vec![shadow(10.0, 0.0, 0.0)]);
on_fill.appearance.effects = vec![turn()];
let art = crate::evaluate_container(None, &group(32, vec![on_fill], vec![uneven()])).unwrap();
assert!(near(&[fill_shadow(&node_in(&art, 33))], &[want]), "fill: {:?}", fill_shadow(&node_in(&art, 33)));
let nested = group(34, vec![group(35, vec![square(36, vec![shadow(10.0, 0.0, 0.0)])], vec![turn()])], vec![uneven()]);
let art = crate::evaluate_container(None, &nested).unwrap();
let inner = crate::evaluate_container(None, &node_in(&art, 35)).unwrap();
assert!(near(&shadows_in(&inner), &[want]), "nested: {:?}", shadows_in(&inner));
}
/// An object's Transform effect transforms the raster effects of its fills and strokes with the
/// art, whatever its options: drawn, drawn through the art an option makes, and baked. One that
/// makes copies leaves them.
#[test]
fn an_objects_transform_moves_its_fills_shadows() {
let cases = [
(json!({"scaleH": 200, "scaleV": 100}), (20.0, 10.0)),
(json!({"scaleH": 200, "scaleV": 100, "strokes": true}), (20.0, 10.0)),
(json!({"rotate": 90, "patterns": true}), (10.0, -10.0)),
(json!({"rotate": 90, "strokes": true}), (10.0, -10.0)),
(json!({"scaleH": 200, "scaleV": 200, "copies": 1}), (10.0, 10.0)),
];
for (params, want) in cases {
let mut n = fill_square(40, vec![shadow(10.0, 10.0, 0.0)]);
n.appearance.effects = vec![transform(params.clone())];
if crate::has_transform_art(&n) {
let art = crate::reshape(None, &n, None).unwrap();
let drawn = shadows_in(&art);
assert!(!drawn.is_empty() && near(&drawn, &vec![want; drawn.len()]), "{params}: drawn {drawn:?}");
} else {
assert!(near(&[fill_shadow(&n)], &[want]), "{params}: drawn {:?}", fill_shadow(&n));
}
let mut d = Document::new(800.0, 800.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
let baked = bake_document(&d).unwrap();
let drawn = shadows_in(baked.node(NodeId(40)).unwrap());
assert!(!drawn.is_empty() && near(&drawn, &vec![want; drawn.len()]), "{params}: baked {drawn:?}");
}
}
/// A 10 pt image at the origin with `effects`.
fn image(id: u64, effects: Vec<Effect>) -> Node {
let mut n = Node::new(
NodeId(id),
vectorcraft_doc::NodeKind::Image(Box::new(vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
})),
);
n.appearance.effects = effects;
n
}
/// A group's Transform effect transforms, with Scale Strokes & Effects off or on, the raster
/// effects that a member's own Transform effect with copies holds back: they keep their value
/// through the member's effect and transform with the group's. That holds for a shadow listed
/// before the member's copying effect, one listed after it, a fill's shadow under it, the shadow
/// of a member of a nested group with such an effect, and an image's shadow.
#[test]
fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() {
let copies = || transform(json!({"moveH": 60, "copies": 1}));
// Halved and turned 90° counterclockwise on screen, (10, 10) goes to (5, -5).
let want = (5.0, -5.0);
for strokes in [false, true] {
let mut on_fill = fill_square(73, vec![shadow(10.0, 10.0, 0.0)]);
on_fill.appearance.effects = vec![copies()];
let members = vec![
square(71, vec![shadow(10.0, 10.0, 0.0), copies()]),
square(72, vec![copies(), shadow(10.0, 10.0, 0.0)]),
on_fill,
group(74, vec![square(75, vec![shadow(10.0, 10.0, 0.0)])], vec![copies()]),
image(76, vec![shadow(10.0, 10.0, 0.0)]),
];
let g = group(70, members, vec![transform(json!({"scaleH": 50, "scaleV": 50, "rotate": 90, "strokes": strokes}))]);
let art = crate::evaluate_container(None, &g).unwrap();
for id in [71, 72, 73, 75, 76] {
let drawn = shadows_in(&node_in(&art, id));
assert!(near(&drawn, &[want]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}");
}
}
}
/// A group's Transform effect transforms the raster effects in the art of a member's linked opacity
/// mask with the member, a path's or an image's, with Scale Strokes & Effects off or on.
#[test]
fn a_groups_transform_moves_the_shadow_in_a_members_mask() {
let masked = |mut n: Node, id: u64| {
n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(square(id, vec![shadow(10.0, 10.0, 0.0)]), true)));
n
};
for strokes in [false, true] {
let params = json!({"scaleH": 50, "scaleV": 50, "rotate": 90, "strokes": strokes});
let g = group(80, vec![masked(square(81, vec![]), 82), masked(image(83, vec![]), 84)], vec![transform(params)]);
let art = crate::evaluate_container(None, &g).unwrap();
for id in [81, 83] {
let member = node_in(&art, id);
let mask = member.extras().mask.as_deref().unwrap();
assert!(near(&shadows_in(&mask.art), &[(5.0, -5.0)]), "Scale Strokes & Effects {strokes}: {id} {:?}", shadows_in(&mask.art));
}
}
}
/// A group's Transform effect transforms the shadows of its members' fills that carry a Transform
/// effect of their own with Scale Strokes & Effects on, with the group's Scale Strokes & Effects off
/// or on: a shadow listed after the fill's Transform effect keeps its value through that effect and
/// transforms with the group's, and one listed before it follows the fill's Transform effect and
/// then the group's. Drawn through each member's art.
#[test]
fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform() {
let scaled = || transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true}));
// Halved and turned 90° counterclockwise on screen, (10, 10) goes to (5, -5); doubled first by
// the fill's own Transform effect, it goes to (10, -10).
let cases = [(91, vec![scaled(), shadow(10.0, 10.0, 0.0)], (5.0, -5.0)), (92, vec![shadow(10.0, 10.0, 0.0), scaled()], (10.0, -10.0))];
for strokes in [false, true] {
let members = cases.iter().map(|(id, effects, _)| fill_square(*id, effects.clone())).collect();
let g = group(90, members, vec![transform(json!({"scaleH": 50, "scaleV": 50, "rotate": 90, "strokes": strokes}))]);
let art = crate::evaluate_container(None, &g).unwrap();
for (id, _, want) in &cases {
let member = node_in(&art, *id);
let drawn = shadows_in(&crate::reshape(None, &member, None).unwrap());
assert!(!drawn.is_empty() && near(&drawn, &vec![*want; drawn.len()]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}");
}
}
}
+5 -1
View File
@@ -588,7 +588,9 @@ fn expand_piece_leaves(
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed, with each raster effect
/// following the Transform effects after it
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)) and those of its fills and
/// strokes following its Transform effects
/// ([`follow_items`](vectorcraft_render::effects::follow_items)). `None` when it has no
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
/// path that one paints goes through [`expand_pieces`] instead.
@@ -604,6 +606,8 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
if carry.moves(&m.appearance) {
carry.apply(&mut m.appearance);
}
// So do the raster effects of its fills and strokes.
vectorcraft_render::effects::follow_items(&mut m);
m.appearance.effects = without_geometry(&m.appearance.effects);
Some(m)
}
+45
View File
@@ -212,6 +212,51 @@ fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() {
}
}
/// The object's Transform effect scales and turns the drop shadow of its gradient fill on the
/// canvas; Object › Expand of the fill keeps the shadow the canvas drew.
#[test]
fn a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it() {
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false);
for (effect, params, item) in [
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100}), json!(0)),
("distort.transform", json!({"scaleH": 200, "scaleV": 100, "rotate": 90}), json!(null)),
] {
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
}
let before = render(&s);
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
let after = render(&s);
let diff = differing(&before, &after, 64);
assert_eq!(diff, 0, "{diff} pixels differ");
}
/// A group's Transform effect scales and turns its member's drop shadow on the canvas, and an
/// object's scales and turns its fill's, with Scale Strokes & Effects off or on; Expand Appearance
/// keeps the shadows the canvas drew.
#[test]
fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them() {
let shadow = json!({"x": 10, "y": 0, "blur": 0, "opacity": 100});
for strokes in [false, true] {
let transform = json!({"scaleH": 200, "scaleV": 100, "rotate": 90, "strokes": strokes});
for group in [false, true] {
let mut s = session();
let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false);
let item = if group { json!(null) } else { json!(0) };
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow, "ids": [id.0], "item": item}));
if group {
run(&mut s, "object.group", json!({}));
}
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": transform}));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let after = render(&s);
let diff = differing(&before, &after, 64);
assert_eq!(diff, 0, "group {group}, Scale Strokes & Effects {strokes}: {diff} pixels differ");
}
}
}
#[test]
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
// The object's own effect, and the fill's (the first appearance item).
+24
View File
@@ -332,6 +332,30 @@ fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() {
}
}
/// The object's Transform effect turns and scales the drop shadow of its stroke (brushed or not)
/// on the canvas; outlining the stroke keeps the shadow the canvas drew.
#[test]
fn a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it() {
for brush in [false, true] {
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
let mut s = session();
square_with(
&mut s,
json!({"color": "#ff0000"}),
json!(1),
&[("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100}))],
);
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 100, "rotate": 90}})).unwrap();
if brush {
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
}
let before = render(&s);
outlined_by(&mut s, command, &p);
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, brush {brush}");
}
}
}
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
#[test]
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
+8 -2
View File
@@ -54,6 +54,12 @@ pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] {
}
}
/// The effects of `item`, a fill or stroke of `n`, that its raster effects are drawn from: each
/// one following `n`'s Transform effects as well ([`effects::item_effects_in`]).
pub(super) fn item_raster<'a>(n: &Node, item: &'a AppearanceItem) -> std::borrow::Cow<'a, [Effect]> {
effects::item_effects_in(&n.appearance.effects, item_effects(item))
}
/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances
/// measured by their art from `symbols`).
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
@@ -81,7 +87,7 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
/// `r`, the art of `n` with its strokes, grown by how far the raster effects of `n`'s fills and
/// strokes and then `n`'s own paint past it.
fn with_raster_reach(symbols: &SymbolBoxes, n: &Node, r: Rect) -> Rect {
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b));
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, &item_raster(n, i), r)).fold(r, |acc, b| acc.union(b));
let o = outset(symbols, &n.appearance.effects, r);
r.inflate(o, o)
}
@@ -109,7 +115,7 @@ pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
let b = art_bounds(symbols, n)?;
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
Some(items.map(|i| spread(symbols, &item_raster(n, i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
}
/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path
+2 -2
View File
@@ -1,4 +1,4 @@
use super::bounds::{apply_carrying, item_effects};
use super::bounds::{apply_carrying, item_effects, item_raster};
use super::entries::{Content, DocInputs, Drawn, FX_RASTER_BUDGET, Inputs, ItemGeom, KeptImage, Made, Reads, feather_outline};
#[cfg(test)]
use super::raster::RASTERIZED;
@@ -677,7 +677,7 @@ impl Renderer {
});
let item = carried.as_ref().and_then(Option::as_ref).unwrap_or(item);
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
let rfx = effects::raster_effects(item_effects(item));
let rfx = effects::raster_effects(&item_raster(n, item));
if rfx.is_empty() {
self.paint_item(ctx, f, n, item, ig, rule, ib);
continue;
+39
View File
@@ -337,6 +337,45 @@ fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() {
}
}
/// An object's or a group's Transform effect transforms the raster effects of what it holds with
/// the art: halving a 40 pt square about its center halves the 10 pt offset of its fill's shadow,
/// and of a group member's own shadow, with Scale Strokes & Effects off or on.
#[test]
fn a_transform_scales_the_shadows_of_fills_and_members_with_the_art() {
let r = Rect::new(20.0, 20.0, 60.0, 60.0);
let shadow =
Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}), visible: true };
for strokes in [false, true] {
let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes}));
let mut fill = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![transform.clone()]);
let l = fill.layers[0].id;
let mut square = (*fill.layers[0].children().and_then(|c| c.first()).cloned().unwrap()).clone();
fill.remove(square.id).unwrap();
if let Some(item) = square.appearance.items.first_mut() {
*item.effects_mut() = vec![shadow.clone()];
}
fill.insert(Some(l), 0, square.clone()).unwrap();
let mut member = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]);
let l = member.layers[0].id;
let mut square = (*member.layers[0].children().and_then(|c| c.first()).cloned().unwrap()).clone();
member.remove(square.id).unwrap();
square.appearance.effects = vec![shadow.clone()];
let mut g = Node::group(member.alloc_id(), vec![Arc::new(square)]);
g.appearance.effects = [transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
member.insert(Some(l), 0, g).unwrap();
for (what, d) in [("fill", fill), ("member", member)] {
let img = render(&d);
// The square now covers 30..50 and its shadow 35..55.
assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}");
assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37));
assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55");
// The area redrawn for it takes in the 5 pt shadow around the 30..50 square.
let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap();
assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}");
}
}
}
/// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and
/// right; `shade` "color" or "darkness".
fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document {
+2 -1
View File
@@ -598,7 +598,8 @@ since 2026-10-01.
- **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged.
- **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged.
- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged.
- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged.
- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged.
- **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim.
- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged.