diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index faeaa20cbc..073b0f8bd5 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -330,12 +330,13 @@ fn lp_segments(pd: &PathData) -> Vec { /// 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 { - 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 { } // 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> = 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 { 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 { 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 + '_ { - 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 { + 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 } } diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index c0fbd5efe0..582371ca6f 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -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] diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 5b6bb3a5b8..a6a6af0915 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -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. diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index e6bfbee7f4..80a2dbd7a4 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -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();