mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Fix #374: Shape Builder cuts regions with open paths and lines
The Shape Builder only used the filled areas of the selected paths, so a stroke-only line or open path across a shape did nothing. Like the reference app's Shape Builder, open paths now act as cutting edges: - pathops::shape_builder: with an open path in the selection, the regions are the Live Paint planar map's faces (every path an edge), so a line across a shape splits it and lines can enclose a region of their own; it also returns each open path cut where other paths meet it. Closed shapes alone, more than SHAPE_BUILDER_MAX_SEGMENTS segments, or a map that can't be built fall back to the filled-area arrangement. - Filled open paths count as closed (an invisible closing edge) where that encloses an area; unfilled open paths and straight lines stay edges. - shapeBuilder.merge: merging or deleting regions on both sides of a line piece removes that piece (the line is split, its other pieces kept); an area only lines enclose takes the current fill and the front-most object's look. Alt (erase) within `tolerance` of a line piece deletes it. Returns `lines` (pieces deleted). - shapeBuilder.regions also lists the line pieces. - The tool shares BuilderMap/Touched with the command, highlights the line piece under the pointer while Alt is held and passes its tolerance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1d04ee0e84
commit
28fc79d57a
@@ -20,7 +20,7 @@ pub use edit::{
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
pub use planar::{interior_point, live_paint};
|
||||
pub use planar::{SHAPE_BUILDER_MAX_SEGMENTS, interior_point, live_paint, shape_builder};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
|
||||
@@ -288,6 +288,32 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
build(shapes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Most segments the Shape Builder sweeps with open paths as edges: the planar map costs far more
|
||||
/// than the filled areas' arrangement, so past this only the filled areas count.
|
||||
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
|
||||
|
||||
/// Shape Builder regions of `shapes` (back → front), and the pieces of their open paths.
|
||||
///
|
||||
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
|
||||
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
|
||||
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
|
||||
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
|
||||
/// its index in `shapes`. With no open path, more than [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or
|
||||
/// a map that can't be built, only the filled areas count and there are no pieces.
|
||||
pub fn shape_builder(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
if has_open && segments <= SHAPE_BUILDER_MAX_SEGMENTS {
|
||||
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
|
||||
let (faces, edges) = live_paint(&keyed);
|
||||
if !faces.is_empty() || !edges.is_empty() {
|
||||
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
|
||||
return (faces, edges.into_iter().filter(|e| is_line(e.key)).collect());
|
||||
}
|
||||
}
|
||||
(crate::regions(shapes), Vec::new())
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
|
||||
for (i, s) in shapes.iter().enumerate() {
|
||||
@@ -736,6 +762,69 @@ mod tests {
|
||||
assert_eq!(edges.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_lines_cut_regions() {
|
||||
// A line across a rectangle: two regions, the line in three pieces keyed by its index.
|
||||
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
assert_eq!(regions.len(), 2);
|
||||
assert!(regions.iter().all(|r| r.sources == [0]));
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines.iter().all(|l| l.key == 1 && !l.path.is_closed()));
|
||||
// Lines alone enclose a region nothing fills.
|
||||
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 0), line((-10.0, 80.0), (110.0, 80.0), 0)];
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(regions[0].sources.is_empty() && regions[0].top().is_none());
|
||||
assert_eq!(lines.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() {
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
|
||||
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
|
||||
// A broken line can't be swept: what the filled areas give (nothing here, without a panic).
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((0.0, 0.0), (f64::NAN, 1.0), 1)];
|
||||
assert_eq!(shape_builder(&s), (crate::regions(&s), vec![]));
|
||||
// Zero-length and doubled-back lines don't break it either.
|
||||
let s = [
|
||||
rect(0.0, 0.0, 100.0, 100.0, 0),
|
||||
line((50.0, 50.0), (50.0, 50.0), 1),
|
||||
line((0.0, 0.0), (100.0, 0.0), 2),
|
||||
line((50.0, 0.0), (50.0, 100.0), 3),
|
||||
];
|
||||
let (regions, _) = shape_builder(&s);
|
||||
assert_eq!(regions.len(), 2);
|
||||
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_stays_quick_up_to_its_cap() {
|
||||
// A wavy ring of nearly the most segments the map takes, crossed by a few lines.
|
||||
let n = SHAPE_BUILDER_MAX_SEGMENTS - 8;
|
||||
let ring: Vec<Point> = (0..n)
|
||||
.map(|k| {
|
||||
let t = k as f64 / n as f64 * std::f64::consts::TAU;
|
||||
let r = 100.0 + 5.0 * (t * 200.0).sin();
|
||||
Point::new(r * t.cos(), r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)];
|
||||
for k in 0..4 {
|
||||
let y = -60.0 + 40.0 * f64::from(k);
|
||||
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let (regions, lines) = shape_builder(&s);
|
||||
assert!(regions.len() >= 5, "the ring cut in five bands at least (the waves make pockets)");
|
||||
assert!(lines.len() >= 12, "{}", lines.len());
|
||||
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
|
||||
// Past the cap, only the filled areas count.
|
||||
s.push(line((0.0, 0.0), (1.0, 1.0), 2));
|
||||
s[0].path.subpaths[0].anchors.extend((0..16).map(|k| vectorcraft_geom::Anchor::corner(Point::new(100.0, f64::from(k)))));
|
||||
assert!(shape_builder(&s).1.is_empty());
|
||||
}
|
||||
|
||||
fn arb_segment() -> impl Strategy<Value = Shape> {
|
||||
let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0);
|
||||
prop_oneof![
|
||||
|
||||
Reference in New Issue
Block a user