Expand stroke: compact plain stroke spines before outlining

Squashed from #3457 onto main.
This commit is contained in:
bflatastic
2026-10-22 02:21:59 +07:00
parent 19c7d61f8a
commit dd2f5da98c
3 changed files with 104 additions and 0 deletions
+19
View File
@@ -127,6 +127,25 @@ fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted"); assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
} }
#[test]
fn expand_stroke_region_of_a_round_line_stays_compact() {
use std::f64::consts::PI;
use vectorcraft_geom::{FillRule, PathData, Point, SubPath};
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round);
let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st);
let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}");
let area = region.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}");
// Extra colinear anchors along the spine must not multiply outline anchors (#3446).
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st);
let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
#[test] #[test]
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
let l = line(0.0, 100.0); let l = line(0.0, 100.0);
+71
View File
@@ -8,11 +8,30 @@ pub use kurbo::{Cap, Join};
use linesweeper::topology::{ContourIdx, Contours}; use linesweeper::topology::{ContourIdx, Contours};
use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez}; use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez};
use crate::edit::remove_redundant_points;
/// Flattening/fit tolerance handed to kurbo's stroker. /// Flattening/fit tolerance handed to kurbo's stroker.
const STROKE_TOL: f64 = 1e-3; const STROKE_TOL: f64 = 1e-3;
/// Drop collinear and coincident spine vertices before stroking (#3446). Uses `tol` only for
/// redundancy; does not simplify or refit the spine.
fn compact_stroke_spine(bp: &BezPath, tol: f64) -> BezPath {
if bp.elements().is_empty() {
return BezPath::new();
}
let tol = if tol.is_finite() && tol > 0.0 { tol } else { STROKE_TOL };
remove_redundant_points(&PathData::from_bezpath(bp), tol).to_bezpath()
}
fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath { fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
let spine = compact_stroke_spine(bp, STROKE_TOL);
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
close_all(&kurbo::stroke(spine.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
}
/// Like [`stroke_bez`] but without [`compact_stroke_spine`] (regression baseline in unit tests).
#[cfg(test)]
fn stroke_bez_without_spine_compact(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath {
let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0)); let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0));
close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL)) close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL))
} }
@@ -217,4 +236,56 @@ mod tests {
assert_eq!(out.subpaths.len(), 1); assert_eq!(out.subpaths.len(), 1);
assert!(after > before, "{before} → {after}"); assert!(after > before, "{before} → {after}");
} }
fn outline_max_deviation(a: &BezPath, b: &BezPath) -> f64 {
use kurbo::ParamCurve;
use vectorcraft_geom::Point;
let pa = PathData::from_bezpath(a);
let pb = PathData::from_bezpath(b);
let mut worst: f64 = 0.0;
for (src, dst) in [(a, &pb), (b, &pa)] {
for seg in src.segments() {
for i in 0..=12 {
let p = seg.eval(i as f64 / 12.0);
let pt = Point::new(p.x, p.y);
worst = worst.max(dst.nearest(pt).unwrap().4);
}
}
}
worst
}
#[test]
fn compact_stroke_spine_drops_colinear_points_without_moving_curved_outlines() {
let width = 10.0;
let cap = Cap::Round;
let join = Join::Round;
let miter = 4.0;
// Dense colinear spine: compact outline should match the uncompacted one closely.
let dense: Vec<vectorcraft_geom::Point> = (0..=40).map(|i| vectorcraft_geom::Point::new(i as f64 * 2.5, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_bez = dense_path.to_bezpath();
let compact = stroke_bez(&dense_bez, width, cap, join, miter);
let raw = stroke_bez_without_spine_compact(&dense_bez, width, cap, join, miter);
assert!(outline_max_deviation(&compact, &raw) <= 1e-3, "colinear compaction must not refit the stroke outline");
let sparse =
PathData::single(SubPath::polyline(&[vectorcraft_geom::Point::new(0.0, 50.0), vectorcraft_geom::Point::new(100.0, 50.0)], false));
let sparse_outline = outline_stroke(&sparse, width, cap, join, miter);
let compact_region = stroke_region(&[compact]);
assert!(
outline_max_deviation(&compact_region.to_bezpath(), &sparse_outline.to_bezpath()) <= 1e-3,
"compacted colinear spine should outline like the endpoints alone"
);
// Sampled arc: only redundant vertices are removed; outline must track the raw stroke.
let arc: Vec<vectorcraft_geom::Point> = (0..=32)
.map(|i| {
let t = i as f64 / 32.0 * std::f64::consts::PI;
vectorcraft_geom::Point::new(50.0 + 50.0 * t.cos(), 50.0 + 8.0 * t.sin())
})
.collect();
let arc_bez = PathData::single(SubPath::polyline(&arc, false)).to_bezpath();
let arc_compact = stroke_bez(&arc_bez, width, cap, join, miter);
let arc_raw = stroke_bez_without_spine_compact(&arc_bez, width, cap, join, miter);
assert!(outline_max_deviation(&arc_compact, &arc_raw) <= 1e-3, "curved spine compaction must not refit the stroke outline");
}
} }
+14
View File
@@ -294,6 +294,20 @@ fn outline_stroke_of_line() {
assert!(close(ar(&o), 216.0, 1e-6)); assert!(close(ar(&o), 216.0, 1e-6));
} }
#[test]
fn outline_stroke_of_straight_line_with_round_caps_is_compact() {
let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false));
let o = outline_stroke(&l, 10.0, Cap::Round, Join::Round, 4.0);
assert!(close(ar(&o), 100.0 * 10.0 + PI * 5.0 * 5.0, 1e-3), "{}", ar(&o));
let anchors = o.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert!(anchors <= 8, "round-cap expand should stay compact, got {anchors} anchors");
let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect();
let dense_path = PathData::single(SubPath::polyline(&dense, false));
let dense_o = outline_stroke(&dense_path, 10.0, Cap::Round, Join::Round, 4.0);
let dense_anchors = dense_o.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
#[test] #[test]
fn outline_stroke_of_square_is_ring() { fn outline_stroke_of_square_is_ring() {
let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0); let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0);