From ff79f332a74c15e6044bd1b9d656a45571db0d6a Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:30:46 +0300 Subject: [PATCH] effects: a Transform effect moves and copies a group's images too (#2023) --- crates/effects/src/distort.rs | 35 ++++++++++------- crates/effects/src/reshape.rs | 48 +++++++++++++++++++++++- crates/effects/src/tests.rs | 56 +++++++++++++++++++++++++++- crates/render/src/tests_container.rs | 24 +++++++++++- 4 files changed, 146 insertions(+), 17 deletions(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 8fabb0591..3fb10a925 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -124,19 +124,7 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData { /// [`transform`] whose copies make at most `max` anchors of the path, the original included. pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData { - let random = flag(p, "random", false); - // The share (0..1) of value `k` applied. - let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 }; - let scale = |key: &str, k: u64| 1.0 + (num(p, key, 100.0).clamp(-MAX_SCALE, MAX_SCALE) / 100.0 - 1.0) * share(k); - let moved = |key: &str, k: u64| num(p, key, 0.0).clamp(-MAX_MOVE, MAX_MOVE) * share(k); - let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 }; - let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2(); - // Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates. - let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o) - * Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians()) - * Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY")) - * Affine::translate(-o); - let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize; + let (m, copies) = transform_map(b, p, seed); if copies == 0 { return path.transformed(m); } @@ -161,6 +149,27 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m out } +/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds +/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map +/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies +/// otherwise (see [`transform_within`], and images, which have no anchors to budget). +pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) { + let random = flag(p, "random", false); + // The share (0..1) of value `k` applied. + let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 }; + let scale = |key: &str, k: u64| 1.0 + (num(p, key, 100.0).clamp(-MAX_SCALE, MAX_SCALE) / 100.0 - 1.0) * share(k); + let moved = |key: &str, k: u64| num(p, key, 0.0).clamp(-MAX_MOVE, MAX_MOVE) * share(k); + let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 }; + let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2(); + // Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates. + let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o) + * Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians()) + * Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY")) + * Affine::translate(-o); + let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize; + (m, copies) +} + /// Tweak: random displacement of anchors and/or control points. pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData { let rel = flag(p, "relative", true); diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 05b686b58..689223044 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind}; -use vectorcraft_geom::{FillRule, PathData, Rect, shapes}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; use crate::{GeomContext, apply_one, is_geometry, merged_params}; @@ -197,7 +197,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { } /// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as -/// its reference box and the art's seed (a random Transform moves the pieces together). +/// its reference box and the art's seed (a random Transform moves the pieces together). Images +/// follow a Transform effect the same way ([`transform_images`]); other geometry effects leave +/// them as they are (a raster follows a non-affine map only as a mesh warp). pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { let seed = art.id.0; for e in fx { @@ -208,6 +210,48 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }); set_geometry(leaf, out, rule); }); + if e.id == "distort.transform" { + let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); + transform_images(art, m, copies); + } + } +} + +/// Apply the Transform effect's map `m` to every image under `n`, as [`for_each_leaf`] does to +/// paths: the map goes into the image's placement (pixel space → document), and `copies` more +/// images follow, each mapped once more than the last. Without copies the image itself takes the +/// map, as a path does. +fn transform_images(n: &mut Node, m: Affine, copies: usize) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + if !matches!(c.kind, NodeKind::Image(_)) { + transform_images(Arc::make_mut(c), m, copies); + continue; + } + let src = (**c).clone(); + let placed = |xf: Affine| { + let mut one = src.clone(); + if let NodeKind::Image(im) = &mut one.kind { + im.xf = xf * im.xf; + } + one + }; + if copies == 0 { + *c = Arc::new(placed(m)); + continue; + } + // The copies become image nodes of their own, under a group with the image's id, name and + // place in the art (as the runs an outlined text gets). + let mut images: Vec> = Vec::with_capacity(copies + 1); + images.push(Arc::new(placed(Affine::IDENTITY))); + let mut xf = Affine::IDENTITY; + for _ in 0..copies { + xf *= m; + images.push(Arc::new(placed(xf))); + } + let mut g = Node::group(src.id, images); + g.name = src.name.clone(); + *c = Arc::new(g); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 944308758..5c7026370 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Value, json}; use vectorcraft_doc::Effect; -use vectorcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes}; +use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; @@ -8,6 +8,16 @@ fn fx(id: &str, params: Value) -> Effect { Effect { id: id.into(), params, visible: true } } +/// Every image under `n`, in document order (its placement's x). +fn image_xs(n: &Node, out: &mut Vec) { + if let NodeKind::Image(im) = &n.kind { + out.push(im.xf.as_coeffs()[4]); + } + for c in n.children().into_iter().flatten() { + image_xs(c, out); + } +} + fn square() -> PathData { shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)) } @@ -230,6 +240,50 @@ fn transform_copies_stop_before_they_overflow() { assert!(all_finite(&out) && out.subpaths.len() < 10, "{}", out.subpaths.len()); } +/// #2023: a Transform effect on a group moves, scales and copies its images like its paths: the +/// map goes into each image's placement, and every copy is an image node of its own. +#[test] +fn transform_on_a_group_moves_and_copies_its_images() { + use std::sync::Arc; + + use vectorcraft_doc::{Appearance, ImageObject, Node, NodeId, NodeKind}; + let id = NodeId(9); + let sq = Node::path(id, square(), Appearance::default()); + let image = Node::new( + id, + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 40, + height: 40, + xf: Affine::translate((10.0, 0.0)), + link: None, + placement: Default::default(), + })), + ); + let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]); + g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2}))); + let art = evaluate_container(&g).unwrap(); + let mut xs = vec![]; + image_xs(&art, &mut xs); + xs.sort_by(f64::total_cmp); + assert_eq!(xs, [10.0, 80.0, 150.0], "the image and its two copies, 70 pt apart"); + // Without copies the image itself takes the transform, as a path does. + let mut g = Node::group(id, vec![Arc::new(Node::new(id, NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 40, + height: 40, + xf: Affine::translate((10.0, 0.0)), + link: None, + placement: Default::default(), + }))))]); + g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8}))); + let art = evaluate_container(&g).unwrap(); + let mut xs = vec![]; + image_xs(&art, &mut xs); + // The bounds are the image itself, so the bottom-right reference is its own: it halves in place. + assert_eq!(xs, [30.0], "scaled about its own bottom-right corner"); +} + #[test] fn pucker_bloat_moves_anchors_in_opposite_directions() { let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square()); diff --git a/crates/render/src/tests_container.rs b/crates/render/src/tests_container.rs index a0e7319ab..f8aeb5e8e 100644 --- a/crates/render/src/tests_container.rs +++ b/crates/render/src/tests_container.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::text::{CharStyle, TextObject}; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Knockout, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{Point, shapes}; use super::*; @@ -133,6 +133,28 @@ fn group_geometry_effects_move_the_members_as_one_piece() { assert_eq!((img.pixel(25, 75), img.pixel(65, 75)), (GREEN, GREEN)); } +/// #2023: a Transform effect on a group moves and copies its images as it does its paths (the +/// image's placement takes the map, and every copy is drawn at its own place). +#[test] +fn group_geometry_effects_move_and_copy_images_too() { + let mut d = Document::new(100.0, 100.0); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); + let b = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), solid(0.0, 0.0, 1.0)); + let im = ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((10.0, 30.0)), link: None, placement: Default::default() }; + let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))); + let mut g = Node::group(d.alloc_id(), vec![b, image]); + g.appearance.effects.push(fx("distort.transform", json!({"moveH": 50, "copies": 1}))); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let img = render(&d); + assert_eq!(img.pixel(25, 45), RED, "the image itself"); + assert_eq!(img.pixel(75, 45), RED, "its copy, 50 pt to the right"); + assert_eq!(img.pixel(60, 10), BLUE, "the path's copy"); + assert_eq!(img.pixel(5, 10), BLUE, "the path itself"); +} + #[test] fn a_layer_paints_its_own_fill_and_a_knockout_group_keeps_knocking_out() { let mut d = group_doc(|_| {});