diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 8d6530860..d16eab497 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Make Opacity Mask", ["Window", "Transparency"], None, - "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several, a layer among several by its art). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", + "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several; a layer among several stands for its art, so the topmost object of a layer on top is the mask). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", can_make, make ), @@ -185,7 +185,7 @@ fn make(s: &mut Session, p: &Value) -> Result { if d.mask_edit.is_some() { return Err(bad(C, "stop editing the opacity mask first")); } - let ids = match ids_param(p, "ids") { + let mut ids = match ids_param(p, "ids") { Some(ids) => { if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { return Err(EngineError::NoNode(*id)); @@ -194,6 +194,25 @@ fn make(s: &mut Session, p: &Value) -> Result { } None => super::appearance::subject_roots(s)?, }; + // A layer among several targets stands for its visible, unlocked art. A hidden, locked or + // template layer gives none. + let art_of = |id: NodeId| match d.node(id) { + Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], + Some(n) if n.is_layer() => n.layer_art(true), + _ => vec![id], + }; + // A layer on top: the topmost object of its art is the mask, and the rest of it is masked. + if ids.len() > 1 + && let Some(&top) = ids.last() + && d.node(top).is_some_and(Node::is_layer) + { + let top_art = super::edit::outermost(d, d.paint_order(art_of(top))); + if top_art.is_empty() { + return Err(bad(C, "select the art and, on top of it, the mask object")); + } + ids.pop(); + ids.extend(top_art); + } let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) }; // One target gets an empty mask and enters mask editing, which isolation mode and pattern // editing mode do not allow. @@ -206,17 +225,10 @@ fn make(s: &mut Session, p: &Value) -> Result { let invert = bool_or(p, "invert", s.menu.new_masks_inverted); // Several objects: the top one is the mask. One: an empty mask, drawn in mask editing. let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) }; - // Several objects go into a new group, and a layer can't: a layer among them stands for its - // visible, unlocked art, which the group takes from it. A hidden, locked or template layer - // gives none. + // Several objects go into a new group, and a layer can't: the group takes a layer's art. let contents; let art = if art.len() > 1 && art.iter().any(|id| d.node(*id).is_some_and(Node::is_layer)) { - let objects = art.iter().flat_map(|id| match d.node(*id) { - Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], - Some(n) if n.is_layer() => n.layer_art(true), - _ => vec![*id], - }); - contents = super::edit::outermost(d, d.paint_order(objects)); + contents = super::edit::outermost(d, d.paint_order(art.iter().flat_map(|id| art_of(*id)))); if contents.is_empty() { return Err(bad(C, "the layers hold no art to mask")); } @@ -549,6 +561,46 @@ mod tests { assert_eq!((d.parent_of(NodeId(b)), d.parent_of(NodeId(b2))), (Some(NodeId(l2)), Some(NodeId(l2)))); } + #[test] + fn make_takes_the_mask_from_the_art_of_a_layer_on_top() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = |s: &mut Session, x: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let (a, a2) = (rect(&mut s, 0), rect(&mut s, 30)); + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let (b, b2) = (rect(&mut s, 60), rect(&mut s, 90)); + let l3 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let top = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 150, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + let layer_ids: Vec = before.iter().map(|l| l.id.0).collect(); + // The topmost object of the top layer is the mask, and the rest of that layer's art is + // masked with the art below it. No layer leaves the layer tree. + for (ids, mask, grouped) in [(vec![a, l3], top, None), (vec![l1, l2], b2, Some(vec![a, a2, b])), (vec![a, l2], b2, Some(vec![a, b]))] { + let r = s.execute("transparency.makeOpacityMask", &json!({ "ids": ids })).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(r["editing"], false, "{ids:?}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| l.id.0).collect::>(), layer_ids, "{ids:?}: every layer stays"); + assert!(d.node(NodeId(mask)).is_none(), "{ids:?}: the mask art left the layer tree"); + let n = d.node(id).unwrap(); + assert_eq!(n.extras().mask.as_ref().unwrap().art.id, NodeId(mask), "{ids:?}"); + match grouped { + Some(g) => assert_eq!(n.children().unwrap().iter().map(|c| c.id.0).collect::>(), g, "{ids:?}"), + None => assert_eq!(id, NodeId(a), "{ids:?}"), + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before, "{ids:?}: undo"); + } + // A top layer without art gives no mask, and nothing changes. + s.execute("layer.setProps", &json!({"id": l3, "visible": false})).unwrap(); + let hidden = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, l3] })).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, hidden); + } + #[test] fn release_restores_mask_art_and_undo_restores_mask() { let (mut s, a, b) = session_with_two();