diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 27d3194eb3..f75490557d 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -201,6 +201,9 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { { return bake_leaf(d, n); } + if let Some(m) = clip_group_with_type_clip(d, n) { + return Some(m); + } let mut m = n.clone(); // 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`]); @@ -226,6 +229,62 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { bake_leaf(d, n) } +/// Clip group or clipping layer `n` whose clipping path is type with geometry effects (its own, its +/// fills' or strokes', or a perspective), baked as the canvas draws it: the type becomes a +/// clipping path of the region the canvas clips to ([`crate::clip_outline`]), its fills (with their +/// effects baked) paint behind the clipped art inside the clip, and its strokes paint over it +/// outside the clip, in a group that `n` becomes with its clip group inside ([`Node::clip_paint`]). +/// The other members are baked as usual. `None` for other objects and when the type has nothing to +/// clip by. +fn clip_group_with_type_clip(d: &mut Document, n: &Node) -> Option { + let (first, rest) = n.children()?.split_first()?; + if !n.clips() + || !matches!(first.kind, NodeKind::Text(_)) + || !(has_geometry(&first.appearance.effects) || first.projection().is_some() || has_item_geometry(first)) + { + return None; + } + let (bp, rule) = crate::clip_outline(first)?; + let mut clip = Node::path(first.id, PathData::from_bezpath(&bp), Appearance::default()); + if let NodeKind::Path { rule: r, clipping, .. } = &mut clip.kind { + (*r, *clipping) = (rule, true); + } + clip.name = first.name.clone(); + clip.visible = first.visible; + let paint = first.clip_paint(); + // The type's fills and strokes as art: baked with the type's id, as the renderer draws them, + // then given ids of their own. + let mut seen = std::collections::HashSet::from([n.id, first.id]); + let mut art = |d: &mut Document, part: Option| { + part.map(|p| { + let mut a = bake_node(d, &p).unwrap_or(p); + fresh_ids(d, &mut a, &mut seen); + Arc::new(a) + }) + }; + let fill = art(d, paint.fill); + let stroke = art(d, paint.stroke); + let members: Vec> = + std::iter::once(Arc::new(clip)).chain(fill).chain(rest.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()))).collect(); + let mut m = n.clone(); + let Some(stroke) = stroke else { + if let Some(ch) = m.children_mut() { + *ch = members; + } + return Some(m); + }; + // `n` keeps its id, name and transparency, and holds the clip group and the strokes over it. + let inner = Node::new(d.alloc_id(), NodeKind::Group { children: members, clip: true }); + match &mut m.kind { + NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } => { + *children = vec![Arc::new(inner), stroke]; + *clip = false; + } + _ => return None, + } + Some(m) +} + /// `m`, art evaluated from an object, with its children baked and then given ids of their own (the /// pieces copied from the source keep the source's ids until then). The pieces are baked with the /// ids the renderer draws them with: Random in a Transform effect takes its seed from the id. diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index d2ba694149..8181f0e02e 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -2,7 +2,7 @@ use super::*; use serde_json::json; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; -use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_geom::{Point, Rect, Shape, shapes}; /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. fn skip_without_craft_fonts() -> bool { @@ -813,6 +813,56 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() { assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}"); } +#[test] +fn clipping_type_whose_fill_has_a_geometry_effect_clips_by_its_plain_outlines() { + if skip_without_craft_fonts() { + return; + } + let near = |a: Rect, b: Rect, tol: f64| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < tol); + for stroke in [Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0))] { + // Type that clips a square, with a Transform effect on its own fill. + let mut t = type_node(60); + t.appearance = Appearance::basic(Paint::solid(Color::BLACK), stroke.clone(), 1.0).into(); + for item in &mut t.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.effects.push(fx("distort.transform", json!({"moveH": 300}))); + } + } + let square = Node::path(NodeId(61), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + let clip = Node::new(NodeId(62), NodeKind::Group { children: vec![Arc::new(t.clone()), Arc::new(square)], clip: true }); + let canvas = crate::clip_outline(&t).map(|(bp, _)| bp.bounding_box()).unwrap(); + assert!(canvas.x1 < 100.0, "the canvas clips by the plain outlines: {canvas:?}"); + let d = bake_document(&doc_with(clip)).unwrap(); + let baked = d.node(NodeId(62)).unwrap(); + // With a stroke, the clip group sits in a group with the stroke over it, outside the clip. + let group = match baked.kind { + NodeKind::Group { clip: true, .. } => { + assert!(stroke.is_none(), "{baked:?}"); + baked + } + _ => { + let ch = baked.children().unwrap(); + assert_eq!(ch.len(), 2, "{baked:?}"); + let strokes = ch[1].appearance.items.iter().all(|i| matches!(i, AppearanceItem::Stroke(_))); + assert!(matches!(ch[1].kind, NodeKind::Text(_)) && strokes, "the type with only its stroke: {:?}", ch[1]); + &ch[0] + } + }; + let ch = group.children().unwrap(); + assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }) && ch.len() == 3, "{group:?}"); + // The export clips by the outlines the canvas clips by. + assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0]); + let b = crate::clip_outline(&ch[0]).map(|(bp, _)| bp.bounding_box()).unwrap(); + assert!(near(b, canvas, 1e-6), "canvas {canvas:?}, export {b:?}"); + // The fills (the characters' and the moved one) paint behind the square, inside the clip. + let fill = ch[1].geometric_bounds().unwrap(); + assert!((fill.x0 - canvas.x0).abs() < 1e-6 && fill.x1 > canvas.x1 + 250.0, "{fill:?}"); + assert_eq!(ch[2].id, NodeId(61)); + let mut ids = std::collections::HashSet::new(); + baked.walk(&mut |n| assert!(ids.insert(n.id), "duplicate id {}", n.id)); + } +} + #[test] fn type_with_its_own_transform_effect_copies_stays_type() { if skip_without_craft_fonts() {