diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2cffad6581..27d3194eb3 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -26,20 +26,20 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, }; -fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { +pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { AppearanceItem::Fill(f) => &f.effects, AppearanceItem::Stroke(s) => &s.effects, } } -fn clear_item_effects(item: &mut AppearanceItem) { +pub(crate) fn clear_item_effects(item: &mut AppearanceItem) { match item { AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), @@ -74,7 +74,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { /// `path` with the geometry effects of `effects` applied, and how gradients follow them /// ([`crate::Carry`]). -fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { +pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { if !has_geometry(effects) || path.is_empty() { return (path.clone(), crate::Carry::default()); } @@ -86,7 +86,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext /// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient /// moved along with them and the Transform effects the geometry pass left out hidden /// ([`crate::Carry::hide_skipped`]). -fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { +pub(crate) fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; if carry.moves(&ap) { @@ -202,7 +202,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return bake_leaf(d, n); } let mut m = n.clone(); - let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + // A clipping path takes the geometry effects in it as the canvas clips by it, those of a + // compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]); + // the pieces it is made of then get ids of their own. + let clip = usize::from(n.clips()); + let baked: Vec> = ch + .iter() + .enumerate() + .map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() { + Some(a) => { + let mut a = bake_node(d, &a).unwrap_or(a); + fresh_ids(d, &mut a, &mut std::collections::HashSet::new()); + Arc::new(a) + } + None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()), + }) + .collect(); if let Some(ch) = m.children_mut() { *ch = baked; } @@ -407,7 +422,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())), } } - None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + None if clipping => clipping_text_outline(c), None => as_art(Some(d), c, None), }; if let Some(o) = art { @@ -469,7 +484,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of /// its effects, and `n` without those effects, its gradients moved along with them. -fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { +pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); let mut m = n.clone(); diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index f3855f57ca..d2ba694149 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -793,7 +793,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { } #[test] -fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { +fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() { if skip_without_craft_fonts() { return; } @@ -808,9 +808,9 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { g.children_mut().unwrap().push(Arc::new(clip)); let d = bake_document(&doc_with(g)).unwrap(); assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. + // The type clips by its outlines moved by its own effect, as the canvas clips by them. let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); + assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}"); } #[test] diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index a3e31f1842..60cc3f2845 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -7,9 +7,124 @@ 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). +/// its members. The live geometry effects of the clipping path (and of the members it clips by) +/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's +/// fills and strokes: the region is then the union of the paths they give, as an export bakes them. +/// `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) + match clip_effects_applied(clip, true) { + Some(m) => m.clip_outline(Some(&outline_text), &unite), + None => clip.clip_outline(Some(&outline_text), &unite), + } +} + +/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by +/// applied, innermost first, and removed: a path or compound path with effects becomes a path of +/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the +/// effects, as an export bakes it), the paths of a compound path and the visible members of a group +/// are reshaped by their own effects before the effects around them, a group's or layer's own +/// effects reshape its members as an export evaluates them, and other objects with effects (type) +/// 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. +pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) +} + +/// [`with_clip_effects_applied`]; 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. +fn clip_effects_applied(n: &Node, items: bool) -> Option { + let mut m: Option = None; + let members: &[std::sync::Arc] = match &n.kind { + NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children, + _ => &[], + }; + // A group or layer clips by its visible members after its shaper; a compound path by all of + // its paths, and a nested clip group by its clipping path, shown or hidden + // ([`Node::clip_shapes`]). + let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. }); + let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some()); + // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. + let compound = matches!(n.kind, NodeKind::Compound { .. }); + for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { + if let Some(r) = clip_effects_applied(c, items && !compound) + && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = std::sync::Arc::new(r); + } + } + let items = items + && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i))); + if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + let children = n + .appearance + .items + .iter() + .zip(&p.appearance.items) + .map(|(item, carried)| { + let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx); + crate::bake::clear_item_effects(&mut it); + let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut piece.kind { + *r = rule; + } + std::sync::Arc::new(piece) + }) + .collect(); + // The path's transparency and raster effects stay on the group of its pieces. + p.appearance.items.clear(); + p.kind = NodeKind::Group { children, clip: false }; + return Some(p); + } + if !crate::has_geometry(&n.appearance.effects) { + return m; + } + let cur = m.as_ref().unwrap_or(n); + // A group's or layer's effects reshape each of its members (a clip group's its clipping + // path), as an export evaluates them; otherwise they reshape its region, below. + if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) + && let Some(e) = crate::group::evaluate_container(None, cur) + { + return Some(e); + } + // A path or compound path, as an export bakes it. + if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) { + let clipping = match &cur.kind { + NodeKind::Path { clipping, .. } => *clipping, + NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })), + _ => false, + }; + p.kind = Node::path(n.id, g, Appearance::default()).kind; + if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind { + (*r, *c) = (rule, clipping); + } + return Some(p); + } + // Type, as its painted outlines that its effects reshape as an export evaluates them. + if matches!(cur.kind, NodeKind::Text(_)) + && let Some(mut o) = crate::outline_text(cur) + { + o.visible = n.visible; + o.appearance.effects = n.appearance.effects.clone(); + if let Some(e) = crate::group::evaluate_container(None, &o) { + return Some(e); + } + } + let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?; + let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n)); + let mut p = Node::path(n.id, g, Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + p.visible = n.visible; + Some(p) } /// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 4df0a105ce..0db50ef925 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -845,9 +845,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every /// depth of its groups and layers, by the art it draws ([`evaluate_container`], /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members -/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the -/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with -/// everything in it. Calls nested in one another, through the +/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry +/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D +/// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members /// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at /// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. @@ -855,6 +856,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { let Some(_frame) = DrawnFrame::enter() else { return }; let clip = usize::from(n.clips()); let Some(ch) = n.children_mut() else { return }; + // The clipping path's own geometry effects shape it before the effects around it do. + if clip == 1 + && let Some(first) = ch.first_mut() + && let Some(applied) = crate::clip::with_clip_effects_applied(first) + { + *first = Arc::new(applied); + } for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { if !DrawnFrame::take_member() { return; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 8e00718c01..947bf262f5 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { assert!((w - 250.0).abs() < 1e-6, "{w}"); } -/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +/// A clip group's clipping path and the clipped members take their own effects first. #[test] -fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { +fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() { let mut clip = with_effect(square_at_100(), offset_10()); clip.id = NodeId(2); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { let clip_group = art.children().unwrap()[0].clone(); let ch = clip_group.children().unwrap(); assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); - assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied"); + let w = ch[0].geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}"); assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 83ff68431f..ee7d3f8322 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -689,18 +689,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, } } +/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its +/// visibility, or with geometry effects as a path of the region they give it, which is what +/// [`crate::clip_outline`] clips by. +pub(crate) fn clipping_text_outline(t: &Node) -> Option { + if let Some(p) = crate::clip::with_clip_effects_applied(t) { + return Some(p); + } + let o = outline_text(t)?; + Some(Node { visible: t.visible, ..o }) +} + /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting -/// their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and +/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { - NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + NodeKind::Text(_) if clipping => clipping_text_outline(c), NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0023fc5b16..c43d383c19 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod clipfx; mod imageoutline; mod painted; mod rasterfollow; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs new file mode 100644 index 0000000000..370f32fa81 --- /dev/null +++ b/crates/effects/src/tests/clipfx.rs @@ -0,0 +1,401 @@ +//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its +//! geometry effects give it, on the canvas as in an export. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, Shape, shapes}; + +use crate::{bake_document, clip_outline}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A 100 pt square clipping path at (50, 50) with `effects`. +fn clipping_path(id: u64, effects: Vec) -> Node { + let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut p.kind { + *clipping = true; + } + p.appearance.effects = effects; + p +} + +/// A clip group: `clip` clipping a red 200 pt square at the origin. +fn clip_group(id: u64, clip: Node) -> Node { + let art = Node::path( + NodeId(id + 100), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) +} + +fn bounds(n: &Node) -> Rect { + clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap() +} + +/// The clipping path of the first clip group in `doc` after an export bakes it. +fn baked_clip(doc: &Document) -> Arc { + fn first(n: &Arc) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(first), + } + } + let baked = bake_document(doc).unwrap(); + baked.layers.iter().find_map(first).unwrap() +} + +#[test] +fn transform_moves_the_clip() { + let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]); + let b = bounds(&clip); + assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}"); + assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn roughen_clips_as_an_export_does() { + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))])); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.1, exported.1); + assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square"); +} + +#[test] +fn nested_members_keep_their_effects() { + // A group as the clipping path: its member's Transform effect moves its part of the region. + let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]); + let group = Node::group(NodeId(1), vec![Arc::new(moved)]); + let b = bounds(&group); + assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}"); + // A clip group as the clipping path clips by its own clipping path, effects and all. + let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))])); + let b = bounds(&nested); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + // So does the nested clip group's own effect. + let mut nested = clip_group(7, clipping_path(8, vec![])); + nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30}))); + let b = bounds(&nested); + assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}"); + // The group's own Transform effect applies on top of its members'. + let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]); + group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10}))); + let b = bounds(&group); + assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_roughened_group_clips_as_an_export_does() { + // Two members side by side, Roughen on the group: each member is roughened, as in the export. + let member = |id: u64, x: f64| { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }; + let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]); + clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap(); + let live = clip_outline(&clip).unwrap(); + assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0)); +} + +#[test] +fn a_compound_paths_member_keeps_its_effect() { + // A ring whose hole a Transform effect moves 20 pt right, as the export bakes it. + let ring = |effects: Vec| { + let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default()); + hole.appearance.effects = effects; + Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd }) + }; + let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap(); + let live = clip_outline(&moved).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves"); + // With a Transform of the compound path's own moving the whole ring 10 pt down after it. + let mut both = moved.clone(); + both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap(); + let live = clip_outline(&both).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down"); +} + +#[test] +fn the_clipping_paths_effects_apply_before_the_clip_groups() { + // Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the + // half-size square grown by 10, as if the clipping path had been drawn at half size. + let group = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + g + }; + let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))])); + let mut half = clipping_path(2, vec![]); + if let NodeKind::Path { path, .. } = &mut half.kind { + *path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)); + } + let want = bounds(&group(half)); + let got = bounds(&scaled); + assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}"); + assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}"); + // The clip group as the canvas evaluates it for its effect, and as an export bakes it. + let evaluated = crate::evaluate_container(None, &scaled).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let b = clip_outline(&children[0]).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, scaled).unwrap(); + let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}"); +} + +#[test] +fn nested_effects_apply_before_the_clip_groups() { + // The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a + // Transform moves a member 60 pt right: the region grows from the moved member. + let evaluated = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + let e = crate::evaluate_container(None, &g).unwrap(); + let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") }; + clip_outline(&children[0]).unwrap().0.bounding_box() + }; + let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]); + let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5; + let want = Rect::new(100.0, 40.0, 220.0, 160.0); + // A group holding the member. + let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())])); + assert!(close(b, want), "group: {b:?}"); + // A compound path of the member, with a Transform of its own moving it 10 pt down. + let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let b = evaluated(compound); + assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}"); +} + +#[test] +fn clipping_type_keeps_its_effect_where_other_type_stays_type() { + // Type moved 30 pt right by its Transform clips a red square, beside other type, in a group + // that a Transform moves 10 pt down: the export keeps the other type as type and outlines the + // clipping type, which still clips where the canvas clips. + use vectorcraft_doc::text::{CharStyle, TextObject}; + let text = |id: u64, effects: Vec| { + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style)))); + t.appearance.effects = effects; + t + }; + let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))])); + let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + fn clip_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()), + _ => n.children()?.iter().find_map(|c| clip_of(c)), + } + } + if vectorcraft_text::CRAFT_FONTS.is_empty() { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box(); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap(); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut exported = None; + for l in &baked.layers { + exported = exported.or_else(|| clip_of(l)); + } + let exported = exported.unwrap(); + assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}"); + assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}"); + // The outlined clipping type keeps its black fill, which paints behind the clipped art. + let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap(); + fn fills(n: &Node) -> usize { + n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count() + + n.children().into_iter().flatten().map(|c| fills(c)).sum::() + } + assert!(fills(&outlined) > 0, "{outlined:?}"); +} + +#[test] +fn a_painted_clipping_paths_gradient_follows_its_effect() { + // A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform + // effect, in a clip group with an effect of its own: the gradient moves with the path. + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]); + clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into(); + let mut group = clip_group(1, clip); + group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0}))); + let evaluated = crate::evaluate_container(None, &group).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") }; + let start = g.geom.unwrap().start; + assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}"); +} + +#[test] +fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() { + // A clip group used as a clipping path clips by its clipping path whether it is shown or not. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]); + hidden.visible = false; + let outer = clip_group(1, clip_group(3, hidden)); + d.insert(Some(layer), 0, outer.clone()).unwrap(); + let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") }; + let b = bounds(&children[0]); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes"); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { + // The clipping path's fill has a Transform effect moving it 60 pt right; the path has none. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut clip = clipping_path(2, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("distort.transform", json!({"moveH": 60}))); + } + } + let group = clip_group(1, clip); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes"); + assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box()); + // A path of a compound clipping path with a fill effect of its own stays part of the region. + let mut member = clipping_path(4, vec![]); + member.appearance = children[0].appearance.clone(); + let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()); + let compound = + Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero }); + assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0)); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { + // Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform + // scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the + // clipping path is the member of a group that clips by its members. + let clip_with = |fill: Paint| { + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + } + } + clip + }; + let scaled = |clip: Node| { + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + outer + }; + 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 clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box(); + let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5; + let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap()); + assert!(close(nested), "nested: {nested:?}"); + let outer = scaled(clip_with(Paint::None)); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()); + assert!(close(canvas), "canvas: {canvas:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + 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. + 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:?}"); +} + +#[test] +fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() { + // An unpainted clipping path whose fill and stroke have Transform effects, in a clip group + // without effects: the export bakes it into a piece for each, every object with its own id. + let mut d = Document::new(400.0, 400.0); + let (group, path) = (d.alloc_id(), d.alloc_id()); + let mut clip = clipping_path(path.0, vec![]); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))), + } + } + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap(); + let baked = bake_document(&d).unwrap(); + let clip = baked_clip(&d); + assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}"); + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let mut all = vec![]; + for l in &baked.layers { + ids(l, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + +#[test] +fn a_hidden_member_with_an_effect_adds_nothing() { + let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default()); + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]); + hidden.visible = false; + let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]); + assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0)); +} + +#[test] +fn raster_and_hidden_effects_leave_the_clip_as_it_is() { + let mut hidden = fx("distort.transform", json!({"moveH": 60})); + hidden.visible = false; + for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] { + assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0)); + } +} diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index ce76713e74..65ce8a91fd 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { - art.visual_bounds_with(symbols) + art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b)) } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) }?; @@ -671,7 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let b = n.visual_bounds_with(symbols)?; let b = match &n.kind { NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), - _ => b, + _ => with_clip_region(symbols, n, b), }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) @@ -679,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b } } +/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping +/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`]) +/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`. +fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect { + match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { + Some(c) if effects::needs_bake(c) => { + let s = c.appearance.outset(); + let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); + let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; + vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) + } + _ => b, + }, + _ => b, + } +} + /// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate), /// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no /// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index f649453663..15bd354496 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -122,7 +122,8 @@ impl Renderer { /// When a plug-in has been installed or removed since the last check /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, - /// of symbol instances and of the objects that hold them, and the art drawn for the effects of + /// of symbol instances and of the objects that hold them, the regions of clipping paths that + /// have one or hold one, and the art drawn for the effects of /// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, /// `live::resources_key`). @@ -148,6 +149,8 @@ impl Renderer { self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); + // A clipping path's plug-in effects shape the region it clips to. + self.clips.retain(|(node, _)| !holds_plugin_effect(node)); } } diff --git a/crates/render/src/tests/clip.rs b/crates/render/src/tests/clip.rs index 7b04c02e2d..205c2a2583 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -95,6 +95,79 @@ fn nothing_to_clip_by_hides_the_art() { assert_eq!(r.pixel(50, 50), CLEAR); } +#[test] +fn the_clipping_paths_geometry_effects_shape_the_clip() { + // A Transform effect moving the left half 50 pt right: the right half shows instead. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let r = clipped(d, Arc::new(clip)); + assert_eq!(r.pixel(25, 50), CLEAR, "the clipping path's own place"); + assert_eq!(r.pixel(75, 50), BLACK, "where its effect moves it"); +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { + // The clipping path covers x 0 to 50 and its Transform effect moves it to x 50 to 100; the + // view shows x 60 to 100, past the clipping path's own shape. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + 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 members = vec![Arc::new(clip), Arc::new(art)]; + let g = Node::new(d.alloc_id(), NodeKind::Group { children: members.clone(), clip: true }); + // The same in a sublayer that clips by its first object. + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind { + (*children, *clip) = (members, true); + } + for clipping in [g, sublayer] { + let mut d = d.clone(); + let l = d.layers[0].id; + d.insert(Some(l), 0, clipping).unwrap(); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip"); + } +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() { + // As above, with the Transform effect on the clipping path itself or on its fill, and the clip + // group with an effect of its own: a Drop Shadow, or a Transform that moves nothing. + let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }; + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}), + visible: true, + }; + let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true }; + for on_fill in [false, true] { + for own in [shadow.clone(), still.clone()] { + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + match clip.appearance.items.iter_mut().find_map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f), + _ => None, + }) { + Some(f) => f.effects.push(moved()), + None => clip.appearance.effects.push(moved()), + } + 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 mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + g.appearance.effects.push(own.clone()); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let what = format!("effect on the fill: {on_fill}, group effect {}", own.id); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})"); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})"); + } + } +} + #[test] fn clip_regions_are_cached_per_clipping_path() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/render/src/tests/container.rs b/crates/render/src/tests/container.rs index 89126cf2a2..dfc8dca91c 100644 --- a/crates/render/src/tests/container.rs +++ b/crates/render/src/tests/container.rs @@ -217,9 +217,9 @@ fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geomet #[test] fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() { - // The red square is clipped by type with a Transform of its own, which the clip leaves out, - // then by a group of that type and a small square, with the type shown and hidden. Offset - // Path 0 on the clip group leaves every path as it is. + // The red square is clipped by type with a Transform of its own, then by a group of that type + // and a small square, with the type shown and hidden. Offset Path 0 on the clip group leaves + // every path as it is, so the clip group draws the same with it and without it. let doc = |effect: bool, grouped: bool, hidden: bool| { let mut d = Document::new(100.0, 100.0); let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() }; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 1f7a49698c..a9226da834 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1667,6 +1667,16 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() { plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone()); let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0})); plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone()); + // A clip group whose clipping path, the square, has the plug-in effect: it clips a red + // rectangle over the whole view by the shape the plug-in draws. + let mut d = Document::new(200.0, 100.0); + let mut clip = square(&mut d, 20.0, 30.0, vec![]); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]); + let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(red)], clip: true }); + plugin_change_draws_as_new(with_art(d, vec![group]), ID, small.clone(), big.clone()); // An instance of symbol `Shaped`, a group of the square around the origin. let mut d = Document::new(200.0, 100.0); let art = square(&mut d, -20.0, -20.0, vec![]); diff --git a/crates/svg/tests/it/effects_export.rs b/crates/svg/tests/it/effects_export.rs index 832e72c8f8..c1a19e8d53 100644 --- a/crates/svg/tests/it/effects_export.rs +++ b/crates/svg/tests/it/effects_export.rs @@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() { assert!(!export(&doc_with(n), &ExportOptions::default()).contains("").expect("its end")]; + body[body.find('>').unwrap()..].to_string() + }; + assert_eq!(clipped(0.0, true), clipped(50.0, false), "the clip path is written where the effect moves it"); + assert_ne!(clipped(0.0, true), clipped(0.0, false)); +} + #[test] fn geometry_effects_on_type_export_as_its_reshaped_outlines() { let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f5a4c40569..d6d6b682fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1590,6 +1590,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, and type used as a clipping path keeps them 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. - **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. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports.