Fix #3426: the Pathfinder panel's Pathfinder Options… sets Precision, Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork

Squashed from #4141 onto main.
This commit is contained in:
Taha-Mahmoodi
2026-10-26 15:28:51 +07:00
committed by bflatastic
parent a8345dcfb1
commit cfd085f34a
41 changed files with 686 additions and 60 deletions
+12 -2
View File
@@ -687,6 +687,11 @@ pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool
/// [`unite_all`] that reports a refused input instead of leaving it out of the union or returning
/// an empty path.
pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result<PathData, PathOpsError> {
try_unite_all_with(paths, DEFAULT_PRECISION)
}
/// [`try_unite_all`] refit to `precision`, dropping slivers thinner than it.
pub fn try_unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> Result<PathData, PathOpsError> {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
@@ -700,13 +705,18 @@ pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result<PathData, PathOp
if all.elements().is_empty() {
return Ok(PathData::default());
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy))
}
/// Union of many filled paths. Each input is first normalised on its own, then all are swept
/// together with a single integer winding number, which keeps this fast for thousands of shapes.
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
unite_all_with(paths, DEFAULT_PRECISION)
}
/// [`unite_all`] refit to `precision`, dropping slivers thinner than it.
pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
@@ -721,7 +731,7 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
if all.elements().is_empty() {
return PathData::default();
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
match normalize_bez(&all, FillRule::NonZero) {
Ok(c) => all_contours_to_path(&c, &tidy),
Err(_) => PathData::default(),
+3 -2
View File
@@ -16,14 +16,15 @@ mod planar;
mod trap;
pub use boolean::{
BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_area, try_boolean, try_boolean_n, try_normalize, try_unite_all, unite_all,
BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_area, try_boolean, try_boolean_n, try_normalize, try_unite_all,
try_unite_all_with, unite_all, unite_all_with,
};
pub use edit::{
AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor,
remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid,
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions};
pub use planar::{
BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point,
live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap,
+79 -9
View File
@@ -8,7 +8,8 @@ use linesweeper::topology::ContourIdx;
use vectorcraft_geom::{FillRule, PathData};
use crate::boolean::{
Arrangement, Multi, Seg, Tidy, all_contours_to_path, contours_to_path, eps_for, fill_bezpath, normalize_bez, segs_to_subpath, to_seg, unite_all,
Arrangement, DEFAULT_PRECISION, Multi, Seg, Tidy, all_contours_to_path, contours_to_path, eps_for, fill_bezpath, normalize_bez, segs_to_subpath,
to_seg, unite_all, unite_all_with,
};
/// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour);
@@ -78,9 +79,12 @@ impl Region {
}
}
fn arrangement(shapes: &[Shape]) -> Option<Arrangement> {
/// The arrangement of `shapes`, its faces refit to `precision` and its slivers thinner than it dropped.
fn arrangement(shapes: &[Shape], precision: f64) -> Option<Arrangement> {
let bps: Vec<(BezPath, FillRule)> = shapes.iter().map(|s| (fill_bezpath(&s.path), s.rule)).collect();
Arrangement::new(bps).ok()
let mut arr = Arrangement::new(bps).ok()?;
arr.tidy.precision = precision;
Some(arr)
}
/// The one item of `it`, if it has exactly one.
@@ -95,6 +99,14 @@ fn one(path: PathData, key: u64) -> Vec<Shape> {
/// Run a Pathfinder operation over `shapes` (back → front).
pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
pathfinder_with(op, shapes, DEFAULT_PRECISION)
}
/// [`pathfinder`] at Pathfinder Options › Precision: results are refit to within `precision`
/// (document units) and pieces thinner than it are dropped. A smaller value follows the inputs more
/// closely. A value that isn't positive and finite is [`DEFAULT_PRECISION`].
pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Vec<Shape> {
let precision = if precision.is_finite() && precision > 0.0 { precision } else { DEFAULT_PRECISION };
let n = shapes.len();
if n == 0 {
return Vec::new();
@@ -102,7 +114,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
let front_key = shapes[n - 1].key;
let unite = || {
let v: Vec<(&PathData, FillRule)> = shapes.iter().map(|s| (&s.path, s.rule)).collect();
one(unite_all(&v), front_key)
one(unite_all_with(&v, precision), front_key)
};
// Unite doesn't need the arrangement.
if op == PathfinderOp::Unite {
@@ -111,7 +123,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
// Open paths are cutting edges for Divide; the filled-only operations keep their
// implicit closure. Reuse Shape Builder's bounded planar map and its fallback.
if op == PathfinderOp::Divide && crate::has_open_path(shapes) {
return crate::shape_builder(shapes, false)
return crate::planar::builder(shapes, false, 0.0, precision)
.regions
.into_iter()
.filter_map(|r| {
@@ -120,7 +132,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
})
.collect();
}
let Some(arr) = arrangement(shapes) else { return Vec::new() };
let Some(arr) = arrangement(shapes, precision) else { return Vec::new() };
let p = &arr.tidy;
match op {
PathfinderOp::Unite => unite(),
@@ -307,7 +319,12 @@ fn vertex_key(p: Point) -> (u64, u64) {
/// All faces of the planar arrangement of `shapes` (every area covered by at least one shape,
/// split wherever coverage changes).
pub fn regions(shapes: &[Shape]) -> Vec<Region> {
arrangement(shapes).map(|a| regions_of(&a)).unwrap_or_default()
regions_with(shapes, DEFAULT_PRECISION)
}
/// [`regions`] refit to `precision`.
pub(crate) fn regions_with(shapes: &[Shape], precision: f64) -> Vec<Region> {
arrangement(shapes, precision).map(|a| regions_of(&a)).unwrap_or_default()
}
/// The face under `point`, if any (Shape Builder hover/click).
@@ -348,7 +365,12 @@ impl FaceMerger {
// The Shape Builder's faces come from the planar map only up to its cap.
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let lines = (segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines, snap: std::cell::OnceCell::new() }
Self {
arr: if has_open { None } else { arrangement(shapes, DEFAULT_PRECISION) },
shapes: shapes.len(),
lines,
snap: std::cell::OnceCell::new(),
}
}
/// Merge faces found with Gap Detection using the same temporary bridges as Live Paint.
@@ -851,7 +873,7 @@ mod tests {
}
fn same_as_one_mask_at_a_time(shapes: &[Shape]) {
let arr = arrangement(shapes).expect("arrangement");
let arr = arrangement(shapes, DEFAULT_PRECISION).expect("arrangement");
assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr));
}
@@ -859,6 +881,54 @@ mod tests {
shapes::rectangle(Rect::new(x0, y0, x1, y1))
}
/// Pathfinder Options › Precision: pieces thinner than the precision are dropped, so a 0.5 pt
/// strip that the default keeps goes at a precision of 1 pt, whether the Pathfinder works on
/// the arrangement (Divide), the planar map (Divide with an open path) or the union (Unite).
#[test]
fn precision_drops_pieces_thinner_than_it() {
let overlapping =
[Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 1), Shape::new(rect(0.0, 99.5, 100.0, 200.0), FillRule::NonZero, 2)];
let line = PathData::single(SubPath::polyline(&[Point::new(-10.0, 99.5), Point::new(110.0, 99.5)], false));
let cut = [Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 1), Shape::new(line, FillRule::NonZero, 2)];
let apart =
[Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 1), Shape::new(rect(0.0, 150.0, 100.0, 150.5), FillRule::NonZero, 2)];
for (what, op, shapes, kept) in [
("Divide", PathfinderOp::Divide, &overlapping, 3),
("Divide with an open path", PathfinderOp::Divide, &cut, 2),
("Unite", PathfinderOp::Unite, &apart, 2),
] {
let count = |r: Vec<Shape>| r.iter().map(|s| s.path.subpaths.len()).sum::<usize>();
assert_eq!(pathfinder_with(op, shapes, DEFAULT_PRECISION), pathfinder(op, shapes), "{what}: the default is the plain Pathfinder");
assert_eq!(count(pathfinder(op, shapes)), kept, "{what}: the default keeps the strip");
assert_eq!(count(pathfinder_with(op, shapes, 1.0)), kept - 1, "{what}: 1 pt drops it");
assert_eq!(pathfinder_with(op, shapes, f64::NAN), pathfinder(op, shapes), "{what}: an invalid precision is the default");
}
}
/// The planar map that Divide with an open path uses merges two edges between the same points
/// only when they are closer than the precision: a lens 0.005 pt thick between two curves is a
/// piece at a precision of 0.001 pt, and not at the default.
#[test]
fn open_path_divide_keeps_a_lens_thicker_than_the_precision() {
// Curves monotonic in x and y, so the sweep keeps each one whole between the shared ends;
// their middles are 0.005 pt apart.
let shift = 0.005 / 0.75 / std::f64::consts::SQRT_2;
let curve = |d: f64| [Point::new(0.0, 0.0), Point::new(d, 50.0 - d), Point::new(50.0 + d, 100.0 - d), Point::new(100.0, 100.0)];
let (top, bottom) = (curve(0.0), curve(shift));
let mut bp = kurbo::BezPath::new();
bp.move_to(top[0]);
bp.curve_to(top[1], top[2], top[3]);
bp.curve_to(bottom[2], bottom[1], bottom[0]);
bp.close_path();
let lens = Shape::new(PathData::from_bezpath(&bp), FillRule::NonZero, 1);
let line = PathData::single(SubPath::polyline(&[Point::new(200.0, 0.0), Point::new(300.0, 0.0)], false));
let shapes = [lens, Shape::new(line, FillRule::NonZero, 2)];
let area = |r: &[Shape]| r.iter().map(|s| crate::area(&s.path, FillRule::NonZero)).sum::<f64>();
let fine = pathfinder_with(PathfinderOp::Divide, &shapes, 0.001);
assert!(area(&fine) > 0.1, "the lens is a piece: {} pt²", area(&fine));
assert_eq!(area(&pathfinder(PathfinderOp::Divide, &shapes)), 0.0, "thinner than the default precision");
}
/// Shapes on a coarse grid, so that edges and corners coincide: rectangles, rectangles with
/// a hole, ellipses and polygons that may cross themselves, under either fill rule.
fn arb_shape() -> impl Strategy<Value = Shape> {
+34 -14
View File
@@ -465,7 +465,7 @@ fn find(parent: &mut [usize], mut v: usize) -> usize {
/// wherever they meet another path, keyed by the input they came from (the front-most where
/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate).
pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
build(shapes).unwrap_or_default()
build(shapes, DEFAULT_PRECISION).unwrap_or_default()
}
/// [`live_paint`] with Gap Detection: the faces are those of `shapes` with a line across each gap
@@ -809,22 +809,27 @@ pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec<Shape> {
/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece
/// of `lines` or `edges`.
pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement {
builder(shapes, edges, gap, DEFAULT_PRECISION)
}
/// [`shape_builder_with_gap`] refitting its results to `precision` (Pathfinder Options › Precision).
pub(crate) fn builder(shapes: &[Shape], edges: bool, gap: f64, precision: f64) -> BuilderArrangement {
let has_open = has_open_path(shapes);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
if has_open && gap.is_finite() && gap > 0.0 {
let (faces, pieces) = live_paint(&gap_inputs(&keyed, gap));
let (faces, pieces) = build(&gap_inputs(&keyed, gap), precision).unwrap_or_default();
// Gap targets split original outlines too, but the bridges themselves aren't art.
(faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect())
} else {
live_paint(&keyed)
build(&keyed, precision).unwrap_or_default()
}
})
.filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty());
let Some((faces, pieces)) = planar else {
return BuilderArrangement { regions: filled_regions(shapes), ..Default::default() };
return BuilderArrangement { regions: filled_regions(shapes, precision), ..Default::default() };
};
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
@@ -832,7 +837,7 @@ pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> Builde
faces
} else {
// The filled areas from their arrangement, whose faces merge exactly, then the gaps.
let mut regions = crate::regions(shapes);
let mut regions = crate::pathfinder::regions_with(shapes, precision);
regions.extend(faces.into_iter().filter(|f| f.sources.is_empty()));
regions
};
@@ -842,14 +847,14 @@ pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> Builde
/// The regions of the shapes' filled areas. A shape that fills nothing (open paths that enclose no
/// area, such as cutting lines) is left out, so a flood of them cannot push the arrangement past its
/// size budget and take the filled shapes down with it; the regions keep the original indices.
fn filled_regions(shapes: &[Shape]) -> Vec<crate::Region> {
fn filled_regions(shapes: &[Shape], precision: f64) -> Vec<crate::Region> {
let fills = |s: &Shape| s.path.subpaths.iter().any(|sp| sp.closed || encloses_area(sp));
let keep: Vec<usize> = (0..shapes.len()).filter(|&i| shapes.get(i).is_some_and(fills)).collect();
if keep.len() == shapes.len() {
return crate::regions(shapes);
return crate::pathfinder::regions_with(shapes, precision);
}
let kept: Vec<Shape> = keep.iter().filter_map(|&i| shapes.get(i).cloned()).collect();
crate::regions(&kept)
crate::pathfinder::regions_with(&kept, precision)
.into_iter()
.map(|mut r| {
r.sources = r.sources.iter().filter_map(|&s| keep.get(s).copied()).collect();
@@ -1080,6 +1085,11 @@ pub(crate) struct Map {
/// With `cover`, also finds which inputs cover each sliver, which only [`Map::merge`] reads;
/// without it the slivers' lists of inputs stay empty.
pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option<Map> {
map_with(shapes, cover, DEFAULT_PRECISION)
}
/// [`map`] whose faces and pieces are refit to `precision`, and whose slivers thinner than it are dropped.
fn map_with(shapes: &[Shape], cover: bool, precision: f64) -> Option<Map> {
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
for (i, s) in shapes.iter().enumerate() {
let mut bp = BezPath::new();
@@ -1110,7 +1120,7 @@ pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option<Map> {
let sweep =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::<Crossings>::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps)));
let top = sweep.ok()?.ok()?;
let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0));
let tidy = Tidy::keeping(precision, inputs.iter().map(|p| &p.0));
// The planar graph: vertices are the sweep's points, edges its segments. Points closer than
// the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge
@@ -1229,7 +1239,7 @@ pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option<Map> {
let twin_of = between.get(&key).and_then(|v| {
v.iter().copied().find(|&e| {
let other = edges.get(e).and_then(|e| mid(&e.segs));
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION)
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < precision)
})
});
if let Some(e) = twin_of {
@@ -1448,8 +1458,8 @@ impl Map {
}
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let m = map(shapes, false)?;
fn build(shapes: &[Shape], precision: f64) -> Option<(Vec<Region>, Vec<Shape>)> {
let m = map_with(shapes, false, precision)?;
let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m;
let snap = *snap;
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
@@ -1465,7 +1475,11 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
let path = unshear(PathData::new(subpaths), anchors, snap);
let sources =
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
// A face finer than the default precision can need a finer outline to find a point in.
interior_point(&path)
.or_else(|| (precision < DEFAULT_PRECISION).then(|| interior_point_within(&path, precision / 4.0)).flatten())
.map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect())
.unwrap_or_default();
Some(Region { path, sources })
})
.collect();
@@ -1530,11 +1544,17 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding
/// a Live Paint face after a rebuild).
pub fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
interior_point_within(path, (bb.width().max(bb.height()) * 1e-3).max(1e-4))
}
/// [`interior_point`] with the path's curves flattened to within `tolerance`.
fn interior_point_within(path: &PathData, tolerance: f64) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::flatten(bp.iter(), tolerance, |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));