Files
vectorcraft/crates/engine/src/cmd/opacitymask.rs
T
dotCooCoo 67a278b053 Commands: ids leave out an object listed with a group that holds it, as the selection does
Commands that read the selection through edit::selected_roots leave
out an object whose group is also selected (edit::roots_of). Fourteen
places that read `ids` kept an object listed together with its group.
Given ids that list an object and a group that holds it, Make Repeat
failed with "no such object", New Symbol failed or stored the object
twice in the symbol, and object.transform moved it twice (20 pt for a
10 pt move).

These places now pass explicit ids through edit::outermost, which
drops an id whose ancestor is also listed, with the filter that
roots_of runs on the selection (now edit::without_inner). Nothing else
is dropped or replaced: unlike roots_of, it keeps layers and
compound-path members. A list without nesting gives the same result as
before. Commands that take both ids and the selection as listed, such
as paint.setFill without item, are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00

508 lines
21 KiB
Rust

//! Opacity masks (Transparency panel): make, release, enable/disable, link/unlink, clip, invert,
//! and the panel's state (`transparency.info`).
//!
//! The mask art is stored on the masked object ([`vectorcraft_doc::OpacityMask`]), outside the
//! layer tree, so it is never hit-tested or selected. Its luminance sets the object's opacity.
//! Every command here (and `transparency.set`) acts on [`transparency_targets`]: explicit ids, else
//! the object whose mask is being edited, else the selection.
use std::collections::HashSet;
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
use super::pathops::{Beside, separate};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"transparency.makeOpacityMask",
"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). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask → {id, editing}",
has_doc,
make
),
cmd!(
"transparency.releaseOpacityMask",
"Release Opacity Mask",
["Window", "Transparency"],
None,
"{id?|ids?} put the mask art back above each masked object (default: the selection, or the object whose mask is being edited, which leaves mask editing)",
has_doc,
release
),
cmd!(
"transparency.disableOpacityMask",
"Disable Opacity Mask",
["Window", "Transparency"],
None,
"{id?|ids?} keep the mask but stop applying it",
has_doc,
|s, p| set_flags(s, p, "Disable Opacity Mask", &json!({ "disabled": true }))
),
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags(
s,
p,
"Enable Opacity Mask",
&json!({ "disabled": false })
)),
cmd!(
"transparency.unlinkOpacityMask",
"Unlink Opacity Mask",
["Window", "Transparency"],
None,
"{id?|ids?} the object moves without its mask",
has_doc,
|s, p| set_flags(s, p, "Unlink Opacity Mask", &json!({ "linked": false }))
),
cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{id?|ids?}", has_doc, |s, p| set_flags(
s,
p,
"Link Opacity Mask",
&json!({ "linked": true })
)),
cmd!(
"transparency.setOpacityMask",
"Opacity Mask Options",
[],
None,
"{id?|ids?, clip?, invert?, disabled?, linked?} change the opacity masks of `ids`, the selection, or the object whose mask is being edited",
has_doc,
|s, p| set_flags(s, p, "Opacity Mask Options", p)
),
cmd!(
"transparency.toggleNewMasksClipping",
"New Opacity Masks Are Clipping",
["Window", "Transparency"],
None,
"{value?} → {value}",
always,
toggle_new_clip
),
cmd!(
"transparency.toggleNewMasksInverted",
"New Opacity Masks Are Inverted",
["Window", "Transparency"],
None,
"{value?} → {value}",
always,
toggle_new_invert
),
cmd!(
query "transparency.opacityMaskInfo",
"Opacity Mask Info",
[],
None,
"{id?|ids?} → [{id, clip, invert, disabled, linked, art}] the opacity masks of `ids`, the selection, or the object whose mask is being edited",
has_doc,
mask_info
),
cmd!(
query "transparency.info",
"Transparency Info",
[],
None,
"{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask, pageIsolatedBlending, pageKnockoutGroup} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null; the page values are the document's",
has_doc,
info
),
cmd!(
"transparency.togglePageIsolatedBlending",
"Page Isolated Blending",
["Window", "Transparency"],
None,
"{value?} make the page an isolated group (default: toggle): blend modes of top-level objects don't blend with what lies under the page; saved with the document and written to PDF → {value}",
has_doc,
|s, p| toggle_page(s, p, false)
),
cmd!(
"transparency.togglePageKnockoutGroup",
"Page Knockout Group",
["Window", "Transparency"],
None,
"{value?} make the page a knockout group (default: toggle): its layers (the contents of neutral layers) hide what they cover instead of showing it through their transparency; saved with the document and written to PDF → {value}",
has_doc,
|s, p| toggle_page(s, p, true)
),
]
}
/// The objects the Transparency panel's commands act on: `ids` / `id`, else the object whose
/// opacity mask is being edited (the selection then is its mask art), else the targeted object,
/// group or layer (`layer.target`), else the selection.
pub(crate) fn transparency_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let st = s.doc()?;
if p.get("ids").is_some() || p.get("id").is_some() {
return targets(s, p);
}
Ok(match st.doc.mask_edit {
Some(me) => vec![me.object],
None => st.selection.subjects().to_vec(),
})
}
/// An opacity parameter, in percent (0..100) everywhere, as the model's 0..1.
pub(crate) fn percent(o: f64) -> f32 {
(o / 100.0).clamp(0.0, 1.0) as f32
}
/// The [`transparency_targets`] that have an opacity mask.
fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = transparency_targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.extras().mask.is_some())).collect();
if ids.is_empty() {
return Err(EngineError::Other("no target object has an opacity mask: select one or give `id`".into()));
}
Ok(ids)
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "transparency.makeOpacityMask";
let d = &s.doc()?.doc;
if d.mask_edit.is_some() {
return Err(bad(C, "stop editing the opacity mask first"));
}
let 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));
}
super::edit::outermost(d, Selection { objects: ids, ..Default::default() }.in_paint_order(d))
}
None => super::appearance::subject_roots(s)?,
};
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
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)) };
let (id, layer) = s.edit("Make Opacity Mask", |d, sel| {
let mask_art = match mask_id {
Some(m) => (*d.remove(m)?).clone(),
None => mask_group(vec![]),
};
// Several objects are grouped so they share one mask.
let target = if let [one] = art {
*one
} else if separate(d, art) {
let gid = d.alloc_id();
d.wrap(art, |children| Node::group(gid, children))?
} else {
let last = art[art.len() - 1];
let (par, idx, _) = d.position(last).ok_or(EngineError::NoNode(last))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in art {
d.move_node(*id, Some(gid), usize::MAX)?;
}
gid
};
let n = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
if n.extras().mask.is_some() {
return Err(EngineError::Other("the object already has an opacity mask".into()));
}
let mut m = OpacityMask::new(mask_art, clip);
m.invert = invert;
n.extras_mut().mask = Some(Box::new(m));
sel.set_target(d, target);
let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None };
Ok((target, layer))
})?;
if layer.is_some() {
show_editing(s, layer)?;
}
Ok(json!({ "id": id.0, "editing": layer.is_some() }))
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids = masked_targets(s, p)?;
let editing = s.doc()?.doc.mask_edit.is_some();
s.edit("Release Opacity Mask", |d, sel| {
// Leave mask editing first: its art goes back into the mask, which is released below.
finish(d, sel);
let mut out = vec![];
let mut released = Beside::above(d, &ids);
for id in &ids {
let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue };
out.push(*id);
// Fresh ids: copies of a masked object share the ids inside their mask art.
let parts: Vec<Node> = mask_parts(&m.art).iter().map(|part| d.reid(part)).collect();
out.extend(parts.iter().map(|p| p.id));
released.put(d, *id, parts)?;
}
released.finish(d)?;
sel.set(out);
Ok(())
})?;
if editing {
show_editing(s, None)?;
}
ok()
}
/// Set the mask flags given in `p` on the masked objects among `target`'s targets.
fn set_flags(s: &mut Session, target: &Value, label: &str, p: &Value) -> Result<Value> {
let get = |k: &str| p.get(k).and_then(Value::as_bool);
let (clip, invert, disabled, linked) = (get("clip"), get("invert"), get("disabled"), get("linked"));
if clip.is_none() && invert.is_none() && disabled.is_none() && linked.is_none() {
return Err(bad("transparency.setOpacityMask", "give at least one of clip, invert, disabled, linked"));
}
let ids = masked_targets(s, target)?;
// When every mask already has the flags given, the document and its undo history stay as they
// are.
let has_flags = |m: &OpacityMask| {
clip.is_none_or(|v| m.clip == v)
&& invert.is_none_or(|v| m.invert == v)
&& disabled.is_none_or(|v| m.disabled == v)
&& linked.is_none_or(|v| m.linked == v)
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).and_then(|n| n.extras().mask.as_deref()).is_none_or(has_flags)) {
return ok();
}
s.edit(label, |d, _| {
for id in &ids {
if let Some(m) = d.node_mut(*id).and_then(|n| n.extras_if_any().and_then(|e| e.mask.as_mut())) {
m.clip = clip.unwrap_or(m.clip);
m.invert = invert.unwrap_or(m.invert);
m.disabled = disabled.unwrap_or(m.disabled);
m.linked = linked.unwrap_or(m.linked);
}
}
Ok(())
})?;
ok()
}
fn toggle_new_clip(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_or(p, "value", s.menu.new_masks_unclipped);
s.menu.new_masks_unclipped = !v;
Ok(json!({ "value": v }))
}
/// Set (or toggle) the page's knockout group flag (`knockout`) or isolated blending flag, as one
/// undo step; setting the current value changes nothing.
fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result<Value> {
fn flag(d: &mut Document, knockout: bool) -> &mut bool {
if knockout { &mut d.page_knockout } else { &mut d.page_isolate }
}
let d = &s.doc()?.doc;
let cur = if knockout { d.page_knockout } else { d.page_isolate };
let v = bool_or(p, "value", !cur);
if v != cur {
s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| {
*flag(d, knockout) = v;
Ok(())
})?;
}
Ok(json!({ "value": v }))
}
fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_or(p, "value", !s.menu.new_masks_inverted);
s.menu.new_masks_inverted = v;
Ok(json!({ "value": v }))
}
/// The Transparency panel's values for its targets ([`Session::transparency_info`]). A value is
/// `None` where the targets differ, or when there are none.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TransparencyInfo {
/// The targets (the selection, in selection order, by default).
pub ids: Vec<NodeId>,
/// Opacity in percent (0..100), to two decimals.
pub opacity: Option<f64>,
pub blend: Option<BlendMode>,
pub isolate: Option<bool>,
pub knockout: Option<Knockout>,
pub knockout_shape: Option<bool>,
}
impl TransparencyInfo {
fn of(d: &Document, ids: Vec<NodeId>) -> Self {
let nodes = nodes_of(d, &ids);
fn same<T: PartialEq>(nodes: &[&Node], f: impl Fn(&Node) -> T) -> Option<T> {
let first = f(nodes.first()?);
nodes[1..].iter().all(|n| f(n) == first).then_some(first)
}
Self {
// Compared in hundredths of a percent, as shown.
opacity: same(&nodes, |n| (n.opacity as f64 * 10_000.0).round() as i64).map(|o| o as f64 / 100.0),
blend: same(&nodes, |n| n.blend),
isolate: same(&nodes, |n| n.isolate),
knockout: same(&nodes, |n| n.knockout),
knockout_shape: same(&nodes, |n| n.knockout_shape),
ids,
}
}
}
/// The nodes of `ids` that exist: looked up one by one when few, else in one walk of the tree.
fn nodes_of<'a>(d: &'a Document, ids: &[NodeId]) -> Vec<&'a Node> {
if ids.len() <= 8 {
return ids.iter().filter_map(|id| d.node(*id)).collect();
}
let set: HashSet<NodeId> = ids.iter().copied().collect();
let mut out = Vec::with_capacity(ids.len());
d.walk(|n| {
if set.contains(&n.id) {
out.push(n);
}
});
out
}
impl Session {
/// Defaults for new opacity masks (clip, invert) from the Transparency panel menu.
pub fn new_mask_defaults(&self) -> (bool, bool) {
(!self.menu.new_masks_unclipped, self.menu.new_masks_inverted)
}
/// The Transparency panel's values for the selection, or for the object whose mask is being
/// edited (`transparency.info` without ids).
pub fn transparency_info(&self) -> TransparencyInfo {
match (self.active(), transparency_targets(self, &Value::Null)) {
(Some(st), Ok(ids)) => TransparencyInfo::of(&st.doc, ids),
_ => TransparencyInfo::default(),
}
}
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = TransparencyInfo::of(&st.doc, transparency_targets(s, p)?);
Ok(json!({
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
"opacity": i.opacity,
"blend": i.blend.map(BlendMode::label),
"isolate": i.isolate,
"knockout": i.knockout.map(Knockout::label),
"knockoutShape": i.knockout_shape,
"editingMask": st.doc.mask_edit.map(|me| me.object.0),
"pageIsolatedBlending": st.doc.page_isolate,
"pageKnockoutGroup": st.doc.page_knockout,
}))
}
fn mask_info(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let v: Vec<Value> = nodes_of(d, &transparency_targets(s, p)?)
.into_iter()
.filter_map(|n| {
let m = n.extras().mask.as_deref()?;
Some(json!({ "id": n.id.0, "clip": m.clip, "invert": m.invert, "disabled": m.disabled, "linked": m.linked, "art": m.art.id.0 }))
})
.collect();
Ok(json!(v))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn session_with_two() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let a = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
(s, a, b)
}
fn select(s: &mut Session, ids: &[u64]) {
s.execute("select.set", &json!({ "ids": ids })).unwrap();
}
#[test]
fn make_moves_top_object_into_mask() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert_eq!(r["id"].as_u64(), Some(a));
assert_eq!(r["editing"], false);
let d = &s.doc().unwrap().doc;
assert!(d.node(NodeId(b)).is_none(), "mask art left the layer tree");
let m = d.node(NodeId(a)).unwrap().extras().mask.as_deref().unwrap();
assert!(m.clip && m.linked && !m.invert && !m.disabled);
assert_eq!(m.art.id, NodeId(b));
}
#[test]
fn make_groups_several_objects() {
let (mut s, a, b) = session_with_two();
let c = s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
select(&mut s, &[a, b, c]);
let g = s.execute("transparency.makeOpacityMask", &json!({"clip": false})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let n = d.node(NodeId(g)).unwrap();
assert_eq!(n.children().unwrap().len(), 2);
assert!(!n.extras().mask.as_ref().unwrap().clip);
}
#[test]
fn release_restores_mask_art_and_undo_restores_mask() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.node(NodeId(a)).unwrap().extras().mask.is_none());
assert_eq!(st.selection.len(), 2);
assert_eq!(st.doc.layers[0].children().unwrap().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.is_some());
}
#[test]
fn flags_and_linking() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.setOpacityMask", &json!({"invert": true, "clip": false})).unwrap();
s.execute("transparency.disableOpacityMask", &json!({})).unwrap();
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
let info = s.execute("transparency.opacityMaskInfo", &json!({})).unwrap();
assert_eq!(info[0]["invert"], true);
assert_eq!(info[0]["clip"], false);
assert_eq!(info[0]["disabled"], true);
assert_eq!(info[0]["linked"], false);
// Unlinked: moving the object leaves the mask art where it was.
let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone();
s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone();
assert_eq!(before.geometric_bounds(), after.geometric_bounds());
s.execute("transparency.linkOpacityMask", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().art.clone();
assert!((moved.geometric_bounds().unwrap().x0 - after.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-9);
}
#[test]
fn new_masks_clipping_toggle() {
let (mut s, a, b) = session_with_two();
assert_eq!(s.execute("transparency.toggleNewMasksClipping", &json!({})).unwrap()["value"], false);
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().extras().mask.as_ref().unwrap().clip);
s.execute("transparency.toggleNewMasksInverted", &json!({})).unwrap();
assert_eq!(s.new_mask_defaults(), (false, true));
}
#[test]
fn masks_survive_native_round_trip() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({"invert": true})).unwrap();
let d = s.doc().unwrap().doc.clone();
let bytes = vectorcraft_format::save(&d, false);
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!(back.node(NodeId(a)).unwrap().extras().mask, d.node(NodeId(a)).unwrap().extras().mask);
}
}