From 133e54b970acebd849bb858fffacdde892921a0d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 08:34:35 -0800 Subject: [PATCH] Gradients: a gradient spanning the selection on a lower stroke fits the box that stroke paints With span: "selection", paint.setStroke placed the gradient on the union of each target's topmost stroke box, also when `item` named a lower stroke, including an aligned row of a graph design's art. With a 40 pt lower stroke under a 1 pt topmost one, the gradient reached 0.5 pt past the objects instead of 20 pt. Each target's box is now that of the row it edits. --- crates/engine/src/cmd/paint.rs | 25 ++++++++++++++++--------- crates/engine/src/tests/gradspan.rs | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 6370f8dbf9..61b734b4d4 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -555,9 +555,15 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let of = super::appearance::reference_of(s, p, item, cmd)?; let paint = if span { - // With a design's art, the gradient spans only the objects with a row equal to the art's, which take it. - let spanned: Vec = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect(); - placed_across(&s.doc()?.doc, &spanned, paint, p, fill) + // With a design's art, the gradient spans only the objects with a row equal to the art's, which take it. Each + // object's box is that of the row it edits. + let d = &s.doc()?.doc; + let mut spanned = Vec::with_capacity(ids.len()); + for (id, it) in ids.iter().zip(item.per_target_of(s, of, &ids)) { + let (Some(it), Some(n)) = (it, d.node(*id)) else { continue }; + spanned.push((*id, it.resolve(&n.appearance, fill, cmd)?)); + } + placed_across(d, &spanned, paint, p, fill) } else { paint }; @@ -730,14 +736,15 @@ fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result Ok(true) } -/// `paint` placed once on the union of the bounds of `ids` (each one's fill box, or the box its -/// stroke paints), at the gradient's angle and aspect: every target gets the same vector in -/// document coordinates, so the ramp runs on across them (#2127). -fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bool) -> Paint { +/// `paint` placed once on the union of the bounds of `ids` (each one's fill box, or the box the +/// stroke it edits paints: the row given with it, `None` for the topmost), at the gradient's angle +/// and aspect: every target gets the same vector in document coordinates, so the ramp runs on +/// across them (#2127). +fn placed_across(d: &Document, ids: &[(NodeId, Option)], paint: Paint, p: &Value, fill: bool) -> Paint { let union = ids .iter() - .filter_map(|id| d.node(*id)) - .filter_map(|n| item_paint_bounds(n, None, fill).or_else(|| n.geometric_bounds())) + .filter_map(|(id, index)| Some((d.node(*id)?, *index))) + .filter_map(|(n, index)| item_paint_bounds(n, index, fill).or_else(|| n.geometric_bounds())) .filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) .reduce(|a, b| a.union(b)); let (Some(union), Paint::Gradient(g)) = (union, &paint) else { return paint }; diff --git a/crates/engine/src/tests/gradspan.rs b/crates/engine/src/tests/gradspan.rs index 9674204642..664afdbdb6 100644 --- a/crates/engine/src/tests/gradspan.rs +++ b/crates/engine/src/tests/gradspan.rs @@ -119,6 +119,28 @@ fn strokes_span_the_targets_too() { assert!(ga.start.x < 0.0 && ga.end.x > 400.0, "the stroke's box, half its weight out: {ga:?}"); } +#[test] +fn a_lower_stroke_row_spans_the_box_that_row_paints() { + let mut s = session(); + let a = rect(&mut s, 0.0, 50.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 50.0, 100.0, 100.0); + // [Fill, Stroke 40 pt, Stroke 1 pt] on both. + s.execute("appearance.addStroke", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0, b.0], "item": 1, "weight": 40})).unwrap(); + for aligned in [false, true] { + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "item": 1, "aligned": aligned, "gradient": black_to_white(), "span": "selection"})) + .unwrap(); + for id in [a, b] { + let Paint::Gradient(g) = &s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_at(Some(1)).unwrap().paint else { + panic!("expected a gradient on stroke row 1") + }; + // The 40 pt row reaches 20 pt past the union 0..400, not the 1 pt topmost stroke's 0.5 pt. + let geom = g.geom.expect("placed in document coordinates"); + assert!(near(geom.start, Point::new(-20.0, 100.0)) && near(geom.end, Point::new(420.0, 100.0)), "aligned {aligned}, {id:?}: {geom:?}"); + } + } +} + #[test] fn the_new_art_default_stays_unplaced() { let mut s = session();