mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Expand stroke: compact plain stroke spines before outlining
Squashed from #3457 onto main.
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user