Simplify: a closed subpath whose refit is one curve stays in the path

A closed subpath that refits to a single curve has one anchor, which is
not a subpath, so Simplify dropped it: a small circle inside a compound
path was lost and the result ended hundreds of points from the original,
and a small closed path on its own came out empty. Such a subpath is now
refitted in two parts, cut at the point farthest from its start, or in
three when two parts would still enclose no area (two lines that retrace
each other with Convert to Straight Lines). A closed subpath of a single
segment stays as it is.
This commit is contained in:
dotCooCoo
2026-11-11 08:21:15 -08:00
parent 9fc43f2a35
commit 9ce1558c5e
2 changed files with 63 additions and 4 deletions
+33 -3
View File
@@ -84,11 +84,14 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
PathData::new(out)
}
/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it.
/// [`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.
fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
let mut segs = subpath_segs(sp);
let n = segs.len();
if n == 0 {
if n == 0 || sp.closed && n < 2 {
return Some(sp.clone());
}
if sp.closed {
@@ -101,7 +104,34 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
// along its chord, and `no_worse` can return the original subpath, so the start can keep a kink.
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 result = simplify_run_within(&segs, opts, MAX_RUN_SAMPLES, across);
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);
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.
let halves: Vec<Seg> = segs
.iter()
.flat_map(|s| {
let (a, b) = s.c.subdivide();
[Seg { c: a, line: s.line }, Seg { c: b, line: s.line }]
})
.collect();
let m = halves.len();
let at = |i: usize| halves[i].c.p0;
let farthest = |from: &dyn Fn(Point) -> f64, skip: usize| (1..m).filter(|&i| i != skip).max_by(|&a, &b| from(at(a)).total_cmp(&from(at(b))));
let p0 = at(0);
let j = farthest(&|p| p.distance(p0), 0).unwrap_or(m / 2);
let chord = at(j) - p0;
let off_chord = |p: Point| if chord.hypot2() > 0.0 { chord.cross(p - p0).abs() } else { p.distance(p0) };
let k = farthest(&off_chord, j).unwrap_or(m / 2);
let refit = |cuts: &[usize]| -> Vec<Seg> {
cuts.windows(2).flat_map(|w| simplify_run_within(&halves[w[0]..w[1]], opts, MAX_RUN_SAMPLES / 3, None)).collect()
};
result = refit(&[0, j, m]);
if flat(&result) && k != j {
result = refit(&[0, j.min(k), j.max(k), m]);
}
}
let s = segs_to_subpath(&result, sp.closed)?;
Some(if opts.straight_lines { s } else { no_worse(sp, s) })
}
+30 -1
View File
@@ -1,7 +1,7 @@
use std::f64::consts::PI;
use kurbo::{ParamCurve, Point, Rect, Shape as _};
use vectorcraft_geom::{AnchorKind, FillRule, PathData, SubPath};
use vectorcraft_geom::{Anchor, AnchorKind, FillRule, PathData, SubPath};
use vectorcraft_pathops::*;
const NZ: FillRule = FillRule::NonZero;
@@ -408,6 +408,35 @@ fn simplify_straight_lines_option() {
assert!(max_deviation(&c, &s) <= 1.0 + 1e-6);
}
/// A closed subpath whose refit is one curve stays a closed subpath that encloses an area, in a
/// compound path and on its own, with curves and with Convert to Straight Lines.
#[test]
fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() {
let small = |cx: f64, cy: f64| circle(cx, cy, 2.0).subpaths.remove(0);
let square = rect(0.0, 0.0, 1000.0, 1000.0).subpaths.remove(0);
let compound = PathData::new(vec![square, small(500.0, 500.0)]);
let lone = PathData::single(small(0.0, 0.0));
// A straight edge and a curve that bows 2.25 pt from it.
let bow = PathData::single(SubPath::new(
vec![
Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 3.0), Point::new(0.0, 0.0)),
Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 3.0)),
],
true,
));
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] {
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));
}
}
}
}
/// The Simplify slider's levels: 1 pt at 50, doubled for every 10 levels down and halved for every
/// 10 up; Auto-Simplify takes the lowest level whose tolerance is at most 1/400 of the size, and
/// level 100 for a size under 12.5 pt.