diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 677eea308..629f5b6c9 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -34,6 +34,8 @@ const MESH_ROWS: usize = 16; const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. const PROFILE_STEP: f64 = 1.0 / 64.0; +/// The most points either way a rib looks along the path for how wide the stroke gets near it. +const WINDOW_POINTS: usize = 256; /// Flattening tolerance, cover margin and growth (see [`gradient_slices`]) of meshes and written /// slices (document points). const SLICE_TOL: f64 = 0.05; @@ -78,14 +80,26 @@ struct Lay<'a> { st: &'a StrokeLayer, /// Half the stroked width ([`aligned_width`]). hw: f64, - /// How far ribs reach either side of the path where nothing wider is near. - cover: f64, + /// How far ribs reach past the stroke's edge. margin: f64, /// How far a strip runs on past the start and end of an open subpath (caps and heads). ext: (f64, f64), } impl Lay<'_> { + /// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider + /// side, just before or just after a step there. + fn half(&self, t: f64) -> f64 { + let Some(p) = &self.st.profile else { return self.hw }; + let ((bl, br), (al, ar)) = p.around(t); + self.hw * bl.max(br).max(al).max(ar).max(0.0) + } + + /// How far a rib at fraction `t` reaches either side of the path to cover the stroke there. + fn cover(&self, t: f64) -> f64 { + self.half(t) + self.margin + } + /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: /// left; `None`: both, a centred stroke). fn band(&self, t: f64, side: Option) -> (f64, f64) { @@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, if !(hw > 0.0 && hw.is_finite()) { return vec![]; } - let full = hw * st.profile_max(); let heads = (st.head_reach(false), st.head_reach(true)); - let cap = cap_extent(st.cap, 2.0 * full); - let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) }; + let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) }; + // Caps reach as far past the ends as the stroke is wide there. + let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t)); + lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin); let subs = subpaths(bp); let count = subs.len(); let mut out = vec![]; @@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, // A subpath of no length: a dot (round and projecting caps paint it). 1 => { let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1)); - vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)] + let r = lay.cover(0.0); + vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)] } _ => { let total = length(&pts, closed); @@ -215,9 +231,59 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); - // Ribs near an arrowhead reach across it. - let reach = |s: f64| { - let mut r = lay.cover; + // How far along the subpath each point is. + let mut along = Vec::with_capacity(n); + let mut s = 0.0; + for i in 0..n { + along.push(s); + s += pts[i].distance(pts[(i + 1) % n]); + } + // How far the ribs at point `i` reach: past the stroke there and on its way to the points + // either side, where it is no wider than at one of the two (each side of a width profile + // stays between its widths at the points of a span, and its points have points here). The + // caps and dots of a dashed stroke reach along the path as far as it is wide where they sit, + // so there the ribs cover the widest the stroke gets that far either way. Not as far as the + // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part + // reaching that far would paint over a wide part beside it (#5960). The start of a closed + // subpath is also its end. Ribs near an arrowhead reach across it. + let cover = |i: usize| { + let t = along[i] / total; + if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t) } + }; + let widest = lay.hw * lay.st.profile_max() + lay.margin; + let window = match lay.st.dash { + Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()), + _ => 0.0, + }; + let reach = |i: usize| { + let mut r = cover(i); + for forward in [false, true] { + // Out to the first point past the window (bounded: past that many, as wide as anywhere). + let (mut j, mut d, mut past) = (i, 0.0, false); + for _ in 0..WINDOW_POINTS { + let k = match (closed, forward) { + (true, true) => (j + 1) % n, + (true, false) => (j + n - 1) % n, + (false, true) if j + 1 < n => j + 1, + (false, false) if j > 0 => j - 1, + _ => { + past = true; + break; + } + }; + r = r.max(cover(k)); + d += pts[j].distance(pts[k]); + j = k; + if d > window { + past = true; + break; + } + } + if !past { + r = r.max(widest); + } + } + let s = along[i]; if heads.0 > 0.0 && s <= heads.0 { r = r.max(heads.0 + lay.margin); } @@ -227,29 +293,26 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, r }; let mut out = Vec::with_capacity(n + 4); - let mut s = 0.0; if !closed { let (d, b) = (dir[0], lay.band(0.0, side)); - out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b)); - out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b)); - s = pts[0].distance(pts[1]); + out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b)); + out.push(Rib::square(pts[0], d, reach(0), 0.0, b)); } let (first, last) = if closed { (0, n) } else { (1, n - 1) }; for i in first..last { let v = i % n; - let t = s / total; - let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side)); + let t = along[v] / total; + let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side)); // A closed subpath starts after the fan of its first corner and ends with it. let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 }; out.extend(ribs.into_iter().skip(skip)); - s += pts[v].distance(pts[(v + 1) % n]); } if closed { - out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side))); + out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side))); } else { let (d, b) = (dir[seg_count - 1], lay.band(1.0, side)); - out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b)); - out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b)); + out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b)); + out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b)); } out } diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index bf63b46b8..78fb8315e 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -237,3 +237,52 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() { } } } + +/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of +/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece +/// runs the way the path does; across, square to it. +fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option { + slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| { + let d = s.to - s.from; + (d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along) + }) +} + +/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one +/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as +/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it. +#[test] +fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() { + // Right along y = 50, round, then down through the first leg at x = 50, near the narrow end. + let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false); + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let mut st = stroke(10.0, mode); + st.profile = Some(WidthProfile::taper_end()); + let sl = slices(&crossing, &st); + // In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg, + // which is less than 1 pt either side of itself there. + let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode); + assert_eq!(level, Some(true), "{mode:?}: painted by the second leg"); + // Where the two overlap, the second leg is on top. + let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode); + assert_eq!(level, Some(false), "{mode:?}: painted by the first leg"); + assert_covered(&crossing, &st); + } +} + +/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge. +#[test] +fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { + // Right along y = 0, then back along y = 7: the way back is the narrow end of a taper. + let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false); + let total = 207.0; + let mut st = stroke(10.0, StrokeGradientMode::Along); + st.profile = Some(WidthProfile::taper_end()); + let sl = slices(&hairpin, &st); + // Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the + // way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8. + let p = Point::new(50.0, 3.0); + let t = param(&sl, p).expect("in a slice"); + assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); + assert_covered(&hairpin, &st); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index af0f12e59..592465351 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Stroke gradients: where a variable-width stroke crosses or runs close by itself, a narrow part no longer paints over a wide part beside it, #5960; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | +| 2026-11-07 | minor | Stroke gradients along or across a variable-width stroke: where the stroke crosses or runs close by itself, a narrow part paints over a wide part only where the two overlap, instead of in a square as wide as the stroke gets anywhere (#5960). Each slice reaches as far as the stroke is wide there (for a dashed stroke, wherever a cap or dot can reach). Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. |