From 8f975aa0c5afba150fa8cfd3079d7094d67ccaf3 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 12:15:55 +0100 Subject: [PATCH 1/3] Fix #5960: a narrow part of a variable-width stroke paints over a wide part only where they overlap With a width profile, every slice of a stroke gradient (along or across) reached as far as the stroke is wide anywhere. Where the stroke crosses or runs close by itself, the later, narrower part painted a square of its colour over the wider part beside it, clipped only to the whole outline. Each rib now reaches as far as the stroke is wide there and on its way to the next points (each side of a profile stays between its widths at a span's points); for a dashed stroke, as wide as it gets within a cap's reach, since caps and dots reach along the path. --- crates/effects/src/stroke/gradient.rs | 101 ++++++++++++++++---- crates/effects/src/stroke/tests_gradient.rs | 49 ++++++++++ docs/target-app-parity.md | 3 +- 3 files changed, 133 insertions(+), 20 deletions(-) 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. | From 6d18549edc67726e39aaf0bb2fdb8fbeade72b59 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sat, 7 Nov 2026 13:05:03 +0100 Subject: [PATCH 2/3] Fix #5960: evidence entry in the parity document --- docs/target-app-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 592465351..56b22bf95 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -597,6 +597,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. +- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap` and `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). From b2acf71764299f777c6cc08b01fa41f3b0d96719 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Sun, 8 Nov 2026 09:40:09 +0100 Subject: [PATCH 3/3] Fix #5960: find how far gradient ribs reach in one pass along the path Each rib of a dashed stroke with a width profile looked up to 256 points either way along the path, asking the profile at every step, and reached as wide as the stroke gets anywhere past that. A sliding maximum over the points' precomputed cover now gives every rib's reach in time in proportion to the points, with no bound and no fallback to the widest width (review on #6093). --- crates/effects/src/stroke/gradient.rs | 103 ++++++++++++-------- crates/effects/src/stroke/tests_gradient.rs | 61 ++++++++++++ docs/target-app-parity.md | 4 +- 3 files changed, 125 insertions(+), 43 deletions(-) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 629f5b6c9..1cb79e9c7 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -13,6 +13,8 @@ //! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh //! ([`gradient_meshes`]). +use std::collections::VecDeque; + use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; @@ -34,8 +36,6 @@ 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; @@ -231,13 +231,16 @@ 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(); - // 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 each point is from the next, and along the subpath. + let seg: Vec = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect(); + let along: Vec = seg + .iter() + .scan(0.0, |s, l| { + let at = *s; + *s += l; + Some(at) + }) + .collect(); // 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 @@ -246,43 +249,18 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, // 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 cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect(); 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 ahead = widest_ahead(&cover, &seg, closed, window); + // Behind a point is ahead of it on the subpath run backwards. + let back_seg: Vec = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect(); + let back_cover: Vec = cover.iter().rev().copied().collect(); + let behind: Vec = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect(); 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 mut r = cover[i].max(ahead[i]).max(behind[i]); let s = along[i]; if heads.0 > 0.0 && s <= heads.0 { r = r.max(heads.0 + lay.margin); @@ -317,6 +295,49 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, out } +/// For each of the points of a subpath, how far the ribs of the points ahead of it reach +/// (`cover`), out to the first point more than `window` along (or the end of an open subpath, +/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last, +/// the first when `closed`). Both ends of the window only move forward, so a queue of the +/// points that reach farther than every point after them gives each maximum, and a subpath +/// costs time in proportion to its points however many a window spans. +pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec { + let n = cover.len().min(seg.len()); + if n == 0 { + return vec![]; + } + // A closed subpath's points ahead are numbered on round it once more. + let last = if closed { 2 * n - 1 } else { n - 1 }; + let mut pos = Vec::with_capacity(last + 1); + let mut s = 0.0; + for k in 0..=last { + pos.push(s); + s += seg[k % n]; + } + let mut out = Vec::with_capacity(n); + let mut queue: VecDeque = VecDeque::new(); + let mut next = 1; + for i in 0..n { + next = next.max(i + 1); + let stop = if closed { i + n } else { last }; + let mut past = next > i + 1 && pos[next - 1] - pos[i] > window; + while !past && next <= stop { + let r = cover[next % n]; + while queue.back().is_some_and(|&k| cover[k % n] <= r) { + queue.pop_back(); + } + queue.push_back(next); + past = pos[next] - pos[i] > window; + next += 1; + } + while queue.front().is_some_and(|&k| k <= i) { + queue.pop_front(); + } + out.push(queue.front().map_or(0.0, |&k| cover[k % n])); + } + out +} + /// A piece of a stroke whose gradient runs along or across it ([`gradient_slices`]). #[derive(Clone, Debug)] pub struct Slice { diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 78fb8315e..a0056d6b3 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -286,3 +286,64 @@ fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); assert_covered(&hairpin, &st); } + +/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along +/// the path, however many points lie that far: not as far as it is wide anywhere when more than +/// 256 do. +#[test] +fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() { + // The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt. + let mut pts = vec![(0.0, 0.0), (100.0, 0.0)]; + pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0))); + let hairpin = poly(&pts, false); + let total = 207.0; + let mut st = stroke(10.0, StrokeGradientMode::Along); + st.profile = Some(WidthProfile::taper_end()); + st.cap = LineCap::Round; + st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() }); + let sl = slices(&hairpin, &st); + 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); +} + +/// The widest the ribs ahead reach is the widest over the points out to the first one more than +/// the window along, as a walk along the subpath finds it, open and closed. +#[test] +fn widest_ahead_is_the_widest_within_the_window() { + let mut seed = 0x9e37_79b9_u64; + let mut rnd = move || { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for _ in 0..200 { + let n = 1 + (rnd() * 40.0) as usize; + let cover: Vec = (0..n).map(|_| rnd() * 10.0).collect(); + // Some points on top of each other. + let seg: Vec = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect(); + let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize]; + for closed in [false, true] { + let got = super::gradient::widest_ahead(&cover, &seg, closed, window); + assert_eq!(got.len(), n); + for (i, &got) in got.iter().enumerate() { + let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64); + for _ in 0..n { + let k = if closed { + (j + 1) % n + } else if j + 1 < n { + j + 1 + } else { + break; + }; + want = want.max(cover[k]); + d += seg[j]; + j = k; + if d > window { + break; + } + } + assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}"); + } + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56b22bf95..4dc76f8dd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -34,7 +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 | 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, found in one pass along the path however densely it is flattened). 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`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone`, `widest_ahead_is_the_widest_within_the_window`. 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. | @@ -597,7 +597,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). - **Escape deselects width points:** with the Width tool, Escape deselects the selected width points, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for deselecting a width point. The path, its widths and the undo history do not change. With no width point selected, Escape does what it does with the other tools, such as leaving isolation mode. - **Smooth widths between width points (#5714):** a variable-width stroke changes width along a smooth curve from one width point to the next, where it used to run in a straight line and turn a corner at each point. The public description is that the path "gradually gets wider or narrower from one point to the next" in "a flowing, continuous curve", and that stacked points make a sharp break ([Peachpit, Expressive Strokes](https://www.peachpit.com/articles/article.aspx?p=2979069)); the exact curve is not documented, so each side follows a monotone cubic (Fritsch–Carlson): no kink at a point, flat where a point is wider or narrower than both neighbours, never past the points either side, points on one line kept on it. The outline samples straight runs between width points closely enough to follow the curve to the drawing tolerance (at most 64 samples per span and 4,096 per piece). Presets with a middle point (Lens, Pinch, Teardrop, Wave) look softer; two-point tapers are unchanged. Tests: `appearance::profile::tests::{widths_run_smoothly_through_width_points, widths_never_pass_the_points_either_side, a_step_ends_the_curve_on_each_side, widest_covers_every_width_along_the_profile}`, `stroke::width::tests::{a_straight_run_follows_the_curve_between_width_points, samples_between_width_points_are_capped}`. -- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap` and `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). +- **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. 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`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html).