From e70272b5923d63a58d75fe39819b45730dcbf09b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:11:59 -0800 Subject: [PATCH] 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. --- crates/cad/src/hpgl/write.rs | 25 ++++++++++++++++++---- crates/cad/src/hpgl/write/tests.rs | 34 ++++++++++++++++++++++++++++++ crates/effects/src/clip.rs | 11 ++++++++++ crates/effects/src/tests/clipfx.rs | 34 ++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 2cbebf7085..bb397d7eaa 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -596,6 +596,9 @@ struct Walk<'a> { clips: Vec, /// 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, 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 { diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index f283d31807..493e53906c 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -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. diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index ee543d7c49..8a8730f66d 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -91,6 +91,17 @@ fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option) { + 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