mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 21:10:40 +00:00
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.
This commit is contained in:
@@ -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<bool>) -> (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<bool>,
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let dir: Vec<Vec2> = (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<bool>,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user