Merge pull request #7476 from dotCooCoo/fix/svg-clip-path-effects

Effects: a clipping path's geometry effects shape the clip on the canvas
This commit is contained in:
BFlat
2026-11-14 20:38:37 +07:00
committed by GitHub
15 changed files with 718 additions and 30 deletions
+24 -9
View File
@@ -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<Node> {
return bake_leaf(d, n);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = 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<Arc<Node>> = 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<Node> {
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();
+3 -3
View File
@@ -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]
+117 -2
View File
@@ -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<Node> {
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<Node> {
let mut m: Option<Node> = None;
let members: &[std::sync::Arc<Node>] = 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
+11 -3
View File
@@ -845,9 +845,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// 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;
+5 -3
View File
@@ -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");
}
+14 -4
View File
@@ -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<Node> {
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,
};
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod clipfx;
mod imageoutline;
mod painted;
mod rasterfollow;
+401
View File
@@ -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<Effect>) -> 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<Node> {
fn first(n: &Arc<Node>) -> Option<Arc<Node>> {
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<Effect>| {
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<Effect>| {
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<Rect> {
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::<usize>()
}
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<Arc<Node>> {
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<NodeId>) {
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));
}
}
+20 -2
View File
@@ -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
+4 -1
View File
@@ -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));
}
}
+73
View File
@@ -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);
+3 -3
View File
@@ -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() };
+10
View File
@@ -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![]);
+31
View File
@@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() {
assert!(!export(&doc_with(n), &ExportOptions::default()).contains("<filter "));
}
#[test]
fn a_clipping_paths_transform_effect_moves_the_written_clip_path() {
// A clip group whose clipping path covers x 0 to 50; `effect` moves it 50 pt right.
let clipped = |x0: f64, effect: bool| {
let mut clip =
Node::path(NodeId(0), shapes::rectangle(Rect::new(x0, 0.0, x0 + 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
if effect {
clip.appearance.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 50}), visible: true });
}
let art = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut d = Document::new(200.0, 200.0);
let (clip, art) = (Node { id: d.alloc_id(), ..clip }, Node { id: d.alloc_id(), ..art });
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let svg = export(&d, &ExportOptions::default());
let open = svg.find("<clipPath").expect("a clip path");
let body = &svg[open..open + svg[open..].find("</clipPath>").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() };
+1
View File
@@ -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.