Merge pull request #6878 from dotCooCoo/fix/opacity-mask-follows-reshape

Opacity masks: linked masks follow group effects, the Free Transform distort and envelopes
This commit is contained in:
BFlat
2026-11-12 23:10:24 +07:00
committed by GitHub
8 changed files with 1051 additions and 64 deletions
+2 -1
View File
@@ -119,7 +119,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node {
{
out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps));
}
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) {
// A linked opacity mask bends with the object it masks; an unlinked one stays where it is.
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|m| m.linked) {
m.art = Arc::new(map_node(&m.art, w));
}
match &mut out.kind {
+519
View File
@@ -690,6 +690,525 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
}
}
/// A white 10 pt square with id `id` at the origin.
fn white_square(id: u64) -> Node {
Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0))
}
/// `n` masked by `art`, linked or not.
fn masked_by(mut n: Node, art: Node, linked: bool) -> Node {
let mut mask = vectorcraft_doc::OpacityMask::new(art, true);
mask.linked = linked;
n.extras_mut().mask = Some(Box::new(mask));
n
}
/// The geometry effects that reshape a group's members, one at a time.
fn reshaping_effects() -> Vec<Effect> {
let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true };
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
vec![
fx("distort.transform", serde_json::json!({"moveH": 30})),
fx("distort.transform", serde_json::json!({"rotate": 90})),
fx("distort.twist", serde_json::json!({"angle": 90})),
fx("distort.freeDistort", corners),
fx("warp.arc", serde_json::json!({})),
fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})),
fx("distort.zigZag", serde_json::json!({})),
fx("distort.puckerBloat", serde_json::json!({"amount": 50})),
fx("distort.tweak", serde_json::json!({})),
fx("path.offsetPath", serde_json::json!({"offset": 3})),
fx("stylize.roundCorners", serde_json::json!({"radius": 3})),
]
}
/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the
/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is.
#[test]
fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() {
let square = white_square(1);
let original = square.path_data().cloned();
for e in reshaping_effects() {
for linked in [true, false] {
let member = masked_by(square.clone(), white_square(4), linked);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = member.extras().mask.as_deref().unwrap();
assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id);
let want = if linked { member.path_data().cloned() } else { original.clone() };
assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id);
assert_eq!(mask.linked, linked);
}
}
}
/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and
/// an inverted mask all take the outer group's effect with the paths they mask.
#[test]
fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() {
for e in reshaping_effects() {
let innermost = masked_by(white_square(7), white_square(8), true);
let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true);
let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true);
if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) {
m.invert = true;
}
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let path = &inner.children().unwrap()[0];
let want = path.path_data().cloned();
let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap();
let (outer_mask, path_mask) = (mask_of(inner), mask_of(path));
assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert));
assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id);
assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id);
assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id);
assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id);
}
}
/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the
/// mask moved with it; the original keeps its mask where it was.
#[test]
fn each_transform_copy_of_a_masked_member_carries_its_mask() {
let member = masked_by(white_square(1), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member)]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
for c in copies {
let mask = c.extras().mask.as_deref().unwrap();
assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds());
}
}
/// A Transform effect's copies of the paths of a member group give the group's linked mask the same
/// copies: the mask covers each copy.
#[test]
fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() {
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let want = inner.geometric_bounds();
assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy");
assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want);
}
/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy
/// of the mask's art is a group of its objects, so the copies do not interleave object by object.
#[test]
fn a_masks_art_of_several_objects_is_copied_whole() {
let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
let copies = mask.children().unwrap();
assert_eq!(copies.len(), 2, "the mask's art and its copy");
for c in copies {
let ids: Vec<NodeId> = c.children().into_iter().flatten().map(|m| m.id).collect();
assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects");
}
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)));
}
/// The linked mask of a mask's art that is copied whole takes the same Transform copies.
#[test]
fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() {
let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let mask_art = masked_by(parts, white_square(6), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy");
let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art;
assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too");
}
/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the
/// budget has no room for that mask's copy, the art keeps its shape with its masks.
#[test]
fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true };
let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let mask_art = masked_by(parts, white_square(6), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the four squares and the copies of three of them, not for the copy of the fourth.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let inner = &art.children().unwrap()[0];
let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape");
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask");
}
/// When a member group's linked mask can't take all the Transform copies its paths take (past the
/// budget), the art keeps its shape under the effect, so the mask still covers the paths.
#[test]
fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true };
let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the path, its mask and a copy of the path, not for a copy of the mask.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let member = &art.children().unwrap()[0];
let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(member.geometric_bounds(), square, "the path keeps its shape");
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask");
}
/// The copies of a path with a linked mask count the art of the mask each copy carries against the
/// budget: they stop where the masks would pass it.
#[test]
fn transform_copies_count_the_art_of_their_masks() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true };
let member = masked_by(white_square(1), white_square(4), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the path, its mask and one copy of both, not for a second copy.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own");
assert_eq!(copies.len(), 2);
for c in copies {
assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds());
}
}
/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them.
/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's
/// paths too.
#[test]
fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() {
let mask_art = masked_by(white_square(4), white_square(5), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]);
let params = serde_json::json!({"moveH": 30, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let want = inner.children().unwrap()[0].geometric_bounds();
assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0)));
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), want, "the group's mask");
assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art");
}
/// A group's Transform effect moves an image member's linked opacity mask with the image, and its
/// copies' masks with the copies; an unlinked mask stays.
#[test]
fn a_groups_transform_moves_an_image_members_linked_mask() {
for linked in [true, false] {
for copies in [0, 2] {
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked);
let mut g = Node::group(NodeId(10), vec![Arc::new(image)]);
let params = serde_json::json!({"moveH": 30, "copies": copies});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let first = &art.children().unwrap()[0];
let images: Vec<&Node> = match first.children() {
Some(ch) => ch.iter().map(|c| &**c).collect(),
None => vec![first],
};
assert_eq!(images.len(), copies + 1);
for im in images {
let mask = im.extras().mask.as_deref().unwrap();
let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) };
assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}");
}
}
}
}
/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it
/// was, rather than part of its art moving with the member, and the member keeps its shape with it.
#[test]
fn a_mask_whose_art_passes_the_budget_stays_whole() {
// Bloat bends a path's sides into curves drawn with more lines the larger they are: many more
// on the mask's large square.
let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true };
let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default());
let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]);
let member = masked_by(white_square(1), mask_art.clone(), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
// Room for the member and the mask's small square to take the effect, not for the large one.
let bounds = art.geometric_bounds().unwrap();
let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) };
let square = white_square(1).path_data().cloned().unwrap();
let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines;
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines };
let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines;
assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines");
crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None);
let member = &art.children().unwrap()[0];
assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask");
assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole");
}
/// Live objects in a member's linked mask are evaluated before the group's geometry effects run
/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with
/// the member.
#[test]
fn a_groups_effects_reshape_a_live_object_in_a_members_mask() {
let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0);
if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind {
(*rows, *cols) = (1, 1);
}
let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid));
let drawn: Vec<Node> = crate::expand_live(None, &repeat);
let leaf = |n: &Node| {
let mut found = None;
n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned()));
found
};
assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member");
for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] {
let member = masked_by(white_square(1), repeat.clone(), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = &member.extras().mask.as_deref().unwrap().art;
assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id);
assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id);
}
}
/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's
/// art with the member.
#[test]
fn a_groups_transform_moves_type_in_a_members_mask() {
if skip_without_craft_fonts() {
return;
}
let member = masked_by(white_square(1), type_h(), true);
let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap();
let mut g = Node::group(NodeId(10), vec![Arc::new(member)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
let mut text = false;
mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
assert!(!text, "the type is outlined");
let after = mask.geometric_bounds().unwrap();
assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}");
}
/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry
/// effects with the art it masks.
#[test]
fn a_compound_paths_and_a_layers_members_masks_take_the_effects() {
let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p);
for e in reshaping_effects() {
let square = white_square(1);
let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero });
let compound = masked_by(compound, white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id);
assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id);
let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]);
}
layer.appearance.effects.push(e.clone());
let art = evaluate_container(None, &layer).unwrap();
let member = &art.children().unwrap()[0];
assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id);
assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id);
}
}
/// A group's Transform effect moves a symbol instance member's linked mask with the instance.
#[test]
fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() {
let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let instance = &art.children().unwrap()[0];
let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") };
assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}");
let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap();
assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}");
}
/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image
/// member's linked mask as it scales that of a path member's mask.
#[test]
fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() {
let stroked =
Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0));
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true);
let path = masked_by(white_square(2), stroked, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]);
let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width);
let ch = art.children().unwrap();
assert_eq!(width(&ch[1]), Some(4.0), "the path's mask");
assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask");
}
/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the
/// strokes of a member's linked mask as it scales the member's, and moves neither.
#[test]
fn a_transform_of_strokes_only_scales_a_members_mask_strokes() {
let stroked = |id: u64| {
Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0))
};
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]);
let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = &member.extras().mask.as_deref().unwrap().art;
assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke");
assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke");
assert_eq!(mask.geometric_bounds(), member.geometric_bounds());
}
/// The art of a member group's mask is copied as it was before the first effect: a mask of one
/// path takes a second Transform effect's copies path by path, as the path it masks does, though
/// the first effect (with Scale Strokes & Effects) made it a group of copies.
#[test]
fn a_masks_art_is_copied_as_it_was_before_the_first_effect() {
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
for strokes in [true, false] {
let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
}
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), inner.geometric_bounds());
let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::<Vec<_>>();
let paths = &inner.children().unwrap()[0];
assert_eq!(kinds(paths).len(), 3, "the first effect's three paths");
assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies");
assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do");
}
/// A 10 × 10 image with id `id` at the origin.
fn image_node(id: u64) -> Node {
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
Node::new(NodeId(id), NodeKind::Image(Box::new(im)))
}
/// A linked mask whose art is an image moves with the path it masks under a group's Transform
/// effect, and a member group's image mask takes the copies its paths take.
#[test]
fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() {
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0)));
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds());
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0)));
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies");
}
/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect
/// the art keeps its shape with its mask.
#[test]
fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() {
let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[4].clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape");
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
}
/// A Warp effect bends an image's pixels but not the art of its linked mask, so art with such an
/// image keeps its shape under the warp, with the image and its mask.
#[test]
fn art_with_a_masked_image_keeps_its_shape_under_a_warp() {
let image = masked_by(image_node(1), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(image.clone()), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[4].clone());
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(*ch[0], image, "the image and its mask");
assert_eq!(ch[1].path_data(), sq(2, 20.0, Color::BLACK).path_data(), "the path keeps its shape");
}
/// A symbol instance in perspective projects the linked masks of the objects in its art with them.
#[test]
fn perspective_projects_the_masks_in_a_symbols_art() {
let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]);
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0);
at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]);
instance.extras_mut().perspective = Some(Box::new(at));
let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap();
let mut masked = vec![];
out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned()))));
let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") };
assert!(path != &white_square(21).path_data().cloned(), "the path is projected");
assert_eq!(mask, path, "and so is its mask");
}
/// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit
/// pitch, each crossing every bar of the other direction: n² crossings.
fn crossing_bars(n: usize, effect: &str) -> Node {
+427 -59
View File
@@ -12,7 +12,7 @@
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
//! them ([`image_rows`]).
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -590,7 +590,7 @@ fn as_art_opts(
transform_items(&mut art, paint_doc);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
project_leaves(&mut art, &h, paint_doc);
}
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
// group).
@@ -608,9 +608,29 @@ fn as_art_opts(
Some(out)
}
/// Map every path under `art` (and its gradients) through the projective map `h`, curves split
/// finely enough to follow it.
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) {
/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity
/// masks of the objects under it, through the projective map `h`, curves split finely enough to
/// follow it. The type and live objects in the art of those masks are outlined and evaluated
/// first ([`outlined_members`], with the symbols of `doc`).
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography, doc: Option<&Document>) {
project_paths(art, h);
each_linked_mask_mut(art, false, 0, &mut |m| {
*m = outlined_mask_art(doc, m);
project_paths(m, h);
});
}
/// `art`, the art of an opacity mask, with type and live objects in it outlined and evaluated
/// ([`outlined_members`]), and as art that keeps its look ([`as_art`]) when it is type itself.
fn outlined_mask_art(doc: Option<&Document>, art: &Node) -> Node {
match art.kind {
NodeKind::Text(_) => as_art(doc, art, None).unwrap_or_else(|| art.clone()),
_ => outlined_members(doc, art),
}
}
/// The paths of [`project_leaves`] outside the art of masks.
fn project_paths(art: &mut Node, h: &vectorcraft_geom::Homography) {
let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p);
let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f);
@@ -687,58 +707,151 @@ fn hoist_copies(art: &mut Node) {
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
/// `owner` does not hold it.
/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of
/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the
/// type and live objects in it outlined and evaluated first ([`outlined_members`]). When a mask
/// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked
/// masks keeps its shape under that effect.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
let mut whole = crate::Carry::default();
if fx.is_empty() {
return whole;
}
let mut leaves_safe = true;
for_each_leaf(art, &mut |leaf| {
if !leaves_safe {
return;
}
if let Some((_, rule)) = leaf_geometry(leaf) {
let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
if rule != FillRule::NonZero || has_stroke {
leaves_safe = false;
}
}
});
let masked = has_linked_mask(art, false);
if masked {
each_linked_mask_mut(art, false, 0, &mut |m| *m = outlined_mask_art(doc, m));
}
let mut masks_safe = true;
each_linked_mask(art, false, 0, &mut |_, m| masks_safe &= leaves_safe(m));
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
let style_safe = leaves_safe && !art_stroke;
let style_safe = leaves_safe(art) && masks_safe && !art_stroke;
let listed = fx;
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
let fx = opt_fx.as_slice();
let seed = art.id.0;
let mut used = art_cost(art);
each_linked_mask(art, false, 0, &mut |_, m| used = used.plus(art_cost(m)));
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
// Read before the effects run: a path's own copies don't make it art of several objects. The
// same holds for the art of each mask, listed by the object it masks.
let copy_whole = copies_whole(art);
let mut wholes = HashMap::new();
each_linked_mask(art, false, 0, &mut |id, m| {
wholes.entry(id).or_insert_with(|| copies_whole(m));
});
let mut warped = WarpedImages::new();
for e in fx {
let Some(bounds) = warped.bounds(art) else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
let step = Step { e, params: &params, bounds, seed, effect, apart, doc, wholes: &wholes };
// Art with linked masks keeps its shape under an effect that a mask in it could not take
// as the art it masks did, so no mask is left apart from its art.
let before = masked.then(|| (art.clone(), used));
let ran = step.run(art, max, &mut used, copy_whole, Some(&mut warped), false, 0);
if (ran.masks || ran.short)
&& let Some((was, cost)) = before
{
*art = was;
used = cost;
continue;
}
if let Some(carry) = ran.whole {
whole.push(carry);
}
}
cut_warped_images(art, &warped);
whole
}
/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its
/// merged params, the reference box and seed of the object's art, where a deferred live-effect
/// plug-in is listed, whether each Transform copy becomes an object, the document that the
/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of
/// each linked mask before the first effect, by the id of the object it masks.
struct Step<'a> {
e: &'a Effect,
params: &'a serde_json::Value,
bounds: Rect,
seed: u64,
effect: Option<usize>,
apart: bool,
doc: Option<&'a Document>,
wholes: &'a HashMap<NodeId, bool>,
}
/// What [`Step::run`] did to a piece of art.
struct Ran {
/// A path in the art kept its shape (past the budget).
kept: bool,
/// The linked opacity mask of an object in the art stayed as it was while the paths it masks
/// took the effect, or could have ([`Step::reshape_masks`]).
masks: bool,
/// A Transform effect made fewer copies of a path, image or symbol instance than it asks for
/// (past the budget or out of reach), where a linked mask could take a different number.
short: bool,
/// How what paints the art as a whole follows the effect, when it does.
whole: Option<crate::Carry>,
}
impl Ran {
/// Nothing in the art kept its shape or made fewer copies, and every linked mask in it moved.
fn complete(&self) -> bool {
!self.kept && !self.masks && !self.short
}
}
impl Step<'_> {
/// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to
/// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the
/// art, or of a mask's art, before the first effect. `warped` lists the images Warp effects
/// bend in the art ([`WarpedImages`]); a mask's art has none. With `own`, `art` is the art of a
/// mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is in. A
/// live-effect plug-in that paints its pieces leaves a mask's art, and art that holds objects
/// with linked masks, as they are and reports the masks as stayed: a mask can't take the
/// pieces as the art it masks does. So does a Warp effect that bends an image with a linked
/// mask.
#[allow(clippy::too_many_arguments)]
fn run(
&self,
art: &mut Node,
max: Budget,
used: &mut Budget,
copy_whole: bool,
mut warped: Option<&mut WarpedImages>,
own: bool,
depth: usize,
) -> Ran {
let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed);
let stayed = Ran { kept: false, masks: true, short: false, whole: None };
if let Some(plugin) = painted_plugin(e) {
let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
if own || has_linked_mask(art, false) {
return stayed;
}
let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
for_each_leaf(art, &mut |leaf| {
let Some((path, _)) = leaf_geometry(leaf) else { return };
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc, at) {
used = used.minus(before).plus(made);
if let Some(made) = paint_leaf(leaf, plugin, params, bounds, room, self.doc, at) {
*used = used.minus(before).plus(made);
}
});
continue;
return Ran { kept: false, masks: false, short: false, whole: None };
}
// A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the
// art of its linked mask.
let warp = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id).filter(|_| crate::warp::bends(&e.id, params));
if warp.is_some() && has_masked_image(art) {
return stayed;
}
// Placed gradients follow a Transform effect, Free Distort and the Warp effects
// ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from
// its bounds before the effect to its bounds after it. Under those and the other effects,
// the placed gradients of type's outlines in `art` (`outline_text`) move and scale with
// the outlines' bounds.
let follows = crate::Carry::follows(&e.id, &params, bounds, seed);
let follows = crate::Carry::follows(&e.id, params, bounds, seed);
let boxes = follows && crate::Carry::by_bounds(&e.id);
let mut outlines = vec![];
if !follows || boxes {
@@ -754,30 +867,61 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// The images earlier Warps bend are measured by their pieces only when a group's
// gradient needs the bounds.
if !groups.is_empty()
&& let Some(images) = warped.when_cut(art)
&& let Some(images) = warped.as_deref().and_then(|w| w.when_cut(art))
{
groups.clear();
group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups);
}
}
// The ids of the paths that kept their shape (past the budget), and their places in the
// order [`for_each_leaf`] visits the paths.
// order [`for_each_leaf`] visits the paths; and the ids of the paths whose linked masks
// moved with them as they were transformed as objects.
let mut kept_ids = HashSet::new();
let mut kept_leaves = HashSet::new();
let mut leaf_at = 0usize;
let mut moved_ids = HashSet::new();
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
(m, copies, options, crate::distort::transform_affine(bounds, &params, seed))
let (m, copies) = crate::distort::transform_map(bounds, params, seed);
let options = crate::distort::TransformOptions::of(params);
(m, copies, options, crate::distort::transform_affine(bounds, params, seed))
});
// In a mask's art, images and symbol instances take only a Transform effect
// ([`transform_placed`]): a mask's art that holds one can't take another effect as the art
// it masks does.
if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) {
return Ran { kept: false, masks: true, short: false, whole: None };
}
// Art of several objects is copied whole, so the copies don't interleave member by member.
// The copies of its members carry their linked masks, moved with them.
if let Some((m, copies, options, _)) = transform
&& copies > 0
&& copy_whole
{
used = copy_art(art, m, copies, copy_scaling(options), max);
continue;
if !own {
*used = copy_art(art, m, copies, copy_scaling(options), max);
return Ran { kept: false, masks: false, short: false, whole: None };
}
// A mask's art: its copies keep to what the rest of the art leaves of `max`, and the
// linked mask of the art itself takes them too. That mask's art stays in `used`, as
// [`copy_art`] counts only the members.
let before = copy_cost(art);
let rest = used.minus(before).plus(art.extras().mask.as_deref().map_or(Budget { anchors: 0, lines: 0 }, |k| copy_cost(&k.art)));
let made = copy_art(art, m, copies, copy_scaling(options), max.minus(rest));
*used = rest.plus(made);
let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies);
let none = HashSet::new();
let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth);
return Ran { kept: false, masks, short, whole: None };
}
// A shortfall in the copies matters for the objects a linked mask above them covers, which
// the mask would take a different number of copies than, and anywhere in a mask's art.
let watch = transform.is_some_and(|(_, copies, options, _)| copies > 0 && options.objects) && (own || has_linked_mask(art, false));
let mut covered = HashSet::new();
if watch && !own {
masked_ids(art, false, &mut covered);
}
let counts = |id: NodeId| watch && (own || covered.contains(&id));
let mut short = false;
for_each_leaf(art, &mut |leaf| {
let at = leaf_at;
leaf_at += 1;
@@ -790,15 +934,26 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// strokes; a gradient paints each copy by itself, not the one piece the copies
// make together; and Expand Appearance (`apart`) makes each copy an object.
// Fills and strokes that stack paint each copy below the one before (#5920, #6316).
// Each copy of a path with a linked mask carries the mask, moved with it.
let separate = copies > 0
&& (apart
&& (self.apart
|| leaf.appearance.has_unplaced_gradient()
|| leaf.appearance.has_placed_gradient()
|| paints_copies_apart(&leaf.appearance, rule));
|| paints_copies_apart(&leaf.appearance, rule)
|| leaf.extras().mask.as_ref().is_some_and(|m| m.linked));
// The copies of a path replace it by a group of them, the path included.
let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len);
if options.reach_appearance() {
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
used = used.minus(before).plus(made);
if !moved {
*used = used.minus(before).plus(made);
if moved {
// With Transform Objects off the path stays, and its mask takes the
// effect's stroke and pattern changes as its own art.
if options.objects {
moved_ids.insert(leaf.id);
}
short |= counts(leaf.id) && copied(leaf) <= copies;
} else {
kept_ids.insert(leaf.id);
}
return;
@@ -806,20 +961,28 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
if separate {
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
let made = copy_leaf(leaf, &path, m, copies, scaling, room);
used = used.minus(before).plus(made);
*used = used.minus(before).plus(made);
moved_ids.insert(leaf.id);
short |= counts(leaf.id) && copied(leaf) <= copies;
return;
}
}
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
let stage = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
kept_leaves.insert(at);
return;
};
used = used.minus(before).plus(Budget::of(&out));
// A Transform effect's copies of a path are subpaths of it, each with the path's anchors.
if counts(leaf.id)
&& let Some((_, copies, _, _)) = transform
{
short |= out.anchor_count() < path.anchor_count().saturating_mul(copies.saturating_add(1));
}
*used = used.minus(before).plus(Budget::of(&out));
if follows && !boxes {
// A gradient fitted to the path fits the path's own bounds.
let carry = crate::Carry::of(&e.id, &params, bounds, path.bounds(), None, seed);
let carry = crate::Carry::of(&e.id, params, bounds, path.bounds(), None, seed);
if carry.moves(&leaf.appearance) {
carry.apply(&mut leaf.appearance);
}
@@ -832,36 +995,230 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// Warp bends images too: each one's pixels follow the warp's map over the same box. The
// images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image
// whose clipping frame kept its shape (past the budget) keeps its pixels as they are.
if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id)
&& crate::warp::bends(&e.id, &params)
if let Some(style) = warp
&& let Some(warped) = warped.as_deref_mut()
{
let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, &params));
let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, params));
warped.reserve(art);
mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped);
mark_warped_images(art, &f, &kept_leaves, false, &mut 0, warped);
}
if let Some((m, copies, options, _)) = transform
&& options.objects
{
transform_placed(art, m, copies);
// A mask's art that is one image or symbol instance is placed as the only member of a
// group of it.
let root = is_placed(art);
if root {
let id = art.id;
let one = std::mem::replace(art, Node::group(id, vec![]));
*art = Node::group(id, vec![Arc::new(one)]);
}
let placed = if watch { masked_placed(art, own) } else { 0 };
transform_placed(art, m, copies, copy_scaling(options));
short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1));
if root && let Some([one]) = art.children().map(Vec::as_slice) {
*art = (**one).clone();
}
}
if outlines.iter().any(Option::is_some) {
rebase_type_outlines(art, &mut outlines.into_iter());
}
// Under Transform copies, a mask takes the copies as the paths it masks do; the copies of a
// path with a mask of its own each carry it ([`copy_leaf`]).
let masks = self.reshape_masks(art, own, &Skip { moved: &moved_ids, kept: &kept_ids }, max, used, depth);
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let images = if groups.is_empty() { None } else { warped.when_cut(art) };
let images = if groups.is_empty() { None } else { warped.as_deref().and_then(|w| w.when_cut(art)) };
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1;
if !groups.is_empty() {
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, &params, bounds, from, to, seed));
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, params, bounds, from, to, seed));
}
if follows && !held {
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), warped.bounds(art), seed));
let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art));
let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed));
Ran { kept: !kept_ids.is_empty(), masks, short, whole }
}
/// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path,
/// compound path, group and layer under it, with the art's reference box, as the paths the mask
/// covers take it: the mask moves and bends with what it masks. The mask of an object
/// transformed as an object moved with it already and is left as it is. A mask stays as it was
/// past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks, and for the reasons
/// [`Step::reshape_own_mask`] gives. Returns whether a mask stayed for one of those reasons.
fn reshape_masks(&self, n: &mut Node, own: bool, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool {
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) {
return false;
}
let mut stayed = own && self.reshape_own_mask(n, skip, max, used, depth);
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
if has_linked_mask(c, true) {
stayed |= self.reshape_masks(Arc::make_mut(c), true, skip, max, used, depth);
}
}
}
stayed
}
/// Run the effect over the art of the linked opacity mask of `n` itself, when `n` is a path,
/// compound path, group or layer that `skip` does not list as moved. The mask stays as it was
/// when a path it masks kept its shape, and when its own art could not take the effect whole: a
/// path in it would keep its shape, its copies would stop short, or a mask inside it would stay
/// (no mask is left half moved). Returns whether the mask stayed for one of those reasons.
fn reshape_own_mask(&self, n: &mut Node, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool {
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !reshapes_mask_of(n) || skip.moved.contains(&n.id) {
return false;
}
let stays = holds_any(n, skip.kept);
let id = n.id;
let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false };
let before = *used;
let mut art = (*m.art).clone();
let whole = self.wholes.get(&id).copied().unwrap_or_else(|| copies_whole(&art));
let ran = (!stays).then(|| self.run(&mut art, max, used, whole, None, true, depth + 1));
match ran {
Some(ran) if ran.complete() => {
// What paints the mask's art as a whole follows the effect as the art does.
if let Some(carry) = ran.whole
&& carry.moves(&art.appearance)
{
carry.apply(&mut art.appearance);
}
m.art = Arc::new(art);
false
}
_ => {
*used = before;
true
}
}
}
cut_warped_images(art, &warped);
whole
}
/// The objects whose linked masks [`Step::reshape_masks`] leaves as they are: those transformed
/// as objects (`moved`), and those holding a path that kept its shape (`kept`).
struct Skip<'a> {
moved: &'a HashSet<NodeId>,
kept: &'a HashSet<NodeId>,
}
/// Is `n` an object whose linked opacity mask a geometry effect on the art it is in reshapes with
/// it: a path, compound path, group or layer? An image or symbol instance moves its mask only
/// with the map a Transform effect places it by ([`transform_placed`]).
fn reshapes_mask_of(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// Does `n` (with `own`) or a path, compound path, group or layer under it carry a linked opacity
/// mask that [`Step::reshape_masks`] reshapes?
fn has_linked_mask(n: &Node, own: bool) -> bool {
let mine = own && reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked);
mine || match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().any(|c| has_linked_mask(c, true)),
_ => false,
}
}
/// Run `f` on the id of `n` (with `own`) and of each path, compound path, group and layer under it
/// that carries a linked opacity mask, with the art of that mask, and on the masks inside that
/// art, as [`Step::reshape_masks`] reaches them: `depth` masks deep already, up to
/// [`vectorcraft_doc::depth::MAX_DEPTH`].
fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(NodeId, &Node)) {
if depth >= vectorcraft_doc::depth::MAX_DEPTH {
return;
}
if own
&& reshapes_mask_of(n)
&& let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked)
{
f(n.id, &m.art);
each_linked_mask(&m.art, true, depth + 1, f);
}
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
for c in children {
each_linked_mask(c, true, depth, f);
}
}
}
/// [`each_linked_mask`] that changes the art of each mask: `f` runs on a mask's art before the
/// masks inside it are reached.
fn each_linked_mask_mut(n: &mut Node, own: bool, depth: usize, f: &mut dyn FnMut(&mut Node)) {
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) {
return;
}
if own
&& reshapes_mask_of(n)
&& let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked)
{
let art = Arc::make_mut(&mut m.art);
f(art);
each_linked_mask_mut(art, true, depth + 1, f);
}
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
if has_linked_mask(c, true) {
each_linked_mask_mut(Arc::make_mut(c), true, depth, f);
}
}
}
}
/// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself?
fn has_own_linked_mask(n: &Node) -> bool {
reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked)
}
/// Add to `out` the ids of the objects under `n` that the linked opacity mask of an object above
/// them covers (every one of them with `under`).
fn masked_ids(n: &Node, under: bool, out: &mut HashSet<NodeId>) {
for c in n.children().into_iter().flatten() {
if under {
out.insert(c.id);
}
masked_ids(c, under || has_own_linked_mask(c), out);
}
}
/// How many images and symbol instances under `n` the linked opacity mask of an object above them
/// covers (every one of them with `under`).
fn masked_placed(n: &Node, under: bool) -> usize {
n.children().into_iter().flatten().map(|c| usize::from(under && is_placed(c)) + masked_placed(c, under || has_own_linked_mask(c))).sum()
}
/// Is `n`, or anything under it, one of `ids`?
fn holds_any(n: &Node, ids: &HashSet<NodeId>) -> bool {
if ids.is_empty() {
return false;
}
let mut any = false;
n.walk(&mut |c| any |= ids.contains(&c.id));
any
}
/// Do the paths under `n` that [`for_each_leaf`] visits all fill by the non-zero rule without a
/// stroke that paints? Effects then reshape their outlines alike in any order that the
/// optimization of [`crate::geometry::optimize_geometry_effects`] takes.
fn leaves_safe(n: &Node) -> bool {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
let has_stroke = n.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
leaf_geometry(n).is_none_or(|(_, rule)| rule == FillRule::NonZero && !has_stroke)
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().all(|c| leaves_safe(c)),
_ => true,
}
}
/// Is there an image with a linked opacity mask among the members of `n`, or among its groups'
/// and layers' members, which [`mark_warped_images`] walks?
fn has_masked_image(n: &Node) -> bool {
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return false };
children.iter().any(|c| match c.kind {
NodeKind::Image(_) => c.extras().mask.as_ref().is_some_and(|m| m.linked),
_ => has_masked_image(c),
})
}
/// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by
@@ -1082,7 +1439,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
let mut copy = leaf.clone();
copy.transform(xf, scaling);
// Each copy's own geometry effects make a different amount, as the copy and its effects scale.
let cost = Budget::of(&moved).plus(effects_cost(&copy, &moved));
let mut cost = Budget::of(&moved).plus(effects_cost(&copy, &moved));
// Each copy carries the art of its linked masks, moved with it.
each_linked_mask(&copy, true, 0, &mut |_, m| cost = cost.plus(art_cost(m)));
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
@@ -1622,17 +1981,20 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
/// 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)
/// as a path does. The art of each one's linked opacity mask takes the map with `scaling`, as a
/// path's mask does.
fn transform_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling) {
let mut room = crate::distort::MAX_COPY_OBJECTS.saturating_sub(placed_count(n));
copy_placed(n, m, copies, scaling, &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).
#[cfg(test)]
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);
copy_placed(n, m, copies, vectorcraft_doc::Scaling::default(), &mut room);
}
/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number).
@@ -1864,11 +2226,11 @@ fn is_placed(n: &Node) -> bool {
/// 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) {
fn copy_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, room: &mut usize) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
if !is_placed(c) {
copy_placed(Arc::make_mut(c), m, copies, room);
copy_placed(Arc::make_mut(c), m, copies, scaling, room);
continue;
}
let src = (**c).clone();
@@ -1887,6 +2249,12 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() {
one.appearance.transform_gradients(xf);
}
// So does the art of its linked opacity mask.
if xf != Affine::IDENTITY
&& let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked)
{
Arc::make_mut(&mut m.art).transform(xf, scaling);
}
one
};
if copies == 0 {
+6 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Free Distort",
[],
None,
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles",
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
has_doc,
distort
),
@@ -104,7 +104,8 @@ impl Projective {
}
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
/// approximation at their centre.
/// approximation at their center. The art of a linked opacity mask is warped with the object it
/// masks; an unlinked one stays where it is.
fn warp_node(n: &mut Node, pr: &Projective) {
n.pin_gradients();
match &mut n.kind {
@@ -131,6 +132,9 @@ fn warp_node(n: &mut Node, pr: &Projective) {
return;
}
}
if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) {
warp_node(Arc::make_mut(&mut m.art), pr);
}
n.appearance.warp_gradients(&|p| pr.affine_near(p));
}
@@ -631,6 +631,32 @@ fn an_opacity_mask_on_type_bends_as_other_art() {
assert_eq!(kept, 1, "the type's stroke stays a stroke");
}
/// An envelope bends the art of a linked opacity mask on its contents with them; an unlinked mask
/// stays where it is.
#[test]
fn an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one() {
for linked in [true, false] {
let mut s = session();
let a = rect(&mut s);
let m = rect(&mut s);
sel(&mut s, &[a, m]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
if !linked {
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
}
let before = node(&s, a).extras().mask.as_ref().unwrap().art.path_data().cloned();
sel(&mut s, &[a]);
let e = arch(&mut s, 60.0, json!({}));
let mut found = vec![];
for n in &evaluated(&s, e) {
n.walk(&mut |c| found.extend(c.extras().mask.as_ref().map(|k| (c.path_data().cloned(), k.art.path_data().cloned()))));
}
let [(bent, mask)] = found.as_slice() else { panic!("one masked object: {found:?}") };
assert!(bent != &before, "the contents bend");
assert_eq!(mask, if linked { bent } else { &before }, "linked: {linked}");
}
}
/// Expand turns an envelope of stroked type into its bent glyph outlines with the stroke on them,
/// at its own weight, as it draws (#6644).
#[test]
+24
View File
@@ -505,6 +505,30 @@ fn a_painted_group_effect_links_to_the_document_s_swatches() {
assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green");
}
/// A painted effect on a group leaves the group's art as it is when a path in it has a linked
/// opacity mask, which can't take the pieces as the path does: the mask still covers the path.
#[test]
fn a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let m = rect(&mut s, 10.0, "#ffffff");
run(&mut s, "select.set", json!({"ids": [a.0, m.0]}));
run(&mut s, "transparency.makeOpacityMask", json!({}));
rect(&mut s, 100.0, "#0000ff");
run(&mut s, "select.all", json!({}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.maskedpaint", true), &out));
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.maskedpaint"}));
let art = vectorcraft_render::effects::evaluate_container(Some(&s.doc().unwrap().doc), &node(&s, g)).unwrap();
let mut masked = vec![];
art.walk(&mut |c| masked.extend(c.extras().mask.as_ref().map(|k| (c.geometric_bounds(), k.art.geometric_bounds()))));
assert!(!masked.is_empty(), "the masked path");
for (path, mask) in masked {
assert_eq!(path, mask, "the mask covers the path");
}
}
/// The output of a painted effect that returns one green square linked to swatch `Leaf`.
fn leaf_square() -> String {
let mut leaf = solid("#00ff00");
+45
View File
@@ -526,6 +526,51 @@ fn distort_command_maps_corners() {
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
}
/// Free Distort warps the art of a linked opacity mask with the object it masks, and one undo puts
/// both back; an unlinked mask stays where it is.
#[test]
fn distort_command_warps_a_linked_opacity_mask_with_its_object() {
for linked in [true, false] {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
if !linked {
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
}
let mask_path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().mask.as_ref().unwrap().art.path_data().cloned();
let path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().cloned();
let before = (path(&s), mask_path(&s));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap();
assert!(path(&s) != before.0);
let want = if linked { path(&s) } else { before.1.clone() };
assert_eq!(mask_path(&s), want, "linked: {linked}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((path(&s), mask_path(&s)), before);
}
}
/// Free Distort of a group warps the linked opacity mask of a path inside it with the path.
#[test]
fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
let c = rect(&mut s, 150.0, 0.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let before = s.doc().unwrap().doc.node(a).unwrap().path_data().cloned();
s.execute("select.set", &json!({"ids": [g]})).unwrap();
s.execute("object.distort", &json!({"corners": [[10, 0], [190, 0], [200, 100], [0, 100]]})).unwrap();
let a = s.doc().unwrap().doc.node(a).unwrap();
assert!(a.path_data().cloned() != before, "the path is warped");
assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask");
}
#[test]
fn distort_perspective_midpoint_is_projective() {
// Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real
+2 -2
View File
@@ -597,8 +597,7 @@ since 2026-10-01.
- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged.
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests.
- **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged.
- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`.
@@ -1247,6 +1246,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group.
Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off.
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged.
- **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged.
- **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged.
- **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.