From b117566fc2a27d92bde412d61b6544a1d0e5a4f2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:10:21 +0700 Subject: [PATCH 1/4] 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 --- crates/doc/src/lib.rs | 26 ++- crates/engine/src/cmd/appearance.rs | 5 +- crates/engine/src/cmd/edit.rs | 4 + crates/engine/src/cmd/fileio/encode.rs | 4 +- crates/engine/src/cmd/maskedit.rs | 149 ++++++++++---- crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/object.rs | 5 +- crates/engine/src/cmd/opacitymask.rs | 231 +++++++++++++++++----- crates/engine/src/cmd/paint.rs | 12 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_opacitymask.rs | 231 ++++++++++++++++++++++ crates/engine/src/tests_xform.rs | 2 +- crates/format/src/lib.rs | 8 +- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/transparency.rs | 173 ++++++++++++---- crates/ui-egui/src/widgets.rs | 24 ++- docs/mcp.md | 15 ++ 18 files changed, 745 insertions(+), 153 deletions(-) create mode 100644 crates/engine/src/tests_opacitymask.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d4b9cc07..357c19b2c 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 10f24bad0..9bfddc7ae 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 885ce4987..4f269465a 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 a2ee69471..54a451700 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 7963c685c..2cf044e85 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 11231ac49..a8b14b53a 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 072b3bad6..d86208fcf 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 712d0af53..ac23f3b71 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 020464db8..103418b12 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 000000000..b45b53558 --- /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 4148e65ce..705070bc2 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 df2a66a94..e0120fb4d 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 1ce16d9e5..c9d599174 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 abf796230..1158167e3 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 63f24b6c3..bdd70fb97 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 968b6e9bc..e598e68c8 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 50b51256c..f3b6930c2 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]}}} +``` From 4f685be57a27c1611296d666f71c812b668ebd6f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:23:57 +0700 Subject: [PATCH 2/4] M3.26: clipping sets with any clip shape Clip groups clip by compound paths (with their holes), even-odd paths, text outlines and groups (the union of their members) on screen, in raster export, in SVG and in PDF alike, through one shared region: Node::clip_outline (the model walk) and effects::clip_outline (text outlined, several shapes united), cached per clipping path in the renderer. Nothing to clip by hides the clipped art everywhere, and clicks hit a clip group only inside its clipping region. Make Clipping Mask takes a compound path or text object on top (it loses its paint) and Release gives it back unchanged in kind. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/doc/src/hit.rs | 13 ++- crates/doc/src/node.rs | 70 ++++++++++++- crates/effects/Cargo.toml | 1 + crates/effects/src/clip.rs | 26 +++++ crates/effects/src/lib.rs | 2 + crates/engine/src/cmd/object.rs | 35 +++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_clip.rs | 172 ++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 33 ++---- crates/render/src/lib.rs | 55 +++++++--- crates/render/src/tests_clip.rs | 106 ++++++++++++++++++++ crates/svg/src/export.rs | 33 +----- docs/mcp.md | 12 +++ xtask/src/layers.rs | 2 +- 15 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 crates/effects/src/clip.rs create mode 100644 crates/engine/src/tests_clip.rs create mode 100644 crates/render/src/tests_clip.rs diff --git a/Cargo.lock b/Cargo.lock index 473150b02..3e0705efb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4122,6 +4122,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-geom", "vectorcraft-pathops", + "vectorcraft-text", ] [[package]] diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 144fd8195..4e1e2c541 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -76,13 +76,22 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { None } +/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame: +/// glyph outlines need the font engine, above this crate. +fn clip_contains(clip: &Node, p: Point) -> bool { + let frame = |n: &Node| { + let b = n.geometric_bounds()?; + Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default())) + }; + clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) +} + fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec) -> Option { let children = parent.children()?; // A clip group only hits inside its clipping path. if let NodeKind::Group { clip: true, .. } = parent.kind && let Some(clip) = children.first() - && let Some(path) = clip.path_data() - && !fill_contains(&path.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p) + && !clip_contains(clip, p) { return None; } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929b..4665da16d 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -4,10 +4,10 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_color::BlendMode; -use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; use crate::appearance::Appearance; -use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; +use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner}; use crate::pattern::RepeatSpec; use crate::text::TextObject; @@ -518,6 +518,72 @@ impl Node { } } +/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans +/// live above this crate (`vectorcraft-pathops`), so callers supply it. +pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath; + +impl Node { + /// The filled regions this object clips to as the clipping path of a clip group, in document + /// space, each with its fill rule: a path, a compound path (with its holes), an image's frame, + /// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible + /// members of a group (their union) and the clipping path of a nested clip group. Live objects + /// are evaluated first. Guides and symbol instances add nothing. + pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> { + let mut out = vec![]; + self.push_clip_shapes(text, &mut out); + out + } + + fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { + match &self.kind { + NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + out.push((bp, *rule)); + } + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + c.push_clip_shapes(text, out); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children.iter().filter(|c| c.visible) { + c.push_clip_shapes(text, out); + } + } + NodeKind::Text(_) => { + if let Some(o) = text.and_then(|f| f(self)) { + o.push_clip_shapes(None, out); + } + } + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + crate::live::expand_deep(self, text).push_clip_shapes(text, out); + } + } + } + + /// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared + /// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its + /// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to + /// clip by: the clipped art is then hidden. + pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> { + let mut shapes = self.clip_shapes(text); + match shapes.len() { + 0 => None, + 1 => shapes.pop(), + _ => Some((unite(&shapes), FillRule::NonZero)), + } + } +} + fn transform_paints(ap: &mut Appearance, a: Affine) { use crate::appearance::AppearanceItem; use vectorcraft_color::Paint; diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 4ab1efaf3..b50e797b3 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -12,6 +12,7 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } +vectorcraft-text = { workspace = true } kurbo = { workspace = true } serde_json = { workspace = true } diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs new file mode 100644 index 000000000..444e814d8 --- /dev/null +++ b/crates/effects/src/clip.rs @@ -0,0 +1,26 @@ +//! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine +//! and several shapes united, so the renderer and the SVG and PDF writers clip alike. + +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{BezPath, FillRule, PathData}; + +/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their +/// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of +/// its members. `None` when there is nothing to clip by (the clipped art is then hidden). +pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { + clip.clip_outline(Some(&outline_text), &unite) +} + +/// Text as one path of its glyph outlines, in document space. +fn outline_text(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let mut bp = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).to_bezpath(); + bp.apply_affine(t.xf); + Some(Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default())) +} + +fn unite(shapes: &[(BezPath, FillRule)]) -> BezPath { + let paths: Vec<(PathData, FillRule)> = shapes.iter().map(|(bp, r)| (PathData::from_bezpath(bp), *r)).collect(); + let refs: Vec<(&PathData, FillRule)> = paths.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::unite_all(&refs).to_bezpath() +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 17c4911f6..612e90549 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] mod bake; +mod clip; mod distort; mod group; mod raster; @@ -31,6 +32,7 @@ use vectorcraft_doc::Effect; use vectorcraft_geom::{BezPath, PathData, Rect}; pub use bake::{bake_document, needs_bake}; +pub use clip::clip_outline; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use raster::{RasterFx, outset, raster_effects}; pub use warp::{WarpStyle, warp_point}; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 9087af1e3..cf7280b25 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -88,8 +88,24 @@ pub fn specs() -> Vec { cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all), cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make), cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release), - cmd!("object.clippingMask.make", "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), "{}", has_multi, clip_make), - cmd!("object.clippingMask.release", "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), "{}", has_selection, clip_release), + cmd!( + "object.clippingMask.make", + "Make", + ["Object", "Clipping Mask"], + Some("Cmd+7"), + "{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn → {id} of the clip group", + has_multi, + clip_make + ), + cmd!( + "object.clippingMask.release", + "Release", + ["Object", "Clipping Mask"], + Some("Cmd+Alt+7"), + "{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, unpainted", + has_selection, + clip_release + ), cmd!( "object.clippingMask.editContents", "Edit Contents", @@ -520,8 +536,8 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let top = *ids.last().unwrap(); let gid = s.edit("Make Clipping Mask", |d, sel| { - if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { .. })) { - return Err(EngineError::Other("the top object must be a path to use as a clipping mask".into())); + if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_))) { + return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into())); } let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let gid = d.alloc_id(); @@ -531,9 +547,16 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { d.move_node(*id, Some(gid), usize::MAX)?; } if let Some(c) = d.node_mut(top) { + // The clipping path loses its paint (it stays unpainted after Release). c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); - if let NodeKind::Path { clipping, .. } = &mut c.kind { - *clipping = true; + match &mut c.kind { + NodeKind::Path { clipping, .. } => *clipping = true, + NodeKind::Text(t) => { + for r in &mut t.runs { + (r.style.fill, r.style.stroke) = (Paint::None, Paint::None); + } + } + _ => {} } } sel.set([gid]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 103418b12..10a34e7f1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -711,6 +711,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_clip; +#[cfg(test)] mod tests_cmdsplit; #[cfg(test)] mod tests_colormgmt; diff --git a/crates/engine/src/tests_clip.rs b/crates/engine/src/tests_clip.rs new file mode 100644 index 000000000..a581aa31b --- /dev/null +++ b/crates/engine/src/tests_clip.rs @@ -0,0 +1,172 @@ +//! Clipping sets with any clip shape (M3.26): Make Clipping Mask takes a compound path or text on +//! top and Release gives it back; compound holes, even-odd fills, text outlines and imported +//! multi-shape clip paths clip on screen, and the SVG and PDF output clip the same way. + +use serde_json::{Value, json}; +use vectorcraft_render::Rendered; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ellipse(s: &mut Session, x: f64, w: f64) -> NodeId { + id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": x, "width": w, "height": w})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +/// The active document's 100×100 artboard, rendered transparent. +fn render(s: &Session) -> Rendered { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, Affine::IDENTITY, &Default::default()) +} + +fn opaque(r: &Rendered, x: u32, y: u32) -> bool { + r.pixel(x, y)[3] > 128 +} + +/// The document exported as `format` and opened again, rendered. +fn reopened(s: &mut Session, format: &str) -> Rendered { + let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": format!("clip.{format}"), "dataBase64": data})).unwrap(); + render(&o) +} + +/// The same probes are opaque on screen and in the SVG and PDF output. +fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) { + let screen = render(s); + for format in ["svg", "pdf"] { + let out = reopened(s, format); + for &(x, y) in probes { + assert_eq!(opaque(&out, x, y), opaque(&screen, x, y), "{format} at ({x}, {y})"); + } + } +} + +/// The leaf a click at (`x`, `y`) hits. +fn hit(s: &Session, x: f64, y: f64) -> Option { + vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf) +} + +/// Art under the whole artboard, then a donut (compound path with a real hole) on top. +fn art_and_donut(s: &mut Session) -> (NodeId, NodeId) { + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let (outer, inner) = (ellipse(s, 10.0, 80.0), ellipse(s, 35.0, 30.0)); + select(s, &[inner]); + s.execute("path.reverse", &json!({})).unwrap(); + select(s, &[outer, inner]); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + let donut = s.doc().unwrap().selection.objects[0]; + (art, donut) +} + +#[test] +fn compound_clip_keeps_its_hole_and_release_gives_the_compound_back() { + let mut s = session(); + let (art, donut) = art_and_donut(&mut s); + select(&mut s, &[art, donut]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let d = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: true } = &d.node(g).unwrap().kind else { panic!("a clip group") }; + assert_eq!(children[0].id, donut); + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].appearance.fill_paint().is_none()); + let r = render(&s); + assert!(opaque(&r, 20, 50), "the ring shows the art"); + assert!(!opaque(&r, 50, 50), "the hole stays transparent"); + assert!(!opaque(&r, 3, 3), "outside the ring"); + assert_outputs_agree(&mut s, &[(20, 50), (50, 50), (3, 3), (80, 50)]); + // Clicks hit the art only where it shows. + assert_eq!(hit(&s, 20.0, 50.0), Some(art)); + assert_eq!(hit(&s, 50.0, 50.0), None, "the hole"); + // Release: a plain group, the compound path back on top of the art, unpainted. + select(&mut s, &[g]); + s.execute("object.clippingMask.release", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: false } = &d.node(g).unwrap().kind else { panic!("a plain group") }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].id == donut); + assert!(opaque(&render(&s), 50, 50), "nothing clips any more"); +} + +#[test] +fn text_on_top_clips_by_its_glyphs() { + let mut s = session(); + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id_of(&s.execute("text.create", &json!({"x": 5, "y": 80, "text": "O", "size": 90})).unwrap()); + let b = s.doc().unwrap().doc.node(t).unwrap().geometric_bounds().unwrap(); + select(&mut s, &[art, t]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let d = &s.doc().unwrap().doc; + let NodeKind::Text(text) = &d.node(t).unwrap().kind else { panic!("still text") }; + assert!(text.runs.iter().all(|r| r.style.fill.is_none() && r.style.stroke.is_none()), "the clipping path loses its paint"); + // Across the middle of the "O": clear, ring, the counter (clear), ring, clear. + let cy = ((b.y0 + b.y1) / 2.0) as u32; + let r = render(&s); + let row: Vec = (0..100).map(|x| opaque(&r, x, cy)).collect(); + assert_eq!(row.windows(2).filter(|w| w[0] != w[1]).count(), 4, "{row:?}"); + let probes: Vec<(u32, u32)> = (0..100).step_by(3).map(|x| (x, cy)).collect(); + assert_outputs_agree(&mut s, &probes); + // Text clips clicks by its frame. + assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art)); + assert!(b.x1 < 95.0, "{b:?}"); + assert_eq!(hit(&s, b.x1 + 3.0, (b.y0 + b.y1) / 2.0), None, "beside the text"); + // Release gives the text back. + select(&mut s, &[g]); + s.execute("object.clippingMask.release", &json!({})).unwrap(); + assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); +} + +#[test] +fn make_refuses_a_top_object_that_cannot_clip() { + let mut s = session(); + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let (a, b) = (ellipse(&mut s, 10.0, 20.0), ellipse(&mut s, 50.0, 20.0)); + select(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + let group = s.doc().unwrap().selection.objects[0]; + select(&mut s, &[art, group]); + let err = s.execute("object.clippingMask.make", &json!({})).unwrap_err(); + assert!(err.to_string().contains("path, compound path or text"), "{err}"); +} + +/// An SVG clip path made of several shapes (imported as one multi-subpath clipping path) and an +/// even-odd star clip path. +const SVG: &str = r##" + + + + + + +"##; + +#[test] +fn imported_multi_shape_and_even_odd_clip_paths_clip() { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "clips.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap(); + let d = &s.doc().unwrap().doc; + let mut kinds = vec![]; + d.walk(|n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + kinds.push(children[0].kind_label()); + } + }); + assert_eq!(kinds.len(), 2, "two clip groups"); + let r = render(&s); + assert!(opaque(&r, 15, 20) && opaque(&r, 75, 20), "inside both shapes of the clip path"); + assert!(!opaque(&r, 45, 20), "between them"); + assert!(!opaque(&r, 15, 43), "below them"); + // The star's arms cross: the centre of the crossing is outside under even-odd. + assert!(opaque(&r, 70, 56), "an arm"); + assert!(!opaque(&r, 70, 75), "the even-odd centre"); + assert_outputs_agree(&mut s, &[(15, 20), (75, 20), (45, 20), (15, 43), (70, 56), (70, 75)]); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3e..ddf76ad18 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -394,32 +394,15 @@ impl Exporter<'_> { } } NodeKind::Group { children, clip: true } => { - let clip = children.first().and_then(|c| match &c.kind { - NodeKind::Path { path, rule, .. } => Some((path.to_bezpath(), *rule)), - NodeKind::Compound { children, rule } => { - let mut bp = BezPath::new(); - for ch in children { - if let Some(p) = ch.path_data() { - bp.extend(p.to_bezpath()); - } - } - Some((bp, *rule)) - } - _ => None, - }); - match clip.and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) { - Some((p, r)) => { - s.push_clip_path(&p, &rule(r)); - for c in children.iter().skip(1) { - self.node(s, c, page, false); - } - s.pop(); - } - None => { - for c in children { - self.node(s, c, page, false); - } + // The region every output clips to; nothing to clip by hides the clipped art. + if let Some((clip, rest)) = children.split_first() + && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) + { + s.push_clip_path(&p, &rule(r)); + for c in rest { + self.node(s, c, page, false); } + s.pop(); } } NodeKind::Path { path, rule, guide, .. } => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 10cfadc64..91448585c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -173,9 +173,14 @@ struct GeomEntry { stamp: u64, } +/// A clipping path (kept alive so its address can't be reused) and the region it clips to. +type ClipEntry = (Arc, Option>); + /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { texts: PtrMap, Arc)>, + /// Clip regions per clipping path (see [`Self::clip_of`]). + clips: PtrMap, /// Context reused when rendering single-threaded (`threads == 0`). ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). @@ -232,6 +237,7 @@ impl Renderer { pub fn new() -> Self { Self { texts: PtrMap::default(), + clips: PtrMap::default(), ctx_st: None, threads: default_threads(), geom: PtrMap::default(), @@ -390,6 +396,23 @@ impl Renderer { b } + /// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]): + /// outlining text and uniting shapes is too slow to repeat every frame. + fn clip_of(&mut self, a: &Arc) -> Option> { + let key = Arc::as_ptr(a) as usize; + if let Some((node, region)) = self.clips.get(&key) + && Arc::ptr_eq(node, a) + { + return region.clone(); + } + let region = effects::clip_outline(a).map(Arc::new); + if self.clips.len() > 1024 { + self.clips.clear(); + } + self.clips.insert(key, (a.clone(), region.clone())); + region + } + /// Cached BezPath of a path node. fn path_of(&mut self, a: &Arc) -> Option> { let key = Arc::as_ptr(a) as usize; @@ -525,23 +548,23 @@ impl Renderer { self.draw_arc(ctx, f, c); } } + NodeKind::Group { children, clip: true } if outline => { + for c in children { + self.draw_node(ctx, f, c, false); + } + } NodeKind::Group { children, clip: true } => { - let clip = children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath()); - match (clip, outline) { - (Some(clip), false) => { - ctx.set_transform(f.view); - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.push_clip_layer(&clip); - for c in children.iter().skip(1) { - self.draw_arc(ctx, f, c); - } - ctx.pop_layer(); - } - _ => { - for c in children { - self.draw_node(ctx, f, c, false); - } + // Nothing to clip by hides the clipped art (as in the SVG and PDF output). + if let Some((clip, rest)) = children.split_first() + && let Some(region) = self.clip_of(clip) + { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(region.1)); + ctx.push_clip_layer(®ion.0); + for c in rest { + self.draw_arc(ctx, f, c); } + ctx.pop_layer(); } } NodeKind::Path { path, rule, guide, .. } => { @@ -978,3 +1001,5 @@ fn now() -> u64 { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_clip; diff --git a/crates/render/src/tests_clip.rs b/crates/render/src/tests_clip.rs new file mode 100644 index 000000000..1f72f8ca9 --- /dev/null +++ b/crates/render/src/tests_clip.rs @@ -0,0 +1,106 @@ +//! Clip groups clip by any clipping path (M3.26): compound holes, even-odd fills, the union of a +//! group's members, text outlines; with nothing to clip by the clipped art is hidden. + +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle}; +use vectorcraft_geom::{PathData, Point, SubPath, shapes}; + +const BLACK: [u8; 4] = [0, 0, 0, 255]; +const CLEAR: [u8; 4] = [0, 0, 0, 0]; + +fn path(d: &mut Document, p: PathData, rule: FillRule) -> Arc { + let mut n = Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + Arc::new(n) +} + +fn rect(r: Rect) -> PathData { + shapes::rectangle(r) +} + +/// `clip` clipping a black square over the whole 100×100 canvas, rendered transparent. +fn clipped(mut d: Document, clip: Arc) -> Rendered { + let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, Arc::new(art)], clip: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()) +} + +#[test] +fn compound_clip_keeps_its_hole() { + let mut d = Document::new(100.0, 100.0); + let (outer, inner) = (rect(Rect::new(10.0, 10.0, 90.0, 90.0)), rect(Rect::new(35.0, 35.0, 65.0, 65.0))); + let children = vec![path(&mut d, outer, FillRule::NonZero), path(&mut d, inner, FillRule::NonZero)]; + let donut = Arc::new(Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd })); + let r = clipped(d, donut); + assert_eq!(r.pixel(20, 50), BLACK, "the ring"); + assert_eq!(r.pixel(50, 50), CLEAR, "the hole stays transparent"); + assert_eq!(r.pixel(5, 5), CLEAR); +} + +#[test] +fn even_odd_star_clips_with_an_empty_centre() { + let mut d = Document::new(100.0, 100.0); + // A pentagram drawn in one stroke: its centre pentagon is outside under even-odd. + let pts: Vec = (0..5) + .map(|k| { + let a = (k * 2 % 5) as f64 * std::f64::consts::TAU / 5.0 - std::f64::consts::FRAC_PI_2; + Point::new(50.0 + 48.0 * a.cos(), 52.0 + 48.0 * a.sin()) + }) + .collect(); + let star = path(&mut d, PathData::single(SubPath::polyline(&pts, true)), FillRule::EvenOdd); + let r = clipped(d, star); + assert_eq!(r.pixel(50, 52), CLEAR, "the centre is empty"); + assert_eq!(r.pixel(50, 12), BLACK, "a point"); +} + +#[test] +fn group_clip_is_the_union_of_its_members() { + let mut d = Document::new(100.0, 100.0); + // Opposite windings: a plain concatenation would cancel out where they overlap. + let mut b = rect(Rect::new(40.0, 0.0, 100.0, 50.0)); + b.reverse(); + let members = vec![path(&mut d, rect(Rect::new(0.0, 0.0, 60.0, 50.0)), FillRule::NonZero), path(&mut d, b, FillRule::NonZero)]; + let g = Arc::new(Node::group(d.alloc_id(), members)); + let r = clipped(d, g); + for x in [20, 50, 80] { + assert_eq!(r.pixel(x, 25), BLACK, "x = {x}"); + } + assert_eq!(r.pixel(50, 75), CLEAR); +} + +#[test] +fn text_clips_by_its_glyph_outlines() { + let mut d = Document::new(100.0, 100.0); + let t = vectorcraft_doc::TextObject::point(Point::new(5.0, 80.0), "O", CharStyle { size: 90.0, ..CharStyle::default() }); + let text = Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))); + let b = text.geometric_bounds().unwrap(); + let r = clipped(d, text); + let cy = ((b.y0 + b.y1) / 2.0) as u32; + // Across the middle of the "O": clear, ring, the counter (clear), ring, clear. + let row: Vec = (0..100).map(|x| r.pixel(x, cy)[3] > 128).collect(); + let runs = row.windows(2).filter(|w| w[0] != w[1]).count(); + assert_eq!(runs, 4, "two ring crossings with the counter between: {row:?}"); +} + +#[test] +fn nothing_to_clip_by_hides_the_art() { + let mut d = Document::new(100.0, 100.0); + let empty = Arc::new(Node::group(d.alloc_id(), vec![])); + let r = clipped(d, empty); + assert_eq!(r.pixel(50, 50), CLEAR); +} + +#[test] +fn clip_regions_are_cached_per_clipping_path() { + let mut d = Document::new(100.0, 100.0); + let clip = path(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 100.0)), FillRule::NonZero); + let mut r = Renderer::new(); + let first = r.clip_of(&clip).unwrap(); + assert!(Arc::ptr_eq(&first, &r.clip_of(&clip).unwrap())); + assert_eq!(first.1, FillRule::NonZero); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ba71ddca8..a2524c8d8 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -523,32 +523,6 @@ impl Writer<'_> { self.line(""); } - /// `(d, rule)` of every path in a clipping object. - fn clip_shapes(&self, n: &Node, out: &mut Vec<(String, FillRule)>) { - match &n.kind { - NodeKind::Path { path, rule, .. } => out.push((self.path_d(path, self.xf), *rule)), - NodeKind::Compound { children, rule } => { - let d: Vec = children.iter().filter_map(|c| c.path_data()).map(|p| self.path_d(p, self.xf)).collect(); - out.push((d.join(" "), *rule)); - } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children { - self.clip_shapes(c, out); - } - } - NodeKind::Image(im) => { - let r = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); - let p = vectorcraft_geom::shapes::rectangle(r).transformed(im.xf); - out.push((self.path_d(&p, self.xf), FillRule::NonZero)); - } - NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {} - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); - self.clip_shapes(&g, out); - } - } - } - /// An object with an opacity mask: `` around the unmasked object. The mask /// art goes in ``; no-clip adds a white backdrop and invert a colour-inverting filter. fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { @@ -615,11 +589,12 @@ impl Writer<'_> { } NodeKind::Group { children, clip: true } => { let Some((clip, rest)) = children.split_first() else { return }; - let mut shapes = Vec::new(); - self.clip_shapes(clip, &mut shapes); let cid = self.fresh_id("clip-path"); self.def(1, &format!("")); - for (d, rule) in shapes { + // The region every output clips to; with nothing to clip by, an empty clip path + // hides the clipped art. + if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { + let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; self.def(2, &format!("")); } diff --git a/docs/mcp.md b/docs/mcp.md index f3b6930c2..1a3587d64 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -148,3 +148,15 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the {"name":"run_command","arguments":{"command":"transparency.setOpacityMask","params":{"id":12,"clip":false}}} {"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}} ``` + +## Clipping masks + +`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a +text object (it loses its paint). Compound holes, even-odd fills, glyph outlines and the union of a group's members +clip alike on screen, in raster export and in SVG and PDF. `object.clippingMask.release` turns the clip group into a +plain group and keeps the clipping path, unpainted. + +```json +{"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}} +{"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 6ca42d1c2..5dc66dde8 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -61,7 +61,7 @@ pub const TABLE: &[(&str, Class)] = &[ /// The L0 foundation is a small chain: `raster` builds on `color` and /// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`) /// reuses the CPU reference (`compose`) for LUTs and parity tests. -pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"]]; +pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"]]; fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to)); From 745d50f76e2f8a295739c698686b45b811ad0a1c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:45:04 +0700 Subject: [PATCH 3/4] M3.25: appearance.setItem takes ids without a selection; undo/redo through mask editing tested Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/appearance.rs | 7 +++-- crates/engine/src/tests_opacitymask.rs | 37 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 9bfddc7ae..d4863d37f 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -22,8 +22,8 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{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, + "{index, ids?, opacity?: 0..100, blend?, visible?, weight?, color?|none?|swatch?|gradient?} edit one fill/stroke of the appearance stack of `ids` (default: the selection)", + has_doc, set_item ), cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item), @@ -98,6 +98,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize; let paint = paint_from(s, p)?; let ids = paint_targets(s, p)?; + if ids.is_empty() { + return Err(bad("appearance.setItem", "select objects or give ids")); + } let blend = str_param(p, "blend").and_then(BlendMode::parse); s.edit("Appearance", |d, _| { for id in &ids { diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index b45b53558..b2c5b661a 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -165,6 +165,11 @@ fn opacity_is_a_percentage_everywhere() { 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); + // Appearance items take ids without a selection too. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("appearance.setItem", &json!({"index": 0, "opacity": 30})).is_err(), "no selection, no ids"); + s.execute("appearance.setItem", &json!({"index": 0, "ids": [a.0], "opacity": 30})).unwrap(); + assert!((fill_opacity(&s) - 0.3).abs() < 1e-6); } #[test] @@ -229,3 +234,35 @@ fn exports_while_editing_leave_the_editing_layer_out() { assert_eq!(pdf(&mut s).len(), pdf_editing.len()); assert_eq!(png(&mut s), png_editing); } + +#[test] +fn undo_and_redo_walk_in_and_out_of_mask_editing() { + 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("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap(); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.mask_edit.map(|m| m.layer), st.isolation, st.active_layer == Some(layer), has_edit_layer(&st.doc)) + }; + let editing = (Some(layer), Some(layer), true, true); + assert_eq!(state(&s), (None, None, false, false)); + // Back into editing with the drawn art, then before it, then before the mask. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(state(&s), editing); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(state(&s), editing); + assert!(doc(&s).node(layer).unwrap().children().unwrap().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(state(&s), (None, None, false, false)); + assert!(mask(&s, obj).is_none()); + // And forward again. + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(state(&s), editing); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(state(&s), (None, None, false, false)); + assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0); +} From 984eb7f10c641aaf70c14c1fe1f1a0575990c633 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:45:04 +0700 Subject: [PATCH 4/4] M3.26: effects crate docs list the shared clip_outline helper Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 612e90549..db3ebd0e1 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -10,6 +10,8 @@ //! the geometry. //! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the //! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. +//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and +//! PDF writers. //! //! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash //! noise (`seed` parameter, default 0).