diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index ae9670b55..56a223e13 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -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) -> 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 /// 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) -> Option { let root = match parts.len() { 0 => return None, @@ -911,6 +913,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec) -> 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(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 2c9e4ef22..59b8db890 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -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(); diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 17a6b5a09..fad35ad43 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -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() {