From 64f1ba414fcde4ffc57e71a34e2d820f529a1d83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:43:38 -0800 Subject: [PATCH] Live Paint: the bucket replaces every fill or stroke row of a face or edge livePaint.fill set only a face's topmost fill, and livePaint.strokeEdge only an edge's topmost shown stroke. A lower fill or stroke kept drawing under the new paint, a fill or stroke of none left it drawing, and a hidden or half-transparent top row with the paint stopped the click. The bucket now replaces every fill of a face, or every stroke of an edge, with one row of the paint, shown, fully opaque and blending normally, in the place of the topmost row of that kind. The row keeps the dashes, caps, arrowheads and effects of the topmost shown row. The face's strokes, the edge's fills and the part's own opacity stay as they are. A click changes nothing, with no undo step, only when the part already has that one row. This changes the earlier rule that kept an edge's hidden strokes. The help page says the tool paints faces with the current fill and edges with the current stroke; the row rule is VectorCraft's own. --- crates/engine/src/cmd/buildcmds.rs | 4 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 118 ++++++++++++------ crates/engine/src/tests/build.rs | 109 +++++++++++++++- docs/mcp.md | 14 ++- docs/target-app-parity.md | 1 + 5 files changed, 196 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 07eb025798..4270d7a80e 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces: each face reached has its fills replaced by one fill with the paint, shown, fully opaque and blending normally, in the place of its topmost fill (none leaves one fill of none), and its strokes and its own opacity stay as they are; when every face it reaches already has only that one fill, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", has_doc, lp_fill ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its 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), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached takes the paint and width in its topmost stroke that is shown, or in a new stroke above the others when it has none; when every edge it reaches already has the paint and the width there, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its 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), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached has its strokes replaced by one stroke with the paint and width, shown, fully opaque and blending normally, in the place of its topmost stroke and with the dashes, caps and arrowheads of its topmost shown stroke (a new stroke above the fills when it has none; none leaves one stroke of none), and its fills stay as they are; when every edge it reaches already has only that one stroke (at any width for none), nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", has_doc, lp_stroke_edge ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 20e22d261e..cda1ac9550 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -517,16 +517,17 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { Some(gid) => { let (fid, targets) = targets_in(&s.doc()?.doc, gid)?; let count = targets.len(); - // Faces that already have the paint stay as they are: when every face has it, nothing - // changes and no undo step is recorded. + // Faces that already have only the fill the bucket would leave ([`bucket_keeps`]) stay as + // they are: when every face does, nothing changes and no undo step is recorded. let d = &s.doc()?.doc; - let unpainted: Vec = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect(); + let unpainted: Vec = + targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| !bucket_keeps(&f.appearance, true, &paint, 0.0))).collect(); if unpainted.is_empty() { return Ok(json!({ "group": gid.0, "face": fid.0, "count": count })); } s.edit("Live Paint Bucket", |d, _| { for target in unpainted { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0); } Ok((gid, fid, count)) })? @@ -536,7 +537,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let (fid, targets) = targets_in(d, gid)?; let count = targets.len(); for target in targets { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0); } Ok((gid, fid, count)) })?, @@ -572,18 +573,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { .collect(), }; let count = targets.len(); - // An edge whose shown stroke already has the paint and the width (or that has no shown stroke - // and gets none) stays as it is; when every edge does, nothing changes and no undo step is - // recorded. - let changed: Vec = targets - .into_iter() - .filter(|t| { - d.node(*t).is_none_or(|e| match shown_stroke(e) { - Some(st) => !(st.paint == paint && st.width == width), - None => !paint.is_none(), - }) - }) - .collect(); + // An edge that already has only the stroke the bucket would leave ([`bucket_keeps`]) stays + // as it is; when every edge does, nothing changes and no undo step is recorded. + let changed: Vec = + targets.into_iter().filter(|t| d.node(*t).is_none_or(|e| !bucket_keeps(&e.appearance, false, &paint, width))).collect(); (eid, count, changed) }; if changed.is_empty() { @@ -591,14 +584,7 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { } s.edit("Live Paint Bucket", |d, _| { for target in &changed { - let e = d.node_mut(*target).ok_or(EngineError::NoNode(*target))?; - match shown_stroke_mut(e) { - Some(st) => { - st.paint = paint.clone(); - st.width = width; - } - None => e.appearance.items.push(super::AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))), - } + bucket_paint(&mut d.node_mut(*target).ok_or(EngineError::NoNode(*target))?.appearance, false, &paint, width); } Ok(()) })?; @@ -606,22 +592,76 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { Ok(json!({ "group": g.0, "edge": eid.0, "count": count })) } -/// The stroke `livePaint.strokeEdge` paints on Live Paint edge `e`: its topmost stroke that is -/// shown. None when it has no stroke or every stroke is hidden; the command then adds a stroke -/// above the others. -fn shown_stroke(e: &Node) -> Option<&super::StrokeLayer> { - e.appearance.items.iter().rev().find_map(|i| match i { - super::AppearanceItem::Stroke(s) if s.visible => Some(s), - _ => None, - }) +/// Whether the Live Paint Bucket leaves appearance `a` as it is when it paints its fills (`fill`) +/// or strokes with `paint` (a stroke at `width`): it has exactly one row of that kind, shown, fully +/// opaque, blending normally and with the paint (a stroke also with the width, unless the paint is +/// none), or it has no row of that kind and the paint is none. +fn bucket_keeps(a: &Appearance, fill: bool, paint: &super::Paint, width: f64) -> bool { + let mut rows = a.items.iter().filter(|i| i.is_fill() == fill); + match (rows.next(), rows.next()) { + (None, _) => paint.is_none(), + (Some(row), None) => { + row.visible() + && row.opacity() == 1.0 + && row.blend() == vectorcraft_color::BlendMode::Normal + && row.paint() == paint + && match row { + AppearanceItem::Fill(_) => true, + AppearanceItem::Stroke(st) => paint.is_none() || st.width == width, + } + } + (Some(_), Some(_)) => false, + } } -/// [`shown_stroke`], to change. -fn shown_stroke_mut(e: &mut Node) -> Option<&mut super::StrokeLayer> { - e.appearance.items.iter_mut().rev().find_map(|i| match i { - super::AppearanceItem::Stroke(s) if s.visible => Some(s), - _ => None, - }) +/// Paint the fills (`fill`) or strokes of appearance `a` with `paint` (a stroke at `width`), as the +/// Live Paint Bucket does: every row of that kind is replaced by one row, shown, fully opaque and +/// blending normally, in the place of the topmost row of that kind. The new row keeps the other +/// settings (a stroke's dashes, caps and arrowheads, a row's effects) of the topmost row of that +/// kind that is shown, or of the topmost one when every row is hidden. Rows of the other kind stay +/// as they are. With no row of that kind, a fill is added below the strokes and a stroke above the +/// fills. +fn bucket_paint(a: &mut Appearance, fill: bool, paint: &super::Paint, width: f64) { + let of_kind = |i: &AppearanceItem| i.is_fill() == fill; + let Some(top) = a.items.iter().rposition(of_kind) else { + if fill { + a.set_fill(paint.clone()); + } else { + a.items.push(AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))); + } + return; + }; + let base = a.items.iter().rposition(|i| of_kind(i) && i.visible()).unwrap_or(top); + let Some(mut row) = a.items.get(base).cloned() else { return }; + match &mut row { + AppearanceItem::Fill(f) => { + f.paint = paint.clone(); + f.opacity = 1.0; + f.blend = vectorcraft_color::BlendMode::Normal; + f.visible = true; + } + AppearanceItem::Stroke(st) => { + st.paint = paint.clone(); + st.width = width; + st.opacity = 1.0; + st.blend = vectorcraft_color::BlendMode::Normal; + st.visible = true; + } + } + let mut row = Some(row); + a.items = std::mem::take(&mut a.items) + .into_iter() + .enumerate() + .filter_map(|(i, item)| { + if i == top { + row.take() + } else if of_kind(&item) { + None + } else { + Some(item) + } + }) + .collect(); } /// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index b1e90324ba..22b3d155dc 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3180,9 +3180,9 @@ fn live_paint_bucket_scopes_follow_the_stroke_an_edge_draws() { assert_eq!(r["count"], json!(clear.len() + top.len() + hidden.len())); } -/// `livePaint.strokeEdge` paints an edge's topmost stroke that is shown, so the edge then draws -/// with the paint; an edge whose strokes are all hidden takes a new stroke above them. Painting -/// again changes nothing. +/// `livePaint.strokeEdge` leaves an edge one stroke, shown, with the paint, so the edge then draws +/// with it, whether its topmost shown stroke sat under a hidden one or every stroke was hidden. +/// Painting again changes nothing. #[test] fn live_paint_stroke_edge_paints_the_shown_stroke() { let mut s = session(); @@ -3217,8 +3217,107 @@ fn live_paint_stroke_edge_paints_the_shown_stroke() { paint(&mut s, at); assert_eq!(history_state(&s).0, depth, "painting {id} again"); } - assert_eq!(node(&s, m).appearance.items.len(), 2, "the hidden stroke stays as it was"); - assert_eq!(node(&s, l).appearance.items.len(), 2, "a new stroke above the hidden one"); + assert_eq!(node(&s, m).appearance.items.len(), 1, "one stroke in place of the hidden and the shown one"); + assert_eq!(node(&s, l).appearance.items.len(), 1, "one shown stroke in place of the hidden one"); +} + +/// A face with two fills that draw, under a hidden top fill: the Bucket replaces every fill with +/// one shown, fully opaque fill of the paint, keeps the face's strokes, opacity and their order, +/// and a click with the paint the face then has records nothing. A fill of none leaves no fill +/// that draws. The route that makes the objects into a group first paints the same way. +#[test] +fn live_paint_bucket_replaces_every_fill_row() { + use vectorcraft_doc::appearance::{FillLayer, StrokeLayer}; + let mut s = session(); + let g = live_squares(&mut s, &[0.0]); + let [f] = faces_at(&s, g, &[(25.0, 25.0)])[..] else { panic!("one face") }; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let stroke = StrokeLayer::new(blue.clone(), 3.0); + s.edit("t", |d, _| { + let n = d.node_mut(f).unwrap(); + n.opacity = 0.5; + let mut half = FillLayer::new(blue.clone()); + half.opacity = 0.5; + let mut hidden = FillLayer::new(green.clone()); + hidden.visible = false; + n.appearance.items = vec![ + AppearanceItem::Fill(FillLayer::new(red.clone())), + AppearanceItem::Stroke(stroke.clone()), + AppearanceItem::Fill(half), + AppearanceItem::Fill(hidden), + ]; + Ok(()) + }) + .unwrap(); + let depth = history_state(&s).0; + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00"})).unwrap(); + assert_eq!(history_state(&s).0, depth + 1, "a hidden top fill with the paint does not stop the fill"); + let n = node(&s, f); + assert_eq!(n.appearance.items, vec![AppearanceItem::Stroke(stroke.clone()), AppearanceItem::Fill(FillLayer::new(green.clone()))]); + assert_eq!(n.opacity, 0.5, "the face keeps its own opacity"); + s.recent_colors.clear(); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00"})).unwrap(); + assert_eq!((history_state(&s).0, s.recent_colors.len()), (depth + 1, 0), "painting again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "none": true})).unwrap(); + let n = node(&s, f); + assert!(!n.appearance.has_drawn_fill(), "no fill draws after a fill of none"); + assert_eq!(n.appearance.items, vec![AppearanceItem::Stroke(stroke), AppearanceItem::Fill(FillLayer::new(Paint::None))]); + + // Objects made into a Live Paint group by the click: each object's two fills become one. + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance.items.insert(0, AppearanceItem::Fill(FillLayer::new(red.clone()))); + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let r = s.execute("livePaint.fill", &json!({"point": [25, 25], "color": "#00ff00"})).unwrap(); + let face = NodeId(r["face"].as_u64().unwrap()); + let fills: Vec = node(&s, face).appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect(); + assert_eq!(fills, vec![green]); +} + +/// An edge with two strokes that draw: `livePaint.strokeEdge` replaces them with one stroke of the +/// paint and width, keeping the topmost one's dashes; a stroke of none leaves no stroke that +/// draws, and `livePaint.info` then lists that one row. +#[test] +fn live_paint_stroke_edge_replaces_every_stroke_row() { + use vectorcraft_doc::appearance::{Dash, StrokeLayer}; + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0]); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + let [m] = middle[..] else { panic!("one middle edge") }; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let dash = Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }; + s.edit("t", |d, _| { + let mut top = StrokeLayer::new(green.clone(), 2.0); + top.opacity = 0.5; + top.dash = Some(dash.clone()); + d.node_mut(m).unwrap().appearance.items = vec![AppearanceItem::Stroke(StrokeLayer::new(red.clone(), 6.0)), AppearanceItem::Stroke(top)]; + Ok(()) + }) + .unwrap(); + let depth = history_state(&s).0; + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 2})).unwrap(); + assert_eq!(history_state(&s).0, depth + 1, "a half-transparent top stroke with the paint does not stop the stroke"); + let mut want = StrokeLayer::new(green.clone(), 2.0); + want.dash = Some(dash); + assert_eq!(node(&s, m).appearance.items, vec![AppearanceItem::Stroke(want)]); + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 2})).unwrap(); + assert_eq!(history_state(&s).0, depth + 1, "painting again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "none": true, "width": 2})).unwrap(); + assert!(!node(&s, m).appearance.has_drawn_stroke(), "no stroke draws after a stroke of none"); + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + let edge = info["edges"].as_array().unwrap().iter().find(|e| e["id"] == json!(m.0)).unwrap().clone(); + let rows = edge["appearance"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!((&rows[0]["kind"], &rows[0]["drawn"], &edge["stroke"]), (&json!("stroke"), &json!(false), &json!(Paint::None.label()))); } /// With Cmd held, the Live Paint Bucket's presses go to the selection tool Cmd lends, and the diff --git a/docs/mcp.md b/docs/mcp.md index d4e03892e9..e2c7bf3857 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3787,8 +3787,13 @@ already walks Image Trace. Short agent entry points: 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, 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 + 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. A face that + the Bucket fills has all its fills replaced by one fill with the paint, shown, fully opaque and blending normally, in the + place of its topmost fill; its strokes, their order and the face's own visibility, opacity and blending mode stay as + they are. An edge that it strokes has all its strokes replaced the same way by one stroke with the paint and weight, + which keeps the dashes, caps, joins and arrowheads of the edge's topmost shown stroke (of its topmost stroke when every + stroke is hidden), and its fills stay as they are. A part with no fill or stroke gets a fill below its strokes or a stroke + above its fills. A fill or stroke of none leaves one row of none, so no fill or stroke of the part draws. 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 @@ -3812,8 +3817,9 @@ already walks Image Trace. Short agent entry points: error. With `ids`, at a point on a hidden face it fills the face there of another Live Paint group among the objects; otherwise it returns an error and does not merge the objects. A Live Paint command that changes nothing records no undo step, keeps the redo steps, leaves a saved document saved - and replies as usual: `livePaint.fill` (a click, or a Bucket drag) when every face it reaches already has the paint, - `livePaint.strokeEdge` when the topmost shown stroke of each edge it reaches already has the paint and the width, + and replies as usual: `livePaint.fill` (a click, or a Bucket drag) when every face it reaches already has one fill, shown, + fully opaque, blending normally and with the paint (or no fill, for none), `livePaint.strokeEdge` when every edge it + reaches already has one such stroke with the paint and the width (at any width for none, or no stroke), `livePaint.gapOptions` (and the Gap Options dialog's OK) when every group already has the options and `closeGaps` finds no gap, and `livePaint.make` or `livePaint.merge` given one Live Paint group alone, which they select. Such a fill or stroke also leaves the recent diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..4864002efc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1537,6 +1537,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). As VectorCraft's own rule, OK in Live Paint Selection Tool Options, Live Paint Bucket Options, Gap Options and Tile Edge Color gives an error for a color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. As VectorCraft's own rule, a Live Paint edit that changes nothing records no undo step, keeps the redo steps and leaves a saved document saved: a Live Paint Bucket click or drag over faces that already have the fill, a stroke on an edge that already has the paint and weight, Gap Options OK with the options the groups have and no gap to close, and Make or Merge with one Live Paint group alone. Such a fill or stroke also leaves the recent colors as they are. Regression tests: `live_paint_fill_same_paint_is_no_op`, `live_paint_bucket_click_with_the_face_paint_is_no_op`, `live_paint_stroke_edge_same_paint_and_width_is_no_op`, `live_paint_gap_options_same_values_are_no_op`, `live_paint_gap_options_close_without_gaps_is_no_op`, `live_paint_make_existing_group_is_no_op` and `live_paint_merge_single_group_is_no_op` in `crates/engine/src/tests/build.rs`, and `ok_on_unchanged_options_records_no_undo_step` in `crates/ui-egui/src/dialogs/live_paint_gaps.rs`. + As VectorCraft's own rule, the Live Paint Bucket replaces all of a face's fills, or all of an edge's strokes, with one fill or stroke of the paint, shown, fully opaque and blending normally, so a lower fill or stroke no longer draws under the new paint and a fill or stroke of none leaves nothing of that kind drawing; the face's strokes, the edge's fills, the part's own opacity and the topmost shown stroke's dashes, caps and arrowheads stay as they are, and a part that already has only that one fill or stroke records no undo step. [The help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) says the tool paints faces with the current fill and edges with the current stroke. Regression tests: `live_paint_bucket_replaces_every_fill_row`, `live_paint_stroke_edge_replaces_every_stroke_row` and `live_paint_stroke_edge_paints_the_shown_stroke` in `crates/engine/src/tests/build.rs`. As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`. As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected.