Merge pull request #6614 from dotCooCoo/fix/simplify-keeps-small-closed-subpaths

Simplify: a closed subpath whose refit is one curve stays in the path
This commit is contained in:
BFlat
2026-11-12 00:17:42 +07:00
committed by GitHub
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.