diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index adbdf20b48..27d3194eb3 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -39,7 +39,7 @@ pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] } } -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), @@ -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; } diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index e39deb32b5..60cc3f2845 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -24,17 +24,21 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { /// 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. The geometry effects of a path's fills and strokes stay -/// live, for the export to bake. `None` when nothing in it has a geometry effect. Effects on the -/// clip group or around it then reshape the result, after its own. +/// 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 { - clip_effects_applied(n, false) + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) } -/// [`with_clip_effects_applied`]; with `region` set, a path or compound path whose fills or strokes -/// have geometry effects becomes a group of one unpainted path per fill and stroke, reshaped by the -/// path's effects and then by that fill's or stroke's, whose union [`clip_outline`] clips by. -fn clip_effects_applied(n: &Node, region: bool) -> Option { +/// [`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(), @@ -49,33 +53,35 @@ fn clip_effects_applied(n: &Node, region: bool) -> Option { // 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, region && !compound) + 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 = region + 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, p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + 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, _) = crate::bake::apply_item(item, carried, &g, &ctx); - let mut piece = Node::path(n.id, ig, Appearance::default()); + 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(); - let mut out = Node::new(n.id, NodeKind::Group { children, clip: false }); - out.visible = n.visible; - return Some(out); + // 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; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index c513be72c3..370f32fa81 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -131,6 +131,15 @@ fn a_compound_paths_member_keeps_its_effect() { 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] @@ -296,6 +305,83 @@ fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { 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()); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 7816d3f187..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,18 +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), - // The geometry effects in a clipping path move or grow the region it clips to - // ([`effects::clip_outline`]) past its plain shape, and its stroke paints along it. - 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, + _ => with_clip_region(symbols, n, b), }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) @@ -690,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/tests/clip.rs b/crates/render/src/tests/clip.rs index cdd2028441..205c2a2583 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -131,6 +131,43 @@ fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { } } +#[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);