diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index e99aec8756..235715a0b6 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -226,6 +226,9 @@ struct LpPress { /// Its fill (a face), or the stroke paint it draws with ([`lp_stroke_paint`], an edge), before /// the press. before: Paint, + /// The other faces or edges the double-click that followed the press reached, each with its + /// fill or drawn stroke paint before the double-click. + also: Vec<(NodeId, Paint)>, } /// The fill of the face under `p` in the Live Paint group that `roots` make: the fill of the @@ -333,29 +336,50 @@ impl LivePaintBucketTool { /// Note a press at `at` that paints face or edge (`edge`) `n`. fn note_part(&mut self, n: &Node, edge: bool, at: Point) { let before = if edge { lp_stroke_paint(n) } else { n.appearance.fill_paint() }; - self.note_press(Some(LpPress { part: Some(n.id), at, before })); + self.note_press(Some(LpPress { part: Some(n.id), at, before, also: vec![] })); + } + + /// Note that the double-click after the last press also reaches `parts` of group `group`, with + /// the paint each has now (a face's fill, or an edge's drawn stroke paint with `edge`). + fn note_reached(&mut self, group: &Node, parts: Vec, edge: bool) { + let Some(Some(last)) = self.presses.last_mut() else { return }; + let reached: std::collections::HashSet = parts.into_iter().filter(|id| last.part != Some(*id)).collect(); + last.also = group + .children() + .into_iter() + .flatten() + .filter(|c| reached.contains(&c.id)) + .map(|n| (n.id, if edge { lp_stroke_paint(n) } else { n.appearance.fill_paint() })) + .collect(); } /// The paint `part` had before the first press of a click sequence of `clicks` presses that - /// painted `part` (the sequence's first press counts when it made the group `part` is in and - /// the face it filled there is `part`); `now` when the last press did not paint it. + /// painted `part`, or before the double-click after a press when that double-click reached it + /// (the sequence's first press counts when it made the group `part` is in and the face it + /// filled there is `part`); `now` when the last press did not paint it. fn before(&self, cx: &ToolContext, part: NodeId, clicks: usize, now: Paint) -> Paint { let Some(run) = self.presses.len().checked_sub(clicks).and_then(|from| self.presses.get(from..)) else { return now }; let made_part = |q: &LpPress| live_paint_at(cx.doc, q.at).and_then(|g| visible_face_at(cx.doc.node(g)?, q.at)).is_some_and(|f| f.id == part); - let on_part = - |(i, q): (usize, &Option)| q.as_ref().is_some_and(|q| q.part == Some(part) || (i == 0 && q.part.is_none() && made_part(q))); - if !run.last().is_some_and(|q| on_part((run.len() - 1, q))) { + let on_part = |i: usize, q: &LpPress| q.part == Some(part) || (i == 0 && q.part.is_none() && made_part(q)); + if !run.last().and_then(Option::as_ref).is_some_and(|q| on_part(run.len() - 1, q)) { return now; } - match run.iter().enumerate().find(|&iq| on_part(iq)) { - Some((_, Some(first))) => first.before.clone(), - _ => now, + for (i, q) in run.iter().enumerate() { + let Some(q) = q else { continue }; + if on_part(i, q) { + return q.before.clone(); + } + if let Some((_, paint)) = q.also.iter().find(|(id, _)| *id == part) { + return paint.clone(); + } } + now } /// What a double-click (`scope` "connected", 2 `clicks`) or a triple-click ("samePaint", 3) at /// `p` runs: the command a click there runs, with `scope`, and the paint it compares as `match`. - fn multi_click(&self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { + /// A double-click notes the other faces or edges it reaches, for the triple-click's `match`. + fn multi_click(&mut self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { // With Alt held, with Space held for the view tools, or with Cmd/Ctrl held (its presses went // to the selection tool Cmd lends), a double-click or triple-click paints nothing. if mods.alt || mods.space || mods.cmd { @@ -367,7 +391,13 @@ impl LivePaintBucketTool { && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { let mut v = json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0), "scope": scope }); - if let Ok(like) = serde_json::to_value(self.before(cx, e.id, clicks, lp_stroke_paint(e))) { + let like = self.before(cx, e.id, clicks, lp_stroke_paint(e)); + if clicks == 2 + && let Some(group) = cx.doc.node(g) + { + self.note_reached(group, connected_edges(group, e.id, &like), true); + } + if let Ok(like) = serde_json::to_value(like) { v["match"] = like; } return vec![Action::Exec("livePaint.strokeEdge".into(), v)]; @@ -377,6 +407,11 @@ impl LivePaintBucketTool { } let Some((g, f)) = live_paint_at(cx.doc, p).and_then(|g| Some((g, visible_face_at(cx.doc.node(g)?, p)?))) else { return vec![] }; let mut v = json!({ "group": g.0, "point": point, "scope": scope }); + if clicks == 2 + && let Some(group) = cx.doc.node(g) + { + self.note_reached(group, flood_faces(group, f.id), false); + } if scope == "samePaint" && let Ok(like) = serde_json::to_value(self.before(cx, f.id, clicks, f.appearance.fill_paint())) { @@ -522,7 +557,7 @@ impl Tool for LivePaintBucketTool { } if self.cache.at(cx, p).is_some() { let roots = sorted_roots(cx.doc, &cx.selection.objects); - self.note_press(Some(LpPress { part: None, at: p, before: made_face_fill(cx.doc, &roots, p) })); + self.note_press(Some(LpPress { part: None, at: p, before: made_face_fill(cx.doc, &roots, p), also: vec![] })); return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p); } self.note_press(None); diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 27a19c3a07..66db1e550c 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1206,6 +1206,40 @@ 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 third click lands on a face the double-click's flood filled matches that +/// face's fill from before the double-click. +#[test] +fn bucket_triple_click_on_a_face_the_double_click_flooded_matches_its_earlier_fill() { + let (mut d, gid, f2, _) = live_group_doc(); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + let f1 = crate::builder::face_at(d.node(gid).unwrap(), Point::new(25.0, 25.0)).unwrap().id; + for f in [f1, f2] { + d.node_mut(f).unwrap().appearance.set_fill(red.clone()); + } + let mut first = d.clone(); + first.node_mut(f1).unwrap().appearance.set_fill(blue.clone()); + let mut flooded = first.clone(); + flooded.node_mut(f2).unwrap().appearance.set_fill(blue.clone()); + let s = Selection::default(); + let p = paint(); + let (start, first, flooded) = (cx(&d, &s, &p), cx(&first, &s, &p), cx(&flooded, &s, &p)); + let mut t = LivePaintBucketTool::default(); + let click = |t: &mut LivePaintBucketTool, cx: &ToolContext, x: f64| { + t.pointer(cx, &ev(PointerKind::Down, x, 25.0)); + t.pointer(cx, &ev(PointerKind::Up, x, 25.0)); + }; + click(&mut t, &start, 25.0); + click(&mut t, &first, 25.0); + t.pointer(&first, &ev(PointerKind::DoubleClick, 25.0, 25.0)); + click(&mut t, &flooded, 75.0); + let like = serde_json::to_value(&red).unwrap(); + assert_eq!( + t.pointer(&flooded, &ev(PointerKind::TripleClick, 75.0, 25.0)), + vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [75.0, 25.0], "scope": "samePaint", "match": like }))] + ); +} + /// On an edge that stores a stroke paint but draws none (its stroke hidden, or the edge fully /// transparent), a double-click or triple-click matches no paint, with or without the clicks /// before it. diff --git a/docs/mcp.md b/docs/mcp.md index bd9ae904be..a63b3924e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3689,7 +3689,8 @@ already walks Image Trace. Short agent entry points: topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke. Each click of a double-click or triple-click runs as it arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's - two clicks or the triple-click's three that painted it, so the clicks before the last do not change what it matches; + two clicks or the triple-click's three that painted it, or before the double-click when the triple-click's double-click + reached it, so the clicks before the last do not change what it matches; a first click that made the Live Paint group counts when the face it filled is the clicked face. Hidden faces and edges are not painted. An edge takes the paint and weight in its topmost stroke that is shown, or in a new stroke above the others when it has none. These rules for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: