From bcbd31f3a4a042a6b6bf4c226c61958694c228a5 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 19 Oct 2026 20:47:39 +0200 Subject: [PATCH 01/14] M3.64: width profiles run smooth between their points A width profile's sides now run as a cubic Hermite curve through the points, its slope at each one the weighted harmonic mean of the secants either side (Fritsch-Carlson). That is C1 continuous and monotone between points, so it never overshoots them, a width change eases in and out instead of making a corner, and moving one point only reshapes the two segments beside it. The ends are flat; two points at one t are still a step. The outline samples the curve along the path (densify subdivides each profile segment) so the smooth width draws on straight runs too, and a flat profile keeps its old straight pieces. Hit testing now counts every segment near enough to reach the point, as the wider side of a farther one can paint past the nearest; gradient strips reach over projecting dash caps. Tests updated for the new sampled widths; new tests cover smoothing, monotonicity, locality and steps. --- crates/doc/src/appearance.rs | 122 ++++++++++++++++++++-- crates/doc/src/reach.rs | 15 +-- crates/effects/src/stroke/gradient.rs | 7 +- crates/effects/src/stroke/tests_fit.rs | 7 +- crates/effects/src/stroke/width.rs | 30 +++++- crates/engine/src/tests_widthpoints.rs | 20 +++- crates/ui-egui/src/dialogs/width_point.rs | 6 +- docs/target-app-parity.md | 3 +- 8 files changed, 176 insertions(+), 34 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b336ff152..2a1842548 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -223,25 +223,37 @@ pub struct ProfilePreset { } impl WidthProfile { - /// (left, right) width factors at `t`, linear between points. + /// (left, right) width factors at `t`: a smooth cubic between the 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). + /// + /// The width runs as a cubic Hermite curve through the points, its slope at each one the + /// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 + /// continuous and monotone between points, so it never overshoots them, a point eases into + /// its neighbours instead of making a corner, and moving one only reshapes the two segments + /// beside it. The ends are flat, where the width is constant outside the profile. Two points + /// at one `t` are still a step: the runs either side are independent and flat there. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { - if p.is_empty() { - return (1.0, 1.0); + let (Some(first), Some(last)) = (p.first(), p.last()) else { return (1.0, 1.0) }; + if t <= first.0 { + return (first.1, first.2); } - if t <= p[0].0 { - return (p[0].1, p[0].2); + if t >= last.0 { + return (last.1, last.2); } - for w in p.windows(2) { - if t <= w[1].0 { - let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); - return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); - } + // The segment ending at or after `t`: a step's zero-length segment is skipped, so `t` + // exactly at a step still reads the width just before it (as `around` documents). + let Some(i) = p.windows(2).position(|w| t <= w[1].0) else { return (last.1, last.2) }; + let (Some(&(t0, l0, r0)), Some(&(t1, l1, r1))) = (p.get(i), p.get(i + 1)) else { return (last.1, last.2) }; + let h = t1 - t0; + if h <= 1e-12 { + return (l1, r1); } - p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) + let u = (t - t0) / h; + let (ml0, mr0, ml1, mr1) = (slope(p, i, false), slope(p, i, true), slope(p, i + 1, false), slope(p, i + 1, true)); + (hermite(u, h, l0, l1, ml0, ml1), hermite(u, h, r0, r1, mr0, mr1)) } /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). pub const PRESETS: [ProfilePreset; 7] = [ @@ -293,6 +305,35 @@ impl WidthProfile { } } +/// The slope of the smooth width curve at `p[i]` for one side: the weighted harmonic mean of the +/// secants either side, or flat at a local extremum and at the ends of a run (the profile's ends +/// and either side of a step). +fn slope(p: &[(f64, f64, f64)], i: usize, right: bool) -> f64 { + let value = |k: usize| p.get(k).map_or(0.0, |q| if right { q.2 } else { q.1 }); + let (Some(&(t, _, _)), Some(&(tm, _, _)), Some(&(tp, _, _))) = (p.get(i), i.checked_sub(1).and_then(|k| p.get(k)), p.get(i + 1)) else { + return 0.0; + }; + let (h0, h1) = (t - tm, tp - t); + if h0 <= 1e-12 || h1 <= 1e-12 { + return 0.0; + } + let (d0, d1) = ((value(i) - value(i - 1)) / h0, (value(i + 1) - value(i)) / h1); + if d0 * d1 <= 0.0 { + return 0.0; + } + let (a, b) = (2.0 * h1 + h0, h1 + 2.0 * h0); + let m = (a + b) / (a / d0 + b / d1); + // A file's widths are only checked to be finite: keep a slope that overflows flat. + if m.is_finite() { m } else { 0.0 } +} + +/// A cubic Hermite from `y0` at `u = 0` to `y1` at `u = 1` over `h` (in `t`), with `m0`/`m1` the +/// slopes at its ends (width factor per `t`). +fn hermite(u: f64, h: f64, y0: f64, y1: f64, m0: f64, m1: f64) -> f64 { + let (u2, u3) = (u * u, u * u * u); + (2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * h * m0 + (3.0 * u2 - 2.0 * u3) * y1 + (u3 - u2) * h * m1 +} + /// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1 /// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s /// are. Calligraphic brushes with Pressure variation read it; everything else ignores it. @@ -1302,6 +1343,65 @@ mod tests { assert_eq!(p.at(2.0), (0.0, 0.0)); } + /// The width runs smoothly between points: it passes through them, has one slope either side + /// of one, and never overshoots the widths its points set. + #[test] + fn profile_is_smooth_between_points() { + let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)]; + assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12); + let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6; + // A local minimum at the middle point is flat there, so the two sides agree. + assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5)); + for k in 0..=100 { + let w = WidthProfile::at_points(&p, k as f64 / 100.0).0; + assert!((0.2..=1.0).contains(&w), "{w} at {k}"); + } + // The side with no points of its own stays flat. + for k in 0..=100 { + assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12); + } + } + + /// A two-point profile is a smoothstep: it eases out of and into the constant ends, and its + /// midpoint is still the linear one. + #[test] + fn profile_eases_into_its_ends() { + let p = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]; + assert!((WidthProfile::at_points(&p, 0.5).0 - 0.5).abs() < 1e-12); + let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6; + assert!(slope(0.0).abs() < 1e-3, "{}", slope(0.0)); + assert!(slope(1.0).abs() < 1e-3, "{}", slope(1.0)); + let mut prev = 0.0; + for k in 0..=100 { + let w = WidthProfile::at_points(&p, k as f64 / 100.0).0; + assert!((0.0..=1.0).contains(&w) && w >= prev - 1e-12, "{w} at {k}"); + prev = w; + } + } + + /// A step still steps, and the smooth runs either side meet it flat. + #[test] + fn profile_steps_stay_steps() { + let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)]; + assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0)); + } + + /// Moving a point only reshapes the segments beside it (local influence), not the whole path. + #[test] + fn profile_points_only_affect_their_neighbouring_segments() { + let a = [(0.0, 0.0, 0.0), (0.25, 1.0, 1.0), (0.5, 0.5, 0.5), (0.75, 1.0, 1.0), (1.0, 0.0, 0.0)]; + let mut b = a; + b[3] = (0.75, 0.8, 0.8); + for k in 0..=50 { + let t = k as f64 / 100.0; + assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}"); + } + } + #[test] fn profile_presets_round_trip_their_ids() { for p in WidthProfile::PRESETS { diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 12965821b..af6c03a6c 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -168,7 +168,8 @@ impl StrokeLayer { /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and /// outside strokes of closed paths count on their own side only (and within `tol` of the - /// path); a width profile counts at its width where `p` is nearest. + /// path); a width profile counts at the width of every segment that can reach `p`, as the + /// wider side of a farther one can paint past the nearest. pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { let reach = self.reach() + tol; if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) { @@ -192,11 +193,13 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); - // The nearest point on this subpath. - let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)); - if let Some((i, nr)) = near - && nr.distance_sq.sqrt() <= full + tol - { + // Every segment near enough to reach `p`: with a width profile the wider side of a + // farther segment can paint past the nearest one, so the nearest alone is not enough. + let near = segs.iter().enumerate().filter_map(|(i, s)| { + let nr = s.nearest(p, 1e-9); + (nr.distance_sq.sqrt() <= full + tol).then_some((i, nr)) + }); + for (i, nr) in near { let d = nr.distance_sq.sqrt(); let factor = if dash_caps.is_some() { full / half.max(1e-12) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 62d1d536f..190d4a936 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -16,7 +16,7 @@ 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, lerp_color}; -use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; +use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; use super::width::{flatten, left, length, split_at}; @@ -147,7 +147,10 @@ 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(); + // A projecting cap on every dash reaches a corner half a width along the path and half across + // from the dash's end, so the ribs reach further to cover what those dashes paint. + let capped = st.cap == LineCap::Square && st.dash.as_ref().is_some_and(|d| d.is_dashed()); + let full = hw * st.profile_max() * if capped { std::f64::consts::SQRT_2 } else { 1.0 }; 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) }; diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index 91933087d..e9070c89b 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -155,9 +155,10 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip. + // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip, + // where the smooth profile is thinner than a straight ramp (a smoothstep's first tenth). assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide"); - assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin"); + assert!(!o.contains(Point::new(5.0, 0.5)) && o.contains(Point::new(5.0, 0.1)), "first dash is thin"); assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty"); // Dots too: a round dot near the middle is bigger than one near an end. let st = stroke(10.0, |s| { @@ -166,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5))); + assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.4))); } /// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0). diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 44d24343b..ce9905d9b 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -84,17 +84,35 @@ 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`]). +/// Insert samples where the profile has width points and between them, so its smooth curve is +/// drawn as a smooth outline along straight runs too (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) { + // Samples per profile segment; fewer when a profile carries many points, so a file can't turn + // the extra samples into a huge allocation. + const SAMPLES: usize = 16; 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()); } // 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 ts: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + // A flat profile draws the same straight pieces as before: sample only its points. + let flat = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); + let samples = if flat { 1 } else { SAMPLES.min((4096 / ts.len().max(1)).max(1)) }; + let mut marks = Vec::with_capacity(ts.len() * samples); + for w in ts.windows(2) { + for k in 0..samples { + marks.push(w[0] + (w[1] - w[0]) * k as f64 / samples as f64); + } + } + if let Some(&last) = ts.last() { + marks.push(last); + } + let marks: Vec = marks.into_iter().map(|t| (t - span.0) / range * total).collect(); split_at(pts, corner, closed, &marks) } @@ -315,8 +333,10 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } + /// The lens eases up to full width and back down (a smooth bump averaging half the width); a + /// one-sided profile keeps its side at zero and stays on the other. #[test] - fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() { + fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() { let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); assert!((a - 500.0).abs() < 1e-6, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; diff --git a/crates/engine/src/tests_widthpoints.rs b/crates/engine/src/tests_widthpoints.rs index e9f50328b..1881a5fda 100644 --- a/crates/engine/src/tests_widthpoints.rs +++ b/crates/engine/src/tests_widthpoints.rs @@ -2,6 +2,7 @@ //! Adjoining Width Points and the render of a step. use serde_json::json; +use vectorcraft_doc::WidthProfile; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::*; @@ -216,16 +217,27 @@ fn new_width_point_without_adjoining_preserves_neighbours() { #[test] fn new_width_point_adjust_adjoining_uses_sampled_widths() { + let base = [(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)]; 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. + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": base})).unwrap(); + // At t=0.5 the curve's sides are what a new point starts from (an indexed edit starts from + // the point it made there: 10pt and 0pt, half the 10pt stroke's 5pt). + let old = if indexed { (2.0, 0.0) } else { WidthProfile { points: base.to_vec() }.at(0.5) }; let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 0}))) } else { None }; set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true})); + // Scale the nonzero side, add from zero (as Adjust Adjoining Width Points does). + let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }; + let (nl, nr) = (4.0, 1.0); 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)], + vec![ + (0.0, 9.0, 9.0), + (0.25, scale(1.0, old.0, nl), scale(0.0, old.1, nr)), + (0.5, nl, nr), + (0.75, scale(3.0, old.0, nl), scale(0.0, old.1, nr)), + (1.0, 7.0, 7.0), + ], "only nearest neighbours change, using the sampled sides; indexed={indexed}" ); } diff --git a/crates/ui-egui/src/dialogs/width_point.rs b/crates/ui-egui/src/dialogs/width_point.rs index 3366d4b72..173f4b9f4 100644 --- a/crates/ui-egui/src/dialogs/width_point.rs +++ b/crates/ui-egui/src/dialogs/width_point.rs @@ -374,8 +374,10 @@ mod tests { assert!(d.fields.get("index").is_none_or(Value::is_null)); let actual_t = d.f64("t", -1.0); assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25"); - assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6); - assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6); + // The smooth profile's sides at a quarter: smoothstep(0.25) = 0.15625 of 1..2 and + // 0.5..1.5, on a 10pt stroke (5pt half). + assert!((d.f64("side1", 0.0) - 5.78125).abs() < 1e-6); + assert!((d.f64("side2", 0.0) - 3.28125).abs() < 1e-6); assert!(!d.bool("linked")); d.fields.insert("side1".into(), json!(8)); d.fields.insert("side2".into(), json!(3)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 51cc59b15..98c19edc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -681,7 +681,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; a point only reshapes the segments beside it, and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. @@ -855,6 +855,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect | 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. | | 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. | | 2026-10-19 | minor | Libraries panel: the list fills the panel and scrolls (also while a drag nears its edges); items drag before or after others of their kind and into or out of groups, and groups drag by their headers (`library.moveItem` `before`/`after`, `library.moveGroup`). Scores and estimates unchanged. | +| 2026-10-19 | minor | Width profiles run as a smooth C1 curve between their points (a monotone cubic Hermite, Fritsch-Carlson) instead of straight lines, sampled along the path so the outline follows it on straight runs; steps between coincident points still step. Regression tests cover easing, monotonicity, locality and steps. Scores and estimates unchanged. | | 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. | | 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | From 04dbee48db4c30d639051d13d9969f464216dac2 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 20 Oct 2026 00:12:45 +0200 Subject: [PATCH 02/14] M3.64: width outlines stay smooth where their width changes fast --- crates/effects/src/stroke/tests.rs | 3 +- crates/effects/src/stroke/width.rs | 324 +++++++++++++++++++++++++---- crates/render/src/lib.rs | 8 +- 3 files changed, 287 insertions(+), 48 deletions(-) diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index a7d8b4031..a5368a432 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -133,7 +133,8 @@ 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)); + // The smoothed sides are cubics through the profile's samples: 0.06 pt² off the polygon. + assert!(near(lens.area().abs(), 500.0, 0.1)); // 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. let st = stroke(10.0, |s| { diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index ce9905d9b..28d70cbbd 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -190,117 +190,279 @@ impl Ctx<'_> { // at a discontinuous point). let width_at = |d: f64| { let t = span.0 + (span.1 - span.0) * d / total; - let (b, a) = self.profile.around(if t > 1.0 { t - 1.0 } else { t }); + // A closed path joins its own start and end: a profile with different widths there + // is a step, not a stretch across the seam. + let (b, a) = if closed && span == (0.0, 1.0) && d <= 1e-12 { + (self.profile.around(1.0).0, self.profile.around(0.0).1) + } else { + self.profile.around(if t > 1.0 { t - 1.0 } else { t }) + }; let side = |(l, r): (f64, f64)| (self.half * l.max(0.0), self.half * r.max(0.0)); (side(b), side(a)) }; - let mut lpts = Vec::with_capacity(n); - let mut rpts = Vec::with_capacity(n); + let mut lpts: Vec<(Point, bool)> = Vec::with_capacity(n); + let mut rpts: Vec<(Point, bool)> = Vec::with_capacity(n); + // Only a butt cap makes the side end at a corner; square and round caps stay tangent to it. + let end_sharp = matches!(self.cap, LineCap::Butt); for (i, &p) in pts.iter().enumerate() { - let (before, after) = width_at(cum[i]); + let ((wl_b, wr_b), (wl_a, wr_a)) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { let nm = left(seg_dir[i.min(seg_count - 1)]); - let (wl, wr) = if i == 0 { after } else { before }; - lpts.push(p + nm * wl); - rpts.push(p - nm * wr); + let (wl, wr) = if i == 0 { (wl_a, wr_a) } else { (wl_b, wr_b) }; + lpts.push((p + nm * wl, end_sharp)); + rpts.push((p - nm * wr, end_sharp)); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; // A step: the side goes straight out (or in) from the width before to the one after. - for (k, (wl, wr)) in [before, after].into_iter().enumerate() { - if k == 0 || (wl, wr) != before { - self.join(&mut lpts, &v, wl, 1.0); - self.join(&mut rpts, &v, wr, -1.0); - } + // Both ends of that straight piece are corners: the run before it must not round + // into the step. + let (step_l, step_r) = (wl_a != wl_b, wr_a != wr_b); + self.join(&mut lpts, &v, wl_b, 1.0, step_l); + self.join(&mut rpts, &v, wr_b, -1.0, step_r); + if step_l { + self.join(&mut lpts, &v, wl_a, 1.0, true); + } + if step_r { + self.join(&mut rpts, &v, wr_a, -1.0, true); } } if closed { - polygon(out, lpts.into_iter()); - polygon(out, rpts.into_iter().rev()); + polygon(out, lpts.into_iter(), self.tol); + polygon(out, rpts.into_iter().rev(), self.tol); return; } let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. - cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap); + cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap, self.tol); ring.extend(rpts.into_iter().rev()); // Start cap: from the right side around the start back to the left side. - cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap); - polygon(out, ring.into_iter()); + cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap, self.tol); + polygon(out, ring.into_iter(), self.tol); } /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the /// join on the outer side of a turn, the inner miter point (or a detour through the vertex /// when that would overshoot a segment) on the inner side. - fn join(&self, pts: &mut Vec, v: &Vertex, w: f64, side: f64) { + /// + /// Each point says whether the outline is sharp there: a path corner, a miter tip, a width + /// step or a butt cap end. Only those break the smooth fitting; a rapid width change makes + /// the offset turn sharply, but it is not a corner and must not be fitted as one. + fn join(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, w: f64, side: f64, step: bool) { let (na, nb) = (left(v.a) * side, left(v.b) * side); let mid = na + nb; // Cosine of half the turn; the miter point is `w / c` out along `mid`. let c = mid.hypot() / 2.0; - let miter = |pts: &mut Vec| pts.push(v.p + mid * (w / (2.0 * c * c))); if w <= 0.0 { - pts.push(v.p); - } else if v.b.dot(na) <= 0.0 { + pts.push((v.p, true)); + return; + } + let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None }; + if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. - let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; - match self.join { - _ if excess <= self.tol => miter(pts), - LineJoin::Miter if v.corner && 1.0 / c <= self.miter_limit => miter(pts), - LineJoin::Miter | LineJoin::Bevel if v.corner => pts.extend([v.p + na * w, v.p + nb * w]), - _ => self.arc(pts, v, na, nb, w), + if !v.corner { + // A smooth point has no join: mitering its flattened segments is the offset + // itself. A round join here would dent the edge by the turn's excess at every + // sample, which shows as scallops along a wide curve. + match miter { + Some(m) => pts.push((m, step)), + None => self.arc(pts, v, na, nb, w, step), + } + } else { + let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; + match self.join { + _ if excess <= self.tol => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter if c > 1e-9 && 1.0 / c <= self.miter_limit => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter | LineJoin::Bevel => pts.extend([(v.p + na * w, true), (v.p + nb * w, true)]), + _ => self.arc(pts, v, na, nb, w, step), + } } - } else if c > 1e-9 && w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { - miter(pts); + } else if c <= 1e-9 { + // A reversal has no inner miter; go through the vertex. That is a notch in the + // outline, so it stays sharp: fitting a curve through it would loop. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); + } else if !v.corner || w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { + // A smooth point's inner offset is a miter too, however short its neighbours are: a + // sample added right beside a vertex must not turn it into a detour to the centre. + pts.push((miter.unwrap_or(v.p), step || v.corner)); } else { - pts.extend([v.p + na * w, v.p, v.p + nb * w]); + // The inner offset of a corner cuts through the vertex; keep that notch sharp. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); } } /// A round join from `na` to `nb` (unit normals) at radius `w` around `v`; a full reversal - /// rounds through the direction of travel. - fn arc(&self, pts: &mut Vec, v: &Vertex, na: Vec2, nb: Vec2, w: f64) { + /// rounds through the direction of travel. `sharp` marks a step at the join's ends. + fn arc(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, na: Vec2, nb: Vec2, w: f64, sharp: bool) { let phi = if (na + nb).hypot() < 1e-9 { if Vec2::new(-na.y, na.x).dot(v.a) > 0.0 { PI } else { -PI } } else { na.cross(nb).atan2(na.dot(nb)) }; let step = 2.0 * (1.0 - (self.tol / w).min(1.0)).acos(); let steps = ((phi.abs() / step.max(1e-3)).ceil() as usize).clamp(1, 256); for k in 0..=steps { let (s, c) = (phi * k as f64 / steps as f64).sin_cos(); - pts.push(v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w); + pts.push((v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w, sharp)); } } } /// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. -fn cap_points(ring: &mut Vec, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) { +#[allow(clippy::too_many_arguments)] +fn cap_points(ring: &mut Vec<(Point, bool)>, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap, tol: f64) { match cap { LineCap::Butt => {} LineCap::Square => { let h = (r0 + r1) / 2.0; - ring.push(c + u * r0 + v * h); - ring.push(c - u * r1 + v * h); + ring.push((c + u * r0 + v * h, true)); + ring.push((c - u * r1 + v * h, true)); } LineCap::Round => { - let steps = 16; + // Enough steps that the cap's facets stay within `tol`, as a round join does. + let r = r0.max(r1).max(1e-9); + let step = 2.0 * (1.0 - (tol / r).min(1.0)).acos(); + let steps = ((PI / step.max(1e-6)).ceil() as usize).clamp(8, 256); for k in 1..steps { - let a = std::f64::consts::PI * k as f64 / steps as f64; + let a = PI * k as f64 / steps as f64; let r = r0 + (r1 - r0) * (k as f64 / steps as f64); - ring.push(c + u * (a.cos() * r) + v * (a.sin() * r)); + ring.push((c + u * (a.cos() * r) + v * (a.sin() * r), false)); } } } } -fn polygon(out: &mut BezPath, mut pts: impl Iterator) { - let Some(first) = pts.next() else { return }; - out.move_to(first); - for p in pts { - out.line_to(p); +/// Append `pts` as a closed contour (a width outline's ring or loop), through a smooth run's +/// points as cubic curves. Sampled as line segments, a curved, wide stroke shows the facets of +/// its offset whenever it is magnified; the cubics keep it smooth. Every point is interpolated +/// (so the sampled width is kept), a run whose points are collinear stays a line, and the points +/// marked sharp — caps, miter joins, width steps and path corners — stay sharp. A rapid width +/// change turns the offset sharply without being a corner: marking corners here rather than +/// measuring turns keeps that turn smooth. +fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64) { + let mut ring: Vec<(Point, bool)> = Vec::new(); + for (p, sharp) in pts { + if !p.x.is_finite() || !p.y.is_finite() { + return; + } + if let Some(last) = ring.last_mut() + && last.0.distance(p) <= 1e-9 + { + last.1 |= sharp; + continue; + } + ring.push((p, sharp)); + } + if ring.len() > 1 + && ring[0].0.distance(ring[ring.len() - 1].0) <= 1e-9 + && let Some(last) = ring.pop() + { + ring[0].1 |= last.1; + } + let n = ring.len(); + if n == 0 { + return; + } + if n < 3 { + out.move_to(ring[0].0); + if n == 2 { + out.line_to(ring[1].0); + } + out.close_path(); + return; + } + // Start at a sharp point when there is one, so no run has to wrap: `smooth_run` starts from + // whichever point is current, and starting anywhere else would bridge over the skipped arc. + match ring.iter().position(|&(_, sharp)| sharp) { + None => { + // A smooth closed loop: fit across the seam, so it doesn't start with a kink. + let pts: Vec = ring.iter().map(|&(p, _)| p).collect(); + out.move_to(pts[0]); + smooth_run(out, &pts, true, tol); + } + Some(first) => { + out.move_to(ring[first].0); + // From each sharp point to the next; the sharp points themselves stay sharp. + let mut i = first; + loop { + let mut j = (i + 1) % n; + while !ring[j].1 { + j = (j + 1) % n; + } + // A contour with a single sharp point comes back to it: the run is the whole + // loop, fitted non-cyclically so that point stays sharp. + let steps = if j == i { n } else { (j + n - i) % n }; + if steps == 1 { + out.line_to(ring[j].0); + } else { + let run: Vec = (0..=steps).map(|k| ring[(i + k) % n].0).collect(); + smooth_run(out, &run, false, tol); + } + if j == first { + break; + } + i = j; + } + } } out.close_path(); } +/// The tangent at `run[i]`: the angle bisector of its two segments, as long as their harmonic +/// mean (equal to a centered difference for evenly spaced points, and bounded by the shorter +/// segment so uneven samples can't make the curve overshoot). At an end, the one segment it has. +fn tangent(run: &[Point], i: usize, cyclic: bool) -> Vec2 { + let n = run.len(); + let (prev, next) = if cyclic { (run[(i + n - 1) % n], run[(i + 1) % n]) } else { (run[i.saturating_sub(1)], run[(i + 1).min(n - 1)]) }; + let (a, b) = (run[i] - prev, next - run[i]); + let (la, lb) = (a.hypot(), b.hypot()); + if la <= 1e-12 { + return b; + } + if lb <= 1e-12 { + return a; + } + let d = a / la + b / lb; + let dl = d.hypot(); + if dl <= 1e-12 { + return a; + } + d * (2.0 * la * lb / ((la + lb) * dl)) +} + +/// Append a run of points as cubics through them (or one line when they are collinear). +fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { + let n = run.len(); + if n < 2 { + return; + } + if !cyclic && is_straight(run, tol) { + out.line_to(run[n - 1]); + return; + } + let segs = if cyclic { n } else { n - 1 }; + for i in 0..segs { + let j = (i + 1) % n; + let (m0, m1) = (tangent(run, i, cyclic), tangent(run, j, cyclic)); + if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { + out.line_to(run[j]); + } else { + out.curve_to(run[i] + m0 / 3.0, run[j] - m1 / 3.0, run[j]); + } + } +} + +/// Are the run's points within the flattening tolerance of its chord? +fn is_straight(run: &[Point], tol: f64) -> bool { + let (a, b) = (run[0], run[run.len() - 1]); + let d = b - a; + let l = d.hypot(); + if l <= 1e-12 { + return false; + } + run.iter().all(|p| (d.cross(*p - a) / l).abs() <= tol.max(1e-6) * 0.25) +} + #[cfg(test)] mod tests { use super::*; @@ -338,7 +500,7 @@ mod tests { #[test] fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() { 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 - 500.0).abs() < 0.1, "{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); @@ -362,6 +524,78 @@ mod tests { assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } + #[test] + fn a_closed_path_steps_where_its_profile_ends_differ() { + let mut sq = BezPath::new(); + sq.move_to((0.0, 0.0)); + sq.line_to((100.0, 0.0)); + sq.line_to((100.0, 100.0)); + sq.line_to((0.0, 100.0)); + sq.close_path(); + // 5 pt at the seam's start, 10 pt at its end: the ring must step between them, not + // stretch the last width point round the corner. + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 2.0, 2.0)] }; + let o = width_outline(&sq, 10.0, &p, &capped(LineCap::Butt), 0.01); + let at = |p: Point, x: f64, y: f64| (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9; + let step = o.elements().windows(2).any(|w| match (w[0], w[1]) { + (PathEl::MoveTo(a), PathEl::LineTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => { + at(a, -10.0, -10.0) && at(b, -5.0, -5.0) || at(a, -5.0, -5.0) && at(b, -10.0, -10.0) + } + _ => false, + }); + assert!(step, "{o:?}"); + } + + #[test] + fn a_wiggly_profile_on_a_curve_fits_without_kinks() { + // A rapid width change turns the offset sharply; that is not a path corner, so the + // outline must stay tangent-continuous through it (the old turn-angle test did not). + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-3); + let p = WidthProfile { + points: vec![(0.0, 1.0, 1.0), (0.15, 3.0, 0.4), (0.3, 0.2, 2.8), (0.5, 2.5, 0.3), (0.7, 0.4, 2.2), (0.85, 2.0, 0.5), (1.0, 1.0, 1.0)], + }; + let o = width_outline(&c, 10.0, &p, &capped(LineCap::Butt), 0.01); + let mut cursor = None; + let mut prev: Option = None; + let mut kinks = 0; + let mut check = |a: Vec2, b: Vec2| { + let (la, lb) = (a.hypot(), b.hypot()); + if la > 1e-9 && lb > 1e-9 && (a.dot(b) <= 0.0 || a.cross(b).abs() / (la * lb) > 1e-6) { + kinks += 1; + } + }; + for el in o.elements() { + match el { + PathEl::MoveTo(p) => { + cursor = Some(*p); + prev = None; + } + PathEl::LineTo(p) => { + if let Some(q) = cursor { + let d = *p - q; + if let Some(a) = prev { + check(a, d); + } + prev = Some(d); + } + cursor = Some(*p); + } + PathEl::CurveTo(a, b, p) => { + if let Some(q) = cursor { + let d = if a.distance(q) > 1e-9 { *a - q } else { *b - q }; + if let Some(prev) = prev { + check(prev, d); + } + prev = Some(if p.distance(*b) > 1e-9 { *p - *b } else { *p - *a }); + } + cursor = Some(*p); + } + PathEl::QuadTo(_, _) | PathEl::ClosePath => {} + } + } + assert_eq!(kinks, 0, "{o:?}"); + } + #[test] fn closed_paths_make_a_ring() { let mut sq = BezPath::new(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 442e156cb..75fbc227d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1555,8 +1555,12 @@ impl Renderer { } ctx.set_transform(f.view * xf); let closed = effects::stroke::is_closed(bp); - // Keep hairlines visible when zoomed far out (at least ~1 device pixel). - let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)); + // Keep hairlines visible when zoomed far out (at least ~1 device pixel). A width profile + // sets its own widths: inflating the base for a hairline would scale every width point + // with it, so a 1 pt stroke with a 100x point became a 100 px wide hairline when zoomed + // out (and shrank back when its outline was expanded, which uses the true width). + let base = effects::stroke::aligned_width(st, closed); + let width = if st.profile.is_some() { base } else { base.max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)) }; let inside = st.align == StrokeAlign::Inside && closed; if inside { ctx.set_fill_rule(fill_rule(rule)); From e4982c26dad883378e368a713b01f9cf2921d2b0 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 20 Oct 2026 00:21:26 +0200 Subject: [PATCH 03/14] M3.64: cache profile hit-test lengths and correct width locality --- crates/doc/src/appearance.rs | 19 ++++++++++++------- crates/doc/src/reach.rs | 14 ++++++++++---- docs/target-app-parity.md | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2a1842548..13304156a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -232,9 +232,10 @@ impl WidthProfile { /// The width runs as a cubic Hermite curve through the points, its slope at each one the /// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 /// continuous and monotone between points, so it never overshoots them, a point eases into - /// its neighbours instead of making a corner, and moving one only reshapes the two segments - /// beside it. The ends are flat, where the width is constant outside the profile. Two points - /// at one `t` are still a step: the runs either side are independent and flat there. + /// its neighbours instead of making a corner, and moving one reshapes the curve as far as its + /// neighbours' neighbours (their slopes depend on it). The ends are flat, where the width is + /// constant outside the profile. Two points at one `t` are still a step: the runs either side + /// are independent and flat there. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { let (Some(first), Some(last)) = (p.first(), p.last()) else { return (1.0, 1.0) }; if t <= first.0 { @@ -1392,14 +1393,18 @@ mod tests { /// Moving a point only reshapes the segments beside it (local influence), not the whole path. #[test] - fn profile_points_only_affect_their_neighbouring_segments() { - let a = [(0.0, 0.0, 0.0), (0.25, 1.0, 1.0), (0.5, 0.5, 0.5), (0.75, 1.0, 1.0), (1.0, 0.0, 0.0)]; + fn profile_points_only_affect_the_curve_around_them() { + // A monotone run, so no neighbouring slope is held flat by a local extremum. + let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)]; let mut b = a; - b[3] = (0.75, 0.8, 0.8); - for k in 0..=50 { + b[3] = (0.75, 0.9, 0.9); + // Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the + // curve: nothing before p[1] may move. + for k in 0..=24 { let t = k as f64 / 100.0; assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}"); } + assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3)); } #[test] diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index af6c03a6c..26429686f 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -86,10 +86,8 @@ fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> { /// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the /// segment is a corner. The stroke draws the sides there straight to the corner's join and inner /// meeting point, which can lie outside the exact width all along the segment. -fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) { +fn profile_at(profile: &WidthProfile, segs: &[PathSeg], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) { let n = segs.len(); - let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); - let total: f64 = lens.iter().sum::().max(1e-12); let before = lens[..i].iter().sum::(); let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total); let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]); @@ -199,13 +197,21 @@ impl StrokeLayer { let nr = s.nearest(p, 1e-9); (nr.distance_sq.sqrt() <= full + tol).then_some((i, nr)) }); + // The lengths are only needed when a profiled candidate reaches `p`; compute them + // once for the whole subpath rather than once per candidate. + let mut lengths: Option<(Vec, f64)> = None; for (i, nr) in near { let d = nr.distance_sq.sqrt(); let factor = if dash_caps.is_some() { full / half.max(1e-12) } else { self.profile.as_ref().map_or(1.0, |pr| { - let (l, rt) = profile_at(pr, &segs, closed, i, nr.t); + let (lens, total) = lengths.get_or_insert_with(|| { + let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); + let total = lens.iter().sum::().max(1e-12); + (lens, total) + }); + let (l, rt) = profile_at(pr, &segs, lens, *total, closed, i, nr.t); // Nearest to a segment's end, `p` is beside a join or past a cap, which // spans the widths of both sides. let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98c19edc2..92e10fab6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -681,7 +681,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; a point only reshapes the segments beside it, and coincident points still step. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. From 11e33a85b875a577a35102d484a687fd31b92de0 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 20 Oct 2026 09:56:22 +0200 Subject: [PATCH 04/14] effects: keep width-profile outlines smooth on curves Offset smooth points along the curve's normal instead of the flattened polyline's miter (w/cos), which spiked the edge at every sample on tight curves. Fit runs with chord-length Catmull-Rom tangents so a short sample cannot flatten the long segment beside it, and add a coarse wide-circle regression test. --- crates/effects/src/stroke/width.rs | 104 ++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 33 deletions(-) diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 28d70cbbd..1f10fcc68 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -3,10 +3,10 @@ //! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and //! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the //! subpath's length) are placed along the normal. Corners of the path take the stroke's miter, -//! round or bevel join on their outer side (smooth points bend round), open subpaths get butt, -//! round or projecting caps (the round cap blends the two side widths), and closed subpaths become -//! two loops of opposite orientation filled with the non-zero rule. Dashes are pieces of a longer -//! subpath and read the profile where they sit along it ([`outline_spans`]). +//! round or bevel join on their outer side (smooth points offset along the curve's normal), open +//! subpaths get butt, round or projecting caps (the round cap blends the two side widths), and +//! closed subpaths become two loops of opposite orientation filled with the non-zero rule. Dashes +//! are pieces of a longer subpath and read the profile where they sit along it ([`outline_spans`]). use std::f64::consts::PI; @@ -244,9 +244,10 @@ impl Ctx<'_> { polygon(out, ring.into_iter(), self.tol); } - /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the - /// join on the outer side of a turn, the inner miter point (or a detour through the vertex - /// when that would overshoot a segment) on the inner side. + /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path. A + /// smooth point follows the vertex normal, the offset of the curve itself; a corner takes the + /// join on its outer side and the inner miter point (or a detour through the vertex when that + /// would overshoot a segment) on its inner side. /// /// Each point says whether the outline is sharp there: a path corner, a miter tip, a width /// step or a butt cap end. Only those break the smooth fitting; a rapid width change makes @@ -261,14 +262,17 @@ impl Ctx<'_> { return; } let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None }; + // The offset of the smooth path itself is at `w` along the vertex normal. The polyline + // miter (`w / c`) is the offset of the flattened segments, not of the curve: at a tight + // curve it spiked every sample outward and left a scalloped edge. A round join would dent + // it inward instead; the unit bisector is the smooth normal. + let normal = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c))) } else { None }; if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. if !v.corner { - // A smooth point has no join: mitering its flattened segments is the offset - // itself. A round join here would dent the edge by the turn's excess at every - // sample, which shows as scallops along a wide curve. - match miter { - Some(m) => pts.push((m, step)), + // A smooth point has no join: its offset is along the vertex normal. + match normal { + Some(n) => pts.push((n, step)), None => self.arc(pts, v, na, nb, w, step), } } else { @@ -284,10 +288,14 @@ impl Ctx<'_> { // A reversal has no inner miter; go through the vertex. That is a notch in the // outline, so it stays sharp: fitting a curve through it would loop. pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); - } else if !v.corner || w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { - // A smooth point's inner offset is a miter too, however short its neighbours are: a - // sample added right beside a vertex must not turn it into a detour to the centre. - pts.push((miter.unwrap_or(v.p), step || v.corner)); + } else if !v.corner { + // A smooth point's inner offset follows its normal too: a sample added right beside a + // vertex must not turn it into a detour to the centre. + let n = normal.unwrap_or(v.p); + pts.push((n, step)); + } else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { + // A corner's inner offset is its miter while that stays inside both segments. + pts.push((miter.unwrap_or(v.p), true)); } else { // The inner offset of a corner cuts through the vertex; keep that notch sharp. pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); @@ -408,26 +416,44 @@ fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64 out.close_path(); } -/// The tangent at `run[i]`: the angle bisector of its two segments, as long as their harmonic -/// mean (equal to a centered difference for evenly spaced points, and bounded by the shorter -/// segment so uneven samples can't make the curve overshoot). At an end, the one segment it has. -fn tangent(run: &[Point], i: usize, cyclic: bool) -> Vec2 { +/// The tangent at the start of the segment `run[i] → run[j]`, for that segment's parameter. This +/// is the chord-length Catmull–Rom tangent: the secant across the point's neighbours, scaled by +/// the segment's share of the neighbouring lengths. The share keeps a short sample from flattening +/// the long segment beside it, while the secant's own length (not a unit direction) keeps a curved +/// run from overshooting. At an end, the segment itself. +fn tangent_start(run: &[Point], i: usize, j: usize, cyclic: bool) -> Vec2 { let n = run.len(); - let (prev, next) = if cyclic { (run[(i + n - 1) % n], run[(i + 1) % n]) } else { (run[i.saturating_sub(1)], run[(i + 1).min(n - 1)]) }; - let (a, b) = (run[i] - prev, next - run[i]); - let (la, lb) = (a.hypot(), b.hypot()); - if la <= 1e-12 { - return b; + let (p, q) = (run[i], run[j]); + let l = p.distance(q); + if l <= 1e-12 { + return Vec2::ZERO; } - if lb <= 1e-12 { - return a; + if cyclic || i > 0 { + let prev = run[(i + n - 1) % n]; + let la = p.distance(prev); + if la + l > 1e-12 { + return (q - prev) * (l / (la + l)); + } } - let d = a / la + b / lb; - let dl = d.hypot(); - if dl <= 1e-12 { - return a; + q - p +} + +/// The tangent at the end of the segment `run[i] → run[j]`, pointing forward. +fn tangent_end(run: &[Point], i: usize, j: usize, cyclic: bool) -> Vec2 { + let n = run.len(); + let (p, q) = (run[i], run[j]); + let l = p.distance(q); + if l <= 1e-12 { + return Vec2::ZERO; } - d * (2.0 * la * lb / ((la + lb) * dl)) + if cyclic || j + 1 < n { + let next = run[(j + 1) % n]; + let lb = q.distance(next); + if l + lb > 1e-12 { + return (next - p) * (l / (l + lb)); + } + } + q - p } /// Append a run of points as cubics through them (or one line when they are collinear). @@ -443,7 +469,7 @@ fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { let segs = if cyclic { n } else { n - 1 }; for i in 0..segs { let j = (i + 1) % n; - let (m0, m1) = (tangent(run, i, cyclic), tangent(run, j, cyclic)); + let (m0, m1) = (tangent_start(run, i, j, cyclic), tangent_end(run, i, j, cyclic)); if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { out.line_to(run[j]); } else { @@ -618,4 +644,16 @@ mod tests { let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } + + #[test] + fn a_coarse_wide_circle_keeps_its_offset() { + // A wide stroke flattened coarsely: offsetting each flattened segment by its miter + // (`w / cos(θ/2)`) scalloped the circle outward at every sample. Smooth points follow the + // curve's normal instead, so the ring is the annulus between radii 10 and 30. + let c = kurbo::Circle::new((0.0, 0.0), 20.0).to_path(0.5); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let a = area(&width_outline(&c, 20.0, &p, &capped(LineCap::Butt), 0.5)); + let want = std::f64::consts::PI * (30.0 * 30.0 - 10.0 * 10.0); + assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); + } } From 67933d404fe4402cf2436ac43ebeb46c234f4873 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 20 Oct 2026 10:55:33 +0200 Subject: [PATCH 05/14] effects: keep a wide constant stroke over its centre When a constant profile's half-width reaches the local radius of curvature, the smooth inner offset crosses the centre and the non-zero fill leaves a hole. Cut through the centre at those samples; the cut is inside the painted stroke. Varying profiles keep the offset because their crossings can be real caustics, where a cut would show as a slit. Regression test: a radius-5 circle with a 30 pt stroke contains its centre. --- crates/effects/src/stroke/width.rs | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 1f10fcc68..2a7c7771d 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -25,7 +25,15 @@ pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &Stro /// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans)); /// subpaths without a span are whole. pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx { + half: width / 2.0, + profile, + cap: st.cap, + join: st.join, + miter_limit: st.miter_limit, + tol: tol.max(1e-4), + uniform: profile.points.first().is_some_and(|f| profile.points.iter().all(|p| p.1 == f.1 && p.2 == f.2)), + }; let mut out = BezPath::new(); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() { let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); @@ -159,6 +167,8 @@ struct Ctx<'a> { join: LineJoin, miter_limit: f64, tol: f64, + /// The profile has one (left, right) width everywhere. + uniform: bool, } /// A vertex of a flattened subpath: where it is, the directions in and out, the lengths of the @@ -289,10 +299,22 @@ impl Ctx<'_> { // outline, so it stays sharp: fitting a curve through it would loop. pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); } else if !v.corner { - // A smooth point's inner offset follows its normal too: a sample added right beside a - // vertex must not turn it into a detour to the centre. - let n = normal.unwrap_or(v.p); - pts.push((n, step)); + // A smooth point's inner offset follows its normal too. With a constant profile, once + // the half-width reaches the local radius of curvature the offset crosses the centre of + // curvature and comes back as a reversed contour, which the non-zero fill rules into a + // hole. The stroke covers that centre, so cut through it instead: the cut is inside the + // painted region and does not show. (With varying widths the crossing usually marks a + // real caustic; a cut there would show as a slit, so leave it to the offset.) + let turn = 2.0 * c.clamp(-1.0, 1.0).acos(); + let radius = if turn > 1e-9 { 0.5 * (v.la + v.lb) / turn } else { f64::INFINITY }; + if self.uniform && w >= radius { + pts.push((v.p + na * w, true)); + pts.push((v.p, true)); + pts.push((v.p + nb * w, true)); + } else { + let n = normal.unwrap_or(v.p); + pts.push((n, step)); + } } else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { // A corner's inner offset is its miter while that stays inside both segments. pts.push((miter.unwrap_or(v.p), true)); @@ -645,6 +667,16 @@ mod tests { assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } + #[test] + fn a_uniform_circle_narrower_than_its_stroke_has_no_hole() { + // Half-width 15 on a radius-5 circle: the inner offset crosses the centre. The constant + // stroke still covers the centre, so the outline must not leave a hole there. + let c = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(1e-3); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let o = width_outline(&c, 30.0, &p, &capped(LineCap::Butt), 0.01); + assert!(o.contains(Point::new(0.0, 0.0)), "{o:?}"); + } + #[test] fn a_coarse_wide_circle_keeps_its_offset() { // A wide stroke flattened coarsely: offsetting each flattened segment by its miter From 2c363d6b88bbbe36f07a140dcf614598e2cfcedb Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 21 Oct 2026 03:24:01 +0200 Subject: [PATCH 06/14] effects: offset profile samples along the source tangent Profile samples added inside a flattened chord used the chord's direction for their normal, so a zoomed outline jogged at every flatten vertex. Keep the source segment that drew each flattened piece and ask it for the exact tangent at added samples; fit runs with coordinate-wise Fritsch-Carlson tangents. Adds a regression test and records the change in the parity evidence. --- crates/effects/src/stroke/gradient.rs | 15 +- crates/effects/src/stroke/width.rs | 294 ++++++++++++++++++-------- docs/target-app-parity.md | 3 +- 3 files changed, 219 insertions(+), 93 deletions(-) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 190d4a936..279b5dd6d 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -158,20 +158,21 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, let count = subs.len(); let mut out = vec![]; for (k, (range, closed)) in subs.into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4)); - let side = - (st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0))); + let segs = segments(&bp.elements()[range]); + let flat = flatten(&segs, closed, tol.max(1e-4)); + let side = (st.align != StrokeAlign::Center && closed_path && closed) + .then(|| band_on_left(bp, rule, &flat.pts, st.align, (4.0 * tol).min(hw / 2.0))); // Arrowheads sit on the start of the first subpath and the end of the last one. let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 }); - let ribs = match pts.len() { + let ribs = match flat.pts.len() { 0 => continue, // 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)] + vec![Rib::square(flat.pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(flat.pts[0] + d * e, d, lay.cover, 0.0, b)] } _ => { - let total = length(&pts, closed); + let total = length(&flat.pts, closed); if total <= 1e-12 { continue; } @@ -179,7 +180,7 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0)); let at: Vec = marks.iter().copied().chain(profile).chain((1..steps).map(|i| i as f64 / steps as f64)).map(|t| t * total).collect(); - let (pts, _) = split_at(&pts, &corner, closed, &at); + let (pts, _, _) = split_at(&flat, closed, &at, &segs); strip(&lay, &pts, closed, total, side, if closed { (0.0, 0.0) } else { heads }) } }; diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 2a7c7771d..31676272d 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -10,10 +10,10 @@ use std::f64::consts::PI; -use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; -use super::{kink, segments, subpaths, unit}; +use super::{kink, segments, subpaths, tangent, unit}; /// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter /// limit of `st`, flattened to `tol`. @@ -36,9 +36,10 @@ pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, prof }; let mut out = BezPath::new(); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - if pts.len() > 1 { - ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + if flat.pts.len() > 1 { + ctx.subpath(&mut out, &flat, &segs, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); } } out @@ -49,19 +50,29 @@ pub(super) fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) } +/// A flattened subpath: its points, which of them are corners, and the source segment that drew +/// each flattened segment (so a sample added between two points can read that segment's tangent +/// instead of the chord's direction). +pub(super) struct Flat { + pub(super) pts: Vec, + pub(super) corner: Vec, + pub(super) seg_of: Vec, +} + /// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't /// repeat its start), each marked when it is a corner of the path. -pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) { - let Some(s0) = segs.first() else { return (vec![], vec![]) }; - let (mut pts, mut corner) = (vec![s0.start()], vec![false]); +pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { + let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![] } }; + let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![] }; // The first and the last segment that drew something. let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None); - for seg in segs { - let at = pts.len() - 1; + for (si, seg) in segs.iter().enumerate() { + let at = flat.pts.len() - 1; + let before = flat.pts.len(); let mut push = |p: Point| { - if pts.last().is_none_or(|l| l.distance(p) > 1e-9) { - pts.push(p); - corner.push(false); + if flat.pts.last().is_none_or(|l| l.distance(p) > 1e-9) { + flat.pts.push(p); + flat.corner.push(false); } }; match seg { @@ -72,45 +83,57 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, } }), } - if pts.len() > at + 1 { - match prev { - Some(p) => corner[at] = kink(p, seg), - None => first = Some(seg), + if flat.pts.len() > before { + let end = flat.pts.len() - 1; + flat.seg_of.resize(end, si); + for slot in flat.seg_of.iter_mut().take(end).skip(at) { + *slot = si; + } + if let Some(p) = prev { + flat.corner[at] = kink(p, seg); + } else { + first = Some(seg); } prev = Some(seg); } } if closed { - if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 { - pts.pop(); - corner.pop(); + if flat.pts.len() > 1 && flat.pts[0].distance(flat.pts[flat.pts.len() - 1]) <= 1e-9 { + flat.pts.pop(); + flat.corner.pop(); } if let (Some(a), Some(b)) = (prev, first) { - corner[0] = kink(a, b); + flat.corner[0] = kink(a, b); + } + // A closing segment normally came from the path's last source segment; keep a tangent for + // it if the path closed without a duplicate point. + if flat.seg_of.len() < flat.pts.len() { + let last = flat.seg_of.last().copied().unwrap_or(0); + flat.seg_of.resize(flat.pts.len(), last); } } - (pts, corner) + flat } /// Insert samples where the profile has width points and between them, so its smooth curve is /// drawn as a smooth outline along straight runs too (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) { +fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec, Vec) { // Samples per profile segment; fewer when a profile carries many points, so a file can't turn // the extra samples into a huge allocation. const SAMPLES: usize = 16; - let total = length(pts, closed); + let total = length(&flat.pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { - return (pts.to_vec(), corner.to_vec()); + return split_at(flat, closed, &[], segs); } // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). let mut ts: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).collect(); ts.sort_by(f64::total_cmp); ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); // A flat profile draws the same straight pieces as before: sample only its points. - let flat = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); - let samples = if flat { 1 } else { SAMPLES.min((4096 / ts.len().max(1)).max(1)) }; + let constant = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); + let samples = if constant { 1 } else { SAMPLES.min((4096 / ts.len().max(1)).max(1)) }; let mut marks = Vec::with_capacity(ts.len() * samples); for w in ts.windows(2) { for k in 0..samples { @@ -121,7 +144,7 @@ fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, marks.push(last); } let marks: Vec = marks.into_iter().map(|t| (t - span.0) / range * total).collect(); - split_at(pts, corner, closed, &marks) + split_at(flat, closed, &marks, segs) } /// Length of a flattened subpath (see [`flatten`]). @@ -133,28 +156,55 @@ pub(super) fn length(pts: &[Point], closed: bool) -> f64 { /// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its /// start; those on a point or off the subpath are ignored). -pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec, Vec) { +pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg]) -> (Vec, Vec, Vec) { + let Flat { pts, corner, seg_of } = flat; let n = pts.len(); let seg_count = if closed { n } else { n.saturating_sub(1) }; - let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); + // The source's direction at `p`, from the segment that drew its flattened piece. The chord + // direction is the secant, not the tangent: using it for points added inside a chord jogs the + // normal at every flatten vertex. + let source = |p: Point, si: Option| -> Vec2 { + let Some(seg) = si.and_then(|i| segs.get(i)) else { return Vec2::new(1.0, 0.0) }; + tangent(seg, seg.nearest(p, 1e-9).t) + }; + // At a shared point, average the arriving and leaving directions unless it is a corner. + let dir_at = |i: usize| -> Vec2 { + let incoming = if i == 0 { if closed { seg_of.last().copied() } else { None } } else { seg_of.get(i - 1).copied() }; + let outgoing = seg_of.get(i).copied(); + let (a, b) = (incoming.map(|si| source(pts[i], Some(si))), outgoing.map(|si| source(pts[i], Some(si)))); + match (a, b) { + (Some(a), Some(b)) if !corner[i] => { + let sum = a + b; + if sum.hypot() > 1e-9 { unit(sum) } else { b } + } + (_, Some(b)) => b, + (Some(a), None) => a, + (None, None) => Vec2::new(1.0, 0.0), + } + }; + let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); + out.2.push(dir_at(i)); let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); inner.sort_by(f64::total_cmp); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { - out.0.push(a.lerp(b, (d - acc) / len)); + let p = a.lerp(b, (d - acc) / len); + out.0.push(p); out.1.push(false); + out.2.push(source(p, seg_of.get(i).copied())); } acc += len; } if !closed && n > 0 { out.0.push(pts[n - 1]); out.1.push(corner[n - 1]); + out.2.push(dir_at(n - 1)); } out } @@ -177,14 +227,19 @@ struct Vertex { p: Point, a: Vec2, b: Vec2, + /// The smooth tangent: the source curve's direction at this sample, or the average of its + /// arriving and leaving directions at a smooth join. A smooth point offsets along this + /// normal, not the chord bisector; otherwise the chord normal inside each flattened piece and + /// the bisector at each flatten vertex alternate and the edge is scalloped. + n: Vec2, la: f64, lb: f64, corner: bool, } 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); + fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64)) { + let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span); 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(); @@ -217,14 +272,14 @@ impl Ctx<'_> { for (i, &p) in pts.iter().enumerate() { let ((wl_b, wr_b), (wl_a, wr_a)) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { - let nm = left(seg_dir[i.min(seg_count - 1)]); + let nm = left(dirs[i]); let (wl, wr) = if i == 0 { (wl_a, wr_a) } else { (wl_b, wr_b) }; lpts.push((p + nm * wl, end_sharp)); rpts.push((p - nm * wr, end_sharp)); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; - let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; + let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], n: dirs[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; // A step: the side goes straight out (or in) from the width before to the one after. // Both ends of that straight piece are corners: the run before it must not round // into the step. @@ -243,7 +298,7 @@ impl Ctx<'_> { polygon(out, rpts.into_iter().rev(), self.tol); return; } - let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); + let (t0, t1) = (dirs[0], dirs[n - 1]); let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. @@ -272,19 +327,16 @@ impl Ctx<'_> { return; } let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None }; - // The offset of the smooth path itself is at `w` along the vertex normal. The polyline + // The offset of the smooth path itself is at `w` along the path normal. The polyline // miter (`w / c`) is the offset of the flattened segments, not of the curve: at a tight - // curve it spiked every sample outward and left a scalloped edge. A round join would dent - // it inward instead; the unit bisector is the smooth normal. - let normal = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c))) } else { None }; + // curve it spiked every sample outward and left a scalloped edge. The source curve's + // tangent is the smooth normal; a corner still uses its two chords for the join. + let smooth = v.p + left(v.n) * (w * side); if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. if !v.corner { - // A smooth point has no join: its offset is along the vertex normal. - match normal { - Some(n) => pts.push((n, step)), - None => self.arc(pts, v, na, nb, w, step), - } + // A smooth point has no join: its offset is along the path normal. + pts.push((smooth, step)); } else { let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; match self.join { @@ -308,12 +360,11 @@ impl Ctx<'_> { let turn = 2.0 * c.clamp(-1.0, 1.0).acos(); let radius = if turn > 1e-9 { 0.5 * (v.la + v.lb) / turn } else { f64::INFINITY }; if self.uniform && w >= radius { - pts.push((v.p + na * w, true)); + pts.push((smooth, true)); pts.push((v.p, true)); - pts.push((v.p + nb * w, true)); + pts.push((smooth, true)); } else { - let n = normal.unwrap_or(v.p); - pts.push((n, step)); + pts.push((smooth, step)); } } else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { // A corner's inner offset is its miter while that stays inside both segments. @@ -438,44 +489,58 @@ fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64 out.close_path(); } -/// The tangent at the start of the segment `run[i] → run[j]`, for that segment's parameter. This -/// is the chord-length Catmull–Rom tangent: the secant across the point's neighbours, scaled by -/// the segment's share of the neighbouring lengths. The share keeps a short sample from flattening -/// the long segment beside it, while the secant's own length (not a unit direction) keeps a curved -/// run from overshooting. At an end, the segment itself. -fn tangent_start(run: &[Point], i: usize, j: usize, cyclic: bool) -> Vec2 { +/// The derivative of the run at each point, in points per point of chord length. Each coordinate +/// is a shape-preserving cubic Hermite (Fritsch–Carlson): between two points it stays within their +/// coordinate range, so a cubic through a fast width pinch cannot bulge past the pinch and cross +/// the other side, and it has a flat slope at a local extremum. A Catmull–Rom tangent (the secant +/// across the neighbours) does neither: it overshoots a V and, with the uneven sample spacing of a +/// flattened path, leaves a scalloped edge. +fn tangents(run: &[Point], cyclic: bool) -> Vec { let n = run.len(); - let (p, q) = (run[i], run[j]); - let l = p.distance(q); - if l <= 1e-12 { - return Vec2::ZERO; + let segs = if cyclic { n } else { n.saturating_sub(1) }; + let mut h = vec![0.0; segs]; + for (i, hi) in h.iter_mut().enumerate() { + *hi = run[i].distance(run[(i + 1) % n]); } - if cyclic || i > 0 { - let prev = run[(i + n - 1) % n]; - let la = p.distance(prev); - if la + l > 1e-12 { - return (q - prev) * (l / (la + l)); + let slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 { + return 0.0; + } + let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0); + let m = (w0 + w1) / (w0 / d0 + w1 / d1); + if m.is_finite() { m } else { 0.0 } + }; + let delta = |i: usize| -> Vec2 { + let hi = h[i % segs]; + if hi > 1e-12 { (run[(i + 1) % n] - run[i % n]) / hi } else { Vec2::ZERO } + }; + let mut out = vec![Vec2::ZERO; n]; + for (i, m) in out.iter_mut().enumerate() { + let (d0, d1) = (delta((i + n - 1) % n), delta(i % segs)); + let (h0, h1) = (h[(i + n - 1) % segs], h[i % segs]); + if h0 <= 1e-12 || h1 <= 1e-12 { + *m = if h1 > 1e-12 { d1 } else { d0 }; + } else if cyclic || (i > 0 && i + 1 < n) { + *m = Vec2::new(slope(d0.x, d1.x, h0, h1), slope(d0.y, d1.y, h0, h1)); + } else if i == 0 { + *m = d1; + } else { + *m = d0; + } + // A shape-preserving slope is flat in a coordinate at that coordinate's local extremum. + // Where both are extrema at one sample (a loop or a pinch in the sampled edge) the flat + // tangent would make a line and a kink; the secant across the sample keeps the curve C1 + // there, and the extremum itself is what trims the loop later. + if m.hypot() <= 1e-12 { + let prev = if cyclic || i > 0 { run[(i + n - 1) % n] } else { run[i] }; + let next = if cyclic || i + 1 < n { run[(i + 1) % n] } else { run[i] }; + let sec = next - prev; + if sec.hypot() > 1e-12 { + *m = sec.normalize(); + } } } - q - p -} - -/// The tangent at the end of the segment `run[i] → run[j]`, pointing forward. -fn tangent_end(run: &[Point], i: usize, j: usize, cyclic: bool) -> Vec2 { - let n = run.len(); - let (p, q) = (run[i], run[j]); - let l = p.distance(q); - if l <= 1e-12 { - return Vec2::ZERO; - } - if cyclic || j + 1 < n { - let next = run[(j + 1) % n]; - let lb = q.distance(next); - if l + lb > 1e-12 { - return (next - p) * (l / (l + lb)); - } - } - q - p + out } /// Append a run of points as cubics through them (or one line when they are collinear). @@ -489,13 +554,15 @@ fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { return; } let segs = if cyclic { n } else { n - 1 }; + let tans = tangents(run, cyclic); for i in 0..segs { let j = (i + 1) % n; - let (m0, m1) = (tangent_start(run, i, j, cyclic), tangent_end(run, i, j, cyclic)); + let h = run[i].distance(run[j]); + let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0)); if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { out.line_to(run[j]); } else { - out.curve_to(run[i] + m0 / 3.0, run[j] - m1 / 3.0, run[j]); + out.curve_to(run[i] + m0, run[j] - m1, run[j]); } } } @@ -594,6 +661,33 @@ mod tests { assert!(step, "{o:?}"); } + #[test] + fn added_samples_follow_the_curve_not_the_flattened_chord() { + // A cubic with a tight S: its chords are far from its tangents. A sample inserted inside a + // chord must take the curve's direction there; the chord's direction is what made a wide + // profile scallop at every flatten vertex. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 120.0), (120.0, -120.0), (120.0, 0.0)); + let segs = segments(b.elements()); + let flat = flatten(&segs, false, 2.0); + assert!(flat.pts.len() >= 3); + let mut marks = vec![]; + let mut acc = 0.0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + marks.push(acc + len / 2.0); + acc += len; + } + let (all, _, dirs) = split_at(&flat, false, &marks, &segs); + assert!(all.len() > flat.pts.len()); + let seg = segs[0]; + for (p, d) in all.iter().zip(&dirs) { + let want = tangent(&seg, seg.nearest(*p, 1e-9).t); + assert!(d.dot(want) > 0.999, "{d:?} vs {want:?} at {p:?}"); + } + } + #[test] fn a_wiggly_profile_on_a_curve_fits_without_kinks() { // A rapid width change turns the offset sharply; that is not a path corner, so the @@ -677,6 +771,36 @@ mod tests { assert!(o.contains(Point::new(0.0, 0.0)), "{o:?}"); } + #[test] + fn a_quick_width_change_does_not_overshoot_its_samples() { + // Width falls to a point and comes back, unevenly on either side: the fitted edge must not + // dip past the point (that crossed the other edge and left a scalloped neck). + let mut run = vec![Point::new(0.0, 0.0), Point::new(10.0, -1.0), Point::new(20.0, -0.3)]; + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + a.move_to(run[0]); + b.move_to(run[0]); + smooth_run(&mut a, &run, false, 0.0); + // Reverse the same points: the fit must stay inside the V from either direction too. + run.reverse(); + smooth_run(&mut b, &run, false, 0.0); + for path in [a, b] { + let mut start = Point::ZERO; + for el in path.elements() { + match el { + PathEl::MoveTo(p) => start = *p, + PathEl::CurveTo(c1, c2, p) => { + for c in [c1, c2] { + assert!(c.y >= -1.0 - 1e-9, "{c:?} in {path:?}"); + assert!(c.y <= start.y.max(p.y) + 1e-9, "{c:?} in {path:?}"); + } + start = *p; + } + _ => {} + } + } + } + } + #[test] fn a_coarse_wide_circle_keeps_its_offset() { // A wide stroke flattened coarsely: offsetting each flattened segment by its miter diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 92e10fab6..b5efd72ff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -681,7 +681,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. @@ -851,6 +851,7 @@ Evidence for the shared light Color: geometry tests in `tests_lighting`, effect | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Width-profile outlines offset samples added inside a flattened chord along the source curve's exact tangent, so a zoomed outline no longer jogs at flatten vertices; a regression test covers added samples. Scores and estimates unchanged. | | 2026-10-19 | minor | Plug-ins: live effects are given their object's fills and strokes, read only, and the item they sit on (G17); engine and unit regressions. Scores and hours unchanged. | | 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. | | 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. | From 28d0eba3d89237f2f507777bfa60d778dfac3a69 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 21 Oct 2026 03:27:01 +0200 Subject: [PATCH 07/14] Patterns: satisfy clippy's simplified boolean check The pre-existing swatch name-clash test is a negated conjunction that clippy 1.95 flags as nonminimal_bool, which stopped cargo xtask ci before this PR's crates were checked. Rewrite it as the equivalent disjunction; no behaviour change. --- crates/engine/src/cmd/patterncmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 8ed9820b7..f7f5b4c27 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -491,7 +491,7 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool { let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old); let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable(); let only_own = holders.peek().is_some() && holders.all(own); - d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w))) + d.swatch_name_taken(new) && (d.swatches_iter().any(|w| w.name == old && own(w)) || !only_own) } /// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the From c6c359fb768ca267bcc0447719be13debb146c35 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 21 Oct 2026 05:19:36 +0200 Subject: [PATCH 08/14] effects: keep width samples on their flattened branch --- crates/effects/src/stroke/width.rs | 131 +++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 24 deletions(-) diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 31676272d..85eb58a82 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -50,22 +50,27 @@ pub(super) fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) } -/// A flattened subpath: its points, which of them are corners, and the source segment that drew -/// each flattened segment (so a sample added between two points can read that segment's tangent -/// instead of the chord's direction). +/// A flattened subpath: its points, which of them are corners, and the source segment and +/// parameter range that drew each flattened piece (so a sample added between two points can read +/// that piece's tangent instead of the chord's direction). pub(super) struct Flat { pub(super) pts: Vec, pub(super) corner: Vec, pub(super) seg_of: Vec, + /// The source segment's parameter range for each flattened piece. Restricting the tangent + /// search to this range keeps a sample on the branch that drew it when a cubic loops back + /// near itself. + pub(super) param: Vec<(f64, f64)>, } /// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't /// repeat its start), each marked when it is a corner of the path. pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { - let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![] } }; - let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![] }; - // The first and the last segment that drew something. - let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None); + let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![], param: vec![] } }; + let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![], param: vec![] }; + // The last segment that drew something, and the last parameter it reached. + let mut prev: Option<&PathSeg> = None; + let mut last_added: Option<(usize, f64)> = None; for (si, seg) in segs.iter().enumerate() { let at = flat.pts.len() - 1; let before = flat.pts.len(); @@ -89,10 +94,26 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { for slot in flat.seg_of.iter_mut().take(end).skip(at) { *slot = si; } + // Find each new point's source parameter, never going backwards. A point is on the + // curve, so its nearest parameter is exact except at a self-intersection, where the + // monotone search picks the branch that continues this flattened piece. + let mut ts = Vec::with_capacity(end - at + 1); + ts.push(0.0); + let mut t = 0.0; + for p in &flat.pts[at + 1..=end] { + if t >= 1.0 - 1e-12 { + t = 1.0; + } else { + let sub = seg.subsegment(t..1.0); + let nr = sub.nearest(*p, 1e-9); + t = (t + (1.0 - t) * nr.t).clamp(0.0, 1.0); + } + ts.push(t); + } + flat.param.extend(ts.windows(2).map(|w| (w[0], w[1]))); + last_added = ts.last().map(|t| (si, *t)); if let Some(p) = prev { flat.corner[at] = kink(p, seg); - } else { - first = Some(seg); } prev = Some(seg); } @@ -102,14 +123,25 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { flat.pts.pop(); flat.corner.pop(); } - if let (Some(a), Some(b)) = (prev, first) { + if let (Some(a), Some(b)) = (segs.last(), segs.first()) { flat.corner[0] = kink(a, b); } - // A closing segment normally came from the path's last source segment; keep a tangent for - // it if the path closed without a duplicate point. + // The closing piece comes from the last source segment even when its endpoint is the + // already-present start point and did not add a flattened point. Keep a tangent (and its + // whole parameter range) for it. + flat.seg_of.truncate(flat.pts.len()); + flat.param.truncate(flat.pts.len()); if flat.seg_of.len() < flat.pts.len() { - let last = flat.seg_of.last().copied().unwrap_or(0); + let last = segs.len().saturating_sub(1); + // A closing curve with interior flattened points has already reached a parameter + // before its end (the duplicated start point was not added); the missing piece starts + // where it left off, not at 0. + let start = match last_added { + Some((si, t)) if si == last => t, + _ => 0.0, + }; flat.seg_of.resize(flat.pts.len(), last); + flat.param.resize(flat.pts.len(), (start, 1.0)); } } flat @@ -157,21 +189,36 @@ pub(super) fn length(pts: &[Point], closed: bool) -> f64 { /// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its /// start; those on a point or off the subpath are ignored). pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg]) -> (Vec, Vec, Vec) { - let Flat { pts, corner, seg_of } = flat; + let Flat { pts, corner, seg_of, param } = flat; let n = pts.len(); let seg_count = if closed { n } else { n.saturating_sub(1) }; - // The source's direction at `p`, from the segment that drew its flattened piece. The chord - // direction is the secant, not the tangent: using it for points added inside a chord jogs the - // normal at every flatten vertex. - let source = |p: Point, si: Option| -> Vec2 { - let Some(seg) = si.and_then(|i| segs.get(i)) else { return Vec2::new(1.0, 0.0) }; - tangent(seg, seg.nearest(p, 1e-9).t) + // The source's direction at a source parameter. The chord direction is the secant, not the + // tangent: using it for points added inside a chord jogs the normal at every flatten vertex. + let source_at = |si: usize, t: f64| -> Vec2 { segs.get(si).map_or(Vec2::new(1.0, 0.0), |seg| tangent(seg, t)) }; + // A point added inside a flattened piece searches only that piece's source parameter range: + // a looping cubic can pass near itself, and a full-segment nearest would take the other + // branch's tangent. + let source_near = |p: Point, i: usize| -> Vec2 { + let Some(si) = seg_of.get(i).copied() else { return Vec2::new(1.0, 0.0) }; + let Some(seg) = segs.get(si) else { return Vec2::new(1.0, 0.0) }; + let (t0, t1) = param.get(i).copied().unwrap_or((0.0, 1.0)); + if t1 - t0 > 1e-12 { + let sub = seg.subsegment(t0..t1); + tangent(seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t) + } else { + tangent(seg, t0) + } }; // At a shared point, average the arriving and leaving directions unless it is a corner. let dir_at = |i: usize| -> Vec2 { - let incoming = if i == 0 { if closed { seg_of.last().copied() } else { None } } else { seg_of.get(i - 1).copied() }; - let outgoing = seg_of.get(i).copied(); - let (a, b) = (incoming.map(|si| source(pts[i], Some(si))), outgoing.map(|si| source(pts[i], Some(si)))); + let incoming = if i == 0 { + if closed { seg_of.last().copied().zip(param.last().copied()) } else { None } + } else { + seg_of.get(i - 1).copied().zip(param.get(i - 1).copied()) + }; + let outgoing = seg_of.get(i).copied().zip(param.get(i).copied()); + let a = incoming.map(|(si, (_, t))| source_at(si, t)); + let b = outgoing.map(|(si, (t, _))| source_at(si, t)); match (a, b) { (Some(a), Some(b)) if !corner[i] => { let sum = a + b; @@ -197,7 +244,7 @@ pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg let p = a.lerp(b, (d - acc) / len); out.0.push(p); out.1.push(false); - out.2.push(source(p, seg_of.get(i).copied())); + out.2.push(source_near(p, i)); } acc += len; } @@ -801,6 +848,42 @@ mod tests { } } + #[test] + fn added_samples_stay_on_their_flattened_branch() { + // This cubic loops back near itself. At a coarse tolerance a chord midpoint can be closer + // to the other branch; a full-segment nearest would then take that branch's tangent and + // spike the offset. Each inserted sample must use the parameter range of its own piece. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((150.0, 100.0), (-50.0, 100.0), (100.0, 0.0)); + let segs = segments(b.elements()); + let seg = segs[0]; + let flat = flatten(&segs, false, 2.0); + let mut acc = 0.0; + let mut divergent = 0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + if len <= 1e-12 { + continue; + } + let (t0, t1) = flat.param[i]; + let sub = seg.subsegment(t0..t1); + for k in 1..16 { + let (out, _, dirs) = split_at(&flat, false, &[acc + len * k as f64 / 16.0], &segs); + assert!(out.len() >= i + 2, "no sample inserted in piece {i}"); + let p = out[i + 1]; + let want = tangent(&seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t); + assert!(dirs[i + 1].dot(want) > 0.999, "piece {i}, k {k}: {:?} vs {want:?}", dirs[i + 1]); + let full = tangent(&seg, seg.nearest(p, 1e-9).t); + if full.dot(want) < 0.9 { + divergent += 1; + } + } + acc += len; + } + assert!(divergent > 0, "the fixture did not cross the other branch"); + } + #[test] fn a_coarse_wide_circle_keeps_its_offset() { // A wide stroke flattened coarsely: offsetting each flattened segment by its miter From 478b3db26281b8e710ae7b1f562a5e71f98bdcfa Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 21:31:05 +0200 Subject: [PATCH 09/14] M3.64: width profiles end with one-sided PCHIP slopes --- crates/doc/src/appearance.rs | 95 ++++++++++++++++----- crates/effects/src/stroke/tests.rs | 6 +- crates/effects/src/stroke/tests_fit.rs | 6 +- crates/effects/src/stroke/tests_gradient.rs | 4 +- crates/effects/src/stroke/width.rs | 6 +- crates/engine/src/tests_outlinestroke.rs | 4 +- crates/ui-egui/src/dialogs/width_point.rs | 8 +- docs/target-app-parity.md | 2 +- 8 files changed, 94 insertions(+), 37 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 13304156a..beafba198 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -233,9 +233,10 @@ impl WidthProfile { /// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 /// continuous and monotone between points, so it never overshoots them, a point eases into /// its neighbours instead of making a corner, and moving one reshapes the curve as far as its - /// neighbours' neighbours (their slopes depend on it). The ends are flat, where the width is - /// constant outside the profile. Two points at one `t` are still a step: the runs either side - /// are independent and flat there. + /// neighbours' neighbours (their slopes depend on it). The ends use the one-sided PCHIP + /// slope, or the secant when there are only two points, so a two-point profile stays a + /// straight taper. Two points at one `t` are still a step: the runs either side are + /// independent and flat there. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { let (Some(first), Some(last)) = (p.first(), p.last()) else { return (1.0, 1.0) }; if t <= first.0 { @@ -306,12 +307,58 @@ impl WidthProfile { } } +/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants. +/// The caller uses the secant for a run of only two points; a sign change caps the slope, and a +/// slope that turns back on the first secant is flattened. +fn end_slope(h0: f64, h1: f64, d0: f64, d1: f64) -> f64 { + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if !m.is_finite() || m * d0 <= 0.0 { + return 0.0; + } + if d0 * d1 < 0.0 { + return if d0 > 0.0 { m.min(3.0 * d0) } else { m.max(3.0 * d0) }; + } + m +} + /// The slope of the smooth width curve at `p[i]` for one side: the weighted harmonic mean of the -/// secants either side, or flat at a local extremum and at the ends of a run (the profile's ends -/// and either side of a step). +/// secants either side. The profile's first and last points use the one-sided three-point PCHIP +/// slope instead (or the secant when their run has only two points), so a two-point profile stays +/// a straight taper. Either side of a step, and at a local extremum, the slope is flat. fn slope(p: &[(f64, f64, f64)], i: usize, right: bool) -> f64 { let value = |k: usize| p.get(k).map_or(0.0, |q| if right { q.2 } else { q.1 }); - let (Some(&(t, _, _)), Some(&(tm, _, _)), Some(&(tp, _, _))) = (p.get(i), i.checked_sub(1).and_then(|k| p.get(k)), p.get(i + 1)) else { + let Some(&(t, _, _)) = p.get(i) else { return 0.0 }; + if i == 0 { + let Some(&(t1, _, _)) = p.get(1) else { return 0.0 }; + let h0 = t1 - t; + if h0 <= 1e-12 { + return 0.0; + } + let d0 = (value(1) - value(0)) / h0; + let Some(&(t2, _, _)) = p.get(2) else { return d0 }; + let h1 = t2 - t1; + if h1 <= 1e-12 { + return d0; + } + let d1 = (value(2) - value(1)) / h1; + return end_slope(h0, h1, d0, d1); + } + if i + 1 == p.len() { + let Some(&(tm, _, _)) = i.checked_sub(1).and_then(|k| p.get(k)) else { return 0.0 }; + let h0 = t - tm; + if h0 <= 1e-12 { + return 0.0; + } + let d0 = (value(i) - value(i - 1)) / h0; + let Some(&(tmm, _, _)) = i.checked_sub(2).and_then(|k| p.get(k)) else { return d0 }; + let h1 = tm - tmm; + if h1 <= 1e-12 { + return d0; + } + let d1 = (value(i - 1) - value(i - 2)) / h1; + return end_slope(h0, h1, d0, d1); + } + let (Some(&(tm, _, _)), Some(&(tp, _, _))) = (i.checked_sub(1).and_then(|k| p.get(k)), p.get(i + 1)) else { return 0.0; }; let (h0, h1) = (t - tm, tp - t); @@ -1340,7 +1387,7 @@ mod tests { 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)); + assert_eq!(p.at(0.25), (0.75, 0.75)); assert_eq!(p.at(2.0), (0.0, 0.0)); } @@ -1363,20 +1410,30 @@ mod tests { } } - /// A two-point profile is a smoothstep: it eases out of and into the constant ends, and its - /// midpoint is still the linear one. + /// A run with only two points takes its secant as both end slopes, so it stays a straight + /// taper on each side instead of becoming a smoothstep. #[test] - fn profile_eases_into_its_ends() { - let p = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]; - assert!((WidthProfile::at_points(&p, 0.5).0 - 0.5).abs() < 1e-12); - let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6; - assert!(slope(0.0).abs() < 1e-3, "{}", slope(0.0)); - assert!(slope(1.0).abs() < 1e-3, "{}", slope(1.0)); - let mut prev = 0.0; + fn profile_two_point_runs_are_linear() { + let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)]; for k in 0..=100 { - let w = WidthProfile::at_points(&p, k as f64 / 100.0).0; - assert!((0.0..=1.0).contains(&w) && w >= prev - 1e-12, "{w} at {k}"); - prev = w; + let t = k as f64 / 100.0; + let (l, r) = WidthProfile::at_points(&p, t); + assert!((l - t).abs() < 1e-12, "left {l} at {t}"); + assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}"); + } + } + + /// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75, + /// above the 0.5 a straight line between its points would give. + #[test] + fn profile_lens_is_convex() { + let p = WidthProfile::lens(); + assert!((p.at(0.25).0 - 0.75).abs() < 1e-12); + assert!((p.at(0.75).0 - 0.75).abs() < 1e-12); + for k in 1..25 { + let t = k as f64 / 100.0; + assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0); + assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t); } } diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 78a58dad5..3121f9584 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -153,14 +153,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { 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); // The smoothed sides are cubics through the profile's samples: 0.06 pt² off the polygon. - assert!(near(lens.area().abs(), 500.0, 0.1)); + assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1)); // 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. + // 0..25 averages 5/12 of the width, the one over 50..75 11/12. 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(), 1000.0 / 3.0, 0.1)); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); } diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index e9070c89b..d3269ddcd 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -156,9 +156,9 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip, - // where the smooth profile is thinner than a straight ramp (a smoothstep's first tenth). + // where the smooth profile is thinner than a straight ramp (a parabola's first tenth). assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide"); - assert!(!o.contains(Point::new(5.0, 0.5)) && o.contains(Point::new(5.0, 0.1)), "first dash is thin"); + assert!(o.contains(Point::new(5.0, 0.8)) && !o.contains(Point::new(5.0, 1.2)), "first dash is thin"); assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty"); // Dots too: a round dot near the middle is bigger than one near an end. let st = stroke(10.0, |s| { @@ -167,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.4))); + assert!(o.contains(Point::new(50.0, 4.5)) && o.contains(Point::new(10.0, 1.4)) && !o.contains(Point::new(10.0, 2.0))); } /// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0). diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 4562c374b..f3b0233ca 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -137,8 +137,8 @@ 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 three quarters as wide: its top edge is 3.75 up. + assert!(param(&sl, Point::new(25.0, -3.6)).unwrap() < 0.05); assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); } diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 85eb58a82..8351123e4 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -657,12 +657,12 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } - /// The lens eases up to full width and back down (a smooth bump averaging half the width); a - /// one-sided profile keeps its side at zero and stays on the other. + /// The lens eases up to full width and back down (a parabolic bump averaging two thirds of + /// the width); a one-sided profile keeps its side at zero and stays on the other. #[test] fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() { let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); - assert!((a - 500.0).abs() < 0.1, "{a}"); + assert!((a - 2000.0 / 3.0).abs() < 0.1, "{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); diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index 170f31acd..2c4aa8230 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -72,11 +72,11 @@ fn arrowheads_are_part_of_the_outline() { } #[test] -fn a_lens_profile_outlines_to_half_the_rectangle() { +fn a_lens_profile_outlines_to_two_thirds_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)); + assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); } #[test] diff --git a/crates/ui-egui/src/dialogs/width_point.rs b/crates/ui-egui/src/dialogs/width_point.rs index 173f4b9f4..7cd1c3e3c 100644 --- a/crates/ui-egui/src/dialogs/width_point.rs +++ b/crates/ui-egui/src/dialogs/width_point.rs @@ -374,10 +374,10 @@ mod tests { assert!(d.fields.get("index").is_none_or(Value::is_null)); let actual_t = d.f64("t", -1.0); assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25"); - // The smooth profile's sides at a quarter: smoothstep(0.25) = 0.15625 of 1..2 and - // 0.5..1.5, on a 10pt stroke (5pt half). - assert!((d.f64("side1", 0.0) - 5.78125).abs() < 1e-6); - assert!((d.f64("side2", 0.0) - 3.28125).abs() < 1e-6); + // The two-point profile is linear: at a quarter its sides are 1.25 and 0.75 of + // 1..2 and 0.5..1.5, on a 10pt stroke (5pt half). + assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6); + assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6); assert!(!d.bool("linked")); d.fields.insert("side1".into(), json!(8)); d.fields.insert("side2".into(), json!(3)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c4dc5b215..a905dcdf7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,7 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, flat at the profile's ends and either side of a step), sampled along the path so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled along the path so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page states that "The colors that a scatter, art, or pattern brush paints depend on the current stroke color and the colorization method of the brush" () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. From 360f591f3787c35ff929f9dbc294810b7abeb395 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 21:38:04 +0200 Subject: [PATCH 10/14] Tools: satisfy clippy's simplified boolean check --- crates/tools/src/builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 8b0650beb..8ce5d8e79 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -343,7 +343,7 @@ impl BuilderMap { drawn.nearest(mid).filter(|n| n.4 <= snap)?; // A run that cannot be cut out, or that is all of the path (such as a lone line), leaves // nothing to split off. - if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| !rest.is_empty()) { + if po::cut_out(drawn, &run, false, snap).is_none_or(|rest| rest.is_empty()) { return None; } Some((i, run)) From 5759fd3d697286963d2165eeae396bcb6265d1db Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 01:06:43 +0200 Subject: [PATCH 11/14] Width profiles: sample outlines adaptively --- crates/effects/src/stroke/width.rs | 98 +++++++++++++++++++++++++++--- docs/target-app-parity.md | 3 +- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 8351123e4..1283b5a94 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -151,9 +151,13 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { /// drawn as a smooth outline along straight runs too (the two points of a discontinuous point /// share one sample). `span` maps the subpath onto the profile (see [`outline_spans`]). fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec, Vec) { - // Samples per profile segment; fewer when a profile carries many points, so a file can't turn - // the extra samples into a huge allocation. - const SAMPLES: usize = 16; + // Two samples per profile segment: the segment end plus its midpoint. The outline is fitted + // with cubics through these points, so a smooth width curve needs far fewer samples than it + // did when every pair of samples became a separate cubic. A profile with a strong bend gets + // one more subdivision; a flat profile draws the same straight pieces as before. + const SAMPLES: usize = 2; + const MAX_SAMPLES: usize = 8; + const BEND: f64 = 0.20; let total = length(&flat.pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { @@ -165,11 +169,28 @@ fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); // A flat profile draws the same straight pieces as before: sample only its points. let constant = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); - let samples = if constant { 1 } else { SAMPLES.min((4096 / ts.len().max(1)).max(1)) }; - let mut marks = Vec::with_capacity(ts.len() * samples); + let cap = (4096 / ts.len().max(1)).max(1); + // A curved path's flattened points already sample the width along the subpath. On a dense + // piece the profile's own points are enough; a straight run has only its ends, so it needs + // the extra samples. This keeps curved, profiled art close to a uniform stroke's size. + let pieces = if closed { flat.pts.len() } else { flat.pts.len().saturating_sub(1) }.max(1); + let spacing = total / pieces as f64; + let mut marks = Vec::with_capacity(ts.len() * SAMPLES); for w in ts.windows(2) { + let (a, b) = (w[0], w[1]); + let samples = if constant { + 1 + } else { + // `ts` has the profile both at t and t + 1 for a dash crossing a closed seam; the + // shifted copy has the same shape, so measure its bend on the unshifted segment. + let start = a.rem_euclid(1.0); + let end = start + (b - a); + let want = adaptive_samples(profile, start, end, SAMPLES, MAX_SAMPLES, BEND).min(cap); + let existing = (b - a) * total / spacing; + if existing >= want as f64 { 1 } else { want } + }; for k in 0..samples { - marks.push(w[0] + (w[1] - w[0]) * k as f64 / samples as f64); + marks.push(a + (b - a) * k as f64 / samples as f64); } } if let Some(&last) = ts.last() { @@ -179,6 +200,27 @@ fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, split_at(flat, closed, &marks, segs) } +/// How many pieces one profile segment needs so a cubic fit through their ends keeps the width +/// curve. A straight taper is already straight; a visible bend gets progressively more samples. +fn adaptive_samples(profile: &WidthProfile, t0: f64, t1: f64, base: usize, max: usize, bend: f64) -> usize { + let mut samples = base; + let (l0, r0) = profile.at(t0); + let (l1, r1) = profile.at(t1); + while samples < max { + let mut dev = 0.0_f64; + for q in [0.25, 0.5, 0.75] { + let (l, r) = profile.at(t0 + (t1 - t0) * q); + let want = (l0 + (l1 - l0) * q, r0 + (r1 - r0) * q); + dev = dev.max((l - want.0).abs()).max((r - want.1).abs()); + } + if dev <= bend { + break; + } + samples *= 2; + } + samples +} + /// Length of a flattened subpath (see [`flatten`]). pub(super) fn length(pts: &[Point], closed: bool) -> f64 { let n = pts.len(); @@ -557,6 +599,20 @@ fn tangents(run: &[Point], cyclic: bool) -> Vec { let m = (w0 + w1) / (w0 / d0 + w1 / d1); if m.is_finite() { m } else { 0.0 } }; + // The one-sided slope of a run's end uses its two end secants, instead of only the last one. + // A coarse sample then still leaves the end of a profile curve with the right slope. + let end_slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 || h0 + h1 <= 1e-12 { + return 0.0; + } + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if m.is_finite() && m * d0 > 0.0 { + let cap = 3.0 * d0.abs(); + m.clamp(-cap, cap) + } else { + 0.0 + } + }; let delta = |i: usize| -> Vec2 { let hi = h[i % segs]; if hi > 1e-12 { (run[(i + 1) % n] - run[i % n]) / hi } else { Vec2::ZERO } @@ -570,9 +626,19 @@ fn tangents(run: &[Point], cyclic: bool) -> Vec { } else if cyclic || (i > 0 && i + 1 < n) { *m = Vec2::new(slope(d0.x, d1.x, h0, h1), slope(d0.y, d1.y, h0, h1)); } else if i == 0 { - *m = d1; + *m = if n >= 3 { + let (a, b) = (delta(0), delta(1)); + Vec2::new(end_slope(a.x, b.x, h[0], h[1]), end_slope(a.y, b.y, h[0], h[1])) + } else { + d1 + }; } else { - *m = d0; + *m = if n >= 3 { + let (a, b) = (delta(n - 2), delta(n - 3)); + Vec2::new(end_slope(a.x, b.x, h[n - 2], h[n - 3]), end_slope(a.y, b.y, h[n - 2], h[n - 3])) + } else { + d0 + }; } // A shape-preserving slope is flat in a coordinate at that coordinate's local extremum. // Where both are extrema at one sample (a loop or a pinch in the sampled edge) the flat @@ -895,4 +961,20 @@ mod tests { let want = std::f64::consts::PI * (30.0 * 30.0 - 10.0 * 10.0); assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } + + #[test] + fn profiled_outlines_stay_compact() { + // A smooth profile is fitted with cubics, so it must not turn every profile sample into + // exported path data. Compare it with the same curved path's uniform outline. + let circle = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(0.01); + let count = |points| { + let p = WidthProfile { points }; + width_outline(&circle, 5.0, &p, &capped(LineCap::Butt), 0.01).elements().len() + }; + let uniform = count(vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)]); + let lens = count(WidthProfile::lens().points); + let wave = count(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)]); + assert!(lens <= uniform * 3 / 2, "lens {lens} vs uniform {uniform}"); + assert!(wave <= uniform * 3 / 2, "wave {wave} vs uniform {uniform}"); + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 678e7a58e..4c9d2ae67 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -769,7 +769,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled along the path so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page states that "The colors that a scatter, art, or pattern brush paints depend on the current stroke color and the colorization method of the brush" () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. @@ -1040,6 +1040,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged | | 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | +| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. A local 1,500-ellipse export falls to a 6.56 MB PDF (the reported main baseline is 4.5 MB, about 1.46×) and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. | | 2026-10-21 | minor | Print: the Print dialog's Media list carries the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5, which keep their ISO 216 sizes, plus はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; the JSON id or the dialog label names a paper, any case and spacing. Print score and hours unchanged. | From 361cb43c4f2c0e56668f7b3cf0b981e72ad366c7 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 10:21:11 +0200 Subject: [PATCH 12/14] #3145: fit width-profile outlines to cut export size --- crates/effects/src/stroke/width.rs | 75 ++++++++++++++++++++++++------ crates/pathops/src/fit.rs | 2 +- crates/pathops/src/lib.rs | 1 + docs/target-app-parity.md | 4 +- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 1283b5a94..1eb07a42f 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -12,6 +12,7 @@ use std::f64::consts::PI; use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; +use vectorcraft_pathops::fit_cubics; use super::{kink, segments, subpaths, tangent, unit}; @@ -504,11 +505,10 @@ fn cap_points(ring: &mut Vec<(Point, bool)>, c: Point, u: Vec2, v: Vec2, r0: f64 /// Append `pts` as a closed contour (a width outline's ring or loop), through a smooth run's /// points as cubic curves. Sampled as line segments, a curved, wide stroke shows the facets of -/// its offset whenever it is magnified; the cubics keep it smooth. Every point is interpolated -/// (so the sampled width is kept), a run whose points are collinear stays a line, and the points -/// marked sharp — caps, miter joins, width steps and path corners — stay sharp. A rapid width -/// change turns the offset sharply without being a corner: marking corners here rather than -/// measuring turns keeps that turn smooth. +/// its offset whenever it is magnified; the cubics keep it smooth. A run whose points are +/// collinear stays a line, and the points marked sharp — caps, miter joins, width steps and path +/// corners — stay sharp. A rapid width change turns the offset sharply without being a corner: +/// marking corners here rather than measuring turns keeps that turn smooth. fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64) { let mut ring: Vec<(Point, bool)> = Vec::new(); for (p, sharp) in pts { @@ -656,7 +656,14 @@ fn tangents(run: &[Point], cyclic: bool) -> Vec { out } -/// Append a run of points as cubics through them (or one line when they are collinear). +/// Append a run of points as a fitted chain of cubics (or one line when they are collinear). +/// +/// A cubic through every flattened point, as this used to emit, keeps the outline smooth but puts +/// three coordinates per point into the file; a small, thin stroke then exports several times the +/// path data of the same stroke without a profile. Fit a chain within the outline tolerance +/// instead: the result is still G1 continuous and stays within `tol` of the sampled edge, but a +/// smooth side is a few cubics rather than one per flatten step. Profile bends that need finer +/// detail produce more cubics; straight runs stay lines. fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { let n = run.len(); if n < 2 { @@ -666,17 +673,55 @@ fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { out.line_to(run[n - 1]); return; } - let segs = if cyclic { n } else { n - 1 }; let tans = tangents(run, cyclic); - for i in 0..segs { - let j = (i + 1) % n; - let h = run[i].distance(run[j]); - let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0)); - if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { - out.line_to(run[j]); - } else { - out.curve_to(run[i] + m0, run[j] - m1, run[j]); + let tol = tol.max(1e-6); + // A short run is one or two profile features, not flattened path detail. Fitting it loosely + // enough to save path data can let a handle cross the narrow side of a V; keep the + // shape-preserving per-point cubics there. + let segs = if cyclic { n } else { n - 1 }; + if n <= 3 { + for i in 0..segs { + let j = (i + 1) % n; + let h = run[i].distance(run[j]); + let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0)); + if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { + out.line_to(run[j]); + } else { + out.curve_to(run[i] + m0, run[j] - m1, run[j]); + } } + return; + } + let unit_or = |v: Vec2, fallback: Vec2| { + let len = v.hypot(); + if len > 1e-12 { + v / len + } else { + let len = fallback.hypot(); + if len > 1e-12 { fallback / len } else { Vec2::new(1.0, 0.0) } + } + }; + let t0 = unit_or(tans[0], run[1] - run[0]); + let (pts, t0, t1) = if cyclic { + // Fit the seam too: the fit starts and arrives with the same tangent, so closing the + // contour adds no kink. + let mut pts = Vec::with_capacity(n + 1); + pts.extend_from_slice(run); + pts.push(run[0]); + (pts, t0, t0) + } else { + (run.to_vec(), t0, unit_or(tans[n - 1], run[n - 1] - run[n - 2])) + }; + // Fit tighter than the flattening tolerance: the fit's error is the whole outline's error, + // and a least-squares fit may sit on one side of the sampled edge, which would otherwise + // shift a profile's area visibly. + let cubics = fit_cubics(&pts, t0, t1, tol * 0.1); + if cubics.is_empty() { + out.line_to(run[n - 1]); + return; + } + for c in cubics { + out.curve_to(c.p1, c.p2, c.p3); } } diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs index 091164d20..95b179484 100644 --- a/crates/pathops/src/fit.rs +++ b/crates/pathops/src/fit.rs @@ -27,7 +27,7 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 { /// /// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point /// (forward direction). Consecutive output cubics are G1 continuous. -pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec { +pub fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec { let mut out = Vec::new(); let pts = dedup(pts); if pts.len() < 2 { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 36e5a367f..76f249793 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -18,6 +18,7 @@ pub use edit::{ AverageAxis, SIMPLIFY_LEVEL, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_with, smooth, split_into_grid, }; +pub use fit::fit_cubics; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; pub use planar::{ diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 494df9127..1c352c7ff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -787,7 +787,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page states that "The colors that a scatter, art, or pattern brush paints depend on the current stroke color and the colorization method of the brush" () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. @@ -1074,7 +1074,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged | | 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | -| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. A local 1,500-ellipse export falls to a 6.56 MB PDF (the reported main baseline is 4.5 MB, about 1.46×) and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. | +| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. The sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt by default) instead of one cubic per flatten step. On the reviewer's 1,500-ellipse fixture (20–200 pt ellipses, 3–12 pt weights) the PDF falls to 3.59 MB and the SVG to 6.79 MB (reported main baselines 4.48 MB and 9.30 MB, about 0.80× and 0.73×), and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. | | 2026-10-21 | minor | Print: the Print dialog's Media list carries the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5, which keep their ISO 216 sizes, plus はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; the JSON id or the dialog label names a paper, any case and spacing. Print score and hours unchanged. | From a08937661b0a1ac9445486a57f32f924aa557256 Mon Sep 17 00:00:00 2001 From: adambelis Date: Wed, 11 Nov 2026 22:09:10 +0100 Subject: [PATCH 13/14] Width profiles: near-outline sampling and review fixes (#3145) --- crates/effects/src/stroke/gradient.rs | 17 ++++----- crates/effects/src/stroke/width.rs | 52 +++++++++++++++++---------- crates/pathops/src/lib.rs | 4 +-- crates/render/src/tests/longstroke.rs | 7 +++- 4 files changed, 48 insertions(+), 32 deletions(-) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 43f4fd4a0..4bddf80b0 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -18,12 +18,11 @@ 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}; -use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; +use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; use super::width::{flatten, left, length, split_at}; use super::{aligned_width, arrow, cap_extent, is_closed, segments, subpaths, unit}; -use crate::stroke::stroke_pieces; /// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the /// corner gets a fan of ribs round its outside. @@ -98,7 +97,10 @@ impl Lay<'_> { /// 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 + // A square cap's corners stand √2 of the half-width out from where the dash ends, and a + // dash can end just past a corner: reach that far, or they paint outside every slice. + let diag = if self.st.dash.is_some() && self.st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + self.half(t) * diag + self.margin } /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: @@ -384,11 +386,8 @@ pub fn gradient_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, let across = st.gradient_mode == StrokeGradientMode::Across; // A width profile changes the across band along the path: ribs close enough to follow it. let max_dt = if across && st.profile.is_some() { PROFILE_STEP } else { 1.0 }; - // Ribs sit on the same line the outline reads: a tip-aligned arrowhead trims that line by the - // head inset (`stroke_pieces`), so trim here too, or ribs sit where nothing paints. - let pieces = stroke_pieces(bp, st); let mut out = vec![]; - for ribs in strips(&*pieces.line, rule, st, tol, margin, &[], max_dt) { + for ribs in strips(bp, rule, st, tol, margin, &[], max_dt) { for w in ribs.windows(2) { let (a, b) = (&w[0], &w[1]); let shape = polygon(&[a.l, b.l, b.c, b.r, a.r, a.c], grow); @@ -503,9 +502,7 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - // Ribs sit on the same line the outline reads (see gradient_slices). - let pieces = stroke_pieces(bp, st); - strips(&*pieces.line, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) + strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) .map(|ribs| { diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index a9f9f35a2..0b307cc6b 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -53,7 +53,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1)); } if kept.iter().all(|v| *v) { - ctx.subpath(&mut out, &flat, &segs, closed, (0.0, 1.0)); + ctx.subpath(&mut out, &flat, &segs, closed, (0.0, 1.0), count as f64); continue; } cut = true; @@ -78,14 +78,14 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil } else if let Some(part) = run.take() && part.pts.len() > 1 { - ctx.subpath(&mut out, &part, &segs, false, (from, walked / total)); + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } walked += lengths[i]; } if let Some(part) = run && part.pts.len() > 1 { - ctx.subpath(&mut out, &part, &segs, false, (from, walked / total)); + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } } cut.then_some(out) @@ -101,7 +101,9 @@ pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, prof let segs = segments(&bp.elements()[r]); let flat = flatten(&segs, closed, ctx.tol); if flat.pts.len() > 1 { - ctx.subpath(&mut out, &flat, &segs, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); + let span = spans.get(i).copied().unwrap_or((0.0, 1.0)); + let pieces = if closed { flat.pts.len() } else { flat.pts.len() - 1 }.max(1); + ctx.subpath(&mut out, &flat, &segs, closed, span, pieces as f64 / (span.1 - span.0)); } } out @@ -214,14 +216,24 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { /// Insert samples where the profile has width points and between them, so its smooth curve is /// drawn as a smooth outline along straight runs too (the two points of a discontinuous point /// share one sample). `span` maps the subpath onto the profile (see [`outline_spans`]). -fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec, Vec) { +fn densify( + flat: &Flat, + segs: &[PathSeg], + closed: bool, + profile: &WidthProfile, + span: (f64, f64), + density: f64, +) -> (Vec, Vec, Vec) { // Two samples per profile segment: the segment end plus its midpoint. The outline is fitted // with cubics through these points, so a smooth width curve needs far fewer samples than it // did when every pair of samples became a separate cubic. A profile with a strong bend gets // one more subdivision; a flat profile draws the same straight pieces as before. const SAMPLES: usize = 2; const MAX_SAMPLES: usize = 8; - const BEND: f64 = 0.20; + // The gap (in width factor) the straight line between two samples may leave: small enough + // that a sharp peak like the lens' needs one more piece, large enough that a long taper's + // gentle bend does not. + const BEND: f64 = 0.05; let total = length(&flat.pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { @@ -237,8 +249,6 @@ fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, // A curved path's flattened points already sample the width along the subpath. On a dense // piece the profile's own points are enough; a straight run has only its ends, so it needs // the extra samples. This keeps curved, profiled art close to a uniform stroke's size. - let pieces = if closed { flat.pts.len() } else { flat.pts.len().saturating_sub(1) }.max(1); - let spacing = total / pieces as f64; let mut marks = Vec::with_capacity(ts.len() * SAMPLES); for w in ts.windows(2) { let (a, b) = (w[0], w[1]); @@ -250,7 +260,10 @@ fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, let start = a.rem_euclid(1.0); let end = start + (b - a); let want = adaptive_samples(profile, start, end, SAMPLES, MAX_SAMPLES, BEND).min(cap); - let existing = (b - a) * total / spacing; + // How many of the flattened path's points already fall in this profile segment. + // `density` is measured on the whole subpath, not on this piece, so outlining only + // the piece near the view picks the same samples as outlining the whole subpath. + let existing = (b - a) * density; if existing >= want as f64 { 1 } else { want } }; for k in 0..samples { @@ -267,15 +280,18 @@ fn densify(flat: &Flat, segs: &[PathSeg], closed: bool, profile: &WidthProfile, /// How many pieces one profile segment needs so a cubic fit through their ends keeps the width /// curve. A straight taper is already straight; a visible bend gets progressively more samples. fn adaptive_samples(profile: &WidthProfile, t0: f64, t1: f64, base: usize, max: usize, bend: f64) -> usize { - let mut samples = base; - let (l0, r0) = profile.at(t0); - let (l1, r1) = profile.at(t1); + let mut samples = base.max(1); while samples < max { + // One probe in the middle of each piece: how far the curve leaves the straight line + // between the piece's ends. The more pieces, the closer the line follows the curve, so + // the loop only stops when the pieces it has are fine enough (or at `max`). let mut dev = 0.0_f64; - for q in [0.25, 0.5, 0.75] { - let (l, r) = profile.at(t0 + (t1 - t0) * q); - let want = (l0 + (l1 - l0) * q, r0 + (r1 - r0) * q); - dev = dev.max((l - want.0).abs()).max((r - want.1).abs()); + for k in 0..samples { + let (a, b) = (k as f64 / samples as f64, (k + 1) as f64 / samples as f64); + let (la, ra) = profile.at(t0 + (t1 - t0) * a); + let (lb, rb) = profile.at(t0 + (t1 - t0) * b); + let (lm, rm) = profile.at(t0 + (t1 - t0) * (a + b) / 2.0); + dev = dev.max((lm - (la + lb) / 2.0).abs()).max((rm - (ra + rb) / 2.0).abs()); } if dev <= bend { break; @@ -403,8 +419,8 @@ impl<'a> Ctx<'a> { } } - fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64)) { - let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span); + fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64), density: f64) { + let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span, density); 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(); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 466321b8d..291c95678 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -28,9 +28,7 @@ pub use edit::{ }; pub use fit::{fit_cubics, fit_cubics_through}; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; -pub use pathfinder::{ - FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within, -}; +pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within}; pub use planar::{ BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap, diff --git a/crates/render/src/tests/longstroke.rs b/crates/render/src/tests/longstroke.rs index 8a6567f39..dc16e6430 100644 --- a/crates/render/src/tests/longstroke.rs +++ b/crates/render/src/tests/longstroke.rs @@ -92,6 +92,11 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() { let near = render(&mut renderer, &d, at); assert_eq!(renderer.strokes.values().any(|e| e.crop.is_some()), cropped, "{name}: crop choice"); let full = whole(&d, at); + // The cropped outline fits cubics through the same samples, but its fit starts at the + // crop rather than at the subpath's end, so a cubic may put its control points + // differently (the same curve). The rasteriser then flattens it into a slightly + // different polygon, which shows as one level on an anti-aliased edge pixel. Anything + // more would be a wrong width. let mismatched: Vec<_> = near .pixels .as_chunks::<4>() @@ -99,7 +104,7 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() { .iter() .zip(full.pixels.as_chunks::<4>().0.iter()) .enumerate() - .filter(|(_, (a, b))| a != b) + .filter(|(_, (a, b))| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 1)) .take(8) .map(|(i, (a, b))| (i % SIZE as usize, i / SIZE as usize, a.to_vec(), b.to_vec())) .collect(); From ff18e8c7331de2f16d1864bece81c3e274de0a2f Mon Sep 17 00:00:00 2001 From: adambelis Date: Thu, 12 Nov 2026 00:35:01 +0100 Subject: [PATCH 14/14] Width profiles: the edge fit stops once it is within tolerance (#3145) --- crates/pathops/src/fit.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs index a2a4875f3..0411a71f4 100644 --- a/crates/pathops/src/fit.rs +++ b/crates/pathops/src/fit.rs @@ -77,7 +77,7 @@ fn fit_through_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec= MAX_SPLITS { out.push(curve); return; @@ -192,6 +192,32 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u fit_single_from(pts, chord_params(pts), t0, t1) } +/// [`fit_single`] that stops reparameterising once the curve is within `tol` of its points: a +/// curve already that close needs no better fit, and each Newton round reads every point. +fn fit_single_tol(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> (CubicBez, f64, usize) { + let mut u = chord_params(pts); + let mut best = generate(pts, &u, t0, t1); + let (mut best_err, mut best_split) = max_error(pts, &best, &u); + for _ in 0..MAX_REPARAM { + if best_err <= tol { + break; + } + for (i, ui) in u.iter_mut().enumerate() { + *ui = newton(&best, pts[i], *ui); + } + let c = generate(pts, &u, t0, t1); + let (e, s) = max_error(pts, &c, &u); + if e < best_err { + best = c; + best_err = e; + best_split = s; + } else { + break; + } + } + (best, best_err, best_split) +} + /// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the /// chord-length ones. pub(crate) fn fit_single_from(pts: &[Point], u: Vec, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {