#1028: Offset Path finishes where the sweep looped forever

The sweep under Offset Path (linesweeper 0.4) never finished on some
miter offsets: on the reported glyph and on a synthetic outline it kept
allocating until the app ran out of memory. linesweeper 0.5 fixes that
infinite loop ("Fixed an infinite loop", 0.5.0 changelog), and builds
without changes.

0.5 can hand back a curve piece that doesn't follow the input curve it
was cut from. In the Shape Builder rosette test, a contour runs out along
one petal and back by such a piece, which became a 59 x 4 pt hole in the
merged shape. Tidy::repair rebuilds a piece that strays from the one input
curve through both its ends as that curve's exact sub-curve (input curves
are indexed by their bounds' left edge, so it stays cheap). The there-and-
back then encloses nothing and is dropped as a sliver. Pieces on their
curve, and pieces whose ends two input curves pass through (a real lens),
are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd
2026-10-11 05:30:41 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent cd59b9cc94
commit 1622a70f85
4 changed files with 171 additions and 7 deletions
Generated
+2 -2
View File
@@ -2167,9 +2167,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
dependencies = [
"arrayvec",
"kurbo 0.13.1",
+1 -1
View File
@@ -61,7 +61,7 @@ usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
+143 -4
View File
@@ -235,12 +235,16 @@ pub(crate) struct Tidy {
keep: Vec<Point>,
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
curves: Vec<(kurbo::Rect, CubicBez)>,
/// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose
/// bounds can hold a point, without testing them all.
by_x: Vec<usize>,
max_width: f64,
}
impl Tidy {
/// Merge any smooth joint.
pub(crate) fn free(precision: f64) -> Self {
Self { precision, keep: Vec::new(), curves: Vec::new() }
Self { precision, ..Default::default() }
}
/// Keep every on-curve point of `paths`.
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
@@ -257,7 +261,10 @@ impl Tidy {
}
}
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
Self { precision, keep, curves }
let mut by_x: Vec<usize> = (0..curves.len()).collect();
by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0));
let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max);
Self { precision, keep, curves, by_x, max_width }
}
fn is_kept(&self, p: Point) -> bool {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
@@ -322,10 +329,11 @@ impl Arrangement {
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
let mut subpaths = Vec::new();
for i in idx {
if is_sliver(&c[i].path, tidy.precision) {
let path = tidy.repair(&c[i].path);
if is_sliver(&path, tidy.precision) {
continue;
}
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
if let Some(sp) = tidy_bezpath(&path, tidy) {
subpaths.push(sp);
}
}
@@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
None
}
impl Tidy {
/// The input curves whose bounds hold both `a` and `b`.
fn curves_around(&self, a: Point, b: Point) -> impl Iterator<Item = &CubicBez> {
let x = a.x.min(b.x);
let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x);
let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width);
self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c)
}
/// `bp` with each curve piece that strays from the one input curve through both its ends
/// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't
/// follow its curve; out and back between two points of one curve, such pieces are a lens
/// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input
/// curves pass through (a real lens between them), is left as it is.
pub(crate) fn repair(&self, bp: &BezPath) -> BezPath {
use kurbo::{ParamCurve, ParamCurveNearest, PathEl};
if self.curves.is_empty() {
return bp.clone();
}
let mut out = BezPath::new();
let mut cur = Point::ZERO;
let mut start = Point::ZERO;
for el in bp.iter() {
let piece = match el {
PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()),
PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)),
_ => None,
};
match (el, piece) {
(_, Some(raw)) => {
let (a, b) = (raw.p0, raw.p3);
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
let tol = 1e-6 * scale;
let mut subs: Vec<CubicBez> = vec![];
let mut follows = false;
for c in self.curves_around(a, b) {
let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12));
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 {
continue;
}
if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) {
follows = true;
break;
}
let sub = if na.t < nb.t {
c.subsegment(na.t..nb.t)
} else {
let r = c.subsegment(nb.t..na.t);
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
};
subs.push(CubicBez::new(a, sub.p1, sub.p2, b));
}
let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0;
match subs.split_first() {
Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b),
_ => out.push(el),
}
cur = b;
}
(PathEl::MoveTo(p), None) => {
out.push(el);
(cur, start) = (p, p);
}
(PathEl::LineTo(p), None) => {
out.push(el);
cur = p;
}
(PathEl::ClosePath, None) => {
out.push(el);
cur = start;
}
(_, None) => out.push(el),
}
}
out
}
}
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
/// oriented contours (outer contours and holes).
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
@@ -592,3 +678,56 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 {
Err(_) => 0.0,
}
}
#[cfg(test)]
mod tests {
use kurbo::ParamCurve;
use super::*;
/// A quarter-circle-like arc, the input curve the pieces below are cut from.
fn arc() -> CubicBez {
CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0))
}
#[test]
fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() {
// Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and
// back by a piece that doesn't follow it, which refit into a visible lens.
let c = arc();
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]);
let out = c.subsegment(0.2..0.6);
let mut contour = BezPath::new();
contour.move_to(out.p0);
contour.curve_to(out.p1, out.p2, out.p3);
// back to the start, bulging off the curve and overshooting the end like the reported piece
contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0);
contour.close_path();
assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens");
let fixed = tidy.repair(&contour);
assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg());
// a piece on its curve is left exactly as it is
let mut on = BezPath::new();
on.move_to(out.p0);
on.curve_to(out.p1, out.p2, out.p3);
on.line_to(out.p0);
on.close_path();
assert_eq!(tidy.repair(&on), on);
}
#[test]
fn a_lens_between_two_curves_through_the_same_points_stays() {
// Two input curves meeting at both ends bound a real face: neither piece is "repaired"
// onto the other.
let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0));
let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0));
let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]);
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]);
let mut lens = BezPath::new();
lens.move_to(lower.p0);
lens.curve_to(lower.p1, lower.p2, lower.p3);
lens.curve_to(upper.p2, upper.p1, upper.p0);
lens.close_path();
assert_eq!(tidy.repair(&lens), lens);
}
}
+25
View File
@@ -192,4 +192,29 @@ mod tests {
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
}
#[test]
fn miter_offset_finishes_where_the_sweep_used_to_loop() {
// Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and
// its memory grew without bound, freezing the app. A synthetic outline (found by search)
// with the same failure as the reported glyph; the exact coordinates matter.
const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \
L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \
L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \
Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \
Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \
Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z";
let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let input = path.clone();
std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0)));
let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else {
// the stuck sweep keeps allocating on its thread: end the test binary now
eprintln!("Offset Path didn't finish in 30 s (issue #1028)");
std::process::exit(101);
};
let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero));
assert_eq!(out.subpaths.len(), 1);
assert!(after > before, "{before} → {after}");
}
}