Merge pull request #1035 from LloydNicholson/fix-offset-path-hang
#1028: Offset Path finishes where the sweep looped forever
This commit is contained in:
commit
2ddbd9293f
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). |
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
|
|
|||
|
|
@ -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,57 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue