From b92f394c0b5d9e4724c48df1cedb3f909717bdaf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:17:28 -0700 Subject: [PATCH 1/3] 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. --- crates/engine/src/cmd/buildcmds.rs | 4 +- crates/engine/src/tests_build.rs | 23 ++++ crates/tools/src/builder.rs | 165 +++++++++++++++++++++++++++-- 3 files changed, 183 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 209339532..430ae5233 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "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 { 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())); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 9c74ee83f..63488afb1 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -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 = 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. diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index c2bbf5d4c..e61106859 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -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) -> Option { - 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) -> Option { + 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) -> Option { + 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) -> 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) -> Touched { + self.along(pts, piece_tol, Self::builder_hit) + } + + fn along(&self, pts: &[Point], piece_tol: Option, hit: fn(&Self, Point, Option) -> Option) -> Touched { let mut touched = Touched::default(); let mut last: Option = 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, to: Point) { let pts: Vec = 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); From 62e562058bfbc3ed9f18f55d1dc8461faf066b38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:26:07 -0700 Subject: [PATCH 2/3] Shape Builder: erasing near the end of a thin region picks the region A point 0.1 pt from the end of a 1.5 pt bar is nearest the end of the bar, and the line from there through the point runs along the bar, so the region did not count as narrow and an Alt-click there touched the end edge. A region now counts as narrow at a point when it is no more than twice the tolerance across from any outline segment within the tolerance whose point square to the line to the point lies inside the segment, as well as from the nearest point of the outline. Segment ends other than the nearest point are not tried, so a point added on an edge does not change the result near a corner. --- crates/tools/src/builder.rs | 62 +++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index e61106859..d13e116ea 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -457,18 +457,37 @@ impl BuilderMap { } } - /// 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. + /// Is region `r` no more than `2 * tol` across at `p`, measured from a point of its outline + /// within `tol` of `p`, through `p`, to its outline on the other side? Every point on that line + /// is then within `tol` of the outline. The points tried are the point of the outline nearest + /// `p` and each point inside a segment where the segment is square to the line to `p`, so a + /// point near the end of a thin bar measures across the bar from its sides as well as along it + /// from the end, and splitting a segment in two does not change the answer. 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) { + use kurbo::{ParamCurve as _, ParamCurveNearest as _}; + let Some((_, bp, _)) = self.regions.get(r) else { return false }; + // Each segment with the parameter, point and distance of its point nearest `p`. + let near: Vec<(kurbo::PathSeg, f64, Point, f64)> = bp + .segments() + .map(|seg| { + let n = seg.nearest(p, 1e-6); + (seg, n.t, seg.eval(n.t), n.distance_sq.sqrt()) + }) + .collect(); + if !near.iter().all(|&(.., d)| d.is_finite() && d > 1e-9) { 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)) + let closest = near.iter().map(|&(.., d)| d).fold(f64::INFINITY, f64::min); + // A line from `p` no longer than `2 * tol` only meets segments that close to `p`. + let reach: Vec = near.iter().filter(|&&(.., d)| d <= 2.0 * tol).map(|&(seg, ..)| seg).collect(); + // A segment's end is its nearest point when the segment turns away from `p` or stops + // short of the point square to it; only the nearest point of all the outline counts there. + let from = |&&(_, t, _, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9)); + near.iter().filter(from).any(|&(_, _, n, d)| { + let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d)); + reach.iter().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 @@ -1996,7 +2015,32 @@ mod tests { 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:?}"); + // 0.1 pt from an end, the nearest outline is the end, and the bar is still 1.5 pt + // across from its sides: a click there and a drag across the bar there touch it. + assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}"); + assert_eq!(map.builder_hit(Point::new(49.75, 89.9), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}"); + let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0)); + assert!(t.regions.contains(&inner), "{rs:?}: {t:?}"); } + // Near a corner of the square, both of its edges there are close, and the square is wide + // across from each: the edge is touched. + let (d, s) = rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), bar]); + let map = map_of(&d, &s); + assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); + // So it is when the square's top edge has a point 2 pt from the corner: the end of the + // segment past that point is not where the square is narrow. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [(0.0, 0.0), (2.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)); + let path = PathData::new(vec![SubPath::polyline(&pts, true)]); + d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let map = map_of(&d, &s); + let square = map.region_at(Point::new(50.0, 50.0)).unwrap(); + assert_eq!(map.regions[square].1.segments().count(), 5, "the point on the top edge stays"); + assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); } #[test] From 529a2296a7894dd9c7fec706248d44c31d17e5b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:43:00 -0700 Subject: [PATCH 3/3] Shape Builder: erasing near the end of a thin region with split sides picks the region When both sides of a 1.5 pt bar have a point level with a click 0.1 pt from its end, the point of each side nearest the click is the end of a segment, so the sides were not tried and an Alt-click there touched the end edge. A segment end now also counts when the segment is square there to the line to the point, so splitting a side at the click gives the same answer as an unsplit side. --- crates/tools/src/builder.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index d13e116ea..44899b3d7 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -460,7 +460,7 @@ impl BuilderMap { /// Is region `r` no more than `2 * tol` across at `p`, measured from a point of its outline /// within `tol` of `p`, through `p`, to its outline on the other side? Every point on that line /// is then within `tol` of the outline. The points tried are the point of the outline nearest - /// `p` and each point inside a segment where the segment is square to the line to `p`, so a + /// `p` and each point of a segment where the segment is square to the line to `p`, so a /// point near the end of a thin bar measures across the bar from its sides as well as along it /// from the end, and splitting a segment in two does not change the answer. False when `p` is /// on the outline or more than `tol` from it. @@ -482,8 +482,20 @@ impl BuilderMap { // A line from `p` no longer than `2 * tol` only meets segments that close to `p`. let reach: Vec = near.iter().filter(|&&(.., d)| d <= 2.0 * tol).map(|&(seg, ..)| seg).collect(); // A segment's end is its nearest point when the segment turns away from `p` or stops - // short of the point square to it; only the nearest point of all the outline counts there. - let from = |&&(_, t, _, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9)); + // short of the point square to it; there, only the nearest point of all the outline and + // an end where the segment is square to the line to `p` count. + let square = |seg: kurbo::PathSeg, t: f64, n: Point| { + use kurbo::ParamCurveDeriv as _; + let dir = match seg { + kurbo::PathSeg::Line(l) => l.p1 - l.p0, + kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + kurbo::PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + }; + let v = p - n; + dir.hypot() > 1e-12 && dir.dot(v).abs() <= 1e-6 * dir.hypot() * v.hypot() + }; + let from = + |&&(seg, t, n, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9) || square(seg, t, n)); near.iter().filter(from).any(|&(_, _, n, d)| { let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d)); reach.iter().any(|seg| seg.intersect_line(line).iter().any(|x| x.line_t > 1e-9 && x.line_t <= 1.0)) @@ -2041,6 +2053,22 @@ mod tests { let square = map.region_at(Point::new(50.0, 50.0)).unwrap(); assert_eq!(map.regions[square].1.segments().count(), 5, "the point on the top edge stays"); assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_)))); + // A bar whose sides each have a point level with a click 0.1 pt from its end: the click + // and a drag across the bar there still touch the bar. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [(49.0, 10.0), (50.5, 10.0), (50.5, 10.1), (50.5, 90.0), (49.0, 90.0), (49.0, 10.1)].map(|(x, y)| Point::new(x, y)); + let path = PathData::new(vec![SubPath::polyline(&pts, true)]); + d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let map = map_of(&d, &s); + let split = map.region_at(Point::new(49.75, 50.0)).unwrap(); + assert_eq!(map.regions[split].1.segments().count(), 6, "the points on the sides stay"); + assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(split))); + let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0)); + assert!(t.regions.contains(&split), "{t:?}"); } #[test]