Effects: expanded, flattened and exported art keeps the random Transform the canvas drew

Random in a Transform effect takes its seed from the object's id. When
art evaluated from an object repeats ids (the copies a Transform effect
makes, a copied group's members included, and the object under Crop
Marks), Expand Appearance and the baking in bake_document gave the
pieces new ids before expanding or baking their effects. The result
then drew with other random values than the canvas did: on main, 2,382
to 5,205 pixels of a 200 x 200 render changed after Expand Appearance,
and 7% to 14% after Flatten Transparency, for 50 pt squares with a
random Transform on their stroke, on their group or under Crop Marks.

bake_pieces now bakes each piece with the ids it has and gives new ids
after. expand_pieces expands the object's own pieces, and the other
members that repeat an id and hold a random Transform, before their
ids change. Expand Appearance now expands a node value
(expanded_node). The Crop Marks branch expands the object before the
object and the marks get new ids. The reply still lists the expanded
object first, then what was expanded inside it.
This commit is contained in:
dotCooCoo
2026-10-21 23:14:34 +07:00
committed by bflatastic
parent 7aa25698d2
commit 8b67c4a943
5 changed files with 216 additions and 38 deletions
+6 -4
View File
@@ -151,17 +151,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
bake_leaf(d, n)
}
/// `m`, art evaluated from an object, with its children baked and given ids of their own (the
/// pieces copied from the source keep the source's ids until then).
/// `m`, art evaluated from an object, with its children baked and then given ids of their own (the
/// pieces copied from the source keep the source's ids until then). The pieces are baked with the
/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id.
fn bake_pieces(d: &mut Document, mut m: Node) -> Node {
let mut seen = std::collections::HashSet::from([m.id]);
let children = m.children().cloned().unwrap_or_default();
let children = children
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
let c = Arc::unwrap_or_clone(c);
let mut c = bake_node(d, &c).unwrap_or(c);
fresh_ids(d, &mut c, &mut seen);
Arc::new(bake_node(d, &c).unwrap_or(c))
Arc::new(c)
})
.collect();
if let Some(ch) = m.children_mut() {
+112 -33
View File
@@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at};
use super::expand::has_seeded;
use super::rasterfx;
use super::*;
@@ -442,18 +443,72 @@ fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
Some((rasterfx::raster_image(d, n, below, true)?, below))
}
/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that
/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with
/// `all` every member.
fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) {
/// Expand the pieces of `m`, art evaluated from the object of the same id (its pieces share that
/// id): those made from its own fills and strokes, or with `all` every member. The other members
/// that repeat an id ([`repeats_an_id`]) and hold a random Transform effect ([`has_seeded`]) are
/// expanded as well ([`expand_in_place`]). Then the members get ids of their own. Random in a
/// Transform effect takes its seed from the id, and the canvas draws the pieces with the ids they
/// have before that. Returns the ids each of those other members' expansions lists, by the member's
/// new id.
fn expand_pieces(
d: &mut Document,
m: &mut Node,
all: bool,
brushes: &[vectorcraft_brush::Brush],
stroke_art: effects::StrokeArt,
) -> std::collections::HashMap<NodeId, Vec<NodeId>> {
let id = m.id;
let mut seen = std::collections::HashSet::from([id]);
let mut expanded = std::collections::HashMap::new();
for c in m.children_mut().into_iter().flatten() {
let own = all || c.id == id;
let c = Arc::make_mut(c);
effects::fresh_ids(d, c, &mut seen);
if own {
let mut listed = None;
if all || c.id == id {
effects::expand_art(d, c, stroke_art);
} else if has_seeded(c) && repeats_an_id(c, &seen) {
listed = Some(expand_in_place(d, c, brushes));
}
fresh_ids_listed(d, c, &mut seen, listed.as_deref_mut().unwrap_or_default());
if let Some(listed) = listed {
expanded.insert(c.id, listed);
}
}
expanded
}
/// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids?
fn repeats_an_id(n: &Node, seen: &std::collections::HashSet<NodeId>) -> bool {
let mut ids = std::collections::HashSet::new();
let mut repeats = false;
n.walk(&mut |c| repeats |= seen.contains(&c.id) || !ids.insert(c.id));
repeats
}
/// [`expanded_node`] of `c` in place. Returns the ids the expansion lists.
fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec<NodeId> {
let mut listed = vec![];
if let Some(e) = expanded_node(d, c.clone(), brushes, &mut listed) {
*c = e;
}
listed
}
/// [`effects::fresh_ids`] on `c`, with each id in `listed` that names a node of `c` changed to
/// that node's new id.
fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet<NodeId>, listed: &mut [NodeId]) {
let mut before = vec![];
c.walk(&mut |n| before.push(n.id));
effects::fresh_ids(d, c, seen);
let mut before = before.into_iter();
let mut renamed = std::collections::HashMap::new();
c.walk(&mut |n| {
if let Some(old) = before.next() {
renamed.entry(old).or_insert(n.id);
}
});
for e in listed {
if let Some(new) = renamed.get(e) {
*e = *new;
}
}
}
@@ -462,46 +517,63 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(mut n) = d.node(id).cloned() else { return };
let Some(n) = d.node(id).cloned() else { return };
if let Some(m) = expanded_node(d, n, brushes, out)
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
}
}
/// [`expand_node`] of `n`, which need not be in `d`: `n` with its appearance expanded, `None` when
/// it has nothing to expand. `out` gets the ids of the objects whose appearance expands, `n`'s
/// first when its own does.
fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) -> Option<Node> {
let id = n.id;
if !expandable(&n) {
return;
return None;
}
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
// recoloured copy), then the rest of the appearance expands.
let mut changed = false;
if effects::has_adjustment(&n)
&& let Some(m) = effects::adjust_in_document(d, &n)
&& let Some(slot) = d.node_mut(id)
{
*slot = m.clone();
out.push(id);
n = m;
changed = true;
if !expandable(&n) {
return;
return Some(n);
}
}
// (A symbol instance has become its art, a group.)
let container = is_container(&n);
// Crop Marks: a group of the object and its marks, then the object expands on its own.
// Crop Marks: the object and its marks become a group. The object expands on its own first,
// with `id`: the canvas draws it with that id, which Random in a Transform effect takes its
// seed from. Then the object and the marks get ids of their own.
if let Some(mut m) = effects::crop_marks_art(&n) {
let mut seen = std::collections::HashSet::from([id]);
for c in m.children_mut().into_iter().flatten() {
effects::fresh_ids(d, Arc::make_mut(c), &mut seen);
let mut expanded = vec![];
for (i, c) in m.children_mut().into_iter().flatten().enumerate() {
let c = Arc::make_mut(c);
if i == 0 {
expanded = expand_in_place(d, c, brushes);
fresh_ids_listed(d, c, &mut seen, &mut expanded);
} else {
effects::fresh_ids(d, c, &mut seen);
}
}
let object = m.children().and_then(|c| c.first()).map(|c| c.id);
if let Some(slot) = d.node_mut(id) {
*slot = m;
out.push(id);
}
if let Some(object) = object {
expand_node(d, object, brushes, out);
}
return;
out.push(id);
out.extend(expanded);
return Some(m);
}
let image = raster_image(d, &n);
let mut v = n.clone();
rasterfx::strip_raster(&mut v, false);
let mut stroke_art =
|d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st);
// Members of a group or layer that `expand_pieces` expanded, by id, with what their expansion lists.
let mut members = std::collections::HashMap::new();
let expanded = match image {
// Blur, feather and inner glow change the object itself: it becomes the image (a layer
// keeps it as its only member).
@@ -510,7 +582,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
let m = if container {
// A group's or layer's own fills and strokes become art among its members.
effects::evaluate_container(&v).map(|mut m| {
expand_pieces(d, &mut m, false, &mut stroke_art);
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
m
})
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
@@ -523,7 +595,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
_ => None,
};
effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| {
expand_pieces(d, &mut m, true, &mut stroke_art);
expand_pieces(d, &mut m, true, brushes, &mut stroke_art);
m
})
};
@@ -537,20 +609,27 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
}
}
};
if let Some(m) = expanded
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
if let Some(m) = expanded {
n = m;
changed = true;
if out.last() != Some(&id) {
out.push(id);
}
}
if container {
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
for c in children {
expand_node(d, c, brushes, out);
for c in n.children_mut().into_iter().flatten() {
match members.remove(&c.id) {
Some(listed) => out.extend(listed),
None => {
if let Some(e) = expanded_node(d, (**c).clone(), brushes, out) {
*c = Arc::new(e);
changed = true;
}
}
}
}
}
changed.then_some(n)
}
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -604,7 +604,7 @@ fn seeded_here(n: &Node) -> bool {
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
/// ([`seeded_here`])?
fn has_seeded(n: &Node) -> bool {
pub(crate) fn has_seeded(n: &Node) -> bool {
seeded_here(n)
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
+75
View File
@@ -84,6 +84,81 @@ fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
assert_eq!(node(&s, id).appearance.items.len(), 3);
}
/// The objects [`random_transforms`] makes.
pub(super) const RANDOM_CASES: [&str; 5] = ["copies", "group copies", "nested group copies", "crop marks", "group crop marks"];
/// Selected art with Random in Transform effects, from 50 pt squares at y 60 with an 8 pt black
/// stroke, as `case` says. "copies": a square at x 20 filled with a gradient, a random Transform
/// effect on its stroke and a Transform effect that copies it 80 pt right. "group copies": the
/// same square without the second effect, in a group that has it. "nested group copies": the same
/// square in a group with a random Transform effect, in a group with the second effect. "crop
/// marks": a red square at x 20 with a random Transform effect and crop marks. "group crop marks":
/// red squares at x 20 and x 110 in a group with a random Transform effect and crop marks. Returns
/// the object's id.
pub(super) fn random_transforms(s: &mut Session, case: &str) -> NodeId {
let random = json!({"moveH": 15, "moveV": 15, "rotate": 40, "random": true});
let square = |s: &mut Session, x: f64, fill: Value| {
let id = run(s, "shape.rectangle", json!({"x": x, "y": 60, "width": 50, "height": 50}))["id"].as_u64().unwrap();
run(s, "paint.setFill", fill);
run(s, "paint.setStroke", json!({"color": "#000000"}));
run(s, "stroke.set", json!({"weight": 8}));
id
};
let red = json!({"color": "#ff0000"});
let id = match case {
"copies" | "group copies" | "nested group copies" => {
let a = square(s, 20.0, json!({"gradient": {"kind": "linear"}}));
run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": 1}));
if case == "nested group copies" {
run(s, "object.group", json!({}));
run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null}));
}
if case == "copies" { a } else { run(s, "object.group", json!({}))["id"].as_u64().unwrap() }
}
"crop marks" => square(s, 20.0, red),
_ => {
let a = square(s, 20.0, red.clone());
let b = square(s, 110.0, red);
run(s, "select.set", json!({"ids": [a, b]}));
run(s, "object.group", json!({}))["id"].as_u64().unwrap()
}
};
if case.ends_with("copies") {
run(s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 80, "copies": 1}, "item": null}));
} else {
run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null}));
run(s, "effect.apply", json!({"effect": "cropMarks", "item": null}));
}
NodeId(id)
}
/// Random in a Transform effect takes its seed from the object's id. The expanded art draws as the
/// canvas drew each of [`random_transforms`]' objects.
#[test]
fn expanded_art_keeps_the_random_transforms_the_canvas_drew() {
let mut differ = vec![];
for case in RANDOM_CASES {
let mut s = session();
let id = random_transforms(&mut s, case);
let before = render(&s);
let r = run(&mut s, "effect.expandAppearance", json!({}));
differ.push((case, differing(&before, &render(&s), 64)));
let mut art = node(&s, id);
// Crop marks: the reply names the group of the object and its marks, then the object.
if case.ends_with("crop marks") {
art = (*art.children().unwrap()[0]).clone();
assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}");
}
// No object keeps an effect, and in the copies cases no object keeps a stroke: every copy
// is expanded.
art.walk(&mut |n| {
let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none());
assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}");
});
}
assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0)));
}
#[test]
fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() {
let mut s = session();
+22
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
use vectorcraft_render::{Rendered, Renderer};
use super::tests_expand::{RANDOM_CASES, random_transforms};
use super::tests_menucmds::{place_undecodable_png, warns_undecoded};
use super::*;
@@ -410,6 +411,27 @@ fn outlined_type_keeps_its_gradient_on_the_outlines_its_transform_effect_moves()
assert!(diffs.iter().all(|&d| d < 0.0002), "shares of the pixels that changed: {diffs:?}");
}
/// Random in a Transform effect takes its seed from the object's id. Each of [`random_transforms`]'
/// objects at 50% opacity draws as the canvas drew it with its geometry effects baked, as Flatten
/// Transparency bakes them first, and after Flatten Transparency, within the seams between its
/// regions (the same objects without Random change 1.4% to 2.3% of the pixels).
#[test]
fn flattened_art_keeps_the_random_transforms_the_canvas_drew() {
let _settings = settings_lock();
let mut diffs = vec![];
for case in RANDOM_CASES {
let mut s = session();
let id = random_transforms(&mut s, case);
run(&mut s, "transparency.set", json!({"ids": [id.0], "opacity": 50}));
let before = picture(&s);
let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap();
let baked = Renderer::new().render_region(&baked, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
flatten(&mut s, json!({"ids": [id.0]}));
diffs.push((case, changed(&before, &baked), changed(&before, &picture(&s))));
}
assert!(diffs.iter().all(|&(_, baked, flat)| baked == 0.0 && flat < 0.03), "shares of the pixels that changed: {diffs:?}");
}
#[test]
fn preserve_alpha_composites_over_nothing() {
let _settings = settings_lock();