diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 80f0deffd..ced1db16e 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -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 { 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 { // 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 = 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 { + 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) }) } diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 587da3344..01e89efe0 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -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.