mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
b117566fc2
commit
4f685be57a
15 files changed
+483
-80
No files matched your search
Generated
+1
@@ -4122,6 +4122,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+11
-2
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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
@@ -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(®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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user