diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 4761eecc..38c0b05c 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -478,7 +478,13 @@ impl Node { pub fn visual_bounds(&self) -> Option { match &self.kind { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - children.first().and_then(|c| c.geometric_bounds()) + // A clipping path that paints (see `clip_paint`) strokes over the clip edge. + let clip = children.first()?; + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + clip.visual_bounds() + } else { + clip.geometric_bounds() + } } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { // The container's own strokes paint around its members. @@ -705,6 +711,54 @@ impl Node { _ => Some((unite(&shapes), FillRule::NonZero)), } } + + /// What this object paints as the clipping path of a clip group, shared by the renderer and + /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted + /// over it (unclipped). Each part is this object keeping only those appearance items (type: + /// also only its characters' fills or strokes); `None` where it paints nothing, as right after + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. + pub fn clip_paint(&self) -> ClipPaint { + use crate::appearance::AppearanceItem; + let runs = match &self.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, + NodeKind::Text(t) => Some(&t.runs), + _ => return ClipPaint::default(), + }; + let paints = |fill: bool| { + let item = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, + }; + let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; + self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) + }; + let part = |fill: bool| { + paints(fill).then(|| { + let mut n = self.clone(); + for i in (0..n.appearance.items.len()).rev() { + if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill { + n.appearance.remove_item(i); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + *(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None; + } + } + n + }) + }; + ClipPaint { fill: part(true), stroke: part(false) } + } +} + +/// What a clipping path paints ([`Node::clip_paint`]). +#[derive(Clone, Debug, Default)] +pub struct ClipPaint { + /// The clipping path with only its fills: painted behind the clipped art (inside the clip). + pub fill: Option, + /// The clipping path with only its strokes: painted over the clipped art, not clipped. + pub stroke: Option, } /// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 64403fca..fc45480b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "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", + "{} 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. Paint given to the clipping path later (paint commands with its id) shows: its fill behind the clipped art, its stroke over it → {id} of the clip group", has_multi, clip_make ), @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "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", + "{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, with the paint it has (none unless painted after Make)", has_selection, clip_release ), @@ -383,11 +383,12 @@ fn ungroup(s: &mut Session, _: &Value) -> Result { d.remove(*id)?; for (k, c) in children.into_iter().enumerate() { let mut c = (*c).clone(); - if clip && k == 0 { - if let NodeKind::Path { clipping, .. } = &mut c.kind { - *clipping = false; - } - c.appearance = Appearance::default(); + // The clipping path keeps its paint (none, unless painted after Make). + if clip + && k == 0 + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; } c.opacity *= opacity; new_sel.push(c.id); @@ -552,7 +553,7 @@ pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> { Ok(()) } -/// Stop clip group or clipped layer `id` clipping; its clipping path stays, unpainted. +/// Stop clip group or clipped layer `id` clipping; its clipping path stays, with its paint. pub(super) fn release_clip(d: &mut Document, id: NodeId) { let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return }; n.set_clips(false); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8637b9dc..efef9dd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -795,6 +795,8 @@ mod tests_charstroke; #[cfg(test)] mod tests_clip; #[cfg(test)] +mod tests_clippaint; +#[cfg(test)] mod tests_cmdsplit; #[cfg(test)] mod tests_colorguide; diff --git a/crates/engine/src/tests_clippaint.rs b/crates/engine/src/tests_clippaint.rs new file mode 100644 index 00000000..673466d3 --- /dev/null +++ b/crates/engine/src/tests_clippaint.rs @@ -0,0 +1,102 @@ +//! Fill and stroke on a clipping path (M3.89): Make Clipping Mask leaves the clipping path +//! unpainted, paint given to it later shows (its fill behind the clipped art, its stroke over it +//! and unclipped) on the canvas, in SVG and in PDF, and Release and Ungroup keep it. + +use serde_json::json; +use vectorcraft_geom::Affine; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; + +fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 50, "width": w, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap(); + id +} + +/// Red art (40..100) clipped by a rectangle (50..150) painted blue with a 10 pt green stroke. +fn painted_clip() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + rect(&mut s, 40.0, 60.0, "#ff0000"); + let clip = rect(&mut s, 50.0, 100.0, "#ffffff"); + s.execute("select.all", &json!({})).unwrap(); + let group = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(clip).unwrap().clone(); + assert!(n.clip_paint().fill.is_none() && n.clip_paint().stroke.is_none(), "Make leaves the clipping path unpainted"); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [clip.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [clip.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 10, "ids": [clip.0]})).unwrap(); + (s, group, clip) +} + +/// RGB at (x, 100) of `doc` rendered on white. +fn rgb(doc: &Document, x: u32) -> [u8; 3] { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let p = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts).pixel(x, 100); + [p[0], p[1], p[2]] +} + +/// Behind the clipped art, its fill; over it (also outside the clip), its stroke. +fn assert_painted(doc: &Document, what: &str) { + assert_eq!(rgb(doc, 70), [255, 0, 0], "{what}: the clipped art over the fill"); + assert_eq!(rgb(doc, 120), [0, 0, 255], "{what}: the fill inside the clip"); + assert_eq!(rgb(doc, 45), [0, 255, 0], "{what}: the stroke's outer half, unclipped (the art below it is clipped away)"); + assert_eq!(rgb(doc, 52), [0, 255, 0], "{what}: the stroke over the clipped art"); + assert_eq!(rgb(doc, 147), [0, 255, 0], "{what}: the stroke over the fill"); + assert_eq!(rgb(doc, 30), [255, 255, 255], "{what}: outside the clip and its stroke"); +} + +#[test] +fn a_clipping_paths_fill_and_stroke_show_in_render_svg_and_pdf() { + let (s, group, clip) = painted_clip(); + let doc = s.doc().unwrap().doc.clone(); + assert_painted(&doc, "canvas"); + // The clip group's bounds take in the stroke. + let b = doc.node(group).unwrap().visual_bounds().unwrap(); + assert!((b.x0 - 45.0).abs() < 1e-6 && (b.x1 - 155.0).abs() < 1e-6, "{b:?}"); + // SVG: the fill inside the clip, the stroke after it, one element keeping the clipping path's id. + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert_painted(&vectorcraft_svg::import(&svg).unwrap(), "SVG"); + // PDF. + let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap(); + assert_painted(&vectorcraft_pdf::import(&pdf).unwrap(), "PDF"); + // Unpainted clipping paths write nothing extra. + let mut bare = s; + bare.execute("paint.setFill", &json!({"none": true, "ids": [clip.0]})).unwrap(); + bare.execute("paint.setStroke", &json!({"none": true, "ids": [clip.0]})).unwrap(); + let doc = bare.doc().unwrap().doc.clone(); + assert_eq!(rgb(&doc, 120), [255, 255, 255]); + assert_eq!(rgb(&doc, 45), [255, 255, 255]); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert_eq!(svg.matches(" { match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - // The region every output clips to; nothing to clip by hides the clipped art. + // The region every output clips to; nothing to clip by hides the clipped art. The + // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. 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))) { + let paint = clip.clip_paint(); s.push_clip_path(&p, &rule(r)); + if let Some(fill) = &paint.fill { + self.node(s, fill, page, false); + } self.children(s, rest, page); s.pop(); + if let Some(stroke) = &paint.stroke { + self.node(s, stroke, page, false); + } } } NodeKind::Path { path, rule, guide, .. } => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 43783ff9..3520eea5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -212,6 +212,8 @@ struct GeomEntry { /// A clipping path (kept alive so its address can't be reused) and the region it clips to. type ClipEntry = (Arc, Option>); +/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). +type ClipPaintEntry = (Arc, Option>, Option>); /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { @@ -260,6 +262,8 @@ pub struct Renderer { /// Clip paths pushed on the current context (path, rule, transform): starting the context /// again from a picture of it pushes them again (see [`Self::group`]). clip_paths: Vec<(BezPath, FillRule, Affine)>, + /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). + clip_paints: PtrMap, } #[derive(Clone, Copy, Debug, Default)] @@ -314,6 +318,7 @@ impl Renderer { backdrop: None, blends: PtrMap::default(), clip_paths: vec![], + clip_paints: PtrMap::default(), } } @@ -503,6 +508,24 @@ impl Renderer { region } + /// Cached fills and strokes of clipping path `a` ([`Node::clip_paint`]): the parts keep their + /// allocations between frames, so their own geometry stays cached. + fn clip_paint_of(&mut self, a: &Arc) -> (Option>, Option>) { + let key = Arc::as_ptr(a) as usize; + if let Some((node, fill, stroke)) = self.clip_paints.get(&key) + && Arc::ptr_eq(node, a) + { + return (fill.clone(), stroke.clone()); + } + let paint = a.clip_paint(); + let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); + if self.clip_paints.len() > 1024 { + self.clip_paints.clear(); + } + self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone())); + (fill, stroke) + } + /// Cached BezPath of a path node. fn path_of(&mut self, a: &Arc) -> Option> { let key = Arc::as_ptr(a) as usize; @@ -672,14 +695,24 @@ impl Renderer { } NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip - // group doesn't isolate: blending inside it reaches the art below. + // group doesn't isolate: blending inside it reaches the art below. The clipping + // path's fill paints behind the clipped art and its stroke over it, unclipped. if let Some((clip, rest)) = children.split_first() && let Some(region) = self.clip_of(clip) { + let (fill, stroke) = self.clip_paint_of(clip); let blends = self.blends_through(n); let bounds = if blends { Some(region.0.bounding_box()) } else { None }; let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() }; - self.group(ctx, f, comp, &mut |r, c, fr| r.draw_children(c, fr, rest, knockout)); + self.group(ctx, f, comp, &mut |r, c, fr| { + if let Some(fill) = &fill { + r.draw_arc(c, fr, fill); + } + r.draw_children(c, fr, rest, knockout) + }); + if let Some(stroke) = &stroke { + self.draw_arc(ctx, f, stroke); + } } } NodeKind::Path { path, rule, guide, .. } => { diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 5494eefb..1db73de1 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -15,6 +15,11 @@ fn rect(d: &mut Document, r: Rect, c: [f32; 3]) -> Node { Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(c[0], c[1], c[2])), Paint::None, 0.0)) } +/// An unpainted clipping path, as Make Clipping Mask leaves it (a painted one paints too). +fn clip_path(d: &mut Document, r: Rect) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)) +} + /// A Multiply rectangle at 20..80 (the group's content). fn multiply(d: &mut Document) -> Node { let mut n = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S); @@ -98,7 +103,7 @@ fn a_group_blend_mode_applies_again_to_the_non_isolated_result() { #[test] fn a_clip_group_does_not_isolate_its_children() { let d = doc(|d| { - let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]); + let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0)); let m = multiply(d); Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(m)], clip: true }) }); @@ -112,7 +117,7 @@ fn a_non_isolated_group_inside_a_clip_group_stays_clipped() { // The half-opaque group copies its backdrop while the clip is in force: the clip still applies // to it and to what follows. let d = doc(|d| { - let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]); + let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0)); let m = multiply(d); let g = group(d, vec![m], 0.5, Blend::Normal, false); let after = rect(d, Rect::new(0.0, 60.0, 100.0, 100.0), S); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 282a9946..38cf6320 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -806,11 +806,32 @@ impl Writer<'_> { self.def(1, ""); let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); - self.line(&format!("")); + // The clipping path's fill paints behind the clipped art and its stroke over it, + // outside the clip (the group then wraps both). + let paint = clip.clip_paint(); + if paint.stroke.is_some() { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } self.depth += 1; + if let Some(fill) = &paint.fill { + self.node(fill); + } self.group_children(n, rest); self.depth -= 1; self.line(""); + if let Some(stroke) = &paint.stroke { + // One element keeps the clipping path's id. + let anonymous = self.anonymous; + self.anonymous |= paint.fill.is_some(); + self.node(stroke); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } } NodeKind::Path { guide: true, .. } => {} NodeKind::Path { path, rule, .. } => { diff --git a/docs/mcp.md b/docs/mcp.md index dc9b9830..566531aa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -239,7 +239,10 @@ like the mask view: the grid shows behind the artboards of the documents that tu `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. +plain group and keeps the clipping path with whatever paint it has. + +A clipping path can be painted after Make (`paint.setFill`, `paint.setStroke`, `stroke.set` with its `ids`): its fill +paints behind the clipped art and its stroke over it, not clipped, on screen and in SVG and PDF. ```json {"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}}