Effects: a painted clipping path's stroke keeps its Transform stroke scaling

A fill or stroke of a painted clipping path whose Transform effect scales
strokes or moves patterns is now drawn through its art, as an export bakes
such a path, so a 2 pt stroke scaled to 200 % paints at 4 pt.

The HPGL writer closes the stroke pieces of a clipping path whose fill
paints, as it closes the outline of any filled path.
This commit is contained in:
dotCooCoo
2026-11-14 22:16:28 -08:00
parent db1feeea10
commit e70272b592
4 changed files with 100 additions and 4 deletions
+21 -4
View File
@@ -596,6 +596,9 @@ struct Walk<'a> {
clips: Vec<Region>,
/// The points those masks hold ([`MAX_MASK_POINTS`]).
mask_points: usize,
/// Set while the stroke pieces of a clipping path whose fill paints are drawn: they close, as
/// its fill closes its outline.
closing: bool,
runs: Vec<Run>,
points: usize,
clip_tests: u64,
@@ -624,6 +627,7 @@ impl<'a> Walk<'a> {
tolerance,
clips: vec![],
mask_points: 0,
closing: false,
runs: vec![],
points: 0,
clip_tests: 0,
@@ -855,9 +859,15 @@ impl<'a> Walk<'a> {
}
// Drawn whole where its stroke paints: a fill closes its outline, and type's characters
// without a stroke are drawn too. Pieces of a clipping path whose fill and stroke effects
// were applied are drawn in its stroke pieces alone.
// were applied are drawn in its stroke pieces alone, closed where its fill paints.
if let Some(stroke) = &paint.stroke {
self.node(if first.extras().clip_pieces { stroke } else { first }, false);
if first.extras().clip_pieces {
let closing = std::mem::replace(&mut self.closing, paint.fill.as_ref().is_some_and(fills_in_pieces));
self.node(stroke, false);
self.closing = closing;
} else {
self.node(first, false);
}
}
}
@@ -877,7 +887,8 @@ impl<'a> Walk<'a> {
/// A path drawn in its color: the topmost stroke's that paints, else the topmost fill's that
/// paints (visible, with a paint and some opacity). A filled path's every subpath is closed,
/// as its fill closes it, also when its stroke gives the color.
/// as its fill closes it, also when its stroke gives the color, and so is each stroke piece
/// of a clipping path whose fill paints ([`Walk::closing`]).
fn shape(&mut self, n: &Node, bp: &BezPath) {
if bp.elements().is_empty() {
return;
@@ -899,7 +910,7 @@ impl<'a> Walk<'a> {
(None, None) => return,
};
let rgb = self.flat(paint);
self.draw(rgb, bp, fill.is_some());
self.draw(rgb, bp, fill.is_some() || self.closing);
}
/// `bp` drawn in `rgb`: flattened (with `close` every subpath closed), then cut and added.
@@ -1155,6 +1166,12 @@ fn strokes_in_pieces(n: &Node) -> bool {
n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_stroke(c).is_some() || strokes_in_pieces(c)))
}
/// Whether a member of `n`, at any depth of its groups, has a fill that paints ([`top_fill`]),
/// with it and the groups around it shown and not fully transparent.
fn fills_in_pieces(n: &Node) -> bool {
n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_fill(c).is_some() || fills_in_pieces(c)))
}
/// The paint of the topmost fill of `n` that paints: visible, with a paint and some opacity.
fn top_fill(n: &Node) -> Option<&Paint> {
n.appearance.items.iter().rev().find_map(|i| match i {
+34
View File
@@ -793,6 +793,40 @@ fn a_clipping_path_is_drawn_where_it_paints() {
// In a group at 0% opacity they are not drawn, and the clipping path is drawn in its fill.
let faded = grouped(0.0);
assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]);
// An open clipping path with a fill is drawn closed also where its stroke's effect moved it.
let open_moved = |fill_opacity: f32| {
doc_with(|d| {
let mut open = BezPath::new();
open.move_to((20.0, 20.0));
open.line_to((40.0, 20.0));
open.line_to((40.0, 40.0));
let a = Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0);
let mut clip = Node::path(d.alloc_id(), PathData::from_bezpath(&open), a);
for item in &mut clip.appearance.items {
match item {
AppearanceItem::Fill(f) => f.opacity = fill_opacity,
AppearanceItem::Stroke(s) => {
let params = serde_json::json!({"moveH": 5});
s.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params, visible: true });
}
}
}
let black = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), black);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
})
};
let filled = open_moved(1.0);
let t = text(&plot(&filled, &opts(&filled)));
assert!(t.ends_with("SP2;PU353,1129;PD635,1129,635,847,353,1129;PU;SP0;"), "{t}");
// A fill at 0% opacity does not close it.
let unfilled = open_moved(0.0);
let t = text(&plot(&unfilled, &opts(&unfilled)));
assert!(t.ends_with("SP2;PU353,1129;PD635,1129,635,847;PU;SP0;"), "{t}");
}
/// A symbol `art` and an instance of it at its place.
+11
View File
@@ -91,6 +91,17 @@ fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option<Nod
.iter()
.zip(&p.appearance.items)
.map(|(item, carried)| {
// A fill or stroke whose Transform effect scales strokes or moves patterns is drawn
// through its art, as an export bakes such a path ([`crate::reshape`]).
let mut whole = Node::path(n.id, g.clone(), Appearance { items: vec![carried.clone()], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut whole.kind {
*r = rule;
}
if crate::has_transform_art(&whole)
&& let Some(art) = crate::reshape(None, &whole, None)
{
return std::sync::Arc::new(art);
}
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() });
+34
View File
@@ -459,6 +459,40 @@ fn a_painted_clipping_paths_fill_and_stroke_effects_apply_before_the_effects_aro
assert_eq!(stroke.children().map(|ch| ch.len()), Some(1), "only the stroke's group: {stroke:?}");
}
#[test]
fn a_painted_clipping_paths_stroke_keeps_the_width_its_transform_scales_it_to() {
// A compound clipping path with a 2 pt stroke whose Transform scales strokes to 200 % without
// transforming the object: the export paints the stroke at 4 pt.
let square = Node::path(NodeId(4), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
let mut clip = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(square)], rule: vectorcraft_geom::FillRule::NonZero });
clip.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0).into();
let scale = json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true});
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Stroke(s) = item {
s.effects.push(fx("distort.transform", scale.clone()));
}
}
fn widths(n: &Node, out: &mut Vec<f64>) {
for item in &n.appearance.items {
if let vectorcraft_doc::AppearanceItem::Stroke(s) = item {
out.push(s.width);
}
}
n.children().into_iter().flatten().for_each(|c| widths(c, out));
}
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(2, clip)).unwrap();
let baked = baked_clip(&d);
let stroke = baked.clip_paint().stroke.unwrap();
let mut found = vec![];
widths(&stroke, &mut found);
assert!(!found.is_empty() && found.iter().all(|w| (w - 4.0).abs() < 1e-6), "{found:?} {stroke:?}");
// The stroke is still drawn where the path lies, as the Transform does not move the object.
let bounds = stroke.geometric_bounds().unwrap();
assert!((bounds.x0 - 50.0).abs() < 0.5 && (bounds.width() - 100.0).abs() < 0.5, "{bounds:?}");
}
#[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