From 8c3efd788ac282bc2b67da997befe348a980340f Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 8 Oct 2026 18:23:01 +0300 Subject: [PATCH] add inner join anchor point --- .../overlay_editor/src/app/stroke/control.rs | 2 +- iOverlay/src/mesh/int/stroke/builder_join.rs | 6 +- iOverlay/tests/stroke_coverage_tests.rs | 78 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/examples/overlay_editor/src/app/stroke/control.rs b/examples/overlay_editor/src/app/stroke/control.rs index c27d70b..0975723 100644 --- a/examples/overlay_editor/src/app/stroke/control.rs +++ b/examples/overlay_editor/src/app/stroke/control.rs @@ -69,7 +69,7 @@ impl EditorApp { if slider( ui, "Stroke Width", - egui::Slider::new(&mut width, 0.1..=10.0).step_by(0.01), + egui::Slider::new(&mut width, 0.1..=100.0).step_by(0.01), ) { self.update(AppMessage::Stroke(StrokeMessage::WidthValueUpdated(width))); } diff --git a/iOverlay/src/mesh/int/stroke/builder_join.rs b/iOverlay/src/mesh/int/stroke/builder_join.rs index fe63880..2989189 100644 --- a/iOverlay/src/mesh/int/stroke/builder_join.rs +++ b/iOverlay/src/mesh/int/stroke/builder_join.rs @@ -35,9 +35,11 @@ impl> JoinBuilder for Join { ) }; if cross >= I::Wide::ZERO { - segments.push_non_degenerate(b.a_left, a.b_left); + segments.push_non_degenerate(b.a_left, b.a); + segments.push_non_degenerate(a.b, a.b_left); } else { - segments.push_non_degenerate(a.b_right, b.a_right); + segments.push_non_degenerate(a.b_right, a.b); + segments.push_non_degenerate(b.a, b.a_right); } self.add( a.b, diff --git a/iOverlay/tests/stroke_coverage_tests.rs b/iOverlay/tests/stroke_coverage_tests.rs index 1df61d3..7f5d44a 100644 --- a/iOverlay/tests/stroke_coverage_tests.rs +++ b/iOverlay/tests/stroke_coverage_tests.rs @@ -25,6 +25,84 @@ fn contains(shapes: &[Vec>], p: [f64; 2]) -> bool { }) } +#[test] +fn wide_strokes_cover_short_segment_rectangles() { + // Issue #95: the inner join must not cut triangles out of either segment's rectangle. + for math in [MathMode::Integer, MathMode::Float] { + for join in [LineJoin::Bevel, LineJoin::Round(0.05), LineJoin::Miter(0.1)] { + for radius in [0.5, 1.025, 2.0, 3.0] { + for sign in [-1.0, 1.0] { + for reversed in [false, true] { + let mut path = [[0.0, 0.0], [1.0, 0.0], [1.0, sign]]; + if reversed { + path.reverse(); + } + let style = StrokeStyle::new(2.0 * radius) + .math(math) + .line_join(join.clone()) + .start_cap(LineCap::Butt) + .end_cap(LineCap::Butt); + let shapes = path.stroke(style, false); + let context = + format!("{math:?}, {join:?}, radius={radius}, sign={sign}, reversed={reversed}"); + assert_eq!(shapes.len(), 1, "{context}"); + assert_eq!(shapes[0].len(), 1, "{context}"); + + for i in 0..10 { + for j in 0..10 { + let along = (i as f64 + 0.5) / 10.0; + let across = radius * (2.0 * (j as f64 + 0.5) / 10.0 - 1.0); + for point in [[along, across], [1.0 + across, sign * along]] { + assert!( + contains(&shapes, point), + "uncovered rectangle: {point:?}, {context}" + ); + } + } + } + if radius > 1.0 { + // Sample the triangles near the caps, even just above the failure threshold. + let d = 0.25 * (radius - 1.0); + for point in [[-d, sign * d], [1.0 - d, sign * (1.0 + d)]] { + assert!( + contains(&shapes, point), + "uncovered overhang: {point:?}, {context}" + ); + } + } + if matches!(join, LineJoin::Bevel | LineJoin::Miter(_)) { + let area: f64 = shapes + .iter() + .flatten() + .map(|contour| { + contour + .iter() + .zip(contour.iter().cycle().skip(1)) + .take(contour.len()) + .map(|(a, b)| a[0] * b[1] - a[1] * b[0]) + .sum::() + * 0.5 + }) + .sum(); + let join_area = if matches!(join, LineJoin::Bevel) { + 0.5 * radius * radius + } else { + radius * radius + }; + // Two segment rectangles, minus their overlap, plus the outer join. + let expected_area = 4.0 * radius - radius.min(1.0).powi(2) + join_area; + assert!( + (area - expected_area).abs() < 1e-6, + "area={area}, expected={expected_area}, {context}" + ); + } + } + } + } + } + } +} + fn assert_vertex_disks_covered(shapes: &[Vec>], path: &[StrokeVertex<[f64; 2]>], case: usize) { for (index, vertex) in path.iter().enumerate() { for sample in 0..16 {