diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a326c85cf2..7292816e3d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -81,7 +81,10 @@ 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, follow_items, item_effects_in, outset, raster_effects, without_geometry}; +pub use raster::{ + RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, rasterize_stage, stage_map, staged_effects, + without_geometry, +}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index b59d61beb2..f24a5dd2ee 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -606,9 +606,62 @@ fn transform_step(e: &Effect) -> Option { (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) } +/// The Transform effects of `effects` that move the image of its last visible Rasterize effect +/// instead of the art it is made from: the indices of the visible Transform effects listed after +/// it that move the art, when from the first visible Transform effect after it on every visible +/// effect is a Transform effect that moves the art once (no copies, not at random). Effects apply +/// in the order they are listed, so the image is made of the art before those Transform effects +/// and moves with them, its pixel grid and room around the art included. `None` when there is no +/// such Transform effect, or the effects after the Rasterize effect hold anything else from there +/// on. +pub fn rasterize_stage(effects: &[Effect]) -> Option> { + let r = effects.iter().rposition(|e| e.visible && e.id == RASTERIZE)?; + let first = r + 1 + effects.get(r + 1..)?.iter().position(|e| e.visible && e.id == "distort.transform")?; + let mut at = vec![]; + for (i, e) in effects.iter().enumerate().skip(first).filter(|(_, e)| e.visible) { + if e.id != "distort.transform" || transform_step(e).is_none() { + return None; + } + if crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).0 != Affine::IDENTITY { + at.push(i); + } + } + (!at.is_empty()).then_some(at) +} + +/// `effects` with the Transform effects at `at` hidden ([`rasterize_stage`]): what the image of +/// the Rasterize effect before them is made from. +pub fn staged_effects(effects: &[Effect], at: &[usize]) -> Vec { + let mut out = effects.to_vec(); + for &i in at { + if let Some(e) = out.get_mut(i) { + e.visible = false; + } + } + out +} + +/// The map the Transform effects at `at` of `effects` ([`rasterize_stage`]) move the Rasterize +/// image by, for `staged`, the geometry the effects before them make: each one's map about its +/// reference point on the bounds of the geometry it takes, as the geometry pass applies it, the +/// last one outermost. `None` when `staged` is empty or the map is not a number. +pub fn stage_map(effects: &[Effect], at: &[usize], staged: &vectorcraft_geom::PathData) -> Option { + let mut cur = staged.clone(); + let mut b = staged.bounds()?; + let mut map = Affine::IDENTITY; + for &i in at { + let e = effects.get(i)?; + let (m, _) = crate::distort::transform_map(b, &merged_params(&e.id, &e.params), 0); + map = m * map; + cur = cur.transformed(m); + b = cur.bounds().unwrap_or(b); + } + map.is_finite().then_some(map) +} + /// 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. +/// around the art: the image moves with those Transform effects instead ([`rasterize_stage`]). pub(crate) fn follows(id: &str) -> bool { crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE } diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 1ebcab785a..9fbb37a39d 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -117,13 +117,71 @@ fn a_hidden_shadow_follows_the_transform_it_loses() { assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); } -/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +/// Rasterize keeps its room around the art: its image keeps its own pixel grid, and the Transform +/// effect moves the image instead ([`crate::rasterize_stage`]). #[test] fn rasterize_before_a_transform_keeps_its_options() { let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; assert_eq!(crate::raster::followed(&effects)[0], rasterize); assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); + assert_eq!(crate::rasterize_stage(&effects), Some(vec![1])); +} + +/// The Transform effects after the last Rasterize effect move its image when, from the first of +/// them on, every visible effect is a Transform effect that moves the art once. Hidden ones, and +/// ones with Transform Objects off, leave the image where it is. +#[test] +fn rasterize_following_ignores_hidden_and_objects_off() { + let rasterize = || fx(crate::RASTERIZE, json!({})); + let scale = || transform(json!({"scaleH": 200, "scaleV": 200})); + let mut hidden = scale(); + hidden.visible = false; + let off = transform(json!({"scaleH": 200, "objects": false})); + let roughen = fx("distort.roughen", json!({})); + let shadow = shadow(5.0, 5.0, 2.0); + let cases: Vec<(Vec, Option>)> = vec![ + (vec![rasterize(), hidden.clone()], None), + (vec![rasterize(), off.clone()], None), + (vec![rasterize(), hidden, off, scale()], Some(vec![3])), + (vec![rasterize(), transform(json!({"moveH": 10})), scale()], Some(vec![1, 2])), + // Effects before the first Transform effect go into the image; anything after it holds + // the image back. + (vec![rasterize(), roughen.clone(), shadow.clone(), scale()], Some(vec![3])), + (vec![rasterize(), scale(), roughen], None), + (vec![rasterize(), scale(), shadow], None), + (vec![rasterize(), transform(json!({"scaleH": 200, "copies": 2}))], None), + (vec![rasterize(), transform(json!({"scaleH": 200, "random": true}))], None), + // The Transform effect before the Rasterize effect moves the art. + (vec![scale(), rasterize()], None), + (vec![scale(), rasterize(), rasterize(), scale()], Some(vec![3])), + ]; + for (effects, want) in cases { + let ids: Vec<&str> = effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(crate::rasterize_stage(&effects), want, "{ids:?}"); + } + // What the image is made from hides those Transform effects; the rest stay as they are. + let effects = [rasterize(), scale()]; + let staged = crate::staged_effects(&effects, &[1]); + assert_eq!((staged[0].visible, staged[1].visible), (true, false)); +} + +/// The map that moves the image is each Transform effect's map about its reference point on the +/// bounds of the geometry it takes, in order. +#[test] +fn the_image_moves_by_each_transform_about_the_art_it_takes() { + let staged = shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)); + let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"moveH": 5}))]; + let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap(); + // Twice the size about the center (20, 20), then 5 pt to the right. + let want = Affine::translate((5.0, 0.0)) * Affine::translate((20.0, 20.0)) * Affine::scale(2.0) * Affine::translate((-20.0, -20.0)); + assert!(map.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9), "{map:?}"); + // The second Transform effect turns about the center of what the first one made. + let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"moveH": 100})), transform(json!({"rotate": 90}))]; + let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap(); + let c = map * Point::new(20.0, 20.0); + assert!((c.x - 120.0).abs() < 1e-9 && (c.y - 20.0).abs() < 1e-9, "{c:?}"); + assert_eq!(crate::stage_map(&effects, &[1, 2], &PathData::default()), None); } /// An SVG filter keeps its options: it paints in its own user space. diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 8f5f532c51..c9efc9d0bb 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -334,10 +334,19 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo /// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when /// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n` -/// without that effect and without its transparency, which stays on the object. `None` otherwise, -/// or when the image can't be made. +/// without that effect and without its transparency, which stays on the object. When Transform +/// effects after the Rasterize effect move its image ([`vectorcraft_render::rasterize_stage`]), +/// the image is made of the art before them and moved by them, as the canvas draws it. `None` +/// otherwise, when those Transform effects flatten the image, or when the image can't be made. fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None }; + let staged = vectorcraft_render::rasterize_stage(d, n); + if let Some((_, map)) = &staged + && !(map.determinant().is_finite() && map.determinant().abs() > 1e-12) + { + return None; + } + let n = staged.as_ref().map_or(n, |(stage, _)| stage); let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?; let p = json!({ "ppi": r.ppi.unwrap_or(d.raster_effects_ppi), @@ -354,7 +363,11 @@ fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { // shortcut: expanded Rasterize effects stay RGB, as the object keeps its own opacity mask and the // image's transparency would need one too; upgrade when CMYK images carry alpha. let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize", false).ok()?; - Some(image.into_node(d)) + let mut image = image.into_node(d); + if let Some((_, map)) = staged { + image.transform(map, false); + } + Some(image) } /// `v` (an object without its raster effects) replaced by `image` of the object with them: the @@ -589,6 +602,112 @@ fn walk_all(out: &mut Document, nodes: &[Arc], report: &mut FlattenReport) .then(|| nodes.iter().map(|n| walk(out, n, report).map(Arc::new).unwrap_or_else(|| n.clone())).collect()) } +/// `n` as an image when its Rasterize image moves with the Transform effects after it +/// ([`vectorcraft_render::rasterize_stage`]): the image is made of the art before those Transform +/// effects and moved by them, as the canvas draws it, which baking the geometry effects first would +/// lose. A map that flattens the image leaves an empty group, as the canvas draws nothing. Inside +/// groups, layers and opacity masks, the members are looked at the same way. `None` when nothing in +/// `n` changes; hidden objects and template layers stay as they are. +fn staged(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option { + if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { + return None; + } + if let Some((_, map)) = vectorcraft_render::rasterize_stage(out, n) { + let mut v = n.clone(); + strip_raster(&mut v, false); + let det = map.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + v.appearance = Default::default(); + v.kind = NodeKind::Group { children: vec![], clip: false }; + return Some(v); + } + let image = raster_image(out, n, false, false)?; + report.record(n, &image); + let mut m = replace_with_image(v, image); + staged_mask(out, &mut m, report); + return Some(m); + } + let mut m = n.clone(); + let mut changed = staged_mask(out, &mut m, report); + if is_container(n) + && let Some(ch) = staged_all(out, n.children()?, report) + && let Some(slot) = m.children_mut() + { + *slot = ch; + changed = true; + } + changed.then_some(m) +} + +/// The art of `n`'s opacity mask with [`staged`] applied; `true` when it changed. +fn staged_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) -> bool { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && let Some(art) = staged(out, &m.art, report) + { + m.art = Arc::new(art); + return true; + } + false +} + +/// Does `n`, or anything inside it or its opacity mask, list a Rasterize effect whose image the +/// Transform effects after it move ([`effects::rasterize_stage`])? +fn holds_stage(n: &Node) -> bool { + effects::rasterize_stage(&n.appearance.effects).is_some() + || n.extras().mask.as_ref().is_some_and(|m| holds_stage(&m.art)) + || n.children().is_some_and(|ch| ch.iter().any(|c| holds_stage(c))) +} + +/// A copy of `doc` with [`staged`] applied in its layers, symbol definitions and pattern tiles, +/// `None` when nothing changes. +fn staged_document(doc: &Document, report: &mut FlattenReport) -> Option { + let holds = doc.layers.iter().any(|n| holds_stage(n)) + || doc.symbols.iter().any(|s| holds_stage(&s.art)) + || doc.patterns.iter().any(|p| p.art.iter().any(|n| holds_stage(n))); + if !holds { + return None; + } + let mut out = doc.clone(); + let mut changed = false; + if let Some(layers) = staged_all(&mut out, &doc.layers, report) { + out.layers = layers; + changed = true; + } + for i in 0..out.symbols.len() { + if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| staged(&mut out, &a, report)) + && let Some(s) = out.symbols.get_mut(i) + { + s.art = Arc::new(art); + changed = true; + } + } + for i in 0..out.patterns.len() { + if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| staged_all(&mut out, &a, report)) + && let Some(p) = out.patterns.get_mut(i) + { + p.art = art; + changed = true; + } + } + changed.then_some(out) +} + +/// `nodes` with [`staged`] applied, `None` when none changes. +fn staged_all(out: &mut Document, nodes: &[Arc], report: &mut FlattenReport) -> Option>> { + let mut changed = false; + let all = nodes + .iter() + .map(|n| match staged(out, n, report) { + Some(m) => { + changed = true; + Arc::new(m) + } + None => n.clone(), + }) + .collect(); + changed.then_some(all) +} + /// A copy of `doc` with raster effects turned into images at the document's raster effects /// resolution, or `None` when there are none: on every kind of object (paths, groups and layers, /// type, images, symbol instances, live objects) and on single fills and strokes, in the layers, @@ -616,6 +735,9 @@ fn flatten(doc: &Document, report: &mut FlattenReport) -> Option { if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) { return None; } + // Rasterize images that Transform effects after them move, before those effects are baked. + let pre = staged_document(doc, report); + let doc = pre.as_ref().unwrap_or(doc); // Geometry effects first, so the vector objects kept above shadows are final. let baked = effects::bake_document(doc); let src = baked.as_ref().unwrap_or(doc); diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index 75659faf32..320ecb6317 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -292,6 +292,58 @@ fn rasterize_measures_the_art_its_geometry_effects_draw() { assert_eq!((xf.translation().x, width), (150.0, 41)); } +/// A Transform effect after Rasterize moves the image the Rasterize effect makes: Expand Appearance +/// and PDF export make the image of the art before the Transform effect, as Object › Rasterize +/// makes it, and place it through the effect's map, as the canvas draws it. +#[test] +fn expand_rasterize_then_transform_matches_canvas() { + let mut o = session(); + red_rectangle(&mut o); + let made = id(&o.execute("object.rasterize", &json!({"ppi": 72, "background": "white", "addAround": 2})).unwrap()); + let od = &o.doc().unwrap().doc; + let (w, h, xf, png, _) = rasterized(od, od.node(made).unwrap()); + // About the center of the rectangle's path. + let about = + |m: vectorcraft_geom::Affine| vectorcraft_geom::Affine::translate((70.5, 65.25)) * m * vectorcraft_geom::Affine::translate((-70.5, -65.25)); + let cases = [ + (json!({"scaleH": 200, "scaleV": 200}), about(vectorcraft_geom::Affine::scale(2.0))), + (json!({"rotate": 30}), about(vectorcraft_geom::Affine::rotate(-30f64.to_radians()))), + ( + json!({"rotate": 30, "moveH": 10, "strokes": true}), + vectorcraft_geom::Affine::translate((10.0, 0.0)) * about(vectorcraft_geom::Affine::rotate(-30f64.to_radians())), + ), + ]; + for (params, map) in cases { + let mut s = session(); + let r = red_rectangle(&mut s); + let raster = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 2}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": raster, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": params.clone(), "ids": [r.0]})).unwrap(); + let check = |d: &Document, n: &vectorcraft_doc::Node, what: &str| { + let (w2, h2, xf2, png2, _) = rasterized(d, n); + assert_eq!((w2, h2), (w, h), "{what} {params}"); + assert!(png2 == png, "{what} {params}: the same pixels"); + let want = map * xf; + assert!(xf2.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{what} {params}: {xf2:?} {want:?}"); + }; + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + check(&flat, &flat.layers[0].children().unwrap()[0], "PDF export"); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + check(d, d.node(r).unwrap(), "Expand Appearance"); + } + // A Transform effect that scales the image to nothing leaves nothing in PDF export, as the + // canvas draws nothing. + let mut s = session(); + let r = red_rectangle(&mut s); + let raster = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 2}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": raster, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"scaleH": 0}, "ids": [r.0]})).unwrap(); + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + let n = &flat.layers[0].children().unwrap()[0]; + assert!(matches!(&n.kind, vectorcraft_doc::NodeKind::Group { children, .. } if children.is_empty()), "{:?}", n.kind); +} + #[test] fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() { let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind { diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index c3aaf98322..ae7ccba77e 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -58,6 +58,15 @@ pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { fx::painted_reach(&doc.symbol_boxes(), n) } +/// For a path or compound path whose own Rasterize effect is followed by Transform effects that +/// move the art once ([`crate::effects::rasterize_stage`]): `n` with those Transform effects +/// hidden, which the image is made of, and the map they move the image by. The canvas draws the +/// image on its own pixel grid and moves it, and the room around the art, with the map. `None` +/// for other objects. +pub fn rasterize_stage(doc: &Document, n: &Node) -> Option<(Node, vectorcraft_geom::Affine)> { + fx::rasterize_stage(doc, n) +} + /// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with /// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0dd0beb0eb..2bf05ef537 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -72,7 +72,7 @@ mod renderer; pub(crate) use bounds::visual_bounds; pub(crate) use bounds::{ brushed_cull_bounds, cull_bounds, drawn_outline, drawn_outlines, painted_brush_width, painted_cull_bounds, painted_reach, path_cull_bounds, - three_d_art_bounds, with_own_raster_reach, + rasterize_stage, three_d_art_bounds, with_own_raster_reach, }; #[cfg(test)] pub(crate) use entries::Drawn; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index c79260e1c0..e6400c90af 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -6,7 +6,7 @@ use super::{ }; use vectorcraft_doc::symbol_overrides::dynamic_art; -fn node_bezpath(n: &Node) -> Option { +pub(super) fn node_bezpath(n: &Node) -> Option { match &n.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), NodeKind::Compound { children, .. } => { @@ -48,6 +48,34 @@ pub(crate) fn effected_path_carrying(doc: &Document, n: &Node, bp: &BezPath) -> apply_carrying(&n.appearance.effects, bp, &GeomContext { doc: Some(doc), ..GeomContext::of(n) }) } +/// `n`, a path or compound path whose own Rasterize effect is followed by Transform effects that +/// move its image ([`effects::rasterize_stage`]), with those Transform effects hidden, and the map +/// they move the image by ([`effects::stage_map`]): the image is made of that node's art on its +/// own pixel grid, then drawn through the map. `None` for any other object, when the node without +/// those Transform effects is drawn through its art ([`has_object_fx`]: a Transform effect that +/// copies it, Crop Marks, a 3D effect…), and when the art before the Transform effects is empty. +pub(crate) fn rasterize_stage(doc: &Document, n: &Node) -> Option<(Node, Affine)> { + if !has_fx(n) { + return None; + } + let at = effects::rasterize_stage(&n.appearance.effects)?; + let mut stage = n.clone(); + stage.appearance.effects = effects::staged_effects(&n.appearance.effects, &at); + if has_object_fx(&stage) { + return None; + } + let g = effected_path(doc, &stage, &node_bezpath(n)?); + let map = effects::stage_map(&n.appearance.effects, &at, &vectorcraft_geom::PathData::from_bezpath(&g))?; + Some((stage, map)) +} + +/// The box `map` ([`rasterize_stage`]) takes `b` to; `None` when it flattens it to a line or a +/// point, which leaves nothing to draw. +pub(crate) fn staged_reach(map: Affine, b: Rect) -> Option { + let det = map.determinant(); + (det.is_finite() && det.abs() > 1e-12).then(|| map.transform_rect_bbox(b)) +} + pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { match item { AppearanceItem::Fill(f) => &f.effects, @@ -74,6 +102,11 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { { return cull_bounds(symbols, &art); } + // A Rasterize image moved by the Transform effects after it reaches as far as the map takes + // what the art before them reaches, the image included. + if let Some((stage, map)) = rasterize_stage(symbols.document(), n) { + return staged_reach(map, visual_bounds(symbols, &stage)?); + } let bp = node_bezpath(n)?; let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) }; let (g, carry) = effected_path_carrying(symbols.document(), n, &bp); @@ -449,6 +482,9 @@ pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { if let Some(art) = effects::crop_marks_art(n) { return drawn_bounds(symbols, &art); } + if let Some((stage, map)) = rasterize_stage(symbols.document(), n) { + return staged_reach(map, painted_reach(symbols, &stage)?); + } let ran = ran(symbols.document(), n); let b = drawn_bounds_ran(symbols, n, Some(&ran))?; Some(spread(symbols, &ran.appearance.effects, b)) diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index d1a8429fd6..0291d998f0 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -1,4 +1,4 @@ -use super::bounds::{cull_bounds, effected_path_carrying}; +use super::bounds::{cull_bounds, effected_path_carrying, node_bezpath, rasterize_stage}; use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ @@ -330,6 +330,11 @@ impl Renderer { /// `draw_shape` for nodes with live effects. pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + if !f.opts.outline + && let Some(stage) = self.rasterize_stage_of(f, n, self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned()) + { + return self.draw_staged(ctx, f, stage, bp, rule); + } // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, // made from the node alone, so the effects give the same result every frame while the // plug-ins installed stay the same (`check_plugins`). @@ -360,6 +365,47 @@ impl Renderer { } } + /// [`rasterize_stage`] of `n`, kept across frames when `owner` is `n` as held in its document + /// (then `true` in the third place: the stage is kept, and so are its caches). + fn rasterize_stage_of(&mut self, f: &Frame, n: &Node, owner: Option>) -> Option<(Arc, Affine, bool)> { + // Checked first: this runs for every path with effects drawn. + effects::rasterize_stage(&n.appearance.effects)?; + let Some(owner) = owner else { + return rasterize_stage(f.symbols.document(), n).map(|(stage, map)| (Arc::new(stage), map, false)); + }; + let key = Arc::as_ptr(&owner) as usize; + let stamp = self.stamp; + if let Some(e) = self.fx_stages.get_mut(&key) + && Arc::ptr_eq(&e.0, &owner) + { + e.3 = stamp; + return Some((e.1.clone(), e.2, true)); + } + let (stage, map) = rasterize_stage(f.symbols.document(), n)?; + let stage = Arc::new(stage); + self.fx_stages.insert(key, (owner, stage.clone(), map, stamp)); + Some((stage, map, true)) + } + + /// Draw `stage`, the node a path's Rasterize image is made from ([`rasterize_stage`]), through + /// `map`, the Transform effects after that Rasterize effect: its art and its image, on the + /// image's own pixel grid, move with the map. A map that flattens the art leaves nothing to + /// draw. `bp` is the path's own path; with `kept`, `stage` is kept across frames and so are + /// the geometry and rasters drawn for it. + fn draw_staged(&mut self, ctx: &mut RenderContext, f: &Frame, (stage, map, kept): (Arc, Affine, bool), bp: &BezPath, rule: FillRule) { + let det = map.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + return; + } + // A pixel's size in document units through the view and the map, measured as a frame + // measures it from its view. + let px = f.px / det.abs().sqrt(); + let fr = Frame { view: f.view * map, visible: map.inverse().transform_rect_bbox(f.visible), px, output: f.output * map, ..*f }; + let outer = if kept { self.fx_owner.replace(stage.clone()) } else { self.fx_owner.take() }; + self.draw_shape_fx(ctx, &fr, &stage, bp, rule); + self.fx_owner = outer; + } + /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry: /// `paints` is `n` with the fills and strokes it paints. #[allow(clippy::too_many_arguments)] @@ -417,6 +463,15 @@ impl Renderer { if !self.charge_fx_instance(f.doc, a) { return; } + // A path whose Transform effects after its Rasterize effect hold options that reach its + // fills and strokes: its image moves with them as for any other path. + if !f.opts.outline + && let NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } = &a.kind + && let Some(stage) = self.rasterize_stage_of(f, a, cache.then(|| a.clone())) + && let Some(bp) = node_bezpath(a) + { + return self.draw_staged(ctx, f, stage, &bp, *rule); + } let art = self.fx_art(f.doc, a, cache); // Art the cache did not keep is made again in the next frame. let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6a92d91e8f..c49671fd8c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -125,6 +125,10 @@ pub struct Renderer { live_root: Option>, /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. fx_geom: PtrMap, + /// Per node whose Rasterize image moves with the Transform effects after it, keyed by `Arc` + /// identity like `fx_geom`: the node, the node it draws its image from and the map that moves + /// the image ([`fx::rasterize_stage`]), and the frame it was last drawn in. + fx_stages: PtrMap, Arc, Affine, u64)>, stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, @@ -274,7 +278,7 @@ mod renderer; mod text_geom; mod threads; -pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds}; +pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds, rasterize_stage}; pub use frame::FrameStats; use frame::{ ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey, diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 500d62d5b4..58d378173d 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -56,6 +56,7 @@ impl Renderer { fx_owner: None, live_root: None, fx_geom: PtrMap::default(), + fx_stages: PtrMap::default(), stamp: 0, alpha: 1.0, contexts: Vec::new(), @@ -230,6 +231,9 @@ impl Renderer { if self.fx_geom.len() > 256 { self.fx_geom.retain(|_, e| g - e.stamp <= 3); } + if self.fx_stages.len() > 256 { + self.fx_stages.retain(|_, e| g - e.3 <= 3); + } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); self.type_edges.sweep(g); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 15bd354496..37f3fbf4f7 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -146,6 +146,7 @@ impl Renderer { } if stale { self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); + self.fx_stages.retain(|_, e| !effects::has_plugin_effect(&e.0)); self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); diff --git a/crates/render/src/tests/rasterize.rs b/crates/render/src/tests/rasterize.rs index 3f27ddfebe..a374d43d7a 100644 --- a/crates/render/src/tests/rasterize.rs +++ b/crates/render/src/tests/rasterize.rs @@ -421,3 +421,99 @@ fn rasterize_fills_its_area_on_white_and_add_around_grows_it() { let stroked = draw(&stroked, 10.0, 400); assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0)); } + +/// Rasterize params for hard-edged pixels a point wide, with `more`. +fn hard(more: Value) -> Value { + let mut p = json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}); + if let (Some(p), Some(more)) = (p.as_object_mut(), more.as_object()) { + p.extend(more.clone()); + } + p +} + +/// A Transform effect after Rasterize enlarges the image it is given: each of the image's pixels +/// is drawn twice as wide, and so is the room around the art. +#[test] +fn rasterize_then_scale_uses_the_pretransform_pixels() { + for strokes in [false, true] { + let scale = ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "strokes": strokes})); + let r = draw(&doc(triangle(), &[("rasterize", hard(json!({}))), scale.clone()], 72.0), 10.0, 400); + // The 20 × 20 pixels of the image over 10..30 pt, each 2 pt wide over 0..40 pt. + assert!(squares_are_flat(&r, 0, 20, 20), "Scale Strokes & Effects {strokes}"); + assert_eq!((pixel(&r, 10, 10), pixel(&r, 390, 390)[3]), ([255, 0, 0, 255], 0), "Scale Strokes & Effects {strokes}"); + // 2 pt of white around the art becomes 4 pt: from -4 pt. + let framed = doc(triangle(), &[("rasterize", hard(json!({"background": "white", "addAround": 2}))), scale], 72.0); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&framed, 600, 600, Affine::translate((100.0, 100.0)) * Affine::scale(10.0), &opts); + assert_eq!((pixel(&r, 65, 300), pixel(&r, 55, 300)[3]), ([255; 4], 0), "Scale Strokes & Effects {strokes}"); + // It is measured where it is drawn: the image over the 10..30 pt art and 2 pt around it, + // twice as wide about its center. + let n = framed.layers[0].children().unwrap()[0].clone(); + let b = crate::drawn_bounds(&framed, &n).unwrap(); + assert!((b.x0 + 4.0).abs() < 1e-9 && (b.x1 - 44.0).abs() < 1e-9, "{b:?}"); + assert!(crate::painted_bounds(&framed, &n).is_some_and(|p| p.x0 <= -4.0 && p.x1 >= 44.0)); + } +} + +/// A Transform effect after Rasterize turns, reflects or stretches the image it is given, its pixel +/// grid included: the canvas draws what the art before the Transform effect draws, moved by the +/// effect's map. +#[test] +fn rasterize_then_rotate_and_reflect_moves_the_pixel_grid() { + let about = |m: Affine| Affine::translate((20.0, 20.0)) * m * Affine::translate((-20.0, -20.0)); + let cases = [ + (json!({"rotate": 30}), about(Affine::rotate(-30f64.to_radians()))), + (json!({"reflectX": true}), about(Affine::scale_non_uniform(-1.0, 1.0))), + (json!({"scaleH": 200, "scaleV": 100}), about(Affine::scale_non_uniform(2.0, 1.0))), + ]; + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let before = doc(triangle(), &[("rasterize", hard(json!({"background": "white", "addAround": 1})))], 72.0); + for (params, map) in cases { + let fx = [("rasterize", hard(json!({"background": "white", "addAround": 1}))), ("distort.transform", params.clone())]; + let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400); + let want = Renderer::new().render(&before, 400, 400, Affine::scale(10.0) * map, &opts); + let off = r.pixels.iter().zip(&want.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count(); + assert_eq!(off, 0, "{params}"); + } + // Twice as wide: each pixel 20 × 10 device pixels. + let r = draw(&doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200, "scaleV": 100}))], 72.0), 10.0, 400); + let flat = (0..20 * 20).all(|k| { + let (x0, y0) = ((k % 20) * 20, 100 + (k / 20) * 10); + (0..200).all(|i| pixel(&r, x0 + i % 20, y0 + i / 20) == pixel(&r, x0, y0)) + }); + assert!(flat); +} + +/// A Transform effect that scales the image to nothing leaves nothing to draw or measure. +#[test] +fn rasterize_then_nonuniform_scale_and_zero_scale_are_bounded() { + let fx = [("rasterize", hard(json!({"background": "white"}))), ("distort.transform", json!({"scaleH": 0, "scaleV": 100}))]; + let d = doc(triangle(), &fx, 72.0); + let r = draw(&d, 10.0, 400); + assert!(r.pixels.chunks(4).all(|p| p[3] == 0)); + let n = d.layers[0].children().unwrap()[0].clone(); + assert_eq!(crate::drawn_bounds(&d, &n), None); + // A huge scale enlarges where the image is drawn, not the image: 20 × 20 pixels still. + let fx = [("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 10000, "scaleV": 10000}))]; + let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get); + let before = drawn(); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&doc(triangle(), &fx, 72.0), 100, 100, Affine::translate((500.0, 500.0)), &opts); + assert_eq!(drawn() - before, 1); + // The view, around -450 pt, lies inside the image's pixel at 15..16 pt, drawn 100 pt wide + // about the art's center at 20 pt. + assert_eq!(pixel(&r, 50, 50), [255, 0, 0, 255]); +} + +/// A hidden Transform effect, or one with Transform Objects off, leaves the image where it is. +#[test] +fn rasterize_before_a_hidden_transform_keeps_its_image() { + let plain = draw(&doc(triangle(), &[("rasterize", hard(json!({})))], 72.0), 10.0, 400); + let off = + draw(&doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200, "objects": false}))], 72.0), 10.0, 400); + assert!(plain.pixels == off.pixels); + let mut hidden = doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200}))], 72.0); + let l = hidden.layers[0].id; + Arc::make_mut(&mut hidden.children_mut(Some(l)).unwrap()[0]).appearance.effects[1].visible = false; + assert!(plain.pixels == draw(&hidden, 10.0, 400).pixels); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 90d82894db..78c5ea76f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -620,8 +620,9 @@ 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. 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; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. - **Raster effects before a Transform effect that makes copies:** the Transform effect can make copies with its transformation applied ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)). Giving each copy the raster effects listed before that effect is VectorCraft's own rule. On a path or compound path, each copy now carries the Drop Shadow, glow, blur, Feather or pixel filter listed before the last Transform effect that makes copies, with its sizes scaled by that copy's mean scale and a Drop Shadow's offset turned, reflected and scaled with the copy; a raster effect listed after it paints once around all the copies. Before, one shadow was drawn under all the copies at the object's own distances. The canvas, the object's bounds, export and Expand Appearance draw the same copies, and copies that stop at the effect budget each keep their effects (effects `tests::rasterfollow::each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it`, `tests::rasterfollow::a_shadow_after_a_copying_transform_paints_once_around_the_copies`, `tests::rasterfollow::copies_past_the_budget_each_keep_their_shadow`; render `objectfx::each_copy_of_a_transform_effect_draws_its_own_shadow`; engine `objexpand::each_copys_shadow_expands_as_the_canvas_draws_it`). A Transform effect without copies draws as before. Not yet: the copies of type, images, symbol instances and groups, and a fill's or stroke's own copying Transform effect, still share one set of raster effects, and so does a path with Rasterize, an SVG filter or another effect that is neither a geometry effect nor one of those raster effects before the copying effect. Scores and hours unchanged. +- **Rasterize before a Transform effect:** effects apply in the order they are listed, each one taking the result of the one before ([the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). On a path or compound path, a Transform effect listed after the object's own Rasterize effect now moves the image that Rasterize makes: the image is made of the art before the Transform effect, at the Rasterize effect's resolution and with its room around the art, and the Transform effect scales, turns, reflects and moves the image with its pixel grid. Before, the Transform effect moved the art and Rasterize made a new image of the moved art on a grid square to the page. Keeping the image's pixel grid under the Transform effect is VectorCraft's own rule. A hidden Transform effect, or one with Transform Objects off, leaves the image where it is, and one that scales it to nothing leaves nothing drawn. The canvas, the object's bounds, Expand Appearance and PDF export draw the same image (effects `tests::rasterfollow::rasterize_following_ignores_hidden_and_objects_off`, `tests::rasterfollow::the_image_moves_by_each_transform_about_the_art_it_takes`; render `rasterize::rasterize_then_scale_uses_the_pretransform_pixels`, `rasterize::rasterize_then_rotate_and_reflect_moves_the_pixel_grid`, `rasterize::rasterize_then_nonuniform_scale_and_zero_scale_are_bounded`; engine `rastersettings::expand_rasterize_then_transform_matches_canvas`). Not yet: a Transform effect that makes copies or moves at random, a Transform effect followed by another kind of effect, a fill's or stroke's own Rasterize effect, and Rasterize on type, images, symbol instances and groups keep the earlier behavior. Scores and hours unchanged. - **Raster effects before a Transform effect past the effect budget:** VectorCraft leaves out a Transform effect whose result would pass its geometry budget of 2 million anchors or 8 million lines drawn, and the art stays where it was. The raster effects listed before that Transform effect now stay with the art too: a Drop Shadow keeps its own offset and blur, and the Transform effects after it that fit still move both. Before, the art stayed while the shadow scaled with the Transform effect it did not take. This is VectorCraft's own rule. It applies to a path's own effects and to its fills' and strokes' own, also when the path is in a group whose geometry effects run after its members' own, on the canvas and in its bounds, in export, Expand Appearance, Object › Expand and Outline Stroke, and a hidden shadow keeps its values once shown (effects `tests::rasterfollow::a_transform_past_the_line_budget_moves_the_art_with_its_shadow`, `tests::rasterfollow::only_transforms_that_ran_move_a_preceding_shadow`, `tests::rasterfollow::a_skipped_item_transform_keeps_its_shadow`, `tests::rasterfollow::the_shape_art_of_a_skipped_transform_keeps_its_shadows`, `tests::rasterfollow::a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects`; render `objectfx::a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn`, `fx::renderer::tests::a_path_paints_from_the_transforms_that_moved_it`; engine `objexpand::a_shadow_before_a_skipped_transform_expands_with_its_own_offset`). Not yet: the shadow of type, an image, a symbol instance or a group whose art a Transform effect leaves in place past the budget still follows that effect. 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. - **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and 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)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. Scores and hours unchanged.