From 4f685be57a27c1611296d666f71c812b668ebd6f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:23:57 +0700 Subject: [PATCH] M3.26: clipping sets with any clip shape Clip groups clip by compound paths (with their holes), even-odd paths, text outlines and groups (the union of their members) on screen, in raster export, in SVG and in PDF alike, through one shared region: Node::clip_outline (the model walk) and effects::clip_outline (text outlined, several shapes united), cached per clipping path in the renderer. Nothing to clip by hides the clipped art everywhere, and clicks hit a clip group only inside its clipping region. Make Clipping Mask takes a compound path or text object on top (it loses its paint) and Release gives it back unchanged in kind. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/doc/src/hit.rs | 13 ++- crates/doc/src/node.rs | 70 ++++++++++++- crates/effects/Cargo.toml | 1 + crates/effects/src/clip.rs | 26 +++++ crates/effects/src/lib.rs | 2 + crates/engine/src/cmd/object.rs | 35 +++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_clip.rs | 172 ++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 33 ++---- crates/render/src/lib.rs | 55 +++++++--- crates/render/src/tests_clip.rs | 106 ++++++++++++++++++++ crates/svg/src/export.rs | 33 +----- docs/mcp.md | 12 +++ xtask/src/layers.rs | 2 +- 15 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 crates/effects/src/clip.rs create mode 100644 crates/engine/src/tests_clip.rs create mode 100644 crates/render/src/tests_clip.rs diff --git a/Cargo.lock b/Cargo.lock index 473150b0..3e0705ef 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 144fd819..4e1e2c54 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -76,13 +76,22 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { None } +/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame: +/// glyph outlines need the font engine, above this crate. +fn clip_contains(clip: &Node, p: Point) -> bool { + let frame = |n: &Node| { + let b = n.geometric_bounds()?; + Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default())) + }; + clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) +} + fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec) -> Option { let children = parent.children()?; // A clip group only hits inside its clipping path. if let NodeKind::Group { clip: true, .. } = parent.kind && let Some(clip) = children.first() - && let Some(path) = clip.path_data() - && !fill_contains(&path.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p) + && !clip_contains(clip, p) { return None; } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929..4665da16 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -4,10 +4,10 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_color::BlendMode; -use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; use crate::appearance::Appearance; -use crate::live::{BlendSpec, EnvelopeKind, GradientMesh}; +use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner}; use crate::pattern::RepeatSpec; use crate::text::TextObject; @@ -518,6 +518,72 @@ impl Node { } } +/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans +/// live above this crate (`vectorcraft-pathops`), so callers supply it. +pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath; + +impl Node { + /// The filled regions this object clips to as the clipping path of a clip group, in document + /// space, each with its fill rule: a path, a compound path (with its holes), an image's frame, + /// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible + /// members of a group (their union) and the clipping path of a nested clip group. Live objects + /// are evaluated first. Guides and symbol instances add nothing. + pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> { + let mut out = vec![]; + self.push_clip_shapes(text, &mut out); + out + } + + fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { + match &self.kind { + NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + out.push((bp, *rule)); + } + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + c.push_clip_shapes(text, out); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children.iter().filter(|c| c.visible) { + c.push_clip_shapes(text, out); + } + } + NodeKind::Text(_) => { + if let Some(o) = text.and_then(|f| f(self)) { + o.push_clip_shapes(None, out); + } + } + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + crate::live::expand_deep(self, text).push_clip_shapes(text, out); + } + } + } + + /// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared + /// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its + /// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to + /// clip by: the clipped art is then hidden. + pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> { + let mut shapes = self.clip_shapes(text); + match shapes.len() { + 0 => None, + 1 => shapes.pop(), + _ => Some((unite(&shapes), FillRule::NonZero)), + } + } +} + fn transform_paints(ap: &mut Appearance, a: Affine) { use crate::appearance::AppearanceItem; use vectorcraft_color::Paint; diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 4ab1efaf..b50e797b 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 00000000..444e814d --- /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 17c4911f..612e9054 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -16,6 +16,7 @@ #![forbid(unsafe_code)] mod bake; +mod clip; mod distort; mod group; mod raster; @@ -31,6 +32,7 @@ use vectorcraft_doc::Effect; use vectorcraft_geom::{BezPath, PathData, Rect}; pub use bake::{bake_document, needs_bake}; +pub use clip::clip_outline; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use raster::{RasterFx, outset, raster_effects}; pub use warp::{WarpStyle, warp_point}; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 9087af1e..cf7280b2 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -88,8 +88,24 @@ pub fn specs() -> Vec { cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all), cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make), cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release), - cmd!("object.clippingMask.make", "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), "{}", has_multi, clip_make), - cmd!("object.clippingMask.release", "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), "{}", has_selection, clip_release), + cmd!( + "object.clippingMask.make", + "Make", + ["Object", "Clipping Mask"], + Some("Cmd+7"), + "{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn → {id} of the clip group", + has_multi, + clip_make + ), + cmd!( + "object.clippingMask.release", + "Release", + ["Object", "Clipping Mask"], + Some("Cmd+Alt+7"), + "{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, unpainted", + has_selection, + clip_release + ), cmd!( "object.clippingMask.editContents", "Edit Contents", @@ -520,8 +536,8 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let top = *ids.last().unwrap(); let gid = s.edit("Make Clipping Mask", |d, sel| { - if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { .. })) { - return Err(EngineError::Other("the top object must be a path to use as a clipping mask".into())); + if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_))) { + return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into())); } let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let gid = d.alloc_id(); @@ -531,9 +547,16 @@ fn clip_make(s: &mut Session, _: &Value) -> Result { d.move_node(*id, Some(gid), usize::MAX)?; } if let Some(c) = d.node_mut(top) { + // The clipping path loses its paint (it stays unpainted after Release). c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); - if let NodeKind::Path { clipping, .. } = &mut c.kind { - *clipping = true; + match &mut c.kind { + NodeKind::Path { clipping, .. } => *clipping = true, + NodeKind::Text(t) => { + for r in &mut t.runs { + (r.style.fill, r.style.stroke) = (Paint::None, Paint::None); + } + } + _ => {} } } sel.set([gid]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 103418b1..10a34e7f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -711,6 +711,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_clip; +#[cfg(test)] mod tests_cmdsplit; #[cfg(test)] mod tests_colormgmt; diff --git a/crates/engine/src/tests_clip.rs b/crates/engine/src/tests_clip.rs new file mode 100644 index 00000000..a581aa31 --- /dev/null +++ b/crates/engine/src/tests_clip.rs @@ -0,0 +1,172 @@ +//! Clipping sets with any clip shape (M3.26): Make Clipping Mask takes a compound path or text on +//! top and Release gives it back; compound holes, even-odd fills, text outlines and imported +//! multi-shape clip paths clip on screen, and the SVG and PDF output clip the same way. + +use serde_json::{Value, json}; +use vectorcraft_render::Rendered; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ellipse(s: &mut Session, x: f64, w: f64) -> NodeId { + id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": x, "width": w, "height": w})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +/// The active document's 100×100 artboard, rendered transparent. +fn render(s: &Session) -> Rendered { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, Affine::IDENTITY, &Default::default()) +} + +fn opaque(r: &Rendered, x: u32, y: u32) -> bool { + r.pixel(x, y)[3] > 128 +} + +/// The document exported as `format` and opened again, rendered. +fn reopened(s: &mut Session, format: &str) -> Rendered { + let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": format!("clip.{format}"), "dataBase64": data})).unwrap(); + render(&o) +} + +/// The same probes are opaque on screen and in the SVG and PDF output. +fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) { + let screen = render(s); + for format in ["svg", "pdf"] { + let out = reopened(s, format); + for &(x, y) in probes { + assert_eq!(opaque(&out, x, y), opaque(&screen, x, y), "{format} at ({x}, {y})"); + } + } +} + +/// The leaf a click at (`x`, `y`) hits. +fn hit(s: &Session, x: f64, y: f64) -> Option { + vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf) +} + +/// Art under the whole artboard, then a donut (compound path with a real hole) on top. +fn art_and_donut(s: &mut Session) -> (NodeId, NodeId) { + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let (outer, inner) = (ellipse(s, 10.0, 80.0), ellipse(s, 35.0, 30.0)); + select(s, &[inner]); + s.execute("path.reverse", &json!({})).unwrap(); + select(s, &[outer, inner]); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + let donut = s.doc().unwrap().selection.objects[0]; + (art, donut) +} + +#[test] +fn compound_clip_keeps_its_hole_and_release_gives_the_compound_back() { + let mut s = session(); + let (art, donut) = art_and_donut(&mut s); + select(&mut s, &[art, donut]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let d = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: true } = &d.node(g).unwrap().kind else { panic!("a clip group") }; + assert_eq!(children[0].id, donut); + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].appearance.fill_paint().is_none()); + let r = render(&s); + assert!(opaque(&r, 20, 50), "the ring shows the art"); + assert!(!opaque(&r, 50, 50), "the hole stays transparent"); + assert!(!opaque(&r, 3, 3), "outside the ring"); + assert_outputs_agree(&mut s, &[(20, 50), (50, 50), (3, 3), (80, 50)]); + // Clicks hit the art only where it shows. + assert_eq!(hit(&s, 20.0, 50.0), Some(art)); + assert_eq!(hit(&s, 50.0, 50.0), None, "the hole"); + // Release: a plain group, the compound path back on top of the art, unpainted. + select(&mut s, &[g]); + s.execute("object.clippingMask.release", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: false } = &d.node(g).unwrap().kind else { panic!("a plain group") }; + assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].id == donut); + assert!(opaque(&render(&s), 50, 50), "nothing clips any more"); +} + +#[test] +fn text_on_top_clips_by_its_glyphs() { + let mut s = session(); + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id_of(&s.execute("text.create", &json!({"x": 5, "y": 80, "text": "O", "size": 90})).unwrap()); + let b = s.doc().unwrap().doc.node(t).unwrap().geometric_bounds().unwrap(); + select(&mut s, &[art, t]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let d = &s.doc().unwrap().doc; + let NodeKind::Text(text) = &d.node(t).unwrap().kind else { panic!("still text") }; + assert!(text.runs.iter().all(|r| r.style.fill.is_none() && r.style.stroke.is_none()), "the clipping path loses its paint"); + // Across the middle of the "O": clear, ring, the counter (clear), ring, clear. + let cy = ((b.y0 + b.y1) / 2.0) as u32; + let r = render(&s); + let row: Vec = (0..100).map(|x| opaque(&r, x, cy)).collect(); + assert_eq!(row.windows(2).filter(|w| w[0] != w[1]).count(), 4, "{row:?}"); + let probes: Vec<(u32, u32)> = (0..100).step_by(3).map(|x| (x, cy)).collect(); + assert_outputs_agree(&mut s, &probes); + // Text clips clicks by its frame. + assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art)); + assert!(b.x1 < 95.0, "{b:?}"); + assert_eq!(hit(&s, b.x1 + 3.0, (b.y0 + b.y1) / 2.0), None, "beside the text"); + // Release gives the text back. + select(&mut s, &[g]); + s.execute("object.clippingMask.release", &json!({})).unwrap(); + assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); +} + +#[test] +fn make_refuses_a_top_object_that_cannot_clip() { + let mut s = session(); + let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let (a, b) = (ellipse(&mut s, 10.0, 20.0), ellipse(&mut s, 50.0, 20.0)); + select(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + let group = s.doc().unwrap().selection.objects[0]; + select(&mut s, &[art, group]); + let err = s.execute("object.clippingMask.make", &json!({})).unwrap_err(); + assert!(err.to_string().contains("path, compound path or text"), "{err}"); +} + +/// An SVG clip path made of several shapes (imported as one multi-subpath clipping path) and an +/// even-odd star clip path. +const SVG: &str = r##" + + + + + + +"##; + +#[test] +fn imported_multi_shape_and_even_odd_clip_paths_clip() { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "clips.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap(); + let d = &s.doc().unwrap().doc; + let mut kinds = vec![]; + d.walk(|n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + kinds.push(children[0].kind_label()); + } + }); + assert_eq!(kinds.len(), 2, "two clip groups"); + let r = render(&s); + assert!(opaque(&r, 15, 20) && opaque(&r, 75, 20), "inside both shapes of the clip path"); + assert!(!opaque(&r, 45, 20), "between them"); + assert!(!opaque(&r, 15, 43), "below them"); + // The star's arms cross: the centre of the crossing is outside under even-odd. + assert!(opaque(&r, 70, 56), "an arm"); + assert!(!opaque(&r, 70, 75), "the even-odd centre"); + assert_outputs_agree(&mut s, &[(15, 20), (75, 20), (45, 20), (15, 43), (70, 56), (70, 75)]); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3..ddf76ad1 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -394,32 +394,15 @@ impl Exporter<'_> { } } NodeKind::Group { children, clip: true } => { - let clip = children.first().and_then(|c| match &c.kind { - NodeKind::Path { path, rule, .. } => Some((path.to_bezpath(), *rule)), - NodeKind::Compound { children, rule } => { - let mut bp = BezPath::new(); - for ch in children { - if let Some(p) = ch.path_data() { - bp.extend(p.to_bezpath()); - } - } - Some((bp, *rule)) - } - _ => None, - }); - match clip.and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) { - Some((p, r)) => { - s.push_clip_path(&p, &rule(r)); - for c in children.iter().skip(1) { - self.node(s, c, page, false); - } - s.pop(); - } - None => { - for c in children { - self.node(s, c, page, false); - } + // The region every output clips to; nothing to clip by hides the clipped art. + if let Some((clip, rest)) = children.split_first() + && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) + { + s.push_clip_path(&p, &rule(r)); + for c in rest { + self.node(s, c, page, false); } + s.pop(); } } NodeKind::Path { path, rule, guide, .. } => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 10cfadc6..91448585 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -173,9 +173,14 @@ struct GeomEntry { stamp: u64, } +/// A clipping path (kept alive so its address can't be reused) and the region it clips to. +type ClipEntry = (Arc, Option>); + /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { texts: PtrMap, Arc)>, + /// Clip regions per clipping path (see [`Self::clip_of`]). + clips: PtrMap, /// Context reused when rendering single-threaded (`threads == 0`). ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). @@ -232,6 +237,7 @@ impl Renderer { pub fn new() -> Self { Self { texts: PtrMap::default(), + clips: PtrMap::default(), ctx_st: None, threads: default_threads(), geom: PtrMap::default(), @@ -390,6 +396,23 @@ impl Renderer { b } + /// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]): + /// outlining text and uniting shapes is too slow to repeat every frame. + fn clip_of(&mut self, a: &Arc) -> Option> { + let key = Arc::as_ptr(a) as usize; + if let Some((node, region)) = self.clips.get(&key) + && Arc::ptr_eq(node, a) + { + return region.clone(); + } + let region = effects::clip_outline(a).map(Arc::new); + if self.clips.len() > 1024 { + self.clips.clear(); + } + self.clips.insert(key, (a.clone(), region.clone())); + region + } + /// Cached BezPath of a path node. fn path_of(&mut self, a: &Arc) -> Option> { let key = Arc::as_ptr(a) as usize; @@ -525,23 +548,23 @@ impl Renderer { self.draw_arc(ctx, f, c); } } + NodeKind::Group { children, clip: true } if outline => { + for c in children { + self.draw_node(ctx, f, c, false); + } + } NodeKind::Group { children, clip: true } => { - let clip = children.first().and_then(|c| c.path_data()).map(|p| p.to_bezpath()); - match (clip, outline) { - (Some(clip), false) => { - ctx.set_transform(f.view); - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.push_clip_layer(&clip); - for c in children.iter().skip(1) { - self.draw_arc(ctx, f, c); - } - ctx.pop_layer(); - } - _ => { - for c in children { - self.draw_node(ctx, f, c, false); - } + // Nothing to clip by hides the clipped art (as in the SVG and PDF output). + if let Some((clip, rest)) = children.split_first() + && let Some(region) = self.clip_of(clip) + { + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(region.1)); + ctx.push_clip_layer(®ion.0); + for c in rest { + self.draw_arc(ctx, f, c); } + ctx.pop_layer(); } } NodeKind::Path { path, rule, guide, .. } => { @@ -978,3 +1001,5 @@ fn now() -> u64 { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_clip; diff --git a/crates/render/src/tests_clip.rs b/crates/render/src/tests_clip.rs new file mode 100644 index 00000000..1f72f8ca --- /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 ba71ddca..a2524c8d 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -523,32 +523,6 @@ impl Writer<'_> { self.line(""); } - /// `(d, rule)` of every path in a clipping object. - fn clip_shapes(&self, n: &Node, out: &mut Vec<(String, FillRule)>) { - match &n.kind { - NodeKind::Path { path, rule, .. } => out.push((self.path_d(path, self.xf), *rule)), - NodeKind::Compound { children, rule } => { - let d: Vec = children.iter().filter_map(|c| c.path_data()).map(|p| self.path_d(p, self.xf)).collect(); - out.push((d.join(" "), *rule)); - } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children { - self.clip_shapes(c, out); - } - } - NodeKind::Image(im) => { - let r = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); - let p = vectorcraft_geom::shapes::rectangle(r).transformed(im.xf); - out.push((self.path_d(&p, self.xf), FillRule::NonZero)); - } - NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {} - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); - self.clip_shapes(&g, out); - } - } - } - /// An object with an opacity mask: `` around the unmasked object. The mask /// art goes in ``; no-clip adds a white backdrop and invert a colour-inverting filter. fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { @@ -615,11 +589,12 @@ impl Writer<'_> { } NodeKind::Group { children, clip: true } => { let Some((clip, rest)) = children.split_first() else { return }; - let mut shapes = Vec::new(); - self.clip_shapes(clip, &mut shapes); let cid = self.fresh_id("clip-path"); self.def(1, &format!("")); - for (d, rule) in shapes { + // The region every output clips to; with nothing to clip by, an empty clip path + // hides the clipped art. + if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { + let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; self.def(2, &format!("")); } diff --git a/docs/mcp.md b/docs/mcp.md index f3b6930c..1a3587d6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -148,3 +148,15 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the {"name":"run_command","arguments":{"command":"transparency.setOpacityMask","params":{"id":12,"clip":false}}} {"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}} ``` + +## Clipping masks + +`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a +text object (it loses its paint). Compound holes, even-odd fills, glyph outlines and the union of a group's members +clip alike on screen, in raster export and in SVG and PDF. `object.clippingMask.release` turns the clip group into a +plain group and keeps the clipping path, unpainted. + +```json +{"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}} +{"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 6ca42d1c..5dc66dde 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));