Live Paint: double-click floods along split edges, triple-click on new art skips open paths

- A face outline segment that runs along several edge segments (the
  face has no corner where a crossing line splits the edge) now joins
  the face to every edge segment it runs along, not only the one under
  its middle, so a double-click flood no longer stops there.
- The first click of a triple-click on selected paths, which makes the
  Live Paint group, takes the fill to match from the paths' closed
  subpaths only, as the group's faces do; an open path in front no
  longer gives the wrong fill.
This commit is contained in:
dotCooCoo
2026-11-13 17:05:06 -08:00
parent d84e81aec4
commit 52aec70e19
4 changed files with 128 additions and 28 deletions
+52 -22
View File
@@ -330,12 +330,13 @@ fn lp_segments(pd: &PathData) -> Vec<kurbo::CubicBez> {
/// The faces of Live Paint group `group` that a flood from face `start` reaches: `start`, and each
/// face that shares with a face already reached an edge that is hidden or draws no stroke
/// ([`unstroked`]). Hidden faces are not reached and do not pass the flood on. A face runs along an
/// edge where a segment of its outline has its middle within [`LP_JOIN_TOL`] of one of the edge's
/// segments, over the part of that segment between its outline segment's ends; two faces share the
/// edge where those parts overlap along more than [`LP_JOIN_TOL`], so two faces that a Gap
/// Detection gap parts on the same side of an edge, whose parts only touch, do not.
/// edge segment over the part of it between the points nearest the ends of one of the face's
/// outline segments, when that part lies within [`LP_JOIN_TOL`] of the outline segment; one outline
/// segment can run along several edge segments. Two faces share the edge where those parts overlap
/// along more than [`LP_JOIN_TOL`], so two faces that a Gap Detection gap parts on the same side of
/// an edge, whose parts only touch, do not.
fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
// Each edge segment, and whether a flood crosses it.
let edges: Vec<(kurbo::CubicBez, bool)> = b::edges(group)
.into_iter()
@@ -351,16 +352,13 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
}
// The faces along each edge segment a flood crosses, each with the part of the segment it runs
// along (from and to, in the segment's own parameter). A face outline segment is usually an
// edge segment, with the same middle; one that is not (a piece of an edge segment where a Gap
// Detection line meets it, or the line itself) is measured against the edge segments near it.
// edge segment, with the same ends; one that is not (a piece of an edge segment where a Gap
// Detection line meets it, the line itself, or a run along several edge segments where the
// face has no corner at the points that split them) is measured against the edge segments
// near it.
let mut along: std::collections::HashMap<usize, Vec<(NodeId, f64, f64)>> = std::collections::HashMap::new();
let mut add = |k: usize, id: NodeId, piece: &kurbo::CubicBez| {
if let Some((s, true)) = edges.get(k) {
let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t);
along.entry(k).or_default().push((id, a.min(c), a.max(c)));
}
};
let mut pieces: Vec<(kurbo::CubicBez, NodeId)> = vec![];
let near = |p: Point, q: Point| (p - q).hypot() <= LP_JOIN_TOL;
for f in b::faces(group).into_iter().filter(|f| f.visible) {
for piece in f.path_data().map(lp_segments).unwrap_or_default() {
let m = piece.eval(0.5);
@@ -368,9 +366,18 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
let same = (-1..=1)
.flat_map(|dx| (-1..=1).map(move |dy| (x.saturating_add(dx), y.saturating_add(dy))))
.flat_map(|c| mids.get(&c).map(Vec::as_slice).unwrap_or_default())
.find(|(q, _)| (*q - m).hypot() <= LP_JOIN_TOL);
.find(|&&(q, k)| {
near(q, m)
&& edges
.get(k)
.is_some_and(|(s, _)| (near(s.p0, piece.p0) && near(s.p3, piece.p3)) || (near(s.p0, piece.p3) && near(s.p3, piece.p0)))
});
match same {
Some(&(_, k)) => add(k, f.id, &piece),
Some(&(_, k)) => {
if edges.get(k).is_some_and(|e| e.1) {
along.entry(k).or_default().push((f.id, 0.0, 1.0));
}
}
None => pieces.push((piece, f.id)),
}
}
@@ -378,10 +385,15 @@ fn flood_faces(group: &Node, start: NodeId) -> Vec<NodeId> {
if !pieces.is_empty() {
let grid = LpEdgeGrid::new(&edges);
for (piece, id) in pieces {
let m = piece.eval(0.5);
for k in grid.near(m) {
if edges.get(k).is_some_and(|(s, cross)| *cross && s.nearest(m, 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL) {
add(k, id, &piece);
for k in grid.near_rect(piece.bounding_box()) {
let Some((s, true)) = edges.get(k) else { continue };
let (a, c) = (s.nearest(piece.p0, 1e-9).t, s.nearest(piece.p3, 1e-9).t);
let (from, to) = (a.min(c), a.max(c));
// The part of the edge segment between the piece's ends runs along the piece.
let on_piece =
[0.25, 0.5, 0.75].iter().all(|u| piece.nearest(s.eval(from + (to - from) * u), 1e-9).distance_sq.sqrt() <= LP_JOIN_TOL);
if from < to && on_piece {
along.entry(k).or_default().push((id, from, to));
}
}
}
@@ -464,9 +476,27 @@ impl LpEdgeGrid {
(at(p.x, self.origin.x), at(p.y, self.origin.y))
}
/// The segments whose widened bounds may hold `p`.
fn near(&self, p: Point) -> impl Iterator<Item = usize> + '_ {
self.wide.iter().chain(self.cells.get(&self.cell_of(p)).into_iter().flatten()).copied()
/// The segments whose widened bounds may meet `r`, each once.
fn near_rect(&self, r: Rect) -> Vec<usize> {
let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(r.x0, r.y0)), self.cell_of(Point::new(r.x1, r.y1)));
let mut out = self.wide.clone();
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > self.cells.len() as i64 {
// More cells than the grid fills: walk the filled ones.
for (&(x, y), ks) in &self.cells {
if (x0..=x1).contains(&x) && (y0..=y1).contains(&y) {
out.extend_from_slice(ks);
}
}
} else {
for x in x0..=x1 {
for y in y0..=y1 {
out.extend(self.cells.get(&(x, y)).into_iter().flatten());
}
}
}
out.sort_unstable();
out.dedup();
out
}
}
+30
View File
@@ -2715,6 +2715,36 @@ fn live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps() {
assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the gap parts the two faces along the bottom");
}
/// The flood crosses an edge into each face along it when a face's outline runs along the edge in
/// one segment and the edge is split where another line meets it.
#[test]
fn live_paint_bucket_double_click_floods_along_edges_split_at_a_junction() {
// A square with a line across its middle, and a line from the top at x = 30 that crosses it and
// stops 20 pt below. The bottom face's outline runs along the middle line in one segment, from
// x = 0 to 100; the middle line's edges are split at x = 30.
let mut s = session();
let mut ids = vec![rect(&mut s, 0.0, 0.0, 100.0, 100.0).0];
for anchors in [json!([{"x": 0, "y": 50}, {"x": 100, "y": 50}]), json!([{"x": 30, "y": 0}, {"x": 30, "y": 70}])] {
ids.push(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap());
}
s.execute("select.set", &json!({"ids": ids})).unwrap();
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
let [left, right, bottom] = faces_at(&s, g, &[(15.0, 25.0), (65.0, 25.0), (65.0, 75.0)])[..] else { panic!("three faces") };
assert!(left != right && right != bottom && left != bottom);
let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 101.0));
paint_parts(&mut s, &all, &Paint::None, &[]);
let down = edges_in(&s, g, Rect::new(30.0, 0.0, 30.0, 70.0));
assert!(!down.is_empty() && down.len() < all.len());
paint_parts(&mut s, &down, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]);
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [65, 75], "color": "#00ff00", "scope": "connected"})).unwrap();
assert_eq!(r["count"], 3, "from the bottom face to both faces above the line");
assert_eq!([left, right, bottom].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone()]);
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [15, 25], "color": "#00ff00", "scope": "connected"})).unwrap();
assert_eq!(r["count"], 3, "from the upper left face down to the bottom face and up again");
}
/// A triple-click on a face fills every face of the group with that face's fill, joined to it or
/// not. Through the tool, the fill compared is the face's fill before the first click.
#[test]
+8 -6
View File
@@ -207,17 +207,19 @@ struct LpPress {
}
/// The fill of the face under `p` in the Live Paint group that `roots` make: the fill of the
/// front-most of their paths whose outline holds `p`, else none.
/// front-most of their paths whose closed subpaths hold `p`, else none. Open subpaths bound no face.
fn made_face_fill(doc: &Document, roots: &[NodeId], p: Point) -> Paint {
let mut leaves = vec![];
for n in roots.iter().filter_map(|id| doc.node(*id)) {
leaf_shapes(n, &mut leaves);
}
leaves
.iter()
.rev()
.find(|l| node_outline(l).is_some_and(|(path, rule)| !path.is_empty() && path_contains(&path, rule, p)))
.map_or(Paint::None, |l| l.appearance.fill_paint())
let holds = |l: &Node| {
node_outline(l).is_some_and(|(path, rule)| {
let closed = vectorcraft_geom::PathData::new(path.subpaths.into_iter().filter(|sp| sp.closed && sp.anchors.len() >= 2).collect());
!closed.is_empty() && path_contains(&closed, rule, p)
})
};
leaves.iter().rev().find(|l| holds(l)).map_or(Paint::None, |l| l.appearance.fill_paint())
}
/// How many presses the Live Paint Bucket keeps: the three of a triple-click.
+38
View File
@@ -1159,6 +1159,44 @@ fn bucket_double_and_triple_click_widen_what_a_click_paints() {
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 400.0, 400.0)).is_empty());
}
/// A triple-click whose first click makes the Live Paint group matches the fill of the face the
/// group makes there: an open path in front, whose outline bounds no face, does not give it.
#[test]
fn bucket_triple_click_on_a_made_group_skips_open_paths() {
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{PathData, SubPath};
let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let back = d.alloc_id();
let mut look = Appearance::default_art();
look.set_fill(red.clone());
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look)).unwrap();
// An open U around (75, 25), filled blue.
let u = [Point::new(60.0, 5.0), Point::new(60.0, 45.0), Point::new(90.0, 45.0), Point::new(90.0, 5.0)];
let front = d.alloc_id();
let mut look = Appearance::default_art();
look.set_fill(blue);
d.insert(Some(l), 1, Node::path(front, PathData::single(SubPath::polyline(&u, false)), look)).unwrap();
let mut s = Selection::default();
s.set([back, front]);
let p = paint();
let (made, ..) = live_group_doc();
let none = Selection::default();
let (cx, later) = (cx(&d, &s, &p), cx(&made, &none, &p));
let mut t = LivePaintBucketTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0));
for _ in 0..2 {
t.pointer(&later, &ev(PointerKind::Down, 75.0, 25.0));
t.pointer(&later, &ev(PointerKind::Up, 75.0, 25.0));
}
let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0));
let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["match"], serde_json::to_value(&red).unwrap());
}
#[test]
fn bucket_options_choose_what_a_click_paints() {
let (d, gid, ..) = live_group_doc();