mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
54427d7a92
commit
45ff33e144
13 files changed
+279
-55
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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(())
|
||||
})?;
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}\">"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user