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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-04 17:23:57 +07:00
1 parent b117566fc2
commit 4f685be57a
15 files changed
+483 -80

No files matched your search

Generated
+1
View File
@@ -4122,6 +4122,7 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-text",
]
[[package]]
+11 -2
View File
@@ -76,13 +76,22 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
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<NodeId>) -> Option<Hit> {
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;
}
+68 -2
View File
@@ -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;
+1
View File
@@ -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 }
+26
View File
@@ -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<Node> {
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()
}
+2
View File
@@ -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};
+29 -6
View File
@@ -88,8 +88,24 @@ pub fn specs() -> Vec<CommandSpec> {
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<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 { .. })) {
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<Value> {
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]);
+2
View File
@@ -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;
+172
View File
@@ -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::<Vec<_>>()})).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<NodeId> {
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<bool> = (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##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<defs>
<clipPath id="two"><rect x="0" y="0" width="30" height="40"/><rect x="60" y="0" width="30" height="40"/></clipPath>
<clipPath id="star"><path clip-rule="evenodd" d="M70 49 L84.7 94.2 L46.2 66.3 L93.8 66.3 L55.3 94.2 Z"/></clipPath>
</defs>
<g clip-path="url(#two)"><rect x="0" y="0" width="100" height="45" fill="#000"/></g>
<g clip-path="url(#star)"><rect x="40" y="45" width="60" height="55" fill="#000"/></g>
</svg>"##;
#[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)]);
}
+8 -25
View File
@@ -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, .. } => {
+40 -15
View File
@@ -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<Node>, Option<Arc<(BezPath, FillRule)>>);
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
pub struct Renderer {
texts: PtrMap<usize, (Arc<Node>, Arc<TextGeom>)>,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: PtrMap<usize, ClipEntry>,
/// Context reused when rendering single-threaded (`threads == 0`).
ctx_st: Option<RenderContext>,
/// 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<Node>) -> Option<Arc<(BezPath, FillRule)>> {
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<Node>) -> Option<Arc<BezPath>> {
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(&region.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;
+106
View File
@@ -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<Node> {
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<Node>) -> 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<Point> = (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<bool> = (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);
}
+4 -29
View File
@@ -523,32 +523,6 @@ impl Writer<'_> {
self.line("</g>");
}
/// `(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<String> = 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: `<g mask="url(#…)">` around the unmasked object. The mask
/// art goes in `<defs>`; 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!("<clipPath id=\"{cid}\">"));
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!("<path d=\"{d}\"{r}/>"));
}
+12
View File
@@ -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":{}}}
```
+1 -1
View File
@@ -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));