diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d4b9cc0..357c19b2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -321,8 +321,9 @@ pub struct Document { /// Pattern editing mode, while active. #[serde(default, skip_serializing_if = "Option::is_none")] pub pattern_edit: Option, - /// 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, 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 { use vectorcraft_color::{Color, Paint}; vec![ diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 10f24bad..9bfddc7a 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -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 { "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 { *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; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 885ce498..4f269465 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -119,6 +119,7 @@ fn undo(s: &mut Session, _: &Value) -> Result { 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 { }); 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 { 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 { }); st.revision += 1; st.selection.prune(&st.doc); + super::maskedit::follow_history(st, editing); Ok(json!({ "redone": label })) } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index a2ee6947..54a45170 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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> { 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), diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 7963c685..2cf044e8 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -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 { vec![ cmd!( @@ -22,7 +27,7 @@ pub fn specs() -> Vec { "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 { ] } +/// 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>) -> 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) -> Vec> { + match art.children() { + Some(ch) if is_mask_group(art) => ch.clone(), + _ => vec![art.clone()], + } +} + fn enter(s: &mut Session, p: &Value) -> Result { 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 { + 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 { 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) -> 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) { + 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()); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 11231ac4..a8b14b53 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -342,3 +342,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result Vec { "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 { fn set_props(s: &mut Session, p: &Value) -> Result { 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, diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 072b3bad..d86208fc 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -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 { "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 { "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 { "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 { "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 { 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> { + 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> { - 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> { + let d = &s.doc()?.doc; + let ids: Vec = 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 { - 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 { 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 { - let ids = masked_ids(s)?; +fn release(s: &mut Session, p: &Value) -> Result { + 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 { - 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 { 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 { 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, + /// Opacity in percent (0..100), to two decimals. + pub opacity: Option, + pub blend: Option, + pub isolate: Option, + pub knockout: Option, +} + +impl TransparencyInfo { + fn of(d: &Document, ids: Vec) -> Self { + let nodes = nodes_of(d, &ids); + fn same(nodes: &[&Node], f: impl Fn(&Node) -> T) -> Option { + 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 = 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 { +fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let v: Vec = 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::>(), + "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 { + let d = &s.doc()?.doc; + let v: Vec = 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(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 712d0af5..ac23f3b7 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -34,8 +34,8 @@ pub fn specs() -> Vec { "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 { } fn transparency(s: &mut Session, p: &Value) -> Result { - 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::>()); s.execute("object.setProps", &q) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..103418b1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs new file mode 100644 index 00000000..b45b5355 --- /dev/null +++ b/crates/engine/src/tests_opacitymask.rs @@ -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 { + 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 = (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); +} diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 4148e65c..705070bc 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -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(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index df2a66a9..e0120fb4 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -59,14 +59,16 @@ struct File { images: BTreeMap, } -/// 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 { + 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 { 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) } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1ce16d9e..c9d59917 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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() { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index abf79623..1158167e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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()); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 63f24b6c..bdd70fb9 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -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) { + type Key = (u64, u64, usize, Option, Option); + 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))>(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 { + fn texts(s: &egui::Shape, out: &mut Vec) { + 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] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 968b6e9b..e598e68c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 { + 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, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { 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::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; let resp = ui diff --git a/docs/mcp.md b/docs/mcp.md index 50b51256..f3b6930c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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]}}} +```