From 043e517ef6927ca74d2b9e3ff277ac3985ed6b5a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:35:45 -0800 Subject: [PATCH 1/2] Effects: a group's or object's Transform effect moves the raster effects inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Transform effect on a group reshaped its members' paths but left the raster effects inside it at their own values: a member's Drop Shadow kept its 10 pt offset under a 200% Transform effect, and with Scale Strokes & Effects on, it scaled by the mean scale without turning. The same held for the raster effects of an object's own fills and strokes under the object's Transform effect. - A Transform effect that moves the art once now transforms the raster effects of a group's members (their own and their fills' and strokes'), of nested groups, of the art of the linked opacity masks in it and of an object's fills and strokes, after the members' own Transform effects: sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales. 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. Scale Strokes & Effects does not scale them a second time. 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 read the same values. - The Transform effect's params text and docs/target-app-parity.md describe the rule. --- crates/effects/src/bake.rs | 2 + crates/effects/src/catalog.rs | 2 +- crates/effects/src/group.rs | 3 + crates/effects/src/group/tests.rs | 17 ++ crates/effects/src/lib.rs | 2 +- crates/effects/src/raster.rs | 92 ++++++++++- crates/effects/src/reshape.rs | 120 ++++++++++++-- crates/effects/src/tests/rasterfollow.rs | 191 +++++++++++++++++++++++ crates/engine/src/cmd/expand.rs | 6 +- crates/engine/src/tests/objexpand.rs | 45 ++++++ crates/engine/src/tests/outlinestroke.rs | 24 +++ crates/render/src/fx/bounds.rs | 10 +- crates/render/src/fx/pixels.rs | 4 +- crates/render/src/fx/tests.rs | 39 +++++ docs/target-app-parity.md | 3 +- 15 files changed, 540 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 24f3d68af6..55a904d02c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -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)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 1dfe06e6aa..0810fb0a7e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -267,7 +267,7 @@ pub fn effect_catalog() -> Vec { "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)}", + "{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)}", 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( diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 015490a74d..0f61fda674 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -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 diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index a7fb05b2a6..c169c807b2 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -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] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 80cfef7b96..4ef8cd818f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 07140e2bc9..ecd7d9a9bb 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -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::{Effect, Node, RasterColorModel, RasterEffectsSettings}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -478,6 +478,94 @@ 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 { + 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 +} + +/// `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 +599,7 @@ fn transform_step(e: &Effect) -> Option { /// 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 } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 58f083ef68..0f77807499 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -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: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes }; + let step = Step { e, params: ¶ms, 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, + 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); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, raster_map.is_some()); *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,14 +1258,91 @@ 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, moved: Option<&HashSet>, up: Option, 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. 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. Returns what the leaf costs after, and `false` when the path kept its shape because the +/// moved path would pass `budget`. fn transform_leaf( leaf: &mut Node, path: &PathData, @@ -1254,8 +1350,12 @@ fn transform_leaf( copies: usize, options: crate::distort::TransformOptions, budget: Budget, + follows: bool, ) -> (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) @@ -1705,7 +1805,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room); + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, false); *used = used.plus(made); let from = crate::raster::held_back(before); if moved && !held_later { @@ -1781,7 +1881,7 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, false); *used = used.minus(before).plus(made); if moved { follow_piece(leaf, was, from, &e); diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 875d916940..93b474f1f5 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -335,3 +335,194 @@ 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, effects: Vec) -> 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) -> 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)); + } + } +} diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 4e7e1fa658..354149bf42 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -548,7 +548,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. @@ -564,6 +566,8 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { 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) } diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 081db4cca0..aab0e11ede 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -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). diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index a92ca10416..16b57a628c 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -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() { diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 4b5055e67f..58c8714306 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -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 { @@ -81,7 +87,7 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { /// `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 { } 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 diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 1169877dc7..052a8401a4 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -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; diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 96bddc17f1..50d0245c12 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -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 { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c492cbe9..bc7ae655c6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -593,7 +593,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, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. -- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). 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. From bd973548d749e36c2c557cc8ef7036b0cff6b464 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:21:38 -0800 Subject: [PATCH 2/2] Effects: an object's Transform effect moves its fills' raster effects beside a fill's 3D effect When only a fill or stroke has an Extrude & Bevel, Inflate, Rotate or Revolve effect, the object's Transform effects reshape the path, and now the raster effects of every fill and stroke follow them too, as they do on an object without a 3D effect: a shadow after the fill's 3D effect and another fill's shadow scale and turn with the art. --- crates/effects/src/raster.rs | 12 +++++++++++- crates/effects/src/revolve.rs | 6 +++++- crates/effects/src/solids.rs | 4 ++++ crates/effects/src/tests/rasterfollow.rs | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index ecd7d9a9bb..b59d61beb2 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -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, Node, RasterColorModel, RasterEffectsSettings}; +use vectorcraft_doc::{AppearanceItem, Effect, Node, RasterColorModel, RasterEffectsSettings}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -553,6 +553,16 @@ fn follow_items_by(n: &mut Node, a: Affine) -> bool { 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 diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 20a66e9a83..36eb9c2b84 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -565,9 +565,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } 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 = 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)); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index a49829f50c..2e96d4dcdb 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -982,6 +982,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } 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 { diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 93b474f1f5..f8e087b368 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -241,6 +241,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 = [