From 0e6151011df6f638112c99bd8b6ec39c8fba71d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:30:45 -0800 Subject: [PATCH] Export: clipping type with geometry effects clips where the canvas does Type used as a clip group's clipping path, with a geometry effect on the object, on one of its fills or strokes, or a perspective, was baked into its outlined pieces with the effects applied, so SVG, PDF and the other exports clipped by all of them while the canvas clips by the plain glyph outlines. The baked clipping path was also a group, so the type's fills and strokes were not painted. bake_node now bakes such a clip group as the canvas draws it: the clipping path is a path of the region effects::clip_outline gives, the type's fills (effects baked) paint behind the clipped art inside the clip, and its strokes paint over it outside the clip, in a group that keeps the clip group's id and transparency. --- crates/effects/src/bake.rs | 59 ++++++++++++++++++++++++++++++++ crates/effects/src/bake/tests.rs | 52 +++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2cffad6581..fe83d129bd 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(); let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); if let Some(ch) = m.children_mut() { @@ -211,6 +214,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 f3855f57ca..3334dd8704 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_is_outlined_plain() { assert!(b.y0 > 30.0 && b.y1 < 70.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() {