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/lib.rs b/crates/doc/src/lib.rs index 79f044ab8..b242ae03e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -325,8 +325,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. @@ -611,6 +612,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/doc/src/node.rs b/crates/doc/src/node.rs index 9ed084cf6..5e36727aa 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; @@ -569,6 +569,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)), + } + } +} + /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); 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 3268d9488..425d3167c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -12,12 +12,15 @@ //! renderer, the exporters and Outline Stroke. //! - [`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). #![forbid(unsafe_code)] mod bake; +mod clip; mod distort; mod group; mod raster; @@ -34,6 +37,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/appearance.rs b/crates/engine/src/cmd/appearance.rs index e3d041045..48ac07dc5 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -14,6 +14,7 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; use super::edit::selected_roots; +use super::opacitymask::percent; use super::paint::paint_from; use super::*; use crate::EngineError; @@ -61,8 +62,8 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, opacity?: 0..1 (values above 1 are percent), blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), ids?} edit one fill/stroke of each selected object's own appearance stack", - has_selection, + "{index: paint-order item index, ids? (default: the selection), opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack", + has_doc, set_item ), cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item), @@ -344,6 +345,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let idx = index_param(p, "index", "appearance.setItem")?; let paint = paint_from(s, p)?; let ids = appearance_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 { @@ -364,7 +368,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { *pp = super::gradient::place_paint(pa, p, bounds); } 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 8ccbb94a9..3de0d5f6a 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -90,15 +90,20 @@ const WEBP_SIDE: f64 = 16383.0; /// renderer's own size limits are [`vectorcraft_render::raster_size`]'s. fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> { if f.id == "webp" && (w.round() > WEBP_SIDE || h.round() > WEBP_SIDE) { - return Err(bad(C, format!("{}×{} px is too large for {} (at most {WEBP_SIDE} px a side): lower the scale", w.round(), h.round(), f.label))); + return Err(bad( + C, + format!("{} × {} pixels is too large for {} (at most {WEBP_SIDE} pixels a side): lower the scale", w.round(), h.round(), f.label), + )); } Ok(()) } /// 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 d58bdd374..c0c5ffac7 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 { 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", @@ -114,7 +131,7 @@ pub fn specs() -> 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 ), @@ -519,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(); @@ -530,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]); @@ -615,7 +639,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 4da442fa8..7ad06d129 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -38,8 +38,8 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null, ids?} opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", - has_selection, + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", + has_doc, transparency ), ] @@ -155,39 +155,40 @@ fn default_paint(s: &mut Session, _: &Value) -> Result { fn transparency(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.set"; - let item = item_target(s, p, C)?; + let ids = super::opacitymask::transparency_targets(s, p)?; + if ids.is_empty() { + return Err(bad(C, "select objects or give ids")); + } + // While an opacity mask is edited the selection is its art, so the Appearance panel's active + // item (a row of that art) doesn't stand for the masked object. + let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some(); + let item = if editing_mask && p.get("item").is_none() { ItemTarget::Top } else { item_target(s, p, C)? }; let mut q = p.as_object().cloned().unwrap_or_default(); q.remove("item"); - let opacity = q.remove("opacity").and_then(|v| v.as_f64()).map(|o| (o / 100.0).clamp(0.0, 1.0)); + q.remove("id"); + q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); let ItemTarget::Item { index, explicit } = item else { - if let Some(o) = opacity { - q.insert("opacity".into(), json!(o)); - } return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level. + let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? }; if explicit { let d = &s.doc()?.doc; - if !appearance_targets(s, p)?.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { + if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { return Err(bad(C, format!("no appearance item {index}"))); } } - let blend = q.remove("blend"); + let (opacity, blend) = (q.remove("opacity"), q.remove("blend")); if let Some(b) = &blend && b.as_str().and_then(BlendMode::parse).is_none() { return Err(bad(C, format!("unknown blend mode {b}"))); } if opacity.is_some() || blend.is_some() { - let mut set = json!({ "index": index, "opacity": opacity, "blend": blend }); - for key in ["ids", "id"] { - if let Some(v) = p.get(key) { - set[key] = v.clone(); - } - } - s.execute("appearance.setItem", &set)?; + let ids: Vec = item_ids.iter().map(|id| id.0).collect(); + s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?; } - if q.keys().all(|k| k == "ids" || k == "id") { + if q.keys().all(|k| k == "ids") { return ok(); } s.execute("object.setProps", &Value::Object(q)) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1e8b17bfe..ecdce7a66 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -723,6 +723,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_clip; +#[cfg(test)] mod tests_cmdsplit; #[cfg(test)] mod tests_colormgmt; @@ -739,6 +741,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_appearance.rs b/crates/engine/src/tests_appearance.rs index d55627a6a..c66a856ae 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -259,7 +259,7 @@ fn text_fill_items_composite_with_their_opacity() { let opaque = render(&s); // A pixel the red fill covers fully (over the black character fill). let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text"); - run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 0.5, "blend": "multiply"})); + run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 50, "blend": "multiply"})); let px = render(&s).pixel(x, y); // Half of red multiplied over black: black. assert!(px[0] < 20 && px[3] == 255, "{px:?}"); @@ -294,7 +294,7 @@ fn set_item_and_remove_item() { let st = stroke(&n, 1); assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0)); assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff"); - run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 0.25})); + run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 25})); let n = node(&s, id); assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25); assert!(s.execute("appearance.setItem", &json!({})).is_err()); @@ -310,7 +310,7 @@ fn clear_and_reduce_to_basic() { let id = rect(&mut s, 100.0); run(&mut s, "appearance.addStroke", json!({})); run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"})); - run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 0.5})); + run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50})); run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"})); 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/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs new file mode 100644 index 000000000..b2c5b661a --- /dev/null +++ b/crates/engine/src/tests_opacitymask.rs @@ -0,0 +1,268 @@ +//! 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); + // 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] +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); +} + +#[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); +} 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/pdf/src/export.rs b/crates/pdf/src/export.rs index 935f442cd..0d34a7506 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -393,32 +393,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 9538d6236..b07e703c4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -198,9 +198,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). @@ -257,6 +262,7 @@ impl Renderer { pub fn new() -> Self { Self { texts: PtrMap::default(), + clips: PtrMap::default(), ctx_st: None, threads: default_threads(), geom: PtrMap::default(), @@ -415,6 +421,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; @@ -550,23 +573,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, .. } => { @@ -987,3 +1010,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 4bce6471e..60064d690 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -530,32 +530,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) { @@ -622,11 +596,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/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 1d7f86c09..05ccd07df 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -338,7 +338,7 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opaci egui::Popup::menu(&resp) .open_memory(clicked.then_some(egui::SetOpenCommand::Toggle)) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), opacity, blend, true) { + .show(|ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) { Some(TransparencyEdit::Blend(b)) => { app.run("transparency.set", json!({"item": item, "blend": b.label()})).ok(); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e76530bd8..a7c50c4b8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -298,7 +298,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 c0a75dca0..d54d94a1b 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -5,28 +5,51 @@ 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, current_transparency, first_selected, live_run, pstate, selection_len, set_pstate}; +use super::{current_paints, current_transparency, live_run, pstate, selection_len, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, TransparencyEdit, 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 (isolate, knockout) = n.as_ref().map_or((false, false), |n| (n.isolate, n.knockout)); - // Opacity and blend of the Appearance panel's active item, else of the object. - let (op, blend) = match editing { - None => current_transparency(app), - Some(_) => n.as_ref().map(|n| (n.opacity, n.blend)), - } - .unwrap_or((1.0, BlendMode::Normal)); + let (isolate, knockout) = (info.isolate.unwrap_or(false), info.knockout.unwrap_or(false)); + // Opacity and blend of the Appearance panel's active item (not while a mask is edited: the + // panel then shows the masked object), else of the targets, blank where they differ; with no + // target, the defaults for new art. + let item = editing.is_none().then(|| app.session.appearance_item()).flatten().and_then(|_| current_transparency(app)); + let (op, blend) = match item { + Some((o, b)) => (Some(o), Some(b)), + None if has => (info.opacity.map(|o| (o / 100.0) as f32), info.blend), + None => (Some(1.0), Some(BlendMode::Normal)), + }; match widgets::opacity_blend(ui, "tr", op, blend, has) { Some(TransparencyEdit::Blend(b)) => { app.run("transparency.set", json!({"blend": b.label()})).ok(); @@ -37,7 +60,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 { @@ -63,8 +86,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( @@ -96,11 +119,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); @@ -108,15 +131,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(); } @@ -150,9 +172,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), @@ -173,23 +218,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 @@ -224,14 +253,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 881996f8b..bf6cf86f9 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 @@ -312,15 +326,15 @@ pub fn blend_separator_before(i: usize) -> bool { } /// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion -/// | component modes). Returns the chosen mode. -pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode, width: f32) -> Option { - combo(ui, id, current.label(), width, |ui| { +/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. +pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: Option, width: f32) -> Option { + combo(ui, id, current.map_or("", BlendMode::label), width, |ui| { let mut chosen = None; for (i, m) in BlendMode::ALL.into_iter().enumerate() { if blend_separator_before(i) { ui.separator(); } - if ui.selectable_label(m == current, m.label()).clicked() { + if ui.selectable_label(Some(m) == current, m.label()).clicked() { chosen = Some(m); } } @@ -336,7 +350,7 @@ pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { /// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode) -> Option { - blend_dropdown(ui, id, current, 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) + blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) } /// An edit made with [`opacity_blend`]. @@ -349,11 +363,12 @@ pub enum TransparencyEdit { /// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity /// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. +/// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank). pub fn opacity_blend( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, - opacity: f32, - blend: BlendMode, + opacity: Option, + blend: Option, enabled: bool, ) -> Option { let t = Tokens::get(ui.ctx()); @@ -367,7 +382,7 @@ pub fn opacity_blend( dim_label(ui, "Opacity:"); ui.spacing_mut().item_spacing.x = 0.0; ui.add_enabled_ui(enabled, |ui| { - if let Some(o) = plain_field(ui, (id, "opacity"), opacity as f64 * 100.0, "%", 0, 50.0) { + if let Some(o) = mixed_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), "%", 0, 50.0) { edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released)); } }); @@ -375,7 +390,7 @@ pub fn opacity_blend( 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 enabled { t.icon } else { t.text_disabled }); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - let mut o = opacity * 100.0; + let mut o = opacity.unwrap_or(1.0) * 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 diff --git a/docs/mcp.md b/docs/mcp.md index 19e0bf18e..8ca47cb8d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -163,3 +163,30 @@ 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]}}} +``` + +## 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));