Merge pull request #2042 from midasmatterhorn/fix/issue-2023-transformed-group-images

effects: a Transform effect moves and copies a group's images too (#2023)
This commit is contained in:
BFlat
2026-10-16 02:13:16 +07:00
committed by GitHub
4 changed files with 222 additions and 17 deletions
+28 -13
View File
@@ -112,6 +112,11 @@ const MAX_REACH: f64 = 1.0e9;
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
/// Most image nodes a Transform effect's copies make of an art, the originals included: 1000
/// copies of a 2000-image art. Stacked Transform effects multiply their copies (three of 1000
/// copies make a thousand million of one image), so the limit is on the result, not on each
/// effect's count.
pub(crate) const MAX_COPY_IMAGES: usize = 2_000_000;
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
@@ -124,19 +129,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 +154,28 @@ 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, whose copies count nodes against
/// [`MAX_COPY_IMAGES`]).
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);
+67 -2
View File
@@ -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,9 +210,72 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let out = apply_one(&e.id, &params, &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, &params, 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) {
transform_images_within(n, m, copies, crate::distort::MAX_COPY_IMAGES)
}
/// [`transform_images`] whose copies make at most `max` image nodes of the art, the originals
/// included: each copy adds the image once more, and a Transform after this one copies the lot
/// again, so copies stop where the result would pass `max` (#2042).
pub(crate) fn transform_images_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
let mut room = max.saturating_sub(image_count(n));
copy_images(n, m, copies, &mut room);
}
/// The walk of [`transform_images`]: the map and its copies go to each image in document order,
/// each copy taking one from `room` (what the art can still grow by), until the budget is full.
fn copy_images(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
if !matches!(c.kind, NodeKind::Image(_)) {
copy_images(Arc::make_mut(c), m, copies, room);
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;
}
let made = copies.min(*room);
*room -= made;
// 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<Arc<Node>> = Vec::with_capacity(made + 1);
images.push(Arc::new(placed(Affine::IDENTITY)));
let mut xf = Affine::IDENTITY;
for _ in 0..made {
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);
}
}
/// How many image nodes `n` holds below itself.
fn image_count(n: &Node) -> usize {
n.children().into_iter().flatten().map(|c| usize::from(matches!(c.kind, NodeKind::Image(_))) + image_count(c)).sum()
}
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(n: &Node) -> Node {
+104 -1
View File
@@ -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<f64>) {
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))
}
@@ -224,12 +234,105 @@ fn stacked_transform_copies_stay_within_the_anchor_budget() {
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
}
/// #2042: stacked Transform effects multiply their image copies as they do their anchors; the
/// image nodes they make are bounded, whatever each one's count.
#[test]
fn stacked_transform_copies_stay_within_the_image_budget() {
use std::sync::Arc;
use vectorcraft_doc::{ImageObject, NodeId};
let id = NodeId(9);
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 count = |art: &Node| {
let mut xs = vec![];
image_xs(art, &mut xs);
xs.len()
};
let m = Affine::translate((70.0, 0.0));
let mut art = Node::group(id, vec![Arc::new(image())]);
let mut counts = vec![];
for _ in 0..3 {
crate::reshape::transform_images_within(&mut art, m, 1000, 100);
counts.push(count(&art));
}
assert_eq!(counts, [100, 100, 100], "99 copies, then no room for a copy of them");
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
let mut art = Node::group(id, vec![Arc::new(image())]);
crate::reshape::transform_images_within(&mut art, m, 24, 100);
assert_eq!(count(&art), 25);
let mut g = Node::group(id, vec![Arc::new(image())]);
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000})));
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
}
#[test]
fn transform_copies_stop_before_they_overflow() {
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
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());
+23 -1
View File
@@ -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(|_| {});