diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 95c8ecfb7a..2cbebf7085 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -827,7 +827,9 @@ impl<'a> Walk<'a> { let mut paint = first.clip_paint(); // A stroke at 0% opacity isn't drawn, so the clipping path is drawn in its fill. let type_stroke = |s: &Node| matches!(&s.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.has_stroke())); - paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s)); + // The strokes of a clipping path whose stroke effects were applied are a group of pieces, + // which effects around it may have put in groups of their own. + paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s) || strokes_in_pieces(s)); if paint.stroke.is_none() && let Some(fill) = &paint.fill { @@ -852,9 +854,10 @@ impl<'a> Walk<'a> { None => {} } // Drawn whole where its stroke paints: a fill closes its outline, and type's characters - // without a stroke are drawn too. - if paint.stroke.is_some() { - self.node(first, false); + // without a stroke are drawn too. Pieces of a clipping path whose fill and stroke effects + // were applied are drawn in its stroke pieces alone. + if let Some(stroke) = &paint.stroke { + self.node(if first.extras().clip_pieces { stroke } else { first }, false); } } @@ -1146,6 +1149,12 @@ fn top_stroke(n: &Node) -> Option<&StrokeLayer> { }) } +/// Whether a member of `n`, at any depth of its groups, has a stroke that paints ([`top_stroke`]), +/// with it and the groups around it shown and not fully transparent. +fn strokes_in_pieces(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_stroke(c).is_some() || strokes_in_pieces(c))) +} + /// The paint of the topmost fill of `n` that paints: visible, with a paint and some opacity. fn top_fill(n: &Node) -> Option<&Paint> { n.appearance.items.iter().rev().find_map(|i| match i { diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 60f7cf5b3d..f283d31807 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -747,6 +747,52 @@ fn a_clipping_path_is_drawn_where_it_paints() { }); let t = text(&plot(&open_doc, &opts(&open_doc))); assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}"); + // A stroke with a geometry effect of its own is drawn once, over the art it clips, in its color. + let moved = doc_with(|d| { + let (blue, red) = (Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0))); + let mut clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(blue, red, 1.0)); + for item in &mut clip.appearance.items { + if let AppearanceItem::Stroke(s) = item { + let params = serde_json::json!({"moveH": 5}); + s.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params, visible: true }); + } + } + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + assert_eq!(plot(&moved, &opts(&moved)).pens, [[0, 0, 0], [255, 0, 0]]); + // So are the stroke's pieces when an effect around them put them in a group of their own. + let grouped = |opacity: f32| { + doc_with(|d| { + // One path for each, carrying only that fill or stroke. + let piece = |d: &mut Document, fill: bool| { + let mut a = Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + a.items.retain(|i| matches!(i, AppearanceItem::Fill(_)) == fill); + Arc::new(Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), a)) + }; + let (fill, stroke) = (piece(d, true), piece(d, false)); + let mut copies = Node::group(d.alloc_id(), vec![stroke]); + copies.opacity = opacity; + let mut clip = Node::group(d.alloc_id(), vec![fill, Arc::new(copies)]); + clip.extras_mut().clip_pieces = true; + let black = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), black); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let shown = grouped(1.0); + assert_eq!(plot(&shown, &opts(&shown)).pens, [[0, 0, 0], [255, 0, 0]]); + // In a group at 0% opacity they are not drawn, and the clipping path is drawn in its fill. + let faded = grouped(0.0); + assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]); } /// A symbol `art` and an instance of it at its place. diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index cb66ba1148..b26fa52e1c 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -74,19 +74,32 @@ impl Node { /// 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. + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths, and so does + /// a group of the pieces a clipping path's fills and strokes draw ([`crate::NodeExtras::clip_pieces`]), + /// even after effects around it split or copy those pieces into groups: each part is then that + /// group keeping only the pieces of those fills or strokes. pub fn clip_paint(&self) -> ClipPaint { use crate::appearance::AppearanceItem; + let item = |fill: bool, 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, + }; + if matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().clip_pieces { + let part = |fill: bool| { + let mut paints = false; + let mut n = pieces_of(self, fill, &|i| item(fill, i), &mut paints)?; + n.extras_mut().clip_pieces = false; + paints.then_some(n) + }; + return ClipPaint { fill: part(true), stroke: part(false) }; + } 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 item = |i: &AppearanceItem| item(fill, i); 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)) }; @@ -110,6 +123,30 @@ impl Node { } } +/// `n`, a group of a clipping path's pieces or one of them, keeping only the pieces of its fills +/// (`fill`) or of its strokes, at every depth of the groups an effect put them in; `None` when it +/// holds none. `paints` is set when one of the kept fills or strokes paints. +fn pieces_of(n: &Node, fill: bool, paints_item: &dyn Fn(&crate::appearance::AppearanceItem) -> bool, paints: &mut bool) -> Option { + use crate::appearance::AppearanceItem; + if let NodeKind::Group { children, clip: false } = &n.kind { + let kept: Vec<_> = children.iter().filter_map(|c| pieces_of(c, fill, paints_item, paints).map(std::sync::Arc::new)).collect(); + if kept.is_empty() { + return None; + } + let mut m = n.clone(); + if let Some(ch) = m.children_mut() { + *ch = kept; + } + return Some(m); + } + let items = &n.appearance.items; + if !items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) == fill) { + return None; + } + *paints |= items.iter().any(paints_item); + Some(n.clone()) +} + /// What a clipping path paints ([`Node::clip_paint`]). #[derive(Clone, Debug, Default)] pub struct ClipPaint { diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index e13750457e..f75e473039 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -79,6 +79,10 @@ pub struct NodeExtras { /// knockout group its knockout shape is the whole area of its triangle, not only where the /// image is opaque ([`Node::knockout_outline`]). pub warp_piece: bool, + /// A group of the pieces a clipping path's fills and strokes draw once their geometry effects + /// are applied, one path or compound path per fill and stroke: as the clipping path of a clip group it paints + /// those pieces as the path paints its fills and strokes ([`Node::clip_paint`]). + pub clip_pieces: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -108,6 +112,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { pixel_aligned: false, color_mask: None, warp_piece: false, + clip_pieces: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 1aad3a27cf..e774a24972 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -369,7 +369,7 @@ impl Node { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // 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(_)) { + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) || clip.extras().clip_pieces { clip.visual(symbols) } else { clip.geometric(symbols) diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 06e68a79d0..db21762f61 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -24,6 +24,7 @@ fn extras_are_written_flat_in_order() { e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); e.pixel_aligned = true; e.warp_piece = true; + e.clip_pieces = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -71,11 +72,13 @@ fn extras_are_written_flat_in_order() { "envelope_edit_margin", "pixel_aligned", "warp_piece", + "clip_pieces", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); assert_eq!(v["pixel_aligned"], true); assert_eq!(v["warp_piece"], true); + assert_eq!(v["clip_pieces"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 885f9a5853..c47097b8cc 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -57,6 +57,7 @@ impl Serialize for Node { is_default(&e.pixel_aligned), e.color_mask.is_none(), is_default(&e.warp_piece), + is_default(&e.clip_pieces), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -102,6 +103,7 @@ impl Serialize for Node { field!("pixel_aligned", &e.pixel_aligned); field!("color_mask", &e.color_mask); field!("warp_piece", &e.warp_piece); + field!("clip_pieces", &e.clip_pieces); st.end() } } @@ -249,6 +251,8 @@ pub(super) struct NodeWire { color_mask: Option>, #[serde(default)] warp_piece: bool, + #[serde(default)] + clip_pieces: bool, } impl From for Node { @@ -279,6 +283,7 @@ impl From for Node { pixel_aligned: w.pixel_aligned, color_mask: w.color_mask, warp_piece: w.warp_piece, + clip_pieces: w.clip_pieces, }; Node { id: w.id, diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index cd9d67fc43..ee543d7c49 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -31,9 +31,10 @@ pub fn reshaped_clip_outline(clip: &Node) -> Option> /// become a path of their reshaped region. A path or compound path whose fills or strokes have /// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects /// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their -/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the -/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a -/// geometry effect. Effects on the clip group or around it then reshape the result, after its own. +/// union, and the group is marked as such pieces +/// ([`vectorcraft_doc::NodeExtras::clip_pieces`]), so a clipping path that paints still paints +/// them ([`Node::clip_paint`]). `None` when nothing in it has a geometry effect. Effects on the +/// clip group or around it then reshape the result, after its own. pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { with_clip_effects_applied_while(n, || true) } @@ -42,18 +43,20 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the /// members after it are not visited. The members of a group or layer that are not visited stay as /// they are, with their effects. A compound path with geometry effects of its own still becomes a -/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or -/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both -/// cases the paths in it that are not visited go -/// into that geometry as they are, and their own effects are not applied. +/// path of its reshaped geometry. A path or compound path whose fills or strokes have geometry +/// effects still becomes a group of one path per fill and stroke. In both cases the paths in it +/// that are not visited go into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { - let paint = n.clip_paint(); - clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) + let mut m = clip_effects_applied(n, true, take)?; + if matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) && matches!(m.kind, NodeKind::Group { clip: false, .. }) { + m.extras_mut().clip_pieces = true; + } + Some(m) } -/// [`with_clip_effects_applied_while`]; with `items` unset (a clipping path that paints, and the -/// paths of a compound path, which draw with the compound path's fills and strokes), the geometry -/// effects of `n`'s own fills and strokes are left out. +/// [`with_clip_effects_applied_while`]; with `items` unset (the paths of a compound path, which +/// draw with the compound path's fills and strokes), the geometry effects of `n`'s own fills and +/// strokes are left out. fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option { let mut m: Option = None; let members: &[std::sync::Arc] = match &n.kind { diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index bf63df61ab..d4fbf2c66e 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -386,9 +386,77 @@ fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { let baked = bake_document(&d).unwrap(); let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); assert!(close(exported), "exported: {exported:?}"); - // A clipping path that paints its fill keeps it, a path with the fill's effect live. + // A clipping path that paints its fill clips alike and paints the fill it clips by. let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); - assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); + assert!(close(clip_outline(&painted).unwrap().0.bounding_box()), "painted: {painted:?}"); + let fill = painted.clip_paint().fill.unwrap(); + assert!(close(fill.geometric_bounds().unwrap()), "painted fill: {fill:?}"); +} + +#[test] +fn a_painted_clipping_paths_fill_and_stroke_effects_apply_before_the_effects_around_it() { + // A clipping path with a black fill grown by Offset Path 10 and a 2 pt blue stroke moved 30 pt + // right by a Transform, in a group that its Transform scales to 50 %: the fill paints the grown + // square at half size, 60 pt across, and the stroke the square at half size, 15 pt right of + // it, on the canvas and in an export alike. + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("path.offsetPath", json!({"offset": 10}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveH": 30}))), + } + } + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip.clone()))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + fn first_clip(n: &Node) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let check = |what: &str, c: &Node| { + let paint = c.clip_paint(); + let fill = paint.fill.as_ref().and_then(Node::geometric_bounds).unwrap(); + assert!((fill.width() - 60.0).abs() < 0.5 && (fill.height() - 60.0).abs() < 0.5, "{what} fill: {fill:?}"); + let stroke = paint.stroke.as_ref().and_then(Node::geometric_bounds).unwrap(); + assert!((stroke.width() - 50.0).abs() < 0.5 && (stroke.x0 - fill.x0 - 20.0).abs() < 0.5, "{what} stroke: {stroke:?} fill: {fill:?}"); + }; + let evaluated = crate::evaluate_container(None, &outer).unwrap(); + let canvas = first_clip(&evaluated).unwrap(); + check("canvas", &canvas); + // The clip group's box takes in the stroke's width, as for a path that paints its stroke. + let stroke = canvas.clip_paint().stroke.as_ref().and_then(Node::geometric_bounds).unwrap(); + let visual = evaluated.children().and_then(|ch| ch.first()).and_then(|g| g.visual_bounds()).unwrap(); + assert!(visual.x1 > stroke.x1 + 0.25, "{visual:?} {stroke:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + check("exported", &baked_clip(&d)); + // Without effects around it, the export paints the fill and the stroke as the canvas does. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(2, clip.clone())).unwrap(); + let paint = baked_clip(&d).clip_paint(); + let fill = paint.fill.as_ref().and_then(Node::geometric_bounds); + let stroke = paint.stroke.as_ref().and_then(Node::geometric_bounds); + assert_eq!(fill.map(|f| f.width()), Some(120.0), "{paint:?}"); + assert_eq!(stroke.map(|s| s.x0), Some(80.0), "{paint:?}"); + // A piece that an effect around it splits into several paths (a compound path), or puts in a + // group with its copies, still paints. + let NodeKind::Group { children, .. } = &canvas.kind else { panic!("not the pieces: {canvas:?}") }; + let mut split = canvas.as_ref().clone(); + let mut compound = children[0].as_ref().clone(); + compound.kind = NodeKind::Compound { children: vec![children[0].clone(), children[0].clone()], rule: vectorcraft_geom::FillRule::NonZero }; + let copies = Node::group(NodeId(9), vec![children[1].clone(), children[1].clone()]); + if let Some(ch) = split.children_mut() { + ch[0] = Arc::new(compound); + ch[1] = Arc::new(copies); + } + let paint = split.clip_paint(); + assert!(paint.fill.is_some() && paint.stroke.is_some(), "{split:?}"); + let stroke = paint.stroke.unwrap(); + assert_eq!(stroke.children().map(|ch| ch.len()), Some(1), "only the stroke's group: {stroke:?}"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4424700be..46604bdfb7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1658,7 +1658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export. When the clipping path has a fill or stroke of its own, the geometry effects of those fills and strokes apply after the group's geometry effects instead. Type used as a clipping path keeps its geometry effects when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. + The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export, also when the clipping path paints a fill or stroke of its own: that fill and stroke draw the shapes their effects give them. Type used as a clipping path keeps its geometry effects when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. Clicks, Select Behind, the Unlock submenu, marquees and the Enclosing Lasso inside a clip group or a clipping layer follow that area too: the clipped art is found where the effects moved it and not where they took it away. The clipping path itself is still found by its own outline and anchors. A hidden member of a group used as a clipping path does not clip, and a geometry effect on it does not change where clicks land. Outline view and layers with Preview off draw the plain clipping path, and clicks and marquees there count it. Direct Selection picks and outlines an object of a dynamic symbol's art by the same rules. `doc::hit::tests` and the engine's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.