M3.25: opacity-mask editing and transparency command reach

While a mask is edited, the Transparency panel and its commands act on the
masked object (Release leaves editing first). Mask commands and
transparency.set take ids and work without a selection; Make Opacity Mask
with one object adds an empty mask and enters editing. Opacity is 0..100 in
every command. transparency.info reports the panel's values with null for
mixed ones, shown blank. Saves and exports never include the editing layer
(Document::without_edit_modes; mask_edit is not serialized).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 17:10:21 +07:00
co-authored by Claude Opus 5.5
parent 8f9d6ef2bd
commit b117566fc2
18 changed files with 745 additions and 153 deletions
+3 -2
View File
@@ -6,6 +6,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeKind, StrokeLayer};
use super::edit::selected_roots;
use super::opacitymask::percent;
use super::paint::paint_from;
use super::*;
use crate::EngineError;
@@ -21,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
"{index, ids?, opacity?: 0..100, blend?, visible?, weight?, color?|none?|swatch?|gradient?} edit one fill/stroke of the appearance stack of the selection (or `ids`)",
has_selection,
set_item
),
@@ -115,7 +116,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
*pp = pa.clone();
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
*op = percent(o);
}
if let Some(b) = blend {
*bl = b;
+4
View File
@@ -119,6 +119,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.redo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
@@ -126,6 +127,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
});
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
Ok(json!({ "undone": label }))
}
@@ -133,6 +135,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.undo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
@@ -140,6 +143,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
});
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
+3 -1
View File
@@ -106,9 +106,11 @@ fn check_pixels(f: &Format, w: f64, h: f64) -> Result<()> {
}
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
/// options from `p` (see `document.formats`). Raster formats leave template layers out.
/// options from `p` (see `document.formats`). Raster formats leave template layers out, and no
/// format writes the opacity-mask editing layer.
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
let f = super::writable(C, Some(format), None)?;
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
Ok(match f.id {
"vectorcraft" => vectorcraft_format::save_file(doc),
+107 -42
View File
@@ -3,18 +3,23 @@
//! The mask art moves onto a temporary isolated layer where every tool and command can edit it.
//! After each edit [`sync`] writes the layer's art back into the object's mask, so the masked
//! object updates live; the renderer doesn't paint the editing layer (the mask is seen through its
//! effect, as in Illustrator). Leaving the mode removes the layer.
//! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting
//! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands
//! act on the masked object (see `opacitymask::transparency_targets`).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, MaskEdit, Node, NodeId};
use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection};
use super::*;
/// Name of the temporary mask-editing layer.
pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing";
/// Name of the group that holds several mask objects (or none) as one mask art node.
const MASK_GROUP: &str = "Opacity Mask";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -22,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Opacity Mask",
["Window", "Transparency"],
None,
"{id?} edit the selected object's mask art in place (the mask thumbnail in the Transparency panel) → {layer}",
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does → {layer}",
has_doc,
enter
),
@@ -38,57 +43,116 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Mask art made of `children`: a plain group, so the mask keeps one art node. Editing spreads it
/// back out onto the editing layer.
pub(crate) fn mask_group(children: Vec<Arc<Node>>) -> Node {
let mut g = Node::group(NodeId(u64::MAX - 1), children);
g.name = Some(MASK_GROUP.into());
g
}
/// A group [`mask_group`] made (named so, with nothing of its own that its members would lose).
fn is_mask_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. })
&& n.name.as_deref() == Some(MASK_GROUP)
&& n.opacity >= 1.0
&& n.blend == vectorcraft_doc::color::BlendMode::Normal
&& !n.isolate
&& !n.knockout
&& n.mask.is_none()
&& n.appearance.items.is_empty()
&& n.appearance.effects.is_empty()
}
/// The objects mask art is made of: the members of a [`mask_group`], else the art itself.
pub(crate) fn mask_parts(art: &Arc<Node>) -> Vec<Arc<Node>> {
match art.children() {
Some(ch) if is_mask_group(art) => ch.clone(),
_ => vec![art.clone()],
}
}
fn enter(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "transparency.editOpacityMask";
if s.doc()?.doc.mask_edit.is_some() {
let st = s.doc()?;
if st.doc.mask_edit.is_some() {
return Err(bad(C, "already editing an opacity mask"));
}
let st = s.doc()?;
let id = id_param(p, "id")
.or_else(|| st.selection.objects.iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())))
.ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
let layer = s.edit("Edit Opacity Mask", |d, sel| {
let art = d
.node(id)
.and_then(|n| n.mask.as_ref())
.map(|m| m.art.clone())
.ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?;
let layer = d.add_layer(Some(MASK_EDIT_LAYER));
// Fresh ids: copies of a masked object share the ids inside their mask art.
let copy = d.reid(&art);
let cid = copy.id;
d.insert(Some(layer), 0, copy)?;
d.mask_edit = Some(MaskEdit { object: id, layer });
sel.set([cid]);
Ok(layer)
})?;
let st = s.doc_mut()?;
st.isolation = Some(layer);
st.active_layer = Some(layer);
st.revision += 1;
let layer = s.edit("Edit Opacity Mask", |d, sel| begin(d, sel, id))?;
show_editing(s, Some(layer))?;
Ok(json!({ "layer": layer.0 }))
}
/// Enter mask editing for `id` inside an edit: copy its mask art onto a new editing layer and
/// select it. Returns the layer.
pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result<NodeId> {
let art =
d.node(id).and_then(|n| n.mask.as_ref()).map(|m| m.art.clone()).ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?;
let layer = d.add_layer(Some(MASK_EDIT_LAYER));
let parts = mask_parts(&art);
let mut ids = Vec::with_capacity(parts.len());
for part in &parts {
// Fresh ids: copies of a masked object share the ids inside their mask art.
let copy = d.reid(part);
ids.push(d.insert(Some(layer), usize::MAX, copy)?);
}
d.mask_edit = Some(MaskEdit { object: id, layer });
sel.set(ids);
Ok(layer)
}
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
let Some(me) = s.doc()?.doc.mask_edit else { return Err(bad("transparency.stopEditingOpacityMask", "not editing an opacity mask")) };
s.edit("Stop Editing Opacity Mask", |d, sel| {
sync(d);
d.mask_edit = None;
let _ = d.remove(me.layer);
if d.node(me.object).is_some() {
sel.set([me.object]);
} else {
sel.clear();
}
finish(d, sel);
Ok(())
})?;
let st = s.doc_mut()?;
st.isolation = None;
st.active_layer = st.doc.default_layer();
st.revision += 1;
show_editing(s, None)?;
Ok(json!({ "id": me.object.0 }))
}
/// Leave mask editing inside an edit (no-op when not editing): write the art back into the mask,
/// drop the editing layer and select the masked object.
pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
let Some(me) = d.mask_edit else { return };
sync(d);
d.drop_edit_modes();
if d.node(me.object).is_some() {
sel.set([me.object]);
} else {
sel.clear();
}
}
/// Isolate the editing layer and draw into it (`Some`), or return to the document (`None`).
pub(crate) fn show_editing(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
let st = s.doc_mut()?;
st.isolation = layer;
st.active_layer = layer.or_else(|| st.doc.default_layer());
st.revision += 1;
Ok(())
}
/// After undo or redo, isolation follows the restored document into or out of mask editing
/// (`was` is the editing layer before, if any), so it never names a layer that is gone (whose id
/// a later object could reuse).
pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<NodeId>) {
match st.doc.mask_edit {
Some(me) => {
st.isolation = Some(me.layer);
st.active_layer = Some(me.layer);
}
None if was.is_some() && st.isolation == was => {
st.isolation = None;
st.active_layer = st.doc.default_layer();
}
None => {}
}
}
/// Write the editing layer's art into the object's mask (called after every edit). Leaves the
/// mode when the object or the layer is gone (e.g. deleted).
pub(crate) fn sync(d: &mut Document) {
@@ -101,12 +165,8 @@ pub(crate) fn sync(d: &mut Document) {
};
let new_art = match art.as_slice() {
[one] => one.clone(),
many => {
// Several objects (or none): a group, so the mask keeps one art node.
let mut g = Node::group(NodeId(u64::MAX - 1), many.to_vec());
g.name = Some("Opacity Mask".into());
Arc::new(g)
}
// Several objects (or none): a group, so the mask keeps one art node.
many => Arc::new(mask_group(many.to_vec())),
};
if let Some(m) = d.node(me.object).and_then(|n| n.mask.as_ref())
&& Arc::ptr_eq(&m.art, &new_art)
@@ -154,6 +214,11 @@ mod tests {
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none());
assert!(!st.doc.layers.iter().any(|l| l.name.as_deref() == Some(super::MASK_EDIT_LAYER)));
assert_eq!(st.selection.objects, vec![NodeId(obj)]);
// Two mask objects went back as one group; editing again spreads them out on the layer.
let layer = s.execute("transparency.editOpacityMask", &json!({"id": obj})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(layer)).unwrap().children().unwrap().len(), 2);
assert_eq!(s.doc().unwrap().selection.len(), 2);
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
// Undo returns to editing mode's last state, then to before.
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.mask_edit.is_some());
+3
View File
@@ -342,3 +342,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
}
Ok(out)
}
/// The Transparency panel's state ([`Session::transparency_info`]).
pub use opacitymask::TransparencyInfo;
+3 -2
View File
@@ -9,6 +9,7 @@ use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
use super::opacitymask::percent;
use super::*;
use crate::EngineError;
@@ -114,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..1 or %, blend?: \"Multiply\"…, isolate?, knockout?}",
"{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?}",
has_doc,
set_props
),
@@ -615,7 +616,7 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32);
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
let blend = match str_param(p, "blend") {
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
None => None,
+182 -49
View File
@@ -1,12 +1,19 @@
//! Opacity masks (Transparency panel): make, release, enable/disable, link/unlink, clip, invert.
//! 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_doc::{Node, NodeId, OpacityMask};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Document, Node, NodeId, OpacityMask, Selection};
use super::edit::selected_roots;
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
use super::*;
use crate::EngineError;
@@ -17,8 +24,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Opacity Mask",
["Window", "Transparency"],
None,
"{clip?, invert?} the topmost selected object becomes the mask of the others (grouped if several) → {id}",
has_multi,
"{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!(
@@ -26,17 +33,22 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Opacity Mask",
["Window", "Transparency"],
None,
"{} put the mask art back above each masked object",
has_mask,
"{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, "{}", has_mask, |s, _| set_flags(
s,
cmd!(
"transparency.disableOpacityMask",
"Disable Opacity Mask",
&json!({ "disabled": true })
)),
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
["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 })
)),
@@ -45,12 +57,13 @@ pub fn specs() -> Vec<CommandSpec> {
"Unlink Opacity Mask",
["Window", "Transparency"],
None,
"{} the object moves without its mask",
has_mask,
|s, _| set_flags(s, "Unlink Opacity Mask", &json!({ "linked": false }))
"{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, "{}", has_mask, |s, _| set_flags(
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 })
)),
@@ -59,9 +72,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Opacity Mask Options",
[],
None,
"{clip?, invert?, disabled?, linked?} change the selected objects' opacity masks",
has_mask,
|s, p| set_flags(s, "Opacity Mask Options", p)
"{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",
@@ -81,35 +94,76 @@ pub fn specs() -> Vec<CommandSpec> {
always,
toggle_new_invert
),
cmd!(query "transparency.opacityMaskInfo", "Opacity Mask Info", [], None, "{} → [{id, clip, invert, disabled, linked}] masks of the selected objects", has_doc, info),
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, editingMask} 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",
has_doc,
info
),
]
}
fn has_mask(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().unwrap();
if st.selection.in_paint_order(&st.doc).into_iter().any(|id| st.doc.node(id).is_some_and(|n| n.mask.is_some())) {
Ok(())
} else {
Err("no selected object has an opacity mask".into())
/// 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 selection.
pub(crate) fn transparency_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match s.doc()?.doc.mask_edit {
Some(me) if p.get("ids").is_none() && p.get("id").is_none() => Ok(vec![me.object]),
_ => targets(s, p),
}
}
fn masked_ids(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(selected_roots(s)?.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| n.mask.is_some())).collect())
/// 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.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> {
let ids = selected_roots(s)?;
if ids.len() < 2 {
return Err(bad("transparency.makeOpacityMask", "select the art and, on top, the mask object"));
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));
}
Selection { objects: ids, ..Default::default() }.in_paint_order(d)
}
None => selected_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);
let (mask_id, art) = (ids[ids.len() - 1], &ids[..ids.len() - 1]);
let id = s.edit("Make Opacity Mask", |d, sel| {
let mask_art = (*d.remove(mask_id)?).clone();
// 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
@@ -131,36 +185,49 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
m.invert = invert;
n.mask = Some(Box::new(m));
sel.set([target]);
Ok(target)
let layer = if mask_id.is_none() { Some(begin(d, sel, target)?) } else { None };
Ok((target, layer))
})?;
Ok(json!({ "id": id.0 }))
if layer.is_some() {
show_editing(s, layer)?;
}
Ok(json!({ "id": id.0, "editing": layer.is_some() }))
}
fn release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = masked_ids(s)?;
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![];
for id in &ids {
let Some(m) = d.node_mut(*id).and_then(|n| n.mask.take()) else { continue };
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
// Fresh ids: copies of a masked object share the ids inside their mask art.
let art = d.reid(&m.art);
out.push(*id);
out.push(d.insert(par, idx + 1, art)?);
for (k, part) in mask_parts(&m.art).iter().enumerate() {
// Fresh ids: copies of a masked object share the ids inside their mask art.
let art = d.reid(part);
out.push(d.insert(par, idx + 1 + k, art)?);
}
}
sel.set(out);
Ok(())
})?;
if editing {
show_editing(s, None)?;
}
ok()
}
fn set_flags(s: &mut Session, label: &str, p: &Value) -> Result<Value> {
let ids = masked_ids(s)?;
/// 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)?;
s.edit(label, |d, _| {
for id in &ids {
if let Some(m) = d.node_mut(*id).and_then(|n| n.mask.as_mut()) {
@@ -187,20 +254,85 @@ fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> {
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<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),
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, _: &Value) -> Result<Value> {
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let v: Vec<Value> = st
.selection
.in_paint_order(&st.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,
"editingMask": st.doc.mask_edit.map(|me| me.object.0),
}))
}
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(|id| st.doc.node(id))
.filter_map(|n| {
let m = n.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 }))
@@ -232,6 +364,7 @@ mod tests {
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().mask.as_deref().unwrap();
+7 -5
View File
@@ -34,8 +34,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{opacity?: 0..100, blend?: name, isolate?, knockout?}",
has_selection,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?} for `ids`, the selection, or the object whose opacity mask is being edited",
has_doc,
transparency
),
]
@@ -171,9 +171,11 @@ fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
}
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
q["opacity"] = json!(o / 100.0);
let ids = super::opacitymask::transparency_targets(s, p)?;
if ids.is_empty() {
return Err(bad("transparency.set", "select objects or give ids"));
}
let mut q = if p.is_object() { p.clone() } else { json!({}) };
q["ids"] = json!(ids.iter().map(|id| id.0).collect::<Vec<_>>());
s.execute("object.setProps", &q)
}