mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
effects: a Transform effect copies a group's symbol instances too, and stops image copies at MAX_REACH
Symbol instances are placed through a transform as images are, so the Transform effect now moves and copies both (transform_placed), sharing one copy budget (MAX_COPY_OBJECTS). Copies of either stop before one reaches past MAX_REACH, as a path's do (distort::within_reach). Adds end-to-end export tests for a group's image copies in svg and pdf.
This commit is contained in:
committed by
bflatastic
parent
789a151ff9
commit
d05ccffebc
@@ -145,11 +145,17 @@ 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;
|
||||
/// Most image and symbol instance 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_OBJECTS: usize = 2_000_000;
|
||||
|
||||
/// Does `r` stay within [`MAX_REACH`] of the origin? A Transform effect's copies stop before one
|
||||
/// doesn't.
|
||||
pub(crate) fn within_reach(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -166,7 +172,6 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
|
||||
if copies == 0 {
|
||||
return path.transformed(m);
|
||||
}
|
||||
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
||||
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||
let each = path.anchor_count();
|
||||
@@ -179,7 +184,7 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
|
||||
break;
|
||||
}
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within) {
|
||||
if !cur.bounds().is_some_and(within_reach) {
|
||||
break;
|
||||
}
|
||||
out.subpaths.extend(cur.subpaths.iter().cloned());
|
||||
@@ -191,7 +196,7 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
|
||||
/// `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`]).
|
||||
/// [`MAX_COPY_OBJECTS`]).
|
||||
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.
|
||||
|
||||
@@ -244,9 +244,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). 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).
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
|
||||
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); 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;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
@@ -263,7 +263,7 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
});
|
||||
if e.id == "distort.transform" {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
transform_images(art, m, copies);
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
|
||||
@@ -342,36 +342,46 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
|
||||
vectorcraft_geom::Affine::new([sx, 0.0, 0.0, sy, tx, ty])
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as
|
||||
/// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more
|
||||
/// follow, each mapped once more than the last. Without copies the object itself takes the map,
|
||||
/// as a path does.
|
||||
fn transform_placed(n: &mut Node, m: Affine, copies: usize) {
|
||||
transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS)
|
||||
}
|
||||
|
||||
/// [`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);
|
||||
/// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art,
|
||||
/// the originals included: each copy adds the object 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_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
|
||||
let mut room = max.saturating_sub(placed_count(n));
|
||||
copy_placed(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) {
|
||||
/// An object that a Transform effect places through its transform rather than reshapes: an image
|
||||
/// (pixel space to document) or a symbol instance (the symbol's art to document).
|
||||
fn is_placed(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. })
|
||||
}
|
||||
|
||||
/// The walk of [`transform_placed`]: the map and its copies go to each image and symbol instance in
|
||||
/// document order, each copy taking one from `room` (what the art can still grow by), until the
|
||||
/// budget is full. As with a path, the copies stop before one reaches past
|
||||
/// [`crate::distort::within_reach`].
|
||||
fn copy_placed(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);
|
||||
if !is_placed(c) {
|
||||
copy_placed(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;
|
||||
match &mut one.kind {
|
||||
NodeKind::Image(im) => im.xf = xf * im.xf,
|
||||
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
|
||||
_ => {}
|
||||
}
|
||||
one
|
||||
};
|
||||
@@ -379,26 +389,28 @@ fn copy_images(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
*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)));
|
||||
// The copies become nodes of their own, under a group with the object's id, name and place
|
||||
// in the art (as the runs an outlined text gets).
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(placed(Affine::IDENTITY))];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..made {
|
||||
for _ in 0..copies.min(*room) {
|
||||
xf *= m;
|
||||
images.push(Arc::new(placed(xf)));
|
||||
let copy = placed(xf);
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
let mut g = Node::group(src.id, images);
|
||||
*room -= placements.len() - 1;
|
||||
let mut g = Node::group(src.id, placements);
|
||||
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()
|
||||
/// How many images and symbol instances `n` holds below itself.
|
||||
fn placed_count(n: &Node) -> usize {
|
||||
n.children().into_iter().flatten().map(|c| usize::from(is_placed(c)) + placed_count(c)).sum()
|
||||
}
|
||||
|
||||
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
|
||||
|
||||
@@ -18,6 +18,16 @@ fn image_xs(n: &Node, out: &mut Vec<f64>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every symbol instance under `n`, in document order (its placement's x).
|
||||
fn instance_xs(n: &Node, out: &mut Vec<f64>) {
|
||||
if let NodeKind::SymbolInstance { xf, .. } = &n.kind {
|
||||
out.push(xf.as_coeffs()[4]);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
instance_xs(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn square() -> PathData {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
|
||||
}
|
||||
@@ -264,13 +274,13 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
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);
|
||||
crate::reshape::transform_placed_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);
|
||||
crate::reshape::transform_placed_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})));
|
||||
@@ -333,6 +343,87 @@ fn transform_on_a_group_moves_and_copies_its_images() {
|
||||
assert_eq!(xs, [30.0], "scaled about its own bottom-right corner");
|
||||
}
|
||||
|
||||
/// #2023: a Transform effect on a group moves and copies its symbol instances as it does its paths
|
||||
/// and images: the map goes into each instance's placement, and every copy is an instance of its
|
||||
/// own.
|
||||
#[test]
|
||||
fn transform_on_a_group_moves_and_copies_its_symbol_instances() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
||||
let id = NodeId(9);
|
||||
let instance = || Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::translate((5.0, 40.0)) });
|
||||
let xs = |art: &Node| {
|
||||
let mut xs = vec![];
|
||||
instance_xs(art, &mut xs);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs
|
||||
};
|
||||
let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart");
|
||||
// Without copies the instance itself takes the transform, as a path does.
|
||||
let mut g = Node::group(id, vec![Arc::new(instance())]);
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70})));
|
||||
assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]);
|
||||
}
|
||||
|
||||
/// Images and symbol instances share the copy budget: copies of one leave less room for the other.
|
||||
#[test]
|
||||
fn images_and_symbol_instances_share_the_copy_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 instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
|
||||
let mut art = Node::group(id, vec![Arc::new(image), Arc::new(instance)]);
|
||||
crate::reshape::transform_placed_within(&mut art, Affine::translate((70.0, 0.0)), 1000, 100);
|
||||
let (mut images, mut instances) = (vec![], vec![]);
|
||||
image_xs(&art, &mut images);
|
||||
instance_xs(&art, &mut instances);
|
||||
// The image takes the 98 copies there is room for; none is left for the instance.
|
||||
assert_eq!((images.len(), instances.len()), (99, 1));
|
||||
}
|
||||
|
||||
/// As a path's do, a Transform effect's copies of an image stop before one reaches further than
|
||||
/// `MAX_REACH` from the origin.
|
||||
#[test]
|
||||
fn transform_copies_of_images_stop_at_the_reach_limit() {
|
||||
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 mut g = Node::group(id, vec![Arc::new(image)]);
|
||||
// The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9.
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000})));
|
||||
let mut xs = vec![];
|
||||
image_xs(&evaluate_container(&g).unwrap(), &mut xs);
|
||||
assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach");
|
||||
assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pucker_bloat_moves_anchors_in_opposite_directions() {
|
||||
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
|
||||
|
||||
@@ -538,6 +538,36 @@ fn image_roundtrip() {
|
||||
assert_eq!(decoded.get_pixel(3, 0).0, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
/// #2023: a group's Transform effect copies an image among its members as it does the paths: the
|
||||
/// PDF draws the image three times, where the effect put it.
|
||||
#[test]
|
||||
fn a_group_transform_effect_exports_its_image_copies() {
|
||||
let mut d = doc(200.0, 100.0);
|
||||
d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png()));
|
||||
let im = ImageObject {
|
||||
key: "img1".into(),
|
||||
width: 4,
|
||||
height: 2,
|
||||
xf: Affine::translate((20.0, 30.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))));
|
||||
let mut square = rect_node(Rect::new(20.0, 60.0, 30.0, 70.0), Color::BLACK);
|
||||
square.id = d.alloc_id();
|
||||
let mut g = Node::group(NodeId(0), vec![Arc::new(square), image]);
|
||||
g.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "distort.transform".into(),
|
||||
params: serde_json::json!({"moveH": 50, "copies": 2}),
|
||||
visible: true,
|
||||
});
|
||||
add(&mut d, g);
|
||||
let out = roundtrip(&d);
|
||||
let lefts: Vec<f64> =
|
||||
leaves(&out).iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect();
|
||||
assert_eq!(lefts, [20.0, 70.0, 120.0]);
|
||||
}
|
||||
|
||||
/// Build a PDF with a correct xref table from object bodies.
|
||||
fn handmade_pdf(content: &str, media: &str) -> Vec<u8> {
|
||||
let objs = [
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export};
|
||||
|
||||
/// A group of a red and a blue square with a green fill of its own (`below`: under the members).
|
||||
@@ -63,3 +63,28 @@ fn group_effects_bake_into_the_members_and_filter_the_group() {
|
||||
g.walk(&mut |n| assert!(ids.insert(n.id), "duplicate id {}", n.id));
|
||||
assert!(g.appearance.items.is_empty() && g.appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
/// #2023: a group's Transform effect copies an image among its members, as it does the paths: the
|
||||
/// exported file draws the image three times, where the effect put it.
|
||||
#[test]
|
||||
fn a_group_transform_copies_its_images() {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
|
||||
let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() };
|
||||
let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))));
|
||||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let square = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 60.0, 30.0, 70.0)), black));
|
||||
let mut g = Node::group(d.alloc_id(), vec![square, image]);
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 40, "copies": 2}), visible: true });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
// Each drawing of the image: an `<image>`, or a `<use>` of one the defs share.
|
||||
let placed: Vec<&str> = svg
|
||||
.lines()
|
||||
.map(str::trim_start)
|
||||
.filter(|l| l.starts_with("<image") || l.starts_with("<use"))
|
||||
.filter_map(|l| l.split("transform=\"").nth(1)?.split('"').next())
|
||||
.collect();
|
||||
assert_eq!(placed, ["matrix(1 0 0 1 20 20)", "matrix(1 0 0 1 60 20)", "matrix(1 0 0 1 100 20)"], "{svg}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user