From 1bc16ea2ba3c416d67b0742015456471983a4593 Mon Sep 17 00:00:00 2001 From: Jeff Muizelaar Date: Thu, 22 Oct 2026 09:38:55 -0400 Subject: [PATCH 1/3] Cap sub-paths that end with a curve. This was regressed by 26c0729 (Rasterize curves directly with trapezoids instead of flattening) Previously, curve_to_internal emitted the last flattened point with line_to_capped if the `end` parameter was true. The new curve_to_direct ignored its end argument and so wasn't capping when it should've. Instead of ignoring `end` cap if it's set. From https://bugzilla.mozilla.org/show_bug.cgi?id=2084919 --- src/lib.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 6739031..b98e2df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -984,6 +984,17 @@ impl<'z> Stroker<'z> { self.last_normal = t.last_normal; self.cur_pt = Some(pt); + + if end { + // cap end + let mut pt = pt; + if self.stroked_path.aa && self.style.cap == LineCap::Butt { + // pull the cap back half a pixel to match the start cap and line_to_capped + pt += perp(self.last_normal) * 0.5; + } + cap_line(&mut self.stroked_path, &self.style, pt, self.last_normal); + self.start_point = None; + } } pub fn close(&mut self) { @@ -1311,3 +1322,39 @@ fn round_cap_points() { stroker.line_to_capped(Point::new(92.0625, 57.5)); assert_eq!(stroker.finish().len(), 576); } + +#[test] +fn capped_curve_followed_by_subpath() { + // from https://bugzilla.mozilla.org/show_bug.cgi?id=2084919 + // A sub-path ending with a curve should be capped even when another sub-path follows. + let mut stroker = Stroker::new(&StrokeStyle{ + cap: LineCap::Round, + join: LineJoin::Round, + width: 20., + ..Default::default()}); + stroker.move_to(Point::new(20., 30.), false); + stroker.curve_to_capped(Point::new(30., 30.), Point::new(40., 30.), Point::new(50., 30.)); + stroker.move_to(Point::new(20., 70.), false); + stroker.line_to_capped(Point::new(50., 70.)); + let result = rasterize_to_mask(&stroker.finish(), 100, 100); + // inside the round cap at the end of the curve + assert_eq!(result[30 * 100 + 57], 255); +} + +#[test] +fn capped_curve_butt_cap() { + // the butt cap at the end of a curve shouldn't extend past the end point + let mut stroker = Stroker::new(&StrokeStyle{ + cap: LineCap::Butt, + join: LineJoin::Bevel, + width: 1., + ..Default::default()}); + stroker.move_to(Point::new(20., 20.5), false); + stroker.curve_to_capped(Point::new(25., 20.5), Point::new(35., 20.5), Point::new(40., 20.5)); + stroker.move_to(Point::new(20., 30.5), false); + stroker.line_to_capped(Point::new(40., 30.5)); + let result = stroker.finish(); + for v in result.iter() { + assert!(v.x >= 19.5 && v.x <= 40.5); + } +} From a8e6888b75b7f4516cc10218ee0c6430ba1229e2 Mon Sep 17 00:00:00 2001 From: Jeff Muizelaar Date: Thu, 22 Oct 2026 09:41:21 -0400 Subject: [PATCH 2/3] Remove the unused _end field from curve_to_direct's flattening sink. --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b98e2df..b6b0a0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -880,7 +880,7 @@ impl<'z> Stroker<'z> { // This lets us make sure that the normal of the stroked path matches the normal // of the curve and we also avoid having to join the segments pub fn curve_to_direct(&mut self, cx1: Point, cx2: Point, pt: Point, end: bool) { - struct Target<'a, 'b> { stroker: &'a mut Stroker<'b>, _end: bool, last_normal: Vector, last_point: GpPointR} + struct Target<'a, 'b> { stroker: &'a mut Stroker<'b>, last_normal: Vector, last_point: GpPointR} impl<'a, 'b> CFlatteningSink for Target<'a, 'b> { fn FirstTangent(&mut self, tangent: Option) { let tangent = tangent.unwrap(); @@ -971,7 +971,7 @@ impl<'z> Stroker<'z> { GpPointR { x: cx2.x, y: cx2.y, }, GpPointR { x: pt.x, y: pt.y, }]); - let mut t = Target{ stroker: self, _end: end, + let mut t = Target{ stroker: self, last_normal: Vector::new(0., 0.), last_point: GpPointR { x: cur_pt.x, y: cur_pt.y } }; From afc09239f678844da5ff687e8499eadb01d3de44 Mon Sep 17 00:00:00 2001 From: Jeff Muizelaar Date: Thu, 22 Oct 2026 09:48:36 -0400 Subject: [PATCH 3/3] Use round joins for sharp turns within curves. This was regressed by 26c0729 (Rasterize curves directly with trapezoids instead of flattening) Previously, curve_to_internal stroked each flattened segment with line_to, which joined the segments. curve_to_direct instead draws trapezoids that share normals and doesn't join them. The outer edge of each trapezoid is a chord of the offset curve, so when a curve turns back on itself over a short distance the chord falls well inside the offset curve (about 5.5px for a 20px wide stroke in the bug) and the inner edge can cross itself. When the normal turns enough that the chord would fall more than half a pixel inside the offset curve, draw the segment as a rectangle with round joins at either end instead. From https://bugzilla.mozilla.org/show_bug.cgi?id=2084919 --- src/lib.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b6b0a0f..e59d715 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -579,6 +579,14 @@ fn is_interior_angle(a: Vector, b: Vector) -> bool { dot(perp(a), b) > 0. || a == b /* 0 degrees is interior */ } +fn join_round(dest: &mut PathBuilder, pt: Point, radius: f32, s1_normal: Vector, s2_normal: Vector) { + if is_interior_angle(s1_normal, s2_normal) { + dest.arc_wedge(pt, radius, flip(s2_normal), flip(s1_normal)); + } else { + dest.arc_wedge(pt, radius, s1_normal, s2_normal); + } +} + fn join_line( dest: &mut PathBuilder, style: &StrokeStyle, @@ -880,7 +888,10 @@ impl<'z> Stroker<'z> { // This lets us make sure that the normal of the stroked path matches the normal // of the curve and we also avoid having to join the segments pub fn curve_to_direct(&mut self, cx1: Point, cx2: Point, pt: Point, end: bool) { - struct Target<'a, 'b> { stroker: &'a mut Stroker<'b>, last_normal: Vector, last_point: GpPointR} + const TOLERANCE: f32 = 0.25; + // how far the outer edge of a trapezoid may fall inside the offset curve + const MAX_OFFSET_ERROR: f32 = 0.5; + struct Target<'a, 'b> { stroker: &'a mut Stroker<'b>, last_normal: Vector, last_point: GpPointR, min_turn_cos: f32 } impl<'a, 'b> CFlatteningSink for Target<'a, 'b> { fn FirstTangent(&mut self, tangent: Option) { let tangent = tangent.unwrap(); @@ -895,6 +906,7 @@ impl<'z> Stroker<'z> { } fn AcceptPointAndTangent(&mut self, pt: &GpPointR, tangent: &GpPointR, _last: bool) -> HRESULT { let normal = flip(Vector::new(tangent.y, -tangent.x).normalize()); + let curve_normal = normal; let last_normal = self.last_normal; let cur_pt = Point::new(self.last_point.x, self.last_point.y); let half_width = self.stroker.half_width; @@ -916,6 +928,24 @@ impl<'z> Stroker<'z> { } else { // we don't need to join the segments because the quads are sharing normals } + + // The trapezoid's outer edge is a chord of the offset curve. If the normal turns + // too much the chord falls too far inside the offset curve (and the inner edge + // can cross itself), so draw the segment as a rectangle with round joins at + // either end instead. The swept stroke of a curve is always round, regardless + // of the join style. + let mut start_normal = last_normal; + let mut end_normal = normal; + if dot(last_normal, normal) < self.min_turn_cos { + let pt = Point::new(pt.x, pt.y); + let seg_normal = compute_normal(cur_pt, pt).unwrap_or(normal); + join_round(stroked_path, cur_pt, half_width, last_normal, seg_normal); + join_round(stroked_path, pt, half_width, seg_normal, normal); + start_normal = seg_normal; + end_normal = seg_normal; + } + let (last_normal, normal) = (start_normal, end_normal); + if stroked_path.aa { stroked_path.ramp( pt.x + normal.x * (half_width - 0.5), @@ -955,7 +985,7 @@ impl<'z> Stroker<'z> { cur_pt.y - last_normal.y * half_width, ); } - self.last_normal = normal; + self.last_normal = curve_normal; self.last_point = *pt; return S_OK; } @@ -971,11 +1001,23 @@ impl<'z> Stroker<'z> { GpPointR { x: cx2.x, y: cx2.y, }, GpPointR { x: pt.x, y: pt.y, }]); + // The chord of a turn of angle a on the offset curve falls half_width * (1 - cos(a / 2)) + // inside it. Find the cosine of the turn where that reaches MAX_OFFSET_ERROR, using + // cos(a) = 2 * cos(a / 2)^2 - 1, so that each segment only needs a dot product. + let max_half_turn_cos = 1. - MAX_OFFSET_ERROR / self.half_width; + let min_turn_cos = if max_half_turn_cos > 0. { + 2. * max_half_turn_cos * max_half_turn_cos - 1. + } else { + // the stroke is too thin for the chord to ever be off by MAX_OFFSET_ERROR + f32::NEG_INFINITY + }; + let mut t = Target{ stroker: self, last_normal: Vector::new(0., 0.), - last_point: GpPointR { x: cur_pt.x, y: cur_pt.y } + last_point: GpPointR { x: cur_pt.x, y: cur_pt.y }, + min_turn_cos, }; - let mut f = CBezierFlattener::new(&bezier, &mut t, 0.25); + let mut f = CBezierFlattener::new(&bezier, &mut t, TOLERANCE); if f.GetFirstTangent().is_none() { // the bezier is degnerate return if end { self.line_to_capped(pt) } else { self.line_to(pt) }; @@ -1358,3 +1400,37 @@ fn capped_curve_butt_cap() { assert!(v.x >= 19.5 && v.x <= 40.5); } } + +#[test] +fn curve_turning_back_on_itself() { + // from https://bugzilla.mozilla.org/show_bug.cgi?id=2084919 + // The outside of a sharp turn within a curve should be covered by a round join. + let mut stroker = Stroker::new(&StrokeStyle{ + cap: LineCap::Round, + join: LineJoin::Round, + width: 20., + ..Default::default()}); + stroker.move_to(Point::new(50., 90.), false); + stroker.line_to(Point::new(50., 40.)); + stroker.curve_to(Point::new(50., 38.), Point::new(49.8, 38.15), Point::new(49.5, 40.5)); + stroker.line_to_capped(Point::new(49.5, 90.)); + let result = rasterize_to_mask(&stroker.finish(), 100, 100); + // just above the turn, well within half the line width of the curve + assert_eq!(result[33 * 100 + 50], 255); +} + +#[test] +fn curve_turning_back_on_itself_bevel() { + // The outside of a sharp turn within a curve should be round even with bevel joins + let mut stroker = Stroker::new(&StrokeStyle{ + cap: LineCap::Round, + join: LineJoin::Bevel, + width: 20., + ..Default::default()}); + stroker.move_to(Point::new(50., 90.), false); + stroker.line_to(Point::new(50., 40.)); + stroker.curve_to(Point::new(50., 38.), Point::new(49.8, 38.15), Point::new(49.5, 40.5)); + stroker.line_to_capped(Point::new(49.5, 90.)); + let result = rasterize_to_mask(&stroker.finish(), 100, 100); + assert_eq!(result[33 * 100 + 50], 255); +}