From 9da56717a0a9dc74acf1e2b9b149daa72000fba7 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Fri, 6 Nov 2026 19:45:33 +0100 Subject: [PATCH 1/3] Fix #5714: widths run smoothly between width points The width between two width points was a straight line, so a variable-width stroke turned a corner at every width point. - Each side of a width profile now follows a monotone cubic (Fritsch-Carlson) through its points: no kink at a point, flat at a point wider or narrower than both neighbours, never past either point of a span. Points on one line stay on it and two-point tapers are unchanged; stacked points still step. - widest() counts each span's wider left and wider right, since the two sides needn't peak together. - The outliner 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); straight spans get none. - Tests that expected straight-line widths now expect the curve. The stroke gradient coverage test uses Teardrop instead of Wave until #5964 is fixed. --- crates/doc/src/appearance/profile.rs | 161 ++++++++++++++++++-- crates/doc/src/appearance/tests.rs | 4 +- crates/effects/src/stroke/tests.rs | 10 +- crates/effects/src/stroke/tests_gradient.rs | 9 +- crates/effects/src/stroke/width.rs | 94 +++++++++++- crates/engine/src/tests/outlinestroke.rs | 5 +- crates/engine/src/tests/widthpoints.rs | 24 +-- docs/target-app-parity.md | 4 +- 8 files changed, 273 insertions(+), 38 deletions(-) diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 01b9c9fc5..0d13dc210 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -22,11 +22,15 @@ pub struct ProfilePreset { } impl WidthProfile { - /// (left, right) width factors at `t`, linear between points. + /// (left, right) width factors at `t`: a smooth curve through the points ([`Self::at_points`]). pub fn at(&self, t: f64) -> (f64, f64) { Self::at_points(&self.points, t) } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through + /// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point + /// without a kink, turns flat at a point wider or narrower than both neighbours, and never + /// goes past either point of the span it is in (#5714). Points on one straight line stay on + /// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); @@ -36,9 +40,21 @@ impl WidthProfile { } // (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.) let i = p.partition_point(|q| q.0 < t); - if let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { - let u = (t - a.0) / (b.0 - a.0).max(1e-9); - return (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u); + if let (Some(j), Some(b)) = (i.checked_sub(1), p.get(i)) + && let Some(a) = p.get(j) + { + let h = b.0 - a.0; + let u = (t - a.0) / h.max(1e-9); + let linear = (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u); + if h <= STEP { + return linear; + } + let side = |v: fn(&(f64, f64, f64)) -> f64, straight: f64| { + let y = hermite(v(a), v(b), tangent(p, j, v) * h, tangent(p, i, v) * h, u); + // Widths read from a file may be huge: where the curve overflows, the straight line. + if y.is_finite() { y } else { straight } + }; + return (side(|q| q.1, linear.0), side(|q| q.2, linear.1)); } p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) } @@ -55,14 +71,17 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; - /// The widest the stroke gets, as a multiple of its weight: the largest total of the left and - /// right factors at any of the points (between them the profile is straight), 1 for a profile - /// without points. + /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each + /// side stays between the widths of the two points of each span ([`Self::at_points`]), but the + /// two sides needn't peak together, so a span counts its wider left and its wider right. pub fn widest(&self) -> f64 { - if self.points.is_empty() { + let p = &self.points; + if p.is_empty() { return 1.0; } - self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max) + let total = |l: f64, r: f64| (l.max(0.0) + r.max(0.0)) / 2.0; + let spans = p.windows(2).map(|w| total(w[0].1.max(w[1].1), w[0].2.max(w[1].2))); + p.iter().map(|&(_, l, r)| total(l, r)).chain(spans).fold(0.0, f64::max) } /// The built-in profile with this id. pub fn preset(id: &str) -> Option { @@ -227,6 +246,41 @@ impl AngleProfile { } } +/// How close two width points' positions are to make a step ([`WidthProfile::around`]). +const STEP: f64 = 1e-7; + +/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in +/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where +/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes +/// of the spans on either side elsewhere (never more than three times either, so no span +/// overshoots its points), and the span's own slope at an end of the profile or beside a step. +fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 { + let Some(q) = p.get(j) else { return 0.0 }; + let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP); + let after = p.get(j + 1).filter(|b| b.0 - q.0 > STEP); + let slope = |a: &(f64, f64, f64), b: &(f64, f64, f64)| (v(b) - v(a)) / (b.0 - a.0); + match (before, after) { + (Some(a), Some(b)) => { + let (d0, d1) = (slope(a, q), slope(q, b)); + if d0 * d1 <= 0.0 { + return 0.0; + } + let (h0, h1) = (q.0 - a.0, b.0 - q.0); + 3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1) + } + (Some(a), None) => slope(a, q), + (None, Some(b)) => slope(q, b), + (None, None) => 0.0, + } +} + +/// The cubic Hermite curve from `y0` to `y1` at `u` (0..1), with end slopes `m0` and `m1` per +/// whole span. +fn hermite(y0: f64, y1: f64, m0: f64, m1: f64, u: f64) -> f64 { + let (u2, u3) = (u * u, u * u * u); + (2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * m0 + (-2.0 * u3 + 3.0 * u2) * y1 + (u3 - u2) * m1 +} + /// The value of samples `p` (position, value) at `t`, linear between them; `None` without any. fn interpolated(p: &[(f64, f64)], t: f64) -> Option { let (first, last) = (p.first()?, p.last()?); @@ -358,4 +412,91 @@ mod tests { let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] }; assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25))); } + + /// The slope of one side of `p` just before and just after `t`. + fn slopes(p: &WidthProfile, t: f64) -> (f64, f64) { + let e = 1e-6; + ((p.at(t).0 - p.at(t - e).0) / e, (p.at(t + e).0 - p.at(t).0) / e) + } + + /// #5714: the width flows through a width point instead of turning a corner there. + #[test] + fn widths_run_smoothly_through_width_points() { + // A swell in the middle: flat at its widest, and a quarter of the way along above the + // straight ramp from 1 to 3 (2). + let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] }; + let (before, after) = slopes(&swell, 0.5); + assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}"); + let (l, r) = swell.at(0.25); + assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}"); + // Rising on both sides of a point: the slope is the same either side of it. + let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] }; + let (before, after) = slopes(&rise, 0.3); + assert!(before > 0.0 && (before - after).abs() < 1e-3 * before, "{before} {after}"); + // Points on one line stay on it. + let straight = WidthProfile { points: vec![(0.0, 0.0, 1.0), (0.25, 0.5, 1.0), (1.0, 2.0, 1.0)] }; + for t in [0.1, 0.25, 0.6, 0.9] { + let (l, r) = straight.at(t); + assert!((l - 2.0 * t).abs() < 1e-12 && (r - 1.0).abs() < 1e-12, "{t}: {l} {r}"); + } + } + + #[test] + fn widths_never_pass_the_points_either_side() { + for preset in WidthProfile::PRESETS { + let p = WidthProfile { points: preset.points.to_vec() }; + for w in p.points.windows(2) { + let (lo, hi) = (w[0].1.min(w[1].1), w[0].1.max(w[1].1)); + for k in 0..=100 { + let t = w[0].0 + (w[1].0 - w[0].0) * k as f64 / 100.0; + let l = p.at(t).0; + assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{}: {l} at {t} outside {lo}..{hi}", preset.id); + } + } + } + // Uneven spacing and a steep jump next to a gentle rise. + let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.05, 1.0, 1.0), (0.9, 1.1, 1.1), (1.0, 5.0, 5.0)] }; + for k in 0..=1000 { + let t = k as f64 / 1000.0; + let l = p.at(t).0; + let span = p.points.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).map(|w| (w[0].1.min(w[1].1), w[0].1.max(w[1].1))); + let (lo, hi) = span.unwrap(); + assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{l} at {t}"); + } + } + + /// A step still breaks the width sharply, and the curve on either side ends at it. + #[test] + fn a_step_ends_the_curve_on_each_side() { + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (0.5, 1.0, 1.0), (1.0, 2.0, 2.0)] }; + assert_eq!(p.around(0.5), ((3.0, 3.0), (1.0, 1.0))); + // Two points between ends: each side of the step is a straight ramp. + assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12); + } + + #[test] + fn widest_covers_every_width_along_the_profile() { + let profiles = [ + vec![(0.0, 1.0, 0.0), (0.5, 0.2, 1.0), (1.0, 1.0, 0.0)], + vec![(0.0, 0.0, 1.0), (0.3, 1.0, 0.0), (0.6, 0.0, 1.0), (1.0, 1.0, 1.0)], + vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)], + ]; + for points in profiles { + let p = WidthProfile { points }; + let most = (0..=1000).map(|k| p.at(k as f64 / 1000.0)).map(|(l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max); + assert!(p.widest() >= most - 1e-12, "{} < {most}: {:?}", p.widest(), p.points); + } + assert_eq!(WidthProfile::lens().widest(), 1.0); + assert_eq!(WidthProfile::default().widest(), 1.0); + } + + /// Widths read from a file may be absurd: the curve stays finite or falls back to the line. + #[test] + fn huge_widths_stay_finite() { + let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, f64::MAX, 1.0), (1.0, -f64::MAX, 0.0)] }; + for k in 0..=10 { + let (l, r) = p.at(k as f64 / 10.0); + assert!(!l.is_nan() && r.is_finite(), "{l} {r}"); + } + } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 089309f34..ebc491800 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -196,7 +196,9 @@ fn strokes_without_pressure_round_trip_unchanged() { fn profile_interp() { let p = WidthProfile::lens(); assert_eq!(p.at(0.5), (1.0, 1.0)); - assert_eq!(p.at(0.25), (0.5, 0.5)); + // Half way up the lens the width curves above the straight ramp (0.5), flattening into its + // widest point (#5714). + assert_eq!(p.at(0.25), (0.625, 0.625)); assert_eq!(p.at(2.0), (0.0, 0.0)); } diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index a79b8e0f7..dbe271cd8 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -231,14 +231,16 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3); assert!(near(plain.area().abs(), 1000.0, 1e-6)); let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); - assert!(near(lens.area().abs(), 500.0, 1e-6)); - // Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over - // 0..25 averages a quarter of the width, the one over 50..75 three quarters. + // The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714). + assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05)); + // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens + // rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers + // its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192. let st = stroke(10.0, |s| { s.profile = Some(WidthProfile::lens()); s.dash = Some(dashed(&[25.0, 25.0], 0.0)); }); - assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6)); + assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05)); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); } diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 4562c374b..b4ffc0573 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -137,8 +137,9 @@ fn across_runs_from_the_left_edge_to_the_right() { let mut lens = st.clone(); lens.profile = Some(WidthProfile::lens()); let sl = slices(&line, &lens); - // A quarter of the way along, the lens is half as wide: its top edge is 2.5 up. - assert!(param(&sl, Point::new(25.0, -2.4)).unwrap() < 0.05); + // A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is + // 3.125 up. + assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05); assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); } @@ -182,7 +183,9 @@ fn slices_cover_everything_the_stroke_paints() { assert_covered(&zig, &st); st.arrow_align = ArrowAlign::Tip; assert_covered(&curve, &st); - st.profile = WidthProfile::preset("wave"); + // Teardrop, not Wave: with most profiles a dash across the zigzag's sharp corner + // paints outside every slice (#5964). Back to Wave once that is fixed. + st.profile = WidthProfile::preset("teardrop"); st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() }); assert_covered(&zig, &st); st.align = StrokeAlign::Outside; diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 6d0a282e8..bcaedc831 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -150,17 +150,56 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, (pts, corner) } -/// Insert samples where the profile has width points (so the piecewise-linear profile is exact -/// along straight runs; the two points of a discontinuous point share one sample). `span` maps the -/// subpath onto the profile (see [`outline_spans`]). -fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec) { +/// The most samples added between two width points. +const MAX_SPAN_SAMPLES: usize = 64; +/// The most samples [`densify`] adds between width points along one piece of a subpath. +const MAX_CURVE_SAMPLES: usize = 4096; + +/// Insert samples where the profile has width points (the two points of a discontinuous point +/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so +/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps +/// the subpath onto the profile (see [`outline_spans`]). +fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec, Vec) { let total = length(pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { return (pts.to_vec(), corner.to_vec()); } + let along = |t: f64| (t - span.0) / range * total; // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). - let marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect(); + let mut marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect(); + let mut added = 0; + for shift in [0.0, 1.0] { + for w in profile.points.windows(2) { + let (a, b) = (&w[0], &w[1]); + let (t0, t1) = (a.0 + shift, b.0 + shift); + if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 { + continue; + } + // A span whose curve is the straight line between its points (a two-point taper, + // points on one line) needs nothing between them. A cubic off the line is off it at a + // quarter or three quarters of the way. + let off_line = |u: f64| { + let (l, r) = profile.at(a.0 + (b.0 - a.0) * u); + half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs()) + }; + if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 { + continue; + } + // A monotone cubic span bends by at most 12 times its change in width over the span + // squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it. + let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs()); + let n = (1.5 * change / tol).sqrt().ceil(); + let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES }; + for k in 1..n { + if added >= MAX_CURVE_SAMPLES { + break; + } + marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64)); + added += 1; + } + } + } split_at(pts, corner, closed, &marks) } @@ -225,7 +264,7 @@ struct Vertex { impl Ctx<'_> { fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) { - let (pts, corner) = densify(pts, corner, closed, self.profile, span); + let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol); let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -385,9 +424,11 @@ mod tests { } #[test] - fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() { + fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { + // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it + // covers 7/12 of the full-width band (a diamond would cover half). let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); - assert!((a - 500.0).abs() < 1e-6, "{a}"); + assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 500.0).abs() < 1e-6); @@ -411,6 +452,43 @@ mod tests { assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } + /// #5714: between width points a straight run follows the profile's smooth curve, not a + /// straight line from one width to the next. + #[test] + fn a_straight_run_follows_the_curve_between_width_points() { + // 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve + // is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt). + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] }; + let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); + for y in [-11.0, 11.0] { + assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}"); + } + for y in [-11.5, 11.5] { + assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}"); + } + // Round the widest point the side is flat: no corner sticks out above 15 pt. + let bb = o.bounding_box(); + assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}"); + } + + /// A taper is a straight ramp: its outline gets no samples between the width points. + #[test] + fn a_straight_taper_gets_no_samples_between_its_points() { + let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01); + assert_eq!(o.elements().len(), 5, "{:?}", o.elements()); + } + + /// A profile read from a file may have thousands of points and absurd widths: the samples added + /// between them stay bounded and the outline finite. + #[test] + fn samples_between_width_points_are_capped() { + let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect(); + let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01); + let n = o.elements().len(); + assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}"); + assert!(o.bounding_box().area().is_finite()); + } + #[test] fn closed_paths_make_a_ring() { let mut sq = BezPath::new(); diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index fad35ad43..926bee91b 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -72,11 +72,12 @@ fn arrowheads_are_part_of_the_outline() { } #[test] -fn a_lens_profile_outlines_to_half_the_rectangle() { +fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() { let mut s = session(); line(&mut s, json!({"weight": 10, "profile": "lens"})); let n = outline(&mut s); - assert!(near(area(&n), 0.5 * 10.0 * 100.0, 2.0), "{}", area(&n)); + // Each half of the lens swells on a cubic, flat at the middle (#5714). + assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); } #[test] diff --git a/crates/engine/src/tests/widthpoints.rs b/crates/engine/src/tests/widthpoints.rs index 786f3b539..fc2b21568 100644 --- a/crates/engine/src/tests/widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -218,16 +218,22 @@ fn new_width_point_without_adjoining_preserves_neighbours() { fn new_width_point_adjust_adjoining_uses_sampled_widths() { for indexed in [false, true] { let (mut s, id) = line_session(); - s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 9, 9], [0.25, 1, 0], [0.75, 3, 0], [1, 7, 7]]})).unwrap(); - // At t=0.5 the old sides are 10pt and 0pt. Indexed editing of that sampled - // point is the existing convention: scale the nonzero side, add from zero. - let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 0}))) } else { None }; + let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)]; + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap(); + // At t=0.5 the old left side is where the profile's curve is there (#5714) and the right + // side is 0pt. Indexed editing of that sampled point is the existing convention: scale the + // nonzero side, add from zero. + let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0; + let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None }; set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true})); - assert_eq!( - points(&s, id), - vec![(0.0, 9.0, 9.0), (0.25, 2.0, 1.0), (0.5, 4.0, 1.0), (0.75, 6.0, 1.0), (1.0, 7.0, 7.0)], - "only nearest neighbours change, using the sampled sides; indexed={indexed}" - ); + let scale = 4.0 / sampled; + let got = points(&s, id); + let want = [(0.0, 9.0, 9.0), (0.25, scale, 1.0), (0.5, 4.0, 1.0), (0.75, 3.0 * scale, 1.0), (1.0, 7.0, 7.0)]; + assert_eq!(got.len(), want.len(), "{got:?}; indexed={indexed}"); + for (g, w) in got.iter().zip(want) { + let close = (g.0 - w.0).abs() < 1e-9 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9; + assert!(close, "only nearest neighbours change, using the sampled sides; indexed={indexed}: {got:?} vs {want:?}"); + } } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b820254c6..757fb0bb3 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-06 · **Change:** minor (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; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; 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-06 · **Change:** minor (Width profiles: smooth widths between width points, #5714; 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 @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-05 | minor | A nearby hit on a plain long stroke or outline avoids assembling its whole path. A hit near the middle of a 200,000-anchor path took about 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine; a miss inside the path's box fell from 18.7 ms to 1.6 ms, while a distant miss was unchanged. Other stroke styles and fill hits keep their existing checks. Scores and estimates unchanged; idle performance budgets remain unverified. | | 2026-11-05 | minor | Translating a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The text appears at its new position with unchanged pixels. Scaling the type, editing its content or formatting, or changing installed fonts rebuilds the geometry; scores and estimates unchanged. | | 2026-11-05 | minor | Moving a large selection reuses the object locations found during command validation. A 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs; object positions, selection, and undo behavior stay the same. Scores and estimates unchanged. | @@ -580,6 +581,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Width-point finite-arithmetic follow-up (G1/M3):** Adjoining adjustments avoid intermediate overflow; non-finite normalized or resulting factors are rejected before committing. Engine regressions in `tests/widthpoints.rs` cover finite extremes, small nonzero results, atomic rejection, Undo/Redo and native save/reopen. Historical focused validation on the `a3357121` integration observed 2 expected RED failures with 11 passing controls, then all 13 tests passing with the repair; none were ignored. All three added regressions passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. Scores and hours unchanged. - **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}`. - **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 d208f5da64b3d3dd9f778ad2857607f46160056f Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Fri, 6 Nov 2026 19:45:33 +0100 Subject: [PATCH 2/3] Contributors: Sven Lafebre's name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index fe8e4ddb1..6d8e37573 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -213,3 +213,6 @@ sync_github_name = false [people.adikshan11] real_name = "Adithya Shankaran" sync_github_name = true + +[people.slafebre] +real_name = "Sven Lafebre" From 1b62f86b8c3f3737fea1dd96aa09e2be3da7cc3d Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Fri, 6 Nov 2026 19:57:32 +0100 Subject: [PATCH 3/3] Fix #5714: doc comments on the width profile tests --- crates/doc/src/appearance/profile.rs | 3 +++ crates/doc/src/appearance/tests.rs | 1 + crates/effects/src/stroke/tests.rs | 1 + crates/effects/src/stroke/tests_gradient.rs | 2 ++ crates/effects/src/stroke/width.rs | 1 + crates/engine/src/tests/outlinestroke.rs | 1 + crates/engine/src/tests/widthpoints.rs | 1 + 7 files changed, 10 insertions(+) diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 0d13dc210..088796173 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -395,6 +395,7 @@ pub struct SavedProfile { mod tests { use super::*; + /// Widths and steps are found by search, at the ends, between points and at a step. #[test] fn width_profile_lookups_by_search() { let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] }; @@ -441,6 +442,7 @@ mod tests { } } + /// Neither side of a profile goes wider or narrower than the two points of the span it is in. #[test] fn widths_never_pass_the_points_either_side() { for preset in WidthProfile::PRESETS { @@ -474,6 +476,7 @@ mod tests { assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12); } + /// `widest()` is at least the stroke's width anywhere along the profile, even where the sides peak apart. #[test] fn widest_covers_every_width_along_the_profile() { let profiles = [ diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index ebc491800..c297b3475 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -192,6 +192,7 @@ fn strokes_without_pressure_round_trip_unchanged() { assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None)); } +/// The Lens profile's widths at its middle, a quarter of the way along and past its end. #[test] fn profile_interp() { let p = WidthProfile::lens(); diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index dbe271cd8..46969ff63 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -225,6 +225,7 @@ fn expand_stroke_region_of_a_round_line_stays_compact() { assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors"); } +/// A line's outline takes its width profile, its dashes (each reading the profile where it sits) or neither. #[test] fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let l = line(0.0, 100.0); diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index b4ffc0573..489182406 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -117,6 +117,7 @@ fn along_keeps_a_corners_colour_round_its_outside() { assert!((param(&sl, Point::new(95.0, 8.0)).unwrap() - 0.54).abs() < 1e-6); } +/// An Across stroke gradient runs from the stroke's left edge to its right edge, also where a width profile moves them. #[test] fn across_runs_from_the_left_edge_to_the_right() { let st = stroke(10.0, StrokeGradientMode::Across); @@ -163,6 +164,7 @@ fn an_aligned_stroke_spans_the_gradient_across_the_side_it_shows_on() { } } +/// The gradient slices cover every point the stroke paints, with each join, caps, arrowheads, a profile and dashes. #[test] fn slices_cover_everything_the_stroke_paints() { let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false); diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index bcaedc831..4a9dc2678 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -423,6 +423,7 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } + /// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side. #[test] fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 926bee91b..955e9798e 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -71,6 +71,7 @@ fn arrowheads_are_part_of_the_outline() { assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape"); } +/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle. #[test] fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() { let mut s = session(); diff --git a/crates/engine/src/tests/widthpoints.rs b/crates/engine/src/tests/widthpoints.rs index fc2b21568..4c837c300 100644 --- a/crates/engine/src/tests/widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -214,6 +214,7 @@ fn new_width_point_without_adjoining_preserves_neighbours() { new_point_adjoining(false); } +/// A new width point with Adjust Adjoining scales only its nearest neighbours, by the widths sampled where it goes. #[test] fn new_width_point_adjust_adjoining_uses_sampled_widths() { for indexed in [false, true] {