Outline Stroke, Expand: hidden and locked objects stay hidden and locked

The art that replaces an outlined or expanded path did not take the
path's hidden, locked or Knockout Shape state. Outline Stroke on a
hidden path, or Expand on a group with a hidden member, made the new art
visible (a filled and stroked path became half visible: the outline
shown, the fill copy hidden), and a locked member's art was unlocked.

The replacement now takes the path's visibility, lock and Knockout Shape
state, and the fill copies and clipping paths inside it are shown and
unlocked, so showing or unlocking the new art shows or unlocks all of it.
This covers Outline Stroke, and Expand's strokes, gradient fills and
pattern fills.
This commit is contained in:
dotCooCoo
2026-10-31 19:43:15 +07:00
committed by bflatastic
parent 9e8e091c86
commit 9571ae0a46
3 changed files with 63 additions and 1 deletions
+4 -1
View File
@@ -888,11 +888,13 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
}
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
/// The copy is shown and unlocked.
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
let mut f = d.reid(l);
f.appearance = Appearance { items, ..Default::default() }.into();
f.opacity = 1.0;
f.blend = BlendMode::Normal;
(f.visible, f.locked, f.knockout_shape) = (true, false, false);
f.name = None;
f.extras_mut().mask = None;
if let NodeKind::Path { live, .. } = &mut f.kind {
@@ -903,7 +905,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>
/// What stands for object `l` once its appearance became `parts` (paint order): the one part (in a
/// group when it has effects of its own) or a group of them, with `l`'s transparency, name, opacity
/// mask and effects. `None` without parts.
/// mask, effects, and hidden, locked and knockout-shape states. `None` without parts.
pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> {
let root = match parts.len() {
0 => return None,
@@ -911,6 +913,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Opti
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
};
let mut root = with_transparency(d, root, l.opacity, l.blend);
(root.visible, root.locked, root.knockout_shape) = (l.visible, l.locked, l.knockout_shape);
root.name = l.name.clone();
root.extras_mut().mask = l.extras().mask.clone();
root.appearance.effects = l.appearance.effects.clone();
+29
View File
@@ -159,6 +159,35 @@ fn eight_strips_match_the_gradient_samples() {
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn expanding_a_group_keeps_its_hidden_and_locked_members_hidden_and_locked() {
// A hidden stroked square with a gradient fill, a locked square with a pattern fill and a
// plain square, grouped.
let mut s = session();
let pat = pattern_rect(&mut s, Rect::new(20.0, 100.0, 80.0, 160.0), false);
let grad = gradient_rect(&mut s, Rect::new(20.0, 20.0, 80.0, 80.0), white_black(), true);
let plain = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [pat.0, grad.0, plain.0]}));
run(&mut s, "object.group", json!({}));
s.edit("t", |d, _| {
d.node_mut(grad).unwrap().visible = false;
d.node_mut(pat).unwrap().locked = true;
Ok(())
})
.unwrap();
run(&mut s, "object.expand", json!({}));
let g = selected(&s);
let ch = g.children().unwrap();
assert_eq!(ch.len(), 3);
assert!(!clip_parts(&ch[0]).1.is_empty() && ch[0].locked && ch[0].visible, "the pattern's tiles stay locked: {:?}", ch[0]);
assert!(!ch[1].visible && !ch[1].locked, "the gradient square's art stays hidden: {:?}", ch[1]);
assert!(ch[2].visible && !ch[2].locked);
// Showing or unlocking the new art shows or unlocks all of it.
for c in ch[..2].iter().flat_map(|c| c.children().unwrap().iter()) {
c.walk(&mut |n| assert!(n.visible && !n.locked, "{n:?}"));
}
}
#[test]
fn a_radial_gradient_becomes_concentric_ellipses() {
let mut s = session();
+30
View File
@@ -122,6 +122,36 @@ fn every_stroke_is_outlined_in_paint_order() {
assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3);
}
/// The art that stands for an outlined path keeps the path's hidden, locked and knockout-shape
/// states, so a hidden member of a selected group stays hidden.
#[test]
fn outlined_art_keeps_the_hidden_locked_and_knockout_shape_states() {
let mut s = session();
// A filled and stroked square (a fill copy and an outline) and a stroked line (one outline).
let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
let ln = line(&mut s, json!({"weight": 4}));
s.execute("select.set", &json!({"ids": [sq.0, ln.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.edit("t", |d, _| {
d.node_mut(sq).unwrap().visible = false;
let l = d.node_mut(ln).unwrap();
l.locked = true;
l.knockout_shape = true;
Ok(())
})
.unwrap();
let g = outline(&mut s);
let ch = g.children().unwrap();
assert_eq!(ch.len(), 2);
let (sq, ln) = (&ch[0], &ch[1]);
assert!(!sq.visible && !sq.locked, "the square's art stays hidden: {sq:?}");
assert_eq!(sq.children().map(|c| c.len()), Some(2), "a fill copy and the outline");
assert!(sq.children().unwrap().iter().all(|c| c.visible && !c.locked), "showing it shows all of it: {sq:?}");
assert!(ln.visible && ln.locked && ln.knockout_shape, "the line's outline stays locked: {ln:?}");
}
/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke.
#[test]
fn the_live_effect_outlines_the_stroke_it_sits_on() {