M3.75: layer clipping masks from the Layers panel button

layer.clippingMask.toggle {id?} -> {clip}: the top object of the current
layer (or of the one selected group, or of `id`) becomes the clipping path,
unpainted and moved to the bottom so art added later is clipped too; pressing
the button again releases the mask and leaves the path unpainted. One undo
step. The Layers panel button runs it and underlines clipping-path names.

Model: NodeKind::Layer.clip (skipped when false). Clipped layers render, hit
test, bound, and export to SVG and PDF like clip groups (Node::clips /
set_clips); the clip-path helpers are shared with Make/Release Clipping Mask.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 00:27:31 +07:00
1 parent 54427d7a92
commit 45ff33e144
13 files changed
+279 -55

No files matched your search

+2 -2
View File
@@ -88,8 +88,8 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
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)
{
+23 -4
View File
@@ -174,6 +174,9 @@ pub enum NodeKind {
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
/// 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<Rect> {
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<Rect> {
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.
+39
View File
@@ -47,9 +47,48 @@ pub fn specs() -> Vec<CommandSpec> {
),
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<Value> {
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<Value> {
let name = str_param(p, "name").map(str::to_string);
let st = s.doc()?;
+34 -28
View File
@@ -532,13 +532,43 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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(())
})?;
+2
View File
@@ -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;
+7 -7
View File
@@ -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::<Vec<_>>()})).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<NodeId> {
pub(crate) fn hit(s: &Session, x: f64, y: f64) -> Option<NodeId> {
vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf)
}
+122
View File
@@ -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");
}
+2 -2
View File
@@ -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)))
+1 -1
View File
@@ -127,7 +127,7 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
/// the subtree (a shadow can reach the view while its object is outside it).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
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),
+6 -4
View File
@@ -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<Rect> = 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()
+2 -2
View File
@@ -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!("<g{id}{a}>"));
@@ -786,7 +786,7 @@ impl Writer<'_> {
self.depth -= 1;
self.line("</g>");
}
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!("<clipPath id=\"{cid}\">"));
+34 -5
View File
@@ -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<NodeId>,
current: Option<NodeId>,
expanded: &mut HashSet<u64>,
@@ -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);
}
}
+5
View File
@@ -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.