Simplify: a closed subpath whose refits enclose no area keeps its outline

Follows #6614. When a closed subpath's refit in two and in three parts is
still lines that enclose no area, for example an S curve closed by a
straight edge whose cut points all lie on that edge, Simplify now splits
each segment in 2, 4, 8 and so on (up to 1,024 pieces, 65,536 in all)
until every piece is within the tolerance of the line between its ends
and those lines enclose an area. With Convert to Straight Lines those
lines are the result; without it the subpath stays as it is. A subpath
whose points are all in line keeps the refit's lines, so its extra
anchors are still removed. Lines count as enclosing no area when every
point is within 1e-6 pt of one line.
This commit is contained in:
dotCooCoo
2026-11-11 11:00:51 -08:00
parent 6a4ef12091
commit 796e2a7ebc
2 changed files with 78 additions and 6 deletions
+35 -5
View File
@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use kurbo::{CubicBez, ParamCurve, PathSeg, Point, Rect, Vec2};
use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Rect, Vec2};
use vectorcraft_geom::path::measured_arclen;
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
@@ -85,9 +85,10 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
}
/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. A closed subpath
/// whose refit is one segment, or two lines that enclose no area, is refitted in two parts, cut at
/// the point farthest from its start, or in three when two still give that; one of a single segment
/// stays as it is.
/// whose refit is one segment, or lines that enclose no area, is refitted in two parts, cut at the
/// point farthest from its start, or in three when two still give that. When three parts still give
/// no area, straight lines follow the subpath within the tolerance, and without straight lines it
/// stays as it is, as does one of a single segment; one that is all in line keeps the refit.
fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
let mut segs = subpath_segs(sp);
let n = segs.len();
@@ -105,7 +106,12 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
let across = if sp.closed && opts.smooth > 0.0 { unit(end_tangent(&segs[n - 1].c) + start_tangent(&segs[0].c)) } else { None };
let across = across.filter(|_| (0..n).all(|i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) <= opts.corner_angle_deg));
let mut result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across);
let flat = |r: &[Seg]| r.len() < 2 || r.len() == 2 && r.iter().all(|s| s.line);
let in_line = |pts: &[Point]| {
let Some(&a) = pts.first() else { return true };
let far = pts.iter().map(|&p| p - a).max_by(|u, v| u.hypot2().total_cmp(&v.hypot2())).unwrap_or_default();
pts.iter().all(|&p| far.cross(p - a).abs() <= 1e-6 * far.hypot())
};
let flat = |r: &[Seg]| r.len() < 2 || r.iter().all(|s| s.line) && in_line(&r.iter().map(|s| s.c.p0).collect::<Vec<_>>());
if sp.closed && flat(&result) {
// Each segment in two, and the loop cut where they meet: first at the point farthest from its
// start, then also at the point farthest from the line between those two.
@@ -131,6 +137,30 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
if flat(&result) && k != j {
result = refit(&[0, j.min(k), j.max(k), m]);
}
let ctrl: Vec<Point> = segs.iter().flat_map(|s| [s.c.p0, s.c.p1, s.c.p2]).collect();
if flat(&result) && !in_line(&ctrl) {
// Each segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 in all, until every
// piece is within the tolerance of the line between its ends and those lines enclose an
// area: with straight lines they are the result, and without them the loop stays as it is.
// A loop whose points are all in line keeps the lines of the refit.
let near = |c: &CubicBez| [c.p1, c.p2].iter().all(|&p| Line::new(c.p0, c.p3).nearest(p, 1e-9).distance_sq.sqrt() <= opts.tolerance);
for k in 1..=10u32 {
let parts = 1u32 << k;
if segs.len().saturating_mul(parts as usize) > 1 << 16 {
break;
}
let at = |i: u32| f64::from(i) / f64::from(parts);
let pieces: Vec<CubicBez> = segs.iter().flat_map(|s| (0..parts).map(move |i| s.c.subsegment(at(i)..at(i + 1)))).collect();
let lines: Vec<Seg> = pieces.iter().map(|c| Seg::line(c.p0, c.p3)).collect();
if pieces.iter().all(near) && !flat(&lines) {
if !opts.straight_lines {
return Some(sp.clone());
}
result = lines;
break;
}
}
}
}
let s = segs_to_subpath(&result, sp.closed)?;
Some(if opts.straight_lines { s } else { no_worse(sp, s) })
+43 -1
View File
@@ -424,17 +424,59 @@ fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() {
],
true,
));
// A straight edge and an S curve whose midpoints all lie on that edge.
let s_curve = |h: f64, x: f64| {
PathData::single(SubPath::new(
vec![
Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(0.0, h)),
Anchor::with_handles(Point::new(100.0, 0.0), Point::new(x, -h), Point::new(100.0, 0.0)),
],
true,
))
};
let (s_low, s_high) = (s_curve(3.0, 0.0), s_curve(13.5, 100.0));
for straight_lines in [false, true] {
for tolerance in [4.0, 10.0, 32.0] {
let opts = SimplifyOptions { tolerance, straight_lines, ..Default::default() };
for path in [&compound, &lone, &bow] {
for path in [&compound, &lone, &bow, &s_low] {
let s = simplify_with(path, &opts);
assert_eq!(s.subpaths.len(), path.subpaths.len(), "{tolerance} pt, straight lines {straight_lines}: {s:?}");
assert!(s.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 2 && sp.area().abs() > 1.0), "{s:?}");
assert!(max_deviation(path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(path, &s));
let lines = s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i)));
assert!(lines || !straight_lines, "{tolerance} pt: {s:?}");
}
}
}
// Lobes on both sides of the edge, 3.9 pt from it, stay within the tolerance.
for straight_lines in [false, true] {
let s = simplify_with(&s_high, &SimplifyOptions { tolerance: 4.0, straight_lines, ..Default::default() });
assert!(max_deviation(&s_high, &s) <= 4.0 + 1e-6, "straight lines {straight_lines}: {}", max_deviation(&s_high, &s));
}
// Curves whose handles lie on the line between their anchors become lines.
let thin = PathData::single(SubPath::new(
vec![
Anchor::with_handles(Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(30.0, 0.0)),
Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, 0.0), Point::new(80.0, 0.0)),
],
true,
));
let s = simplify_with(&thin, &SimplifyOptions { tolerance: 4.0, straight_lines: true, ..Default::default() });
assert!(s.subpaths.iter().all(|sp| (0..sp.segment_count()).all(|i| sp.segment_is_line(i))), "{s:?}");
// Anchors in line on a closed path are still removed.
let in_line = PathData::single(SubPath::new([0.0, 25.0, 50.0, 100.0].map(|x| Anchor::corner(Point::new(x, 0.0))).to_vec(), true));
let s = simplify_with(&in_line, &SimplifyOptions { tolerance: 4.0, ..Default::default() });
assert!(s.subpaths[0].anchors.len() < 4, "{s:?}");
let many = PathData::single(SubPath::new((0..5000).map(|i| Anchor::corner(Point::new(f64::from(i), 0.0))).collect(), true));
for tolerance in [4.0, f64::INFINITY, f64::NAN] {
for path in [&in_line, &thin, &many] {
simplify_with(path, &SimplifyOptions { tolerance, straight_lines: true, ..Default::default() });
}
}
// A long, thin rectangle keeps its corners.
let long = rect(0.0, 0.0, 1e9, 0.5);
let s = simplify_with(&long, &SimplifyOptions { tolerance: 0.1, straight_lines: true, ..Default::default() });
assert!(max_deviation(&long, &s) <= 0.1 + 1e-6, "{s:?}");
}
/// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every