Merge pull request #6621 from makesomethingshit/fix/6373-live-paint-drag

Fix #6373: Live Paint Bucket fills faces crossed by a drag
This commit is contained in:
BFlat
2026-11-12 13:17:02 +07:00
committed by GitHub
9 changed files with 315 additions and 21 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket",
[],
None,
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], 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 → {group, face}",
"{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), 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 → {group, face}",
has_doc,
lp_fill
),
+56 -1
View File
@@ -272,6 +272,35 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let parse_chunk = |input: &Value, start: Point| -> Result<Vec<Point>> {
let values = input
.as_array()
.filter(|points| !points.is_empty() && points.len() <= b::LP_FILL_CHUNK_POINTS)
.ok_or_else(|| bad(C, "each path chunk must contain 1 to 4096 points"))?;
let points: Vec<_> = values
.iter()
.map(|point| {
let pair = point.as_array().filter(|pair| pair.len() == 2).ok_or_else(|| bad(C, "points must be [x,y] pairs"))?;
let x = pair.first().and_then(Value::as_f64).ok_or_else(|| bad(C, "point x must be a number"))?;
let y = pair.get(1).and_then(Value::as_f64).ok_or_else(|| bad(C, "point y must be a number"))?;
let point = Point::new(x, y);
if !finite(point) {
return Err(bad(C, "points must be finite canvas points"));
}
Ok(point)
})
.collect::<Result<_>>()?;
if points.first() != Some(&start) {
return Err(bad(C, "path chunks must share their boundary point"));
}
Ok(points)
};
let points = p.get("points");
let continuation = match p.get("continuation") {
None => &[][..],
Some(input) if points.is_some() => input.as_array().ok_or_else(|| bad(C, "continuation must be an array of path chunks"))?.as_slice(),
Some(_) => return Err(bad(C, "continuation requires points")),
};
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
@@ -302,7 +331,33 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
.and_then(|n| b::visible_face_at(n, pt))
.map(|f| f.id)
.ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone());
let mut targets = std::collections::BTreeSet::from([fid]);
if let Some(points) = points {
let n = d.node(gid).ok_or(EngineError::NoNode(gid))?;
// Use the existing region-crossing probes, so even a face narrower than the distance
// between pointer events is filled. Reversing matches visible_face_at's front first.
let faces: Vec<_> = b::faces(n).into_iter().rev().filter(|f| f.visible).collect();
let mut face_ids = vec![];
let mut regions = vec![];
for f in faces {
if let Some(path) = f.path_data()
&& let Some(bounds) = path.bounds()
{
face_ids.push(f.id);
regions.push((po::Region { path: path.clone(), sources: vec![] }, path.to_bezpath(), bounds));
}
}
let map = b::BuilderMap { regions, ..Default::default() };
let mut last = pt;
for input in std::iter::once(points).chain(continuation) {
let chunk = parse_chunk(input, last)?;
last = chunk.last().copied().unwrap_or(last);
targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied()));
}
}
for target in targets {
d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone());
}
Ok((gid, fid))
})?;
s.remember_paint(&paint);
+114
View File
@@ -2945,6 +2945,120 @@ fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() {
assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue });
}
#[test]
fn live_paint_bucket_drag_fills_multiple_faces() {
let mut s = session();
let g = live(&mut s);
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
let before = s.doc().unwrap().doc.clone();
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
// Skip the middle face between pointer events, as can happen in a fast drag.
s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
let gn = node(&s, g);
for x in [25.0, 75.0, 125.0] {
let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill, "dragged face at {x}");
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before, "one Undo restores every face");
s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap();
assert!(!s.overlays(v).is_empty(), "hover still highlights a face");
assert_eq!(s.doc().unwrap().doc, before, "hover does not paint");
}
#[test]
fn live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel() {
for (deferred, cancel) in [(false, false), (false, true), (true, false)] {
let mut s = session();
let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (50.25, 0.0), (50.25, 100.0))];
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let before = s.doc().unwrap().doc.clone();
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.prefs.real_time_drawing = !deferred;
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap();
if cancel {
assert!(s.tool_busy());
assert!(s.tool_claims_key(ToolKey::Escape, v));
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert!(!s.tool_busy());
assert!(!s.tool_claims_key(ToolKey::Escape, v));
}
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
if !cancel {
let g = s.doc().unwrap().selection.objects[0];
let gn = node(&s, g);
for x in [25.0, 50.125, 125.0] {
let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill, "deferred={deferred}, x={x}");
}
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(s.doc().unwrap().doc == before, "deferred={deferred}, cancel={cancel}: original paths and selection art must be restored");
}
}
#[test]
fn live_paint_bucket_long_drag_keeps_late_turns_and_release_as_one_undo() {
for cancel in [false, true] {
let mut s = session();
let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (100.0, 0.0), (100.0, 100.0))];
s.execute("select.set", &json!({"ids": ids.map(|id| id.0)})).unwrap();
let before = s.doc().unwrap().doc.clone();
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.prefs.real_time_drawing = false;
s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0));
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
for i in 1..4096 {
s.pointer(&PointerEvent::new(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 50.0), v).unwrap();
}
// Turn around the middle face after the chunk limit, then enter the right face on Up.
for (x, y) in [(25.0, 150.0), (125.0, 150.0)] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
}
if cancel {
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
if !cancel {
let g = s.doc().unwrap().selection.objects[0];
let gn = node(&s, g);
for x in [25.0, 125.0] {
assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), s.paint.fill);
}
assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(75.0, 50.0)).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE));
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(s.doc().unwrap().doc == before, "long drag is one cancellable undo step");
}
}
#[test]
fn live_paint_bucket_drag_rejects_invalid_points_atomically() {
let mut s = session();
let g = live(&mut s);
let before = s.doc().unwrap().doc.clone();
for points in
[json!([]), json!([[26.0, 50.0]]), json!([[25.0, 50.0], [null, 0]]), json!([[25.0, 50.0], [1e30, 0]]), json!(vec![[25.0, 50.0]; 4097])]
{
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": points})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
}
for continuation in [json!(null), json!([[[26.0, 50.0], [125.0, 50.0]]]), json!([vec![[25.0, 50.0]; 4097]])] {
assert!(
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": [[25.0, 50.0]], "continuation": continuation}))
.is_err()
);
assert_eq!(s.doc().unwrap().doc, before);
}
}
#[test]
fn live_paint_bucket_tool_fills_face() {
let mut s = session();
+2 -2
View File
@@ -37,8 +37,8 @@ use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache};
use live_paint::LpHover;
use live_paint::lp_color;
pub use live_paint::{
LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool,
LivePaintSelectionTool,
LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_FILL_CHUNK_POINTS, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE,
LivePaintBucketTool, LivePaintSelectionTool,
};
pub use lookup::{
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces,
+70 -3
View File
@@ -114,7 +114,8 @@ enum LpPaint {
}
/// Live Paint Bucket: by default, a click fills the face under the cursor with the current fill,
/// and Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or
/// and dragging fills the faces crossed in that group as one undo step (Escape cancels).
/// Shift-click paints the nearest edge with the current stroke. Alt-click samples a fill or
/// stroke into the paint defaults; Alt+Shift-click samples only the color under the pointer.
/// Clicking selected ordinary paths without Alt makes them a Live Paint group first. Its Tool
/// Options (double-click the tool) are `paintFills` (on) and `paintStrokes` (off), `highlight`
@@ -130,6 +131,8 @@ pub struct LivePaintBucketTool {
pub(super) hover: LpHover,
/// Where the pointer last moved, and whether Shift and Alt were held.
at: Option<(Point, bool, bool)>,
/// The fill command and polyline previewed from the press's snapshot.
fill_drag: Option<(Value, Vec<Vec<Point>>)>,
fills: bool,
strokes: bool,
highlight: bool,
@@ -137,12 +140,16 @@ pub struct LivePaintBucketTool {
width: f64,
}
/// Maximum points in one Live Paint fill path chunk.
pub const LP_FILL_CHUNK_POINTS: usize = 4096;
impl Default for LivePaintBucketTool {
fn default() -> Self {
Self {
cache: RegionCache::live_paint(),
hover: LpHover::None,
at: None,
fill_drag: None,
fills: true,
strokes: false,
highlight: true,
@@ -153,6 +160,36 @@ impl Default for LivePaintBucketTool {
}
impl LivePaintBucketTool {
fn begin_fill(&mut self, params: Value, p: Point) -> Vec<Action> {
self.fill_drag = Some((params.clone(), vec![vec![p]]));
vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)]
}
fn drag_fill(&mut self, p: Point) -> Vec<Action> {
let Some((params, chunks)) = &mut self.fill_drag else { return vec![] };
let Some(points) = chunks.last_mut() else { return vec![] };
let Some(&last) = points.last() else { return vec![] };
if last == p {
return vec![];
}
if points.len() == LP_FILL_CHUNK_POINTS {
// Share the boundary point so no segment is lost between chunks.
chunks.push(vec![last, p]);
} else {
points.push(p);
}
let mut params = params.clone();
let mut chunks = chunks.iter().map(|chunk| json!(chunk.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()));
if let Some(first) = chunks.next() {
params["points"] = first;
}
let continuation: Vec<_> = chunks.collect();
if !continuation.is_empty() {
params["continuation"] = Value::Array(continuation);
}
vec![Action::Preview("livePaint.fill".into(), params)]
}
/// What a click paints, Shift held or not. Shift switches Paint Fills alone to Paint Strokes
/// alone and back, and switches both to Paint Fills alone.
fn paints(&self, shift: bool) -> LpPaint {
@@ -191,6 +228,12 @@ impl Tool for LivePaintBucketTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
// A second press finishes the preceding click, including callers without a release.
if ev.kind == PointerKind::Down && self.fill_drag.take().is_some() {
let mut acts = vec![Action::Commit];
acts.extend(self.pointer(cx, ev));
return acts;
}
match ev.kind {
PointerKind::Move => {
self.at = Some((p, ev.mods.shift, ev.mods.alt));
@@ -232,18 +275,42 @@ impl Tool for LivePaintBucketTool {
return vec![];
}
if let Some(g) = live_paint_at(cx.doc, p) {
return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))];
return self.begin_fill(json!({ "group": g.0, "point": point }), p);
}
if self.cache.at(cx, p).is_some() {
let roots = sorted_roots(cx.doc, &cx.selection.objects);
return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))];
return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p);
}
vec![]
}
PointerKind::Drag => self.drag_fill(p),
PointerKind::Up => {
let mut acts = self.drag_fill(p);
if self.fill_drag.take().is_some() {
acts.push(Action::Commit);
}
acts
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] }
}
fn busy(&self) -> bool {
self.fill_drag.is_some()
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
key == ToolKey::Escape && self.busy()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32)
}
+45 -9
View File
@@ -1045,6 +1045,25 @@ fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) {
(gid, ids)
}
#[test]
fn bucket_long_drag_keeps_turns_and_release_after_the_point_limit() {
let (d, gid, ..) = live_group_doc();
let s = Selection::default();
let paint = paint();
let cx = cx(&d, &s, &paint);
let mut t = LivePaintBucketTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0));
for i in 1..4096 {
t.pointer(&cx, &ev(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 25.0));
}
t.pointer(&cx, &ev(PointerKind::Drag, 25.0, 100.0));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 100.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0));
let [Action::Preview(id, params), Action::Commit] = acts.as_slice() else { panic!("release must extend the drag before committing") };
assert_eq!(id, "livePaint.fill");
assert_eq!(params["group"], gid.0);
}
#[test]
fn bucket_click_fills_face_and_shift_click_strokes_edge() {
let (d, gid, f2, _e) = live_group_doc();
@@ -1058,9 +1077,10 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() {
let ov = t.overlays(&cx);
assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. }));
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
let Action::Exec(id, v) = &acts[0] else { panic!() };
let [Action::Begin(_), Action::Preview(id, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["group"], gid.0);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)), vec![Action::Commit]);
let shift = Mods { shift: true, ..Default::default() };
let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift));
let Action::Exec(id, _) = &acts[0] else { panic!() };
@@ -1074,9 +1094,15 @@ fn bucket_options_choose_what_a_click_paints() {
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Default::default() };
let fill = |x: f64| vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))];
let fill = |x: f64| {
vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), json!({ "group": gid.0, "point": [x, 25.0] }))]
};
let stroke = |x: f64| vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": gid.0, "point": [x, 25.0], "tolerance": 4.0 }))];
let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m));
let click = |t: &mut LivePaintBucketTool, x: f64, m: Mods| {
let acts = t.pointer(&cx, &ev(PointerKind::Down, x, 25.0).with_mods(m));
t.pointer(&cx, &ev(PointerKind::Up, x, 25.0).with_mods(m));
acts
};
let hovered = |t: &mut LivePaintBucketTool, x: f64, m: Mods| {
t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m));
match t.overlays(&cx).as_slice() {
@@ -1162,7 +1188,9 @@ fn bucket_options_set_the_highlight() {
// Highlight off: nothing is highlighted, and a click still paints.
t.set_option("highlight", &json!(false));
assert_eq!(highlight(&mut t), None);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)).len(), 1);
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, _)] if id == "livePaint.fill"));
t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0));
t.set_option("highlight", &json!(true));
assert!(highlight(&mut t).is_some());
}
@@ -1326,7 +1354,7 @@ fn bucket_on_plain_selection_makes_live_paint() {
let cx = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!() };
let [Action::Begin(_), Action::Preview(id, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["ids"], json!([a.0, b.0]));
}
@@ -1344,7 +1372,13 @@ fn bucket_rebuilds_its_map_for_a_new_revision_at_the_same_address() {
d = two_rects_at(300.0).0;
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
let acts = t.pointer(&cx2, &ev(PointerKind::Down, 375.0, 50.0));
assert_eq!(acts, vec![Action::Exec("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))]);
assert_eq!(
acts,
vec![
Action::Begin("Live Paint Bucket".into()),
Action::Preview("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))
]
);
}
#[test]
@@ -1357,7 +1391,8 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() {
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0));
let acts = t.pointer(&cx1, &ev(PointerKind::Down, 75.0, 50.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "livePaint.fill"), "{acts:?}");
assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, _)] if id == "livePaint.fill"), "{acts:?}");
t.pointer(&cx1, &ev(PointerKind::Up, 75.0, 50.0));
// The fill left the document as it was: the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing");
@@ -1745,8 +1780,9 @@ fn live_paint_tools_skip_hidden_faces() {
let mut bucket = LivePaintBucketTool::default();
bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] }));
assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]);
let fill = Action::Preview("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] }));
assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Begin("Live Paint Bucket".into()), fill]);
bucket.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0));
let mut sel = LivePaintSelectionTool::default();
sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -151,7 +151,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
@@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. |
| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. |
| 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged |
| 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged |
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -509,10 +509,17 @@ rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/
coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain
separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22).
## Live Paint Bucket
Dragging from a face fills the visible faces crossed in that Live Paint group, even when a
narrow face lies between pointer events. Undo restores the whole drag, and Escape cancels it
before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. |
| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. |
| 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). |
| 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. |