mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Shape Builder: erasing picks a region narrower than twice the tolerance
In erase mode the tool tests each point for a line piece or an edge within the tolerance before the region under it. Every point of a region narrower than twice the tolerance is that close to its edges, so an Alt-click, the Alt hover and an Alt-drag over a thin overlap touched its edges and not the overlap, and the overlap was left behind. A point in a region now touches the region when the region is no more than twice the tolerance across there, measured from the nearest point of its outline through the point to the outline on the other side. This covers a drag at any angle, a drag that starts or ends in the region, and a thin shape inside another or on its own. Near an edge of a wider region, the edge is touched as before.
This commit is contained in:
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -339,7 +339,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok((ids, None, touched, Some(split)));
|
||||
}
|
||||
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
|
||||
touched.extend(&map.hits_along(&pts, piece_tol));
|
||||
touched.extend(&map.builder_hits_along(&pts, piece_tol));
|
||||
touched.edges = map.erased_edges(&touched);
|
||||
if touched.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||
|
||||
@@ -1052,6 +1052,29 @@ fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses()
|
||||
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_deletes_an_overlap_narrower_than_twice_the_tolerance() {
|
||||
let mut s = session();
|
||||
// A 1.5 pt overlap: every point of it is within the 2 pt tolerance of its edges.
|
||||
two_at(&mut s, 98.5);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [150, 50]], "erase": true})).unwrap();
|
||||
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(3), Some(0)), "{r}");
|
||||
assert_eq!(layer_children(&s), 0, "no strip is left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_click_or_drag_from_inside_a_narrow_overlap_deletes_it() {
|
||||
for points in [json!([[99.2, 50]]), json!([[99.2, 50], [150, 50]]), json!([[95, 10], [103.5, 60]])] {
|
||||
let mut s = session();
|
||||
two_at(&mut s, 98.5);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": points, "erase": true})).unwrap();
|
||||
assert_eq!(r["edges"].as_u64(), Some(0), "{points}: {r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let left: Vec<f64> = d.children(d.default_layer()).unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect();
|
||||
assert!(left.iter().all(|w| *w > 2.0), "{points}: no strip is left: {left:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In Merge Mode, Clicking Stroke Splits the Path: a click on a lone rectangle's top side, 1 pt
|
||||
/// outside it, splits the rectangle into that side and the rest. Without the option, the same click
|
||||
/// is refused as outside every region and leaves the rectangle as it was.
|
||||
|
||||
+158
-7
@@ -388,12 +388,17 @@ impl BuilderMap {
|
||||
/// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that
|
||||
/// close, else the region under it.
|
||||
pub fn hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
|
||||
let piece = piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) {
|
||||
self.piece_hit(p, piece_tol).or_else(|| self.region_at(p).map(Hit::Region))
|
||||
}
|
||||
|
||||
/// The line piece or edge within `piece_tol` of `p` that [`Self::hit`] gives (None without
|
||||
/// `piece_tol`).
|
||||
fn piece_hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
|
||||
piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) {
|
||||
(Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)),
|
||||
(Some(l), _) => Some(Hit::Line(l.1)),
|
||||
(None, e) => e.map(|e| Hit::Edge(e.1)),
|
||||
});
|
||||
piece.or_else(|| self.region_at(p).map(Hit::Region))
|
||||
})
|
||||
}
|
||||
|
||||
/// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the
|
||||
@@ -439,19 +444,57 @@ impl BuilderMap {
|
||||
Some((i, run))
|
||||
}
|
||||
|
||||
/// What a Shape Builder gesture at `p` touches: [`Self::hit`], except that while erasing, a
|
||||
/// point in a region that is no more than `2 * piece_tol` across there (see
|
||||
/// [`Self::narrow_at`]) touches the region and not the line piece or edge beside it.
|
||||
pub fn builder_hit(&self, p: Point, piece_tol: Option<f64>) -> Option<Hit> {
|
||||
match (piece_tol, self.piece_hit(p, piece_tol)) {
|
||||
(Some(tol), Some(piece)) => Some(match self.region_at(p) {
|
||||
Some(r) if self.narrow_at(r, p, tol) => Hit::Region(r),
|
||||
_ => piece,
|
||||
}),
|
||||
(_, piece) => piece.or_else(|| self.region_at(p).map(Hit::Region)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is region `r` no more than `2 * tol` across at `p`, measured from the point of its outline
|
||||
/// nearest `p`, through `p`, to its outline on the other side? Every point on that line is
|
||||
/// then within `tol` of the outline. False when `p` is on the outline or more than `tol` from
|
||||
/// it.
|
||||
fn narrow_at(&self, r: usize, p: Point, tol: f64) -> bool {
|
||||
let Some((reg, bp, _)) = self.regions.get(r) else { return false };
|
||||
let Some((.., n, d)) = reg.path.nearest(p) else { return false };
|
||||
if !(d.is_finite() && d > 1e-9 && d <= tol) {
|
||||
return false;
|
||||
}
|
||||
let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d));
|
||||
bp.segments().any(|seg| seg.intersect_line(line).iter().any(|x| x.line_t > 1e-9 && x.line_t <= 1.0))
|
||||
}
|
||||
|
||||
/// What a gesture along the polyline `pts` touches, in order: [`Self::hit`] at each vertex and
|
||||
/// at the points [`Self::probes`] picks between them, so it touches every region it crosses,
|
||||
/// however narrow.
|
||||
pub fn hits_along(&self, pts: &[Point], piece_tol: Option<f64>) -> Touched {
|
||||
self.along(pts, piece_tol, Self::hit)
|
||||
}
|
||||
|
||||
/// What a Shape Builder gesture along the polyline `pts` touches: [`Self::hits_along`] with
|
||||
/// [`Self::builder_hit`], so an erasing gesture also touches each region it goes into where the
|
||||
/// region is no more than `2 * piece_tol` across.
|
||||
pub fn builder_hits_along(&self, pts: &[Point], piece_tol: Option<f64>) -> Touched {
|
||||
self.along(pts, piece_tol, Self::builder_hit)
|
||||
}
|
||||
|
||||
fn along(&self, pts: &[Point], piece_tol: Option<f64>, hit: fn(&Self, Point, Option<f64>) -> Option<Hit>) -> Touched {
|
||||
let mut touched = Touched::default();
|
||||
let mut last: Option<Point> = None;
|
||||
for &p in pts {
|
||||
if let Some(a) = last {
|
||||
for q in self.probes(a, p, piece_tol) {
|
||||
touched.add(self.hit(q, piece_tol));
|
||||
touched.add(hit(self, q, piece_tol));
|
||||
}
|
||||
}
|
||||
touched.add(self.hit(p, piece_tol));
|
||||
touched.add(hit(self, p, piece_tol));
|
||||
last = Some(p);
|
||||
}
|
||||
touched
|
||||
@@ -1016,7 +1059,7 @@ impl ShapeBuilderTool {
|
||||
self.stroke = self.stroke_at(cx, p, self.alt);
|
||||
self.hover = match self.stroke {
|
||||
Some(_) => None,
|
||||
None => self.cache.get(cx, self.opts.open_filled_as_closed).hit(p, Self::line_tol(cx, self.alt)),
|
||||
None => self.cache.get(cx, self.opts.open_filled_as_closed).builder_hit(p, Self::line_tol(cx, self.alt)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1028,7 +1071,7 @@ impl ShapeBuilderTool {
|
||||
fn touch(&mut self, cx: &ToolContext, from: Option<Point>, to: Point) {
|
||||
let pts: Vec<Point> = from.into_iter().chain([to]).collect();
|
||||
let tol = Self::line_tol(cx, self.alt);
|
||||
let touched = self.cache.get(cx, self.opts.open_filled_as_closed).hits_along(&pts, tol);
|
||||
let touched = self.cache.get(cx, self.opts.open_filled_as_closed).builder_hits_along(&pts, tol);
|
||||
self.touched.extend(&touched);
|
||||
}
|
||||
|
||||
@@ -1896,6 +1939,114 @@ mod tests {
|
||||
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
|
||||
}
|
||||
|
||||
/// Rect A (0..100) and rect B (98..198), both 100 tall, selected: they overlap in a 2 pt strip.
|
||||
fn sliver_rects() -> (Document, Selection) {
|
||||
rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(98.0, 0.0, 198.0, 100.0)])
|
||||
}
|
||||
|
||||
/// The rectangles `rs`, back to front, selected.
|
||||
fn rects(rs: &[Rect]) -> (Document, Selection) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut s = Selection::default();
|
||||
for (k, r) in rs.iter().enumerate() {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), k, Node::path(id, shapes::rectangle(*r), Appearance::default_art())).unwrap();
|
||||
s.add(id);
|
||||
}
|
||||
(d, s)
|
||||
}
|
||||
|
||||
fn map_of(d: &Document, s: &Selection) -> BuilderMap {
|
||||
BuilderMap::new(d, &sorted_roots(d, &s.objects), true, true).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_across_an_overlap_narrower_than_twice_the_tolerance_touches_it() {
|
||||
let (d, s) = sliver_rects();
|
||||
let map = map_of(&d, &s);
|
||||
let strip = map.region_at(Point::new(99.0, 50.0)).unwrap();
|
||||
// Every point of the strip is within 4 pt of its edges.
|
||||
let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(150.0, 50.0)], Some(4.0));
|
||||
assert_eq!(t.regions.len(), 3, "{t:?}");
|
||||
assert!(t.regions.contains(&strip), "{t:?}");
|
||||
assert!(map.erased_edges(&t).is_empty(), "the strip's edges go with it");
|
||||
// A drag at a shallow angle goes about 10 pt through the strip and touches it too.
|
||||
let t = map.builder_hits_along(&[Point::new(90.0, 10.0), Point::new(106.0, 90.0)], Some(4.0));
|
||||
assert_eq!(t.regions.len(), 3, "{t:?}");
|
||||
// So does a drag that starts in the strip, and one that goes into it and back out.
|
||||
let t = map.builder_hits_along(&[Point::new(99.0, 50.0), Point::new(150.0, 50.0)], Some(4.0));
|
||||
assert!(t.regions.contains(&strip), "{t:?}");
|
||||
let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(99.0, 50.0), Point::new(50.0, 51.0)], Some(4.0));
|
||||
assert!(t.regions.contains(&strip), "{t:?}");
|
||||
// Merging is unchanged, and so is the plain hit test other tools use.
|
||||
assert_eq!(map.builder_hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Region(strip)));
|
||||
assert!(matches!(map.hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Edge(_))));
|
||||
assert_eq!(map.builder_hit(Point::new(99.0, 50.0), None), Some(Hit::Region(strip)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_across_a_thin_bar_touches_it_from_either_side() {
|
||||
// A 1.5 pt bar inside a square, and the same bar on its own.
|
||||
let bar = Rect::new(49.0, 10.0, 50.5, 90.0);
|
||||
for rs in [vec![Rect::new(0.0, 0.0, 100.0, 100.0), bar], vec![bar]] {
|
||||
let (d, s) = rects(&rs);
|
||||
let map = map_of(&d, &s);
|
||||
let inner = map.region_at(Point::new(49.75, 50.0)).unwrap();
|
||||
let t = map.builder_hits_along(&[Point::new(20.0, 50.0), Point::new(80.0, 50.0)], Some(4.0));
|
||||
assert!(t.regions.contains(&inner), "{rs:?}: {t:?}");
|
||||
assert_eq!(t.regions.len(), rs.len(), "{rs:?}: {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_erase_drag_along_an_edge_does_not_touch_the_regions_beside_it() {
|
||||
let (d, s) = grid();
|
||||
let map = map_of(&d, &s);
|
||||
// 1 pt above the edges between the top row and the middle one, through all three squares.
|
||||
let t = map.builder_hits_along(&[Point::new(50.0, 99.0), Point::new(250.0, 99.0)], Some(4.0));
|
||||
assert!(t.regions.is_empty(), "{t:?}");
|
||||
assert!(!t.edges.is_empty());
|
||||
// Crossing the edge 2 pt before a junction clips the square below without touching it.
|
||||
let pts = [Point::new(50.0, 99.0), Point::new(97.0, 99.0), Point::new(99.0, 101.0), Point::new(150.0, 101.0)];
|
||||
let t = map.builder_hits_along(&pts, Some(4.0));
|
||||
let clipped = map.region_at(Point::new(99.0, 101.0)).unwrap();
|
||||
assert!(!t.regions.contains(&clipped), "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_alt_hover_over_a_narrow_overlap_highlights_it() {
|
||||
let (d, s) = sliver_rects();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 99.0, 50.0).with_mods(alt));
|
||||
let strip = t.cache.map.region_at(Point::new(99.0, 50.0));
|
||||
assert_eq!(t.hover, strip.map(Hit::Region));
|
||||
// Beside a wide region, the edge.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 50.0, 99.0).with_mods(alt));
|
||||
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_alt_drag_highlights_an_overlap_it_crosses_over_two_moves() {
|
||||
let (d, s) = sliver_rects();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(alt));
|
||||
// One move ends inside the strip, and the next one leaves it.
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 99.0, 50.0).with_mods(alt));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 50.0).with_mods(alt));
|
||||
assert_eq!(t.touched.regions.len(), 3, "{:?}", t.touched);
|
||||
assert!(t.cache.map.erased_edges(&t.touched).is_empty());
|
||||
let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 50.0).with_mods(alt));
|
||||
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0)));
|
||||
}
|
||||
|
||||
/// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), selected.
|
||||
fn grid() -> (Document, Selection) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
|
||||
Reference in New Issue
Block a user