diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index ced1db16e..a82503391 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -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 { let mut segs = subpath_segs(sp); let n = segs.len(); @@ -105,7 +106,12 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { 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::>()); 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 { if flat(&result) && k != j { result = refit(&[0, j.min(k), j.max(k), m]); } + let ctrl: Vec = 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 = segs.iter().flat_map(|s| (0..parts).map(move |i| s.c.subsegment(at(i)..at(i + 1)))).collect(); + let lines: Vec = 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) }) diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 01e89efe0..9a6fb5713 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -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