diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index a82503391..da8e5e4d8 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -88,7 +88,9 @@ pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { /// 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. +/// stays as it is, as does one of a single segment. When no lines within the limits on pieces enclose +/// an area, it stays as it is, or with straight lines follows the lines between samples of each +/// curved 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(); @@ -139,27 +141,51 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { } 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. + // Each curved segment split in 2, 4, 8 and so on, up to 1,024 pieces and 65,536 pieces in all + // with the line segments, 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. When no split within those limits encloses an area, the loop + // stays as it is, or with straight lines each curved segment becomes the lines between its + // samples, up to `MAX_RUN_RESULT` lines in all. 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); + let curved = segs.iter().filter(|s| !s.line).count(); for k in 1..=10u32 { let parts = 1u32 << k; - if segs.len().saturating_mul(parts as usize) > 1 << 16 { + if curved.saturating_mul(parts as usize).saturating_add(n - curved) > 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 pieces: Vec = segs + .iter() + .flat_map(|s| { + let count = if s.line { 1 } else { parts }; + (0..count).map(move |i| if s.line { s.c } else { 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; + return segs_to_subpath(&lines, true); } } + if !opts.straight_lines { + return Some(sp.clone()); + } + let room = (MAX_RUN_RESULT / curved.max(1)).min(MAX_SEGMENT_SAMPLES); + let mut lines: Vec = Vec::new(); + for s in &segs { + if s.line { + lines.push(Seg::line(s.c.p0, s.c.p3)); + continue; + } + let mut pts = Vec::new(); + sample(&s.c, sample_steps(&s.c, opts.tolerance.max(1e-6)).min(room).max(1), &mut pts, true); + lines.extend(pts.windows(2).map(|p| Seg::line(p[0], p[1]))); + } + return segs_to_subpath(&lines, true); } } let s = segs_to_subpath(&result, sp.closed)?; diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 9a6fb5713..81e7f83ce 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -479,6 +479,42 @@ fn simplify_keeps_a_closed_subpath_that_refits_to_one_curve() { assert!(max_deviation(&long, &s) <= 0.1 + 1e-6, "{s:?}"); } +/// An S curve closed by a straight edge of 20,000 anchors, whose curve shows only when it is split in +/// four, keeps its outline within the tolerance. With corner anchors on the edge, only the curve is +/// split. With smooth anchors whose handles lie along the edge, four pieces of every segment pass the +/// limit on pieces: the loop stays as it is, or with straight lines follows lines between samples. +#[test] +fn simplify_keeps_a_flat_loop_with_many_anchors_on_its_edge() { + let n = 20_000; + let step = 100.0 / f64::from(n); + let curve_anchors = vec![ + Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(0.0, 3.0)), + Anchor::with_handles(Point::new(100.0, 0.0), Point::new(0.0, -3.0), Point::new(100.0, 0.0)), + ]; + let curve = PathData::single(SubPath::new(curve_anchors.clone(), false)); + for smooth_edge in [false, true] { + let mut anchors = curve_anchors.clone(); + anchors.extend((1..n).map(|i| { + let p = Point::new(100.0 - f64::from(i) * step, 0.0); + if smooth_edge { + Anchor::with_handles(p, Point::new(p.x + step / 3.0, 0.0), Point::new(p.x - step / 3.0, 0.0)) + } else { + Anchor::corner(p) + } + })); + let path = PathData::single(SubPath::new(anchors, true)); + for straight_lines in [false, true] { + let s = simplify_with(&path, &SimplifyOptions { tolerance: 4.0, straight_lines, ..Default::default() }); + let at = format!("smooth edge {smooth_edge}, straight lines {straight_lines}"); + assert_eq!(s.subpaths.len(), 1, "{at}"); + let sp = &s.subpaths[0]; + assert!(sp.closed && sp.area().abs() > 1.0, "{at}: area {}", sp.area()); + assert!(max_deviation(&curve, &s) <= 4.0 + 1e-6, "{at}: {}", max_deviation(&curve, &s)); + assert!(!straight_lines || (0..sp.segment_count()).all(|i| sp.segment_is_line(i)), "{at}: a curve is left"); + } + } +} + /// 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.