diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 4e1e2c54..84e46d0d 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -88,8 +88,8 @@ fn clip_contains(clip: &Node, p: Point) -> bool { 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 + // A clip group (or a layer with a clipping mask) only hits inside its clipping path. + if parent.clips() && let Some(clip) = children.first() && !clip_contains(clip, p) { diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 55a37261..7f6617e6 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -174,6 +174,9 @@ pub enum NodeKind { #[serde(default = "yes")] printable: bool, children: Vec>, + /// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + clip: bool, }, /// A group. With `clip`, the first (bottom-most) child is the clipping path. Group { @@ -336,7 +339,7 @@ impl Node { Self::new(id, NodeKind::Group { children, clip: false }) } pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self { - let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![] }); + let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false }); n.name = Some(name.to_string()); n } @@ -427,7 +430,9 @@ impl Node { pub fn geometric_bounds(&self) -> Option { match &self.kind { NodeKind::Path { path, .. } => path.bounds(), - NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()), + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + children.first().and_then(|c| c.geometric_bounds()) + } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children .iter() .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) @@ -447,7 +452,9 @@ impl Node { /// Visual bounds (includes stroke outset). pub fn visual_bounds(&self) -> Option { match &self.kind { - NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()), + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + children.first().and_then(|c| c.geometric_bounds()) + } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())) } @@ -731,7 +738,7 @@ impl Node { /// A plain group or layer that is neutral to knockout and has no transparency or appearance /// of its own: inside a knockout group its children composite as the group's own children. pub fn passes_knockout_through(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, .. }) + matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. }) && self.has_default_transparency() && self.appearance.items.is_empty() && self.appearance.effects.is_empty() @@ -753,6 +760,18 @@ impl Node { out } + /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + pub fn clips(&self) -> bool { + matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + } + + /// Make this group or layer clip its children by its first child, or stop it. + pub fn set_clips(&mut self, on: bool) { + if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind { + *clip = on; + } + } + /// Whether blending inside this object reaches the art below it unless the object isolates /// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke), /// directly or through children that don't isolate their own blending. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 26d0feb8..f749f192 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -47,9 +47,48 @@ pub fn specs() -> Vec { ), cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art), cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel), + cmd!( + "layer.clippingMask.toggle", + "Make/Release Clipping Mask", + ["Window", "Layers"], + None, + "{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}", + has_doc, + clip_toggle + ), ] } +/// The Layers panel's clipping mask button: the top object of a layer (or group) clips the rest, +/// or the clipping mask is released. +fn clip_toggle(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let group = match st.selection.objects.as_slice() { + [id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id), + _ => None, + }; + let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?; + let clip = s.edit("Make/Release Clipping Mask", |d, _| { + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) { + return Err(bad("layer.clippingMask.toggle", "not a layer or group")); + } + if n.clips() { + super::object::release_clip(d, id); + return Ok(false); + } + let top = + n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; + super::object::make_clipping_path(d, top)?; + d.move_node(top, Some(id), 0)?; + if let Some(n) = d.node_mut(id) { + n.set_clips(true); + } + Ok(true) + })?; + Ok(json!({ "clip": clip })) +} + fn new_layer(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").map(str::to_string); let st = s.doc()?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index debcf0af..64403fca 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -532,13 +532,43 @@ fn compound_release(s: &mut Session, _: &Value) -> Result { ok() } +/// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it +/// stays unpainted after Release). +pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> { + let c = d.node_mut(top).ok_or(EngineError::NoNode(top))?; + if !matches!(c.kind, 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())); + } + c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); + 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); + } + } + _ => {} + } + Ok(()) +} + +/// Stop clip group or clipped layer `id` clipping; its clipping path stays, unpainted. +pub(super) fn release_clip(d: &mut Document, id: NodeId) { + let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return }; + n.set_clips(false); + let clip = n.children().and_then(|c| c.first()).map(|c| c.id); + if let Some(c) = clip.and_then(|c| d.node_mut(c)) + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; + } +} + 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 { 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())); - } + make_clipping_path(d, top)?; let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let gid = d.alloc_id(); d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?; @@ -546,19 +576,6 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { for id in &ids[..ids.len() - 1] { 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); - 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]); Ok(gid) })?; @@ -569,18 +586,7 @@ fn clip_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; s.edit("Release Clipping Mask", |d, _| { for id in &ids { - let clip_child = match d.node(*id).map(|n| &n.kind) { - Some(NodeKind::Group { children, clip: true }) => children.first().map(|c| c.id), - _ => continue, - }; - if let Some(NodeKind::Group { clip, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { - *clip = false; - } - if let Some(c) = clip_child.and_then(|c| d.node_mut(c)) - && let NodeKind::Path { clipping, .. } = &mut c.kind - { - *clipping = false; - } + release_clip(d, *id); } Ok(()) })?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 436db81c..635f123d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -770,6 +770,8 @@ mod tests_gradpanel; #[cfg(test)] mod tests_knockout; #[cfg(test)] +mod tests_layerclip; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_menucmds; diff --git a/crates/engine/src/tests_clip.rs b/crates/engine/src/tests_clip.rs index a581aa31..f3ea91f8 100644 --- a/crates/engine/src/tests_clip.rs +++ b/crates/engine/src/tests_clip.rs @@ -7,13 +7,13 @@ use vectorcraft_render::Rendered; use super::*; -fn session() -> Session { +pub(crate) 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 { +pub(crate) fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) } @@ -21,16 +21,16 @@ 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]) { +pub(crate) 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 { +pub(crate) 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 { +pub(crate) fn opaque(r: &Rendered, x: u32, y: u32) -> bool { r.pixel(x, y)[3] > 128 } @@ -43,7 +43,7 @@ fn reopened(s: &mut Session, format: &str) -> Rendered { } /// The same probes are opaque on screen and in the SVG and PDF output. -fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) { +pub(crate) fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) { let screen = render(s); for format in ["svg", "pdf"] { let out = reopened(s, format); @@ -54,7 +54,7 @@ fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) { } /// The leaf a click at (`x`, `y`) hits. -fn hit(s: &Session, x: f64, y: f64) -> Option { +pub(crate) 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) } diff --git a/crates/engine/src/tests_layerclip.rs b/crates/engine/src/tests_layerclip.rs new file mode 100644 index 00000000..074be859 --- /dev/null +++ b/crates/engine/src/tests_layerclip.rs @@ -0,0 +1,122 @@ +//! Layer clipping masks (M3.75): the Layers panel button makes the top object of the current layer +//! (or of the selected group) clip the rest, and pressing it again releases the mask. + +use serde_json::json; + +use super::tests_clip::{assert_outputs_agree, hit, id_of, opaque, render, select, session}; +use super::*; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn toggle(s: &mut Session) -> bool { + s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap() +} + +fn layer(s: &Session) -> vectorcraft_doc::Node { + let st = s.doc().unwrap(); + st.doc.node(st.current_layer().unwrap()).unwrap().clone() +} + +/// A full-page square under a 50 × 50 square at 25..75 (the clip to be), nothing selected. +fn art_and_clip(s: &mut Session) -> (NodeId, NodeId) { + let art = rect(s, 0.0, 0.0, 100.0, 100.0); + let clip = rect(s, 25.0, 25.0, 50.0, 50.0); + s.execute("select.none", &json!({})).unwrap(); + (art, clip) +} + +#[test] +fn the_top_object_clips_the_layer_and_pressing_again_releases_it() { + let mut s = session(); + let (art, clip) = art_and_clip(&mut s); + assert!(toggle(&mut s)); + let l = layer(&s); + assert!(l.clips()); + // The clipping path loses its paint and goes to the bottom, so art added on top is clipped. + let c = &l.children().unwrap()[0]; + assert_eq!(c.id, clip); + assert!(c.appearance.fill_paint().is_none() && matches!(c.kind, NodeKind::Path { clipping: true, .. })); + let r = render(&s); + assert!(opaque(&r, 50, 50) && !opaque(&r, 10, 10)); + // Clicks only reach the art inside the clip. + assert_eq!(hit(&s, 50.0, 50.0), Some(art)); + assert_eq!(hit(&s, 10.0, 10.0), None); + rect(&mut s, 0.0, 80.0, 100.0, 20.0); + assert!(!opaque(&render(&s), 50, 90), "new art is clipped"); + // Again: released, the clipping path stays unpainted. + s.execute("select.none", &json!({})).unwrap(); + assert!(!toggle(&mut s)); + let l = layer(&s); + assert!(!l.clips()); + let c = &l.children().unwrap()[0]; + assert!(c.appearance.fill_paint().is_none() && matches!(c.kind, NodeKind::Path { clipping: false, .. })); + let r = render(&s); + assert!(opaque(&r, 10, 10) && opaque(&r, 50, 90)); +} + +#[test] +fn making_a_layer_clipping_mask_is_one_undo_step() { + let mut s = session(); + let (_, clip) = art_and_clip(&mut s); + let before = layer(&s); + toggle(&mut s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer(&s), before, "the clip path is back on top with its paint"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(layer(&s).clips()); + assert_eq!(layer(&s).children().unwrap()[0].id, clip); +} + +#[test] +fn layer_clipping_masks_save_and_export() { + let mut s = session(); + art_and_clip(&mut s); + toggle(&mut s); + let doc = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert!(back.layers[0].clips()); + assert_eq!(back, **doc); + // An unclipped layer writes no `clip` key (older files stay as they were). + let plain = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); + assert!(!plain.contains("\"clip\""), "{plain}"); + // SVG and PDF clip the same way as the canvas. + assert_outputs_agree(&mut s, &[(50, 50), (10, 10), (30, 30), (80, 80), (74, 26)]); +} + +#[test] +fn a_selected_group_becomes_a_clip_group() { + let mut s = session(); + let (art, clip) = art_and_clip(&mut s); + select(&mut s, &[art, clip]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + select(&mut s, &[g]); + assert!(toggle(&mut s)); + let n = s.doc().unwrap().doc.node(g).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Group { clip: true, .. })); + assert_eq!(n.children().unwrap()[0].id, clip); + assert!(!layer(&s).clips(), "the layer is left alone"); + assert!(!opaque(&render(&s), 10, 10)); + assert!(!toggle(&mut s)); + assert!(!s.doc().unwrap().doc.node(g).unwrap().clips()); +} + +#[test] +fn the_top_object_must_be_able_to_clip() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + let b = rect(&mut s, 50.0, 50.0, 50.0, 50.0); + select(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let before = layer(&s); + assert!(s.execute("layer.clippingMask.toggle", &json!({})).is_err(), "a group on top"); + assert_eq!(layer(&s), before); + // An explicit id picks the layer even with a group selected. + let l = before.id; + s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap(); + let sub = layer(&s).children().unwrap().last().unwrap().id; + let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0})); + assert!(err.is_err(), "an empty sublayer has nothing to clip by"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 54f5a110..ebf48ca8 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -469,8 +469,8 @@ impl Exporter<'_> { pushes += 1; } match &n.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), - NodeKind::Group { children, clip: true } => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // 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))) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 643adede..9066238e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -127,7 +127,7 @@ pub(crate) fn visual_bounds(n: &Node) -> Option { /// the subtree (a shadow can reach the view while its object is outside it). pub(crate) fn cull_bounds(n: &Node) -> Option { match &n.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) } _ if has_fx(n) => visual_bounds(n), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9eda6757..9654d292 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -456,7 +456,7 @@ impl Renderer { return e.bounds; } let b = match &a.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => { let mut acc: Option = None; for c in children { if c.visible { @@ -642,13 +642,15 @@ impl Renderer { /// What `n` draws inside its transparency group (`knockout`: its children knock each other out). fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) { match &n.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => self.draw_children(ctx, f, children, knockout), - NodeKind::Group { children, clip: true } if f.opts.outline => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + self.draw_children(ctx, f, children, knockout) + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } if f.opts.outline => { for c in children { self.draw_node(ctx, f, c, false); } } - NodeKind::Group { children, clip: true } => { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip // group doesn't isolate: blending inside it reaches the art below. if let Some((clip, rest)) = children.split_first() diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index df0c07a5..4832eb7f 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -777,7 +777,7 @@ impl Writer<'_> { } match &n.kind { NodeKind::Layer { template: true, .. } => {} - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); self.line(&format!("")); @@ -786,7 +786,7 @@ impl Writer<'_> { self.depth -= 1; self.line(""); } - NodeKind::Group { children, clip: true } => { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { let Some((clip, rest)) = children.split_first() else { return }; let cid = self.fresh_id("clip-path"); self.def(1, &format!("")); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index fee74ddb..8988d335 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -31,7 +31,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; for l in doc.layers.iter().rev() { - row(ui, &doc, l, 0, &sel, current, &mut expanded, &mut actions, &t); + row(ui, &doc, l, 0, false, &sel, current, &mut expanded, &mut actions, &t); } }); ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); @@ -64,7 +64,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { actions.push(("layer.newSublayer".into(), json!({}))); } if widgets::icon_button(&mut child, "frame", "Make/Release Clipping Mask", false, 24.0).clicked() { - actions.push(("object.clippingMask.make".into(), json!({}))); + actions.push(("layer.clippingMask.toggle".into(), json!({}))); } if widgets::icon_button(&mut child, "search", "Locate Object", false, 24.0).clicked() { // Expand ancestors of the selection. @@ -89,6 +89,7 @@ fn row( doc: &vectorcraft_doc::Document, n: &Node, depth: usize, + clip_path: bool, sel: &HashSet, current: Option, expanded: &mut HashSet, @@ -199,7 +200,12 @@ fn row( } } _ => { - ui.painter().with_clip_rect(name_rect).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text); + let painter = ui.painter().with_clip_rect(name_rect); + let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text); + // A clipping path's name is underlined. + if clip_path { + painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text)); + } } } // Target circle and selection square. @@ -269,8 +275,8 @@ fn row( } if has_children && expanded.contains(&n.id.0) { - for c in n.children().unwrap().iter().rev() { - row(ui, doc, c, depth + 1, sel, current, expanded, actions, t); + for (i, c) in n.children().unwrap().iter().enumerate().rev() { + row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, current, expanded, actions, t); } } } @@ -374,4 +380,27 @@ mod tests { run(&mut app, "appearance.addStroke", json!({})); assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes"); } + + #[test] + fn a_layer_clipping_mask_underlines_its_clipping_path() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 25, "y": 25, "width": 50, "height": 50})); + run(&mut app, "select.none", json!({})); + let ctx = egui::Context::default(); + let text = Tokens::get(&ctx).text; + // Line segments in the text colour: the underlines. + let underlines = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == text)).count() + }; + assert_eq!(underlines(&mut app), 0); + assert_eq!(run(&mut app, "layer.clippingMask.toggle", json!({}))["clip"], true); + assert_eq!(underlines(&mut app), 1); + run(&mut app, "layer.clippingMask.toggle", json!({})); + assert_eq!(underlines(&mut app), 0); + } } diff --git a/docs/mcp.md b/docs/mcp.md index d0291c19..cdbc5d6f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -222,6 +222,11 @@ plain group and keeps the clipping path, unpainted. {"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} ``` +`layer.clippingMask.toggle {id?}` is the Layers panel's clipping mask button: the top object of the layer `id` (default: +the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer, +so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel +underlines clipping-path names. + ## Graphic styles A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout.