From bc2caaaa37e2ac13e0e88d9264fd600990801c9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:10:40 -0800 Subject: [PATCH] Live Paint: a triple-click matches the first fill of a face clicked again A triple-click whose clicks land on a face, another face, then the first face again matched the fill the first click gave that face. It now matches the fill the face had before the first click that painted it, as the tool's doc comment and docs/mcp.md say. - With Space held for the view tools, the Live Paint Bucket outlines no face or edge, as a press there paints nothing. - A scoped livePaint.fill or livePaint.strokeEdge where every face or edge reached already has the paint records no undo step; a test covers it, and one where only some faces need the paint. - An MCP test drives the bucket through ui.pointer with mods.space. - The tool's doc comment and the progress line state the rule. --- crates/engine/src/tests/build.rs | 36 +++++++++++++++++++ crates/mcp/src/tests/livepaint.rs | 24 +++++++++++++ crates/tools/src/builder/live_paint.rs | 50 ++++++++++++++------------ crates/tools/src/builder/tests.rs | 45 +++++++++++++++++++++++ docs/mcp.md | 5 +-- docs/target-app-parity.md | 2 +- 6 files changed, 136 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 299dd78a37..89f0877621 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2566,6 +2566,42 @@ fn live_paint_stroke_edge_same_paint_and_width_is_no_op() { assert_eq!(history_state(&s).0, before.1.0 + 1); } +/// With `scope`, faces and edges that already have the paint (and the width) are skipped: when +/// every face or edge the call reaches has it, nothing changes, and when some do, only the others +/// are painted, in one undo step. `count` is every face or edge reached. +#[test] +fn live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint() { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"})).unwrap(); + let edges = + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "color": "#ff0000", "width": 3})).unwrap(); + redo_waiting_and_saved(&mut s, g); + let mut before = snapshot(&s); + let same = json!({"group": g.0, "point": [75, 50], "scope": "samePaint", "color": "#00ff00"}); + assert_eq!(s.execute("livePaint.fill", &same).unwrap()["count"], 3); + assert_unchanged(&mut s, &mut before, "a samePaint fill where every face has the paint"); + let again = json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "color": "#ff0000", "width": 3}); + assert_eq!(s.execute("livePaint.strokeEdge", &again).unwrap()["count"], edges["count"]); + assert_unchanged(&mut s, &mut before, "a samePaint stroke where every edge has the paint and the width"); + // One face of the three without the paint: only it is filled, in one undo step. + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let red = Paint::solid(Color::from_hex("#ff0000").unwrap()); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#ff0000"})).unwrap(); + let depth = history_state(&s).0; + let like = serde_json::to_value(&green).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "scope": "samePaint", "match": like, "color": "#00ff00"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!(history_state(&s).0, depth + 1); + let fill_at = |s: &Session, x: f64| vectorcraft_tools::builder::face_at(&node(s, g), Point::new(x, 50.0)).unwrap().appearance.fill_paint(); + for x in [25.0, 75.0, 125.0] { + assert_eq!(fill_at(&s, x), green, "{x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_at(&s, 25.0), red); + assert_eq!((fill_at(&s, 75.0), fill_at(&s, 125.0)), (green.clone(), green)); +} + #[test] fn live_paint_gap_options_same_values_are_no_op() { let mut s = session(); diff --git a/crates/mcp/src/tests/livepaint.rs b/crates/mcp/src/tests/livepaint.rs index f3952a90f6..0165823e6f 100644 --- a/crates/mcp/src/tests/livepaint.rs +++ b/crates/mcp/src/tests/livepaint.rs @@ -47,3 +47,27 @@ fn a_triple_click_fills_every_face_of_the_same_fill() { ok(&mut h, "pointer_gesture", json!({"events": [ev("tripleclick")]})); assert_eq!(fills(&mut h, &g), [after_two[0].clone(), after_two[0].clone()], "both faces were red"); } + +/// With Space held (`mods.space`), the Live Paint Bucket's clicks, double-click and triple-click +/// paint nothing. +#[test] +fn clicks_with_space_held_paint_nothing() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + let a = run(&mut h, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let b = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let g = run(&mut h, "livePaint.make", json!({"ids": [a, b]}))["id"].clone(); + for x in [25, 125] { + run(&mut h, "livePaint.fill", json!({"group": g, "point": [x, 25], "color": "#ff0000"})); + } + run(&mut h, "select.none", json!({})); + run(&mut h, "paint.setFill", json!({"color": "#00ff00"})); + let red = fills(&mut h, &g); + let ev = |kind: &str| json!({"kind": kind, "x": 25, "y": 25}); + let clicks = [ev("down"), ev("up"), ev("down"), ev("up"), ev("doubleclick"), ev("down"), ev("up"), ev("tripleclick")]; + ok(&mut h, "pointer_gesture", json!({"tool": "livePaintBucket", "mods": {"space": true}, "events": clicks})); + assert_eq!(fills(&mut h, &g), red); + // Without Space, the same clicks paint. + ok(&mut h, "pointer_gesture", json!({"events": [ev("down"), ev("up")]})); + assert_ne!(fills(&mut h, &g)[0], red[0]); +} diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index ff59b22333..56e8e654e3 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -178,14 +178,16 @@ enum LpPaint { /// A triple-click on a face fills every face of the group with the face's fill, and on an edge it /// paints every edge of the group with the edge's stroke (`scope: "samePaint"`). Each click of a /// double-click or triple-click paints as it arrives, as its own undo step, and the paint compared -/// is the one the face or edge had before the first of its clicks. Shift and the options choose a -/// face or an edge as for a click; with Alt held a double-click or triple-click paints nothing. With -/// Space held for the view tools, a click, double-click or triple-click paints nothing. +/// is the one the face or edge had before the first of its clicks that painted it. A first click +/// that made the Live Paint group counts when the face it filled is the clicked face. Shift and the +/// options choose a face or an edge as for a click; with Alt held a double-click or triple-click +/// paints nothing. With Space held for the view tools, a click, double-click or triple-click paints +/// nothing and no face or edge is highlighted. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, - /// Where the pointer last moved, and whether Shift and Alt were held. - at: Option<(Point, bool, bool)>, + /// Where the pointer last moved, and the modifiers held there. + at: Option<(Point, Mods)>, /// The fill command and polyline previewed from the press's snapshot. fill_drag: Option<(Value, Vec>)>, /// The last [`LP_PRESSES`] presses, oldest first: the face or edge each one painted, with its @@ -304,17 +306,19 @@ impl LivePaintBucketTool { self.note_press(Some(LpPress { part: Some(n.id), at, before })); } - /// The paint `part` had before the first of the last presses of a click sequence of `clicks` - /// presses that all painted `part` (the sequence's first press may have made the group `part` - /// is in, when the face it filled there is `part`); `now` when the last press did not paint it. + /// 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. 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))); - let from = run.len().saturating_sub(run.iter().enumerate().rev().take_while(|&iq| on_part(iq)).count()); - match run.get(from) { - Some(Some(first)) => first.before.clone(), + if !run.last().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, } } @@ -322,7 +326,7 @@ impl LivePaintBucketTool { /// 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 { - // With Space held the press pans, zooms or rotates the view and does not reach the tool. + // With Alt held, or with Space held for the view tools, a double-click or triple-click paints nothing. if mods.alt || mods.space { return vec![]; } @@ -350,14 +354,14 @@ impl LivePaintBucketTool { vec![Action::Exec("livePaint.fill".into(), v)] } - /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with - /// Highlight off, else the face or edge a click there paints. With Highlight off the selection's - /// map is still made, for the cursor ([`Self::paints_at`]). - fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover { - if alt { + /// The highlight at `p` for a pointer with the modifiers `mods`: none with Alt, with Space held + /// for the view tools or with Highlight off, else the face or edge a click there paints. With + /// Highlight off the selection's map is still made, for the cursor ([`Self::paints_at`]). + fn hover_at(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> LpHover { + if mods.alt || mods.space { return LpHover::None; } - let target = self.target_at(cx, p, shift); + let target = self.target_at(cx, p, mods.shift); if self.highlight { target } else { LpHover::None } } @@ -432,13 +436,13 @@ impl Tool for LivePaintBucketTool { } match ev.kind { PointerKind::Move => { - self.at = Some((p, ev.mods.shift, ev.mods.alt)); - self.hover = self.hover_at(cx, p, ev.mods.shift, ev.mods.alt); + self.at = Some((p, ev.mods)); + self.hover = self.hover_at(cx, p, ev.mods); vec![] } PointerKind::Down => { self.hover = LpHover::None; - // With Space held the press pans, zooms or rotates the view. + // With Space held for the view tools, a press paints nothing. if ev.mods.space { self.note_press(None); return vec![]; @@ -573,8 +577,8 @@ impl Tool for LivePaintBucketTool { // of a Live Paint group the hover takes no map. loop { self.cache.settle(wait); - if let Some((p, shift, alt)) = self.at { - self.hover = self.hover_at(cx, p, shift, alt); + if let Some((p, mods)) = self.at { + self.hover = self.hover_at(cx, p, mods); } if !wait || !self.cache.waiting() { break; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index a0811c5071..42efee075c 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1254,6 +1254,51 @@ fn bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_fa assert_eq!(v["match"], own); } +/// A triple-click whose clicks land on one face, another face, then the first face again matches +/// the fill the first face had before the first click on it, not the fill that click gave it. +#[test] +fn bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill() { + use vectorcraft_color::{Color, Paint}; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let (d, gid, f2, _) = live_group_doc(); + let mut painted = d.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(red.clone()); + let s = Selection::default(); + let p = paint(); + let (cx, later) = (cx(&d, &s, &p), cx(&painted, &s, &p)); + let mut t = LivePaintBucketTool::default(); + // The first click paints the face at (75, 25) red; the second fills the face at (25, 25). + for (ctx, x) in [(&cx, 75.0), (&later, 25.0), (&later, 75.0)] { + t.pointer(ctx, &ev(PointerKind::Down, x, 25.0)); + t.pointer(ctx, &ev(PointerKind::Up, x, 25.0)); + } + let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let own = serde_json::to_value(d.node(f2).unwrap().appearance.fill_paint()).unwrap(); + assert_ne!(own, serde_json::to_value(&red).unwrap()); + assert_eq!( + acts, + vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [75.0, 25.0], "scope": "samePaint", "match": own }))] + ); +} + +/// With Space held for the view tools, the pointer outlines no face or edge, as a press there +/// paints nothing. +#[test] +fn bucket_highlights_nothing_with_space_held() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let space = Mods { space: true, ..Default::default() }; + for (x, m) in [(75.0, space), (51.0, Mods { shift: true, ..space })] { + t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m)); + assert!(t.overlays(&cx).is_empty(), "at {x} with {m:?}"); + } + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + assert!(!t.overlays(&cx).is_empty()); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc(); diff --git a/docs/mcp.md b/docs/mcp.md index 9887f22044..4ece60e032 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3531,14 +3531,15 @@ already walks Image Trace. Short agent entry points: the pointer is not outlined. A double-click runs the command a click there runs with `scope: "connected"`, and a triple-click with `scope: "samePaint"`; Alt held runs nothing. With Space held (`mods.space`, the view tools), a press, a - double-click or a triple-click runs nothing. On a face, `connected` fills the face and every face reached from it + double-click or a triple-click runs nothing, and a move outlines no face or edge. On a face, `connected` fills the face and every face reached from it across edges that draw no stroke (hidden, stroke paint none, or weight 0); a stroked edge, a hidden face, a gap that Gap Detection closes and the group's outside stop it. `samePaint` fills every face of the group, joined to it or not, whose fill is the `match` paint. On an edge, `connected` paints the edge and the edges joined to it end to end whose stroke paint is `match`, and `samePaint` paints every edge of the group with that stroke paint; the stroke's weight is not compared. 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. Hidden faces and edges are not painted. These rules + two clicks or the triple-click's three that painted 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. These rules for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html. `livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4a7bb708b..93639260db 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1437,7 +1437,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. - Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face}`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). + Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks that painted it; a first click that made the Live Paint group counts when the face it filled is the clicked face. With Space held for the view tools, a click, double-click or triple-click paints nothing. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face}`, `tests::livepaint::a_triple_click_fills_every_face_of_the_same_fill` (MCP). Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)).