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
parent 8f9d6ef2bd
commit b117566fc2
18 changed files with 745 additions and 153 deletions

View File

@ -321,8 +321,9 @@ pub struct Document {
/// Pattern editing mode, while active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern_edit: Option<PatternEdit>,
/// Opacity-mask editing mode, while active.
#[serde(default, skip_serializing_if = "Option::is_none")]
/// Opacity-mask editing mode, while active. Never saved (see [`Document::without_edit_modes`]);
/// still read so files saved mid-edit by older versions load without the editing layer.
#[serde(default, skip_serializing)]
pub mask_edit: Option<MaskEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
@ -610,6 +611,27 @@ impl Document {
}
}
impl Document {
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
}
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
if self.mask_edit.is_none() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
d.drop_edit_modes();
std::borrow::Cow::Owned(d)
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
use vectorcraft_color::{Color, Paint};
vec![

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;

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 }))
}

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),

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());

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;

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,

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();

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)
}

View File

@ -725,6 +725,8 @@ mod tests_live;
#[cfg(test)]
mod tests_menucmds;
#[cfg(test)]
mod tests_opacitymask;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_pathops;

View File

@ -0,0 +1,231 @@
//! Opacity-mask editing and the Transparency panel's command reach (M3.25): commands act on the
//! masked object while its mask is edited, accept ids without a selection, take opacity in percent,
//! report mixed values, and saves and exports never carry the editing layer.
use serde_json::{Value, json};
use super::*;
use crate::cmd::maskedit::MASK_EDIT_LAYER;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, w: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
/// An object (0..200) masked by a white rectangle (0..100), with the mask being edited.
fn editing_mask(s: &mut Session) -> NodeId {
let obj = rect(s, 0.0, 200.0);
let m = rect(s, 0.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m.0]})).unwrap();
s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
obj
}
fn doc(s: &Session) -> &Document {
&s.doc().unwrap().doc
}
fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> {
doc(s).node(id).and_then(|n| n.mask.as_deref())
}
fn has_edit_layer(d: &Document) -> bool {
d.layers.iter().any(|l| l.name.as_deref() == Some(MASK_EDIT_LAYER))
}
fn data(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn mask_options_act_on_the_masked_object_while_editing() {
let mut s = session();
let obj = editing_mask(&mut s);
// The selection is the mask art; the panel's commands still reach the object's mask.
assert!(!s.doc().unwrap().selection.contains(obj));
s.execute("transparency.setOpacityMask", &json!({"clip": false, "invert": true})).unwrap();
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
s.execute("transparency.disableOpacityMask", &json!({})).unwrap();
let m = mask(&s, obj).unwrap();
assert!(!m.clip && m.invert && !m.linked && m.disabled);
assert!(doc(&s).mask_edit.is_some(), "still editing");
let info = s.execute("transparency.opacityMaskInfo", &json!({})).unwrap();
assert_eq!(info[0]["id"], obj.0);
// Opacity and blend go to the masked object too, not to the mask art.
s.execute("transparency.set", &json!({"opacity": 40, "blend": "Multiply"})).unwrap();
let n = doc(&s).node(obj).unwrap();
assert!((n.opacity - 0.4).abs() < 1e-6 && n.blend == vectorcraft_color::BlendMode::Multiply);
let info = s.execute("transparency.info", &json!({})).unwrap();
assert_eq!((info["ids"].clone(), info["opacity"].clone(), info["editingMask"].clone()), (json!([obj.0]), json!(40.0), json!(obj.0)));
}
#[test]
fn release_while_editing_leaves_editing_first() {
let mut s = session();
let obj = editing_mask(&mut s);
// Widen the mask while editing; releasing keeps that edit.
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc));
assert!(mask(&s, obj).is_none());
let layer = &st.doc.layers[0];
let kids = layer.children().unwrap();
assert_eq!(kids.len(), 2, "the object and its released mask art");
assert_eq!(kids[1].geometric_bounds().unwrap().x0, 50.0);
assert_eq!(st.active_layer, Some(layer.id));
// One undo step restores the mask and editing mode.
s.execute("edit.undo", &json!({})).unwrap();
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
}
#[test]
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
let mut s = session();
let obj = rect(&mut s, 0.0, 200.0);
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert_eq!(r, json!({"id": obj.0, "editing": true}));
let me = doc(&s).mask_edit.unwrap();
assert_eq!(me.object, obj);
assert_eq!(s.doc().unwrap().isolation, Some(me.layer));
assert!(s.doc().unwrap().selection.is_empty());
// An empty clipping mask hides the object; drawing white in editing mode reveals it there.
let render = |s: &Session| vectorcraft_render::Renderer::new().render(doc(s), 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default());
assert_eq!(render(&s).pixel(50, 100)[3], 0);
let m = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m]})).unwrap();
let img = render(&s);
assert!(img.pixel(50, 100)[3] > 200);
assert_eq!(img.pixel(150, 100)[3], 0);
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0);
// Making a mask while editing one is refused.
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
assert!(s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0]})).is_err());
// One undo step undoes making the empty mask and entering editing; isolation follows.
let mut s = session();
let obj = rect(&mut s, 0.0, 200.0);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
let layer = doc(&s).mask_edit.unwrap().layer;
s.execute("edit.undo", &json!({})).unwrap();
assert!(mask(&s, obj).is_none() && doc(&s).mask_edit.is_none());
assert_eq!(s.doc().unwrap().isolation, None);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer)));
}
#[test]
fn mask_and_transparency_commands_take_ids_without_a_selection() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
s.execute("select.none", &json!({})).unwrap();
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [b.0, a.0], "invert": true})).unwrap();
assert_eq!(r["id"], a.0, "the top object (b) masks the one below, whatever the order given");
s.execute("select.none", &json!({})).unwrap();
s.execute("transparency.setOpacityMask", &json!({"id": a.0, "clip": false})).unwrap();
s.execute("transparency.disableOpacityMask", &json!({"ids": [a.0]})).unwrap();
let m = mask(&s, a).unwrap();
assert!(!m.clip && m.invert && m.disabled);
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 25})).unwrap();
assert!((doc(&s).node(a).unwrap().opacity - 0.25).abs() < 1e-6);
assert!(s.execute("transparency.set", &json!({"opacity": 25})).is_err(), "no selection, no ids");
let layer = s.execute("transparency.editOpacityMask", &json!({"id": a.0})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(doc(&s).mask_edit.unwrap().layer, NodeId(layer));
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap();
assert!(mask(&s, a).is_none());
let err = s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap_err();
assert!(err.to_string().contains("no target object has an opacity mask"), "{err}");
}
#[test]
fn opacity_is_a_percentage_everywhere() {
let mut s = session();
let a = rect(&mut s, 0.0, 100.0);
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 1})).unwrap();
let fill_opacity = |s: &Session| match &doc(s).node(a).unwrap().appearance.items[0] {
vectorcraft_doc::AppearanceItem::Fill(f) => f.opacity,
vectorcraft_doc::AppearanceItem::Stroke(st) => st.opacity,
};
assert!((fill_opacity(&s) - 0.01).abs() < 1e-6);
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 100})).unwrap();
assert_eq!(fill_opacity(&s), 1.0);
s.execute("object.setProps", &json!({"opacity": 1})).unwrap();
assert!((doc(&s).node(a).unwrap().opacity - 0.01).abs() < 1e-6);
s.execute("object.setProps", &json!({"opacity": 150})).unwrap();
assert_eq!(doc(&s).node(a).unwrap().opacity, 1.0);
s.execute("transparency.set", &json!({"opacity": 0.5})).unwrap();
assert!((doc(&s).node(a).unwrap().opacity - 0.005).abs() < 1e-6);
}
#[test]
fn info_reports_mixed_values_as_null() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 120.0, 100.0));
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 50, "isolate": true})).unwrap();
s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let info = s.execute("transparency.info", &json!({})).unwrap();
assert_eq!(info, json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": false, "editingMask": null}));
let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap();
assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true)));
let i = s.transparency_info();
assert_eq!((i.opacity, i.blend, i.knockout), (None, Some(vectorcraft_color::BlendMode::Screen), Some(false)));
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["opacity"], Value::Null);
// Many targets are gathered in one walk of the tree.
let ids: Vec<u64> = (0..12).map(|i| rect(&mut s, i as f64 * 20.0, 10.0).0).collect();
s.execute("transparency.set", &json!({"ids": ids, "opacity": 30})).unwrap();
let many = s.execute("transparency.info", &json!({"ids": ids})).unwrap();
assert_eq!(many["opacity"], json!(30.0));
}
#[test]
fn saving_while_editing_writes_no_editing_layer() {
let mut s = session();
let obj = editing_mask(&mut s);
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
let bytes = data(&s.execute("document.save", &json!({})).unwrap());
let back = vectorcraft_format::load(&bytes).unwrap();
assert!(back.mask_edit.is_none() && !has_edit_layer(&back));
assert_eq!(back.layers.len(), 1);
// The edit made while editing is in the saved mask.
assert_eq!(back.node(obj).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0);
// Still editing in the session.
assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s)));
// A file saved mid-edit by an older version (editing record and layer written) opens clean.
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(doc(&s), false)).unwrap();
let full = serde_json::to_value(doc(&s)).unwrap();
v["document"]["layers"] = full["layers"].clone();
v["document"]["mask_edit"] = json!({"object": obj.0, "layer": doc(&s).mask_edit.unwrap().layer.0});
let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert!(old.mask_edit.is_none() && !has_edit_layer(&old) && old.layers.len() == 1);
}
#[test]
fn exports_while_editing_leave_the_editing_layer_out() {
let mut s = session();
editing_mask(&mut s);
let svg = |s: &mut Session| s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
let pdf = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "pdf"})).unwrap());
let png = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "png"})).unwrap());
let (svg_editing, pdf_editing, png_editing) = (svg(&mut s), pdf(&mut s), png(&mut s));
assert!(!svg_editing.contains("Opacity Mask Editing") && !svg_editing.contains("Opacity_Mask_Editing"));
// Before the fix the editing layer's art was written too: the PDF would differ from the one
// the document gives without it (same length: only the creation date may differ).
let raw = crate::export_pdf(doc(&s), &Default::default()).unwrap();
assert_ne!(raw.len(), pdf_editing.len(), "the editing layer would add content");
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert_eq!(svg(&mut s), svg_editing);
assert_eq!(pdf(&mut s).len(), pdf_editing.len());
assert_eq!(png(&mut s), png_editing);
}

View File

@ -162,7 +162,7 @@ fn eyedropper_copies_appearance_to_selection_and_defaults() {
let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("object.setProps", &json!({"opacity": 0.5})).unwrap();
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.default", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();

View File

@ -59,14 +59,16 @@ struct File {
images: BTreeMap<String, Image>,
}
/// Serialize a document (pretty = human-diffable).
/// Serialize a document (pretty = human-diffable). Editing-mode working copies are left out
/// ([`Document::without_edit_modes`]).
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
let doc = doc.without_edit_modes();
let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect();
let f = File {
format: "vectorcraft".into(),
version: VERSION,
generator: format!("VectorCraft {}", env!("CARGO_PKG_VERSION")),
document: doc.clone(),
document: doc.into_owned(),
images,
};
if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() }
@ -95,6 +97,8 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?;
doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) });
}
// Saved mid-edit by an older version: drop the opacity-mask editing layer.
doc.drop_edit_modes();
doc.fix_next_id();
Ok(doc)
}

View File

@ -134,7 +134,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& !sel.is_empty()
{
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
ui.separator();
if sel.is_empty() {

View File

@ -299,7 +299,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.label(egui::RichText::new("Opacity").size(12.0));
ui.add_space(8.0);
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Transparency", false, 22.0).clicked() {
app.ui.open_panel = Some("transparency".into());

View File

@ -5,34 +5,55 @@
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::json;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Node, NodeId, OpacityMask};
use vectorcraft_engine::cmd::TransparencyInfo;
use super::{current_paints, first_selected, live_run, pstate, selection_len, set_pstate};
use super::{current_paints, live_run, pstate, selection_len, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, Live, menu_item};
use crate::{VectorcraftApp, icons};
/// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target
/// (the selection, or the object whose mask is being edited), recomputed only when the document
/// or the selection changes.
fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option<Node>) {
type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>);
let Some(st) = app.session.active() else { return Default::default() };
let sel = &st.selection.objects;
let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied());
let id = egui::Id::new("tr-state");
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option<Node>))>(id))
&& k == key
{
return v;
}
let info = app.session.transparency_info();
let first = info.ids.first().and_then(|id| st.doc.node(*id)).cloned();
let v = (info, first);
ctx.data_mut(|d| d.insert_temp(id, (key, v.clone())));
v
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// While editing a mask the panel shows the masked object (the selection is its mask art).
let editing = app.session.active().and_then(|d| d.doc.mask_edit);
let n = match editing {
Some(me) => app.session.active().and_then(|d| d.doc.node(me.object).cloned()),
None => first_selected(app),
};
let (info, n) = state(app, ui.ctx());
let has = n.is_some();
let (blend, op, isolate, knockout) =
n.as_ref().map(|n| (n.blend, n.opacity, n.isolate, n.knockout)).unwrap_or((BlendMode::Normal, 1.0, false, false));
// Values the targets don't share show blank; with no target, the defaults for new art.
let (blend, op) = if has { (info.blend.map_or("", BlendMode::label), info.opacity) } else { (BlendMode::Normal.label(), Some(100.0)) };
let (isolate, knockout) = (info.isolate.unwrap_or(false), info.knockout.unwrap_or(false));
ui.horizontal(|ui| {
let labels: Vec<&str> = BlendMode::ALL.iter().map(|b| b.label()).collect();
ui.add_enabled_ui(has, |ui| {
if let Some(i) = widgets::dropdown(ui, "tr-blend", blend.label(), &labels, 104.0) {
if let Some(i) = widgets::dropdown(ui, "tr-blend", blend, &labels, 104.0) {
app.run("transparency.set", json!({"blend": labels[i]})).ok();
}
});
widgets::dim_label(ui, "Opacity:");
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_enabled_ui(has, |ui| {
if let Some(o) = widgets::plain_field(ui, "tr-op", op as f64 * 100.0, "%", 0, 50.0) {
if let Some(o) = widgets::mixed_field(ui, "tr-op", op, "%", 0, 50.0) {
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
});
@ -40,7 +61,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if has { t.icon } else { t.text_disabled });
egui::Popup::menu(&resp).show(|ui| {
let mut o = op * 100.0;
let mut o = op.unwrap_or(100.0);
let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false));
let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) {
Live::Released
@ -55,7 +76,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::divider(ui);
// Thumbnails and the opacity-mask controls.
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");
let mask = n.as_ref().and_then(|n| n.mask.as_deref().cloned());
let mask = n.as_ref().and_then(|n| n.mask.as_deref());
let (new_clip, new_invert) = app.session.new_mask_defaults();
ui.horizontal(|ui| {
if !hide_thumbs {
@ -81,8 +102,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
}
match &mask {
Some(m) => {
match (mask, n.as_ref().map(|n| n.id)) {
(Some(m), Some(id)) => {
// Link toggle between the object and its mask.
let (lr, lresp) = ui.allocate_exact_size(vec2(14.0, 50.0), Sense::click());
icons::paint(
@ -114,11 +135,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if shift {
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok();
} else if editing.is_none() {
app.run("transparency.editOpacityMask", json!({})).ok();
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
}
}
}
None => {
_ => {
let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled);
@ -126,15 +147,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.vertical(|ui| {
let can_make = selection_len(app) >= 2;
let label = if mask.is_some() { "Release" } else { "Make Mask" };
let enabled = mask.is_some() || can_make;
let enabled = mask.is_some() || (has && editing.is_none());
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
if r.on_disabled_hover_text("Select the art and, on top of it, the mask object").clicked() {
if r.on_disabled_hover_text("Select the art (and, on top of it, the mask object)").clicked() {
let id = if mask.is_some() { "transparency.releaseOpacityMask" } else { "transparency.makeOpacityMask" };
app.run(id, json!({})).ok();
}
let (clip, invert) = mask.as_ref().map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
let (clip, invert) = mask.map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
if widgets::check(ui, "Clip", clip, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"clip": !clip})).ok();
}
@ -168,9 +188,32 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "tr-hide-options", !hide_opts);
}
ui.separator();
let mask = first_selected(app).and_then(|n| n.mask.as_deref().cloned());
let can_make = selection_len(app) >= 2 && mask.is_none();
let items: [(&str, &str, bool); 4] = match &mask {
// The masked object's mask, also while editing it (the selection then is the mask art).
let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some());
let (_, n) = state(app, ui.ctx());
let mask = n.as_ref().and_then(|n| n.mask.as_deref());
for (label, id, enabled) in mask_items(mask, n.is_some() && !editing) {
if menu_item(ui, label, enabled, false) {
app.run(id, json!({})).ok();
}
}
ui.separator();
let (clip, invert) = app.session.new_mask_defaults();
if menu_item(ui, "New Opacity Masks Are Clipping", true, clip) {
app.run("transparency.toggleNewMasksClipping", json!({})).ok();
}
if menu_item(ui, "New Opacity Masks Are Inverted", true, invert) {
app.run("transparency.toggleNewMasksInverted", json!({})).ok();
}
ui.separator();
menu_item(ui, "Page Isolated Blending", false, false);
menu_item(ui, "Page Knockout Group", false, false);
}
/// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a
/// target without one, outside mask editing (`can_make`).
fn mask_items(mask: Option<&OpacityMask>, can_make: bool) -> [(&'static str, &'static str, bool); 4] {
match mask {
Some(m) => [
("Make Opacity Mask", "transparency.makeOpacityMask", false),
("Release Opacity Mask", "transparency.releaseOpacityMask", true),
@ -191,23 +234,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
("Disable Opacity Mask", "", false),
("Unlink Opacity Mask", "", false),
],
};
for (label, id, enabled) in items {
if menu_item(ui, label, enabled, false) {
app.run(id, json!({})).ok();
}
}
ui.separator();
let (clip, invert) = app.session.new_mask_defaults();
if menu_item(ui, "New Opacity Masks Are Clipping", true, clip) {
app.run("transparency.toggleNewMasksClipping", json!({})).ok();
}
if menu_item(ui, "New Opacity Masks Are Inverted", true, invert) {
app.run("transparency.toggleNewMasksInverted", json!({})).ok();
}
ui.separator();
menu_item(ui, "Page Isolated Blending", false, false);
menu_item(ui, "Page Knockout Group", false, false);
}
/// A rendered thumbnail of `n` (which may live outside the tree, like mask art). One texture per
@ -242,14 +269,80 @@ mod tests {
use super::*;
use vectorcraft_engine::Session;
/// Run the panel and its ≡ menu for one headless frame.
fn frame(app: &mut VectorcraftApp) {
/// Run the panel and its menu for one headless frame: the texts drawn.
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(app, ui);
menu(app, ui);
});
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
fn run(app: &mut VectorcraftApp, id: &str, p: serde_json::Value) -> serde_json::Value {
app.session.execute(id, &p).unwrap()
}
#[test]
fn panel_in_mask_editing_mode_shows_and_edits_the_masked_object() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
run(&mut app, "transparency.set", json!({"opacity": 60}));
// One object: an empty mask, drawn in editing mode.
run(&mut app, "transparency.makeOpacityMask", json!({}));
run(&mut app, "shape.ellipse", json!({"x": 0, "y": 0, "width": 30, "height": 30}));
let texts = frame(&mut app);
assert!(texts.iter().any(|t| t == "60%"), "the masked object's opacity, not the mask art's: {texts:?}");
assert!(texts.iter().any(|t| t == "Release"));
let (info, n) = state(&app, &egui::Context::default());
assert_eq!((info.ids, n.as_ref().map(|n| n.id.0)), (vec![vectorcraft_doc::NodeId(a)], Some(a)));
// The menu is enabled for the masked object while editing (Make is not).
let items = mask_items(n.as_ref().and_then(|n| n.mask.as_deref()), false);
assert_eq!(items.map(|(_, _, on)| on), [false, true, true, true]);
// The panel's controls run without ids and reach the masked object.
run(&mut app, "transparency.setOpacityMask", json!({"clip": false}));
run(&mut app, "transparency.unlinkOpacityMask", json!({}));
run(&mut app, "transparency.set", json!({"blend": "Multiply"}));
frame(&mut app);
let st = app.session.active().unwrap();
let obj = st.doc.node(vectorcraft_doc::NodeId(a)).unwrap();
let m = obj.mask.as_ref().unwrap();
assert!(!m.clip && !m.linked && obj.blend == BlendMode::Multiply);
assert!(st.doc.mask_edit.is_some());
// Release leaves editing and puts the drawn mask art back.
run(&mut app, "transparency.releaseOpacityMask", json!({}));
assert!(frame(&mut app).iter().any(|t| t == "Make Mask"));
assert!(app.session.active().unwrap().doc.mask_edit.is_none());
}
#[test]
fn mixed_values_show_blank() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
run(&mut app, "transparency.set", json!({"opacity": 50, "blend": "Screen"}));
let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
let texts = frame(&mut app);
assert!(!texts.iter().any(|t| t.ends_with('%') || t == "Screen" || t == "Normal"), "{texts:?}");
run(&mut app, "select.set", json!({"ids": [a]}));
let texts = frame(&mut app);
assert!(texts.iter().any(|t| t == "50%") && texts.iter().any(|t| t == "Screen"), "{texts:?}");
// Nothing selected: the defaults for new art, disabled.
run(&mut app, "select.none", json!({}));
let texts = frame(&mut app);
assert!(texts.iter().any(|t| t == "100%") && texts.iter().any(|t| t == "No Selection"), "{texts:?}");
}
#[test]

View File

@ -132,13 +132,27 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
/// A plain number field (percent, degrees, counts) with optional suffix.
pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
mixed_field(ui, id, Some(value), suffix, decimals, width)
}
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
pub fn mixed_field(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
value: Option<f64>,
suffix: &str,
decimals: usize,
width: f32,
) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let id = ui.id().with(id);
let shown = {
let s = format!("{:.*}", decimals, value);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
format!("{s}{suffix}")
};
let shown = value
.map(|value| {
let s = format!("{:.*}", decimals, value);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
format!("{s}{suffix}")
})
.unwrap_or_default();
let editing = ui.memory(|m| m.has_focus(id));
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
let resp = ui

View File

@ -133,3 +133,18 @@ vectorcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`,
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
(`vectorcraft-cli --help` lists them).
## Transparency and opacity masks
Transparency and opacity-mask commands take `ids` (or `id`), so they need no selection. Opacity is a percentage
(0..100) in every command (`transparency.set`, `object.setProps`, `appearance.setItem`). `transparency.info` returns
the Transparency panel's values, with `null` where the objects differ. Making a mask from one object gives it an
empty mask and enters mask editing: art drawn then becomes the mask, and the mask commands act on the masked object
until `transparency.stopEditingOpacityMask`. Saves and exports never include the editing layer.
```json
{"name":"run_command","arguments":{"command":"transparency.set","params":{"ids":[12],"opacity":40,"blend":"Multiply"}}}
{"name":"run_command","arguments":{"command":"transparency.makeOpacityMask","params":{"ids":[12,15],"invert":true}}}
{"name":"run_command","arguments":{"command":"transparency.setOpacityMask","params":{"id":12,"clip":false}}}
{"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}}
```