Live Paint: Merge keeps the group's lock, visibility, mask and graphic style link

Object › Live Paint › Merge built the merged group anew and copied
only the bottom Live Paint group's name, opacity and blend mode, so
its lock, visibility, appearance, opacity mask, graphic style link and
other properties were lost. Make with a Live Paint group selected, and
a Live Paint Bucket click on a selected path beside a selected Live
Paint group, made a new group with a new id instead, and every face
took the fill of its paths again.

Merge now starts from the bottom Live Paint group, as Gap Options
does, and replaces only its faces, edges and paths and its Gap
Options. Make, and livePaint.fill with ids when the point is outside
that group's faces, add the other objects to the bottom Live Paint
group among them as Merge does.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-21 16:46:12 +07:00
committed by bflatastic
parent 0ec23d90bc
commit 67448e9245
4 changed files with 159 additions and 67 deletions
+78 -67
View File
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Live Paint"],
Some("Cmd+Alt+X"),
"{ids?|id?} build a Live Paint group from the selected paths → {id, faces, edges}",
"{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
has_selection_or_ids,
lp_make
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge",
["Object", "Live Paint"],
None,
"{ids?|id?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
"{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
has_multi_or_ids,
lp_merge
),
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket",
[],
None,
"{group?: id | ids?|id?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
"{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
has_doc,
lp_fill
),
@@ -501,8 +501,12 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
}
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them
/// as Merge does; returns (group, faces, edges).
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) {
return merge_live(d, sel, roots);
}
let mut sources = vec![];
for r in roots {
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
@@ -523,71 +527,78 @@ fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its
/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which
/// are merged; returns (group, faces, edges).
fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
let target = roots
.iter()
.copied()
.find(|r| d.node(*r).is_some_and(is_live_paint))
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
})
.collect();
let mut sources = source_nodes(&old);
for r in roots {
if *r != target {
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
}
}
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
let options = merged_gap_options(&groups);
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
// The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic
// style link and the rest; its children are made again and its Gap Options set to the merged ones.
let mut g = old;
if let Some(ch) = g.children_mut() {
*ch = children;
}
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
}
}
for (pt, app) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
}
}
let mut merged = Beside::replacing(d, roots);
for r in roots.iter().filter(|r| **r != target) {
if d.node(*r).is_some() {
merged.put(d, *r, vec![])?;
}
}
merged.put(d, target, vec![g])?;
merged.finish(d)?;
sel.set([target]);
Ok((target, nf, ne))
}
fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| {
let target = roots
.iter()
.copied()
.find(|r| d.node(*r).is_some_and(is_live_paint))
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
})
.collect();
let mut sources = source_nodes(&old);
for r in &roots {
if *r != target {
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
}
}
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
let options = merged_gap_options(&groups);
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
let mut g = Node::group(target, children);
g.name = old.name.clone();
g.opacity = old.opacity;
g.blend = old.blend;
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
}
}
for (pt, app) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
}
}
let mut merged = Beside::replacing(d, &roots);
for r in roots.iter().filter(|r| **r != target) {
if d.node(*r).is_some() {
merged.put(d, *r, vec![])?;
}
}
merged.put(d, target, vec![g])?;
merged.finish(d)?;
sel.set([target]);
Ok((target, nf, ne))
})?;
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?;
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
+77
View File
@@ -599,6 +599,83 @@ fn live_paint_merge_keeps_paint_and_adds_faces() {
assert_eq!(layer_children(&s), 1);
}
/// Give Live Paint group `g` a lock, hidden state, appearance, knockout, opacity mask and
/// graphic style link, and return it as it then is.
fn dress_live_group(s: &mut Session, g: NodeId) -> Node {
use vectorcraft_doc::{Knockout, OpacityMask};
let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default());
s.edit("t", |d, _| {
let n = d.node_mut(g).unwrap();
n.locked = true;
n.visible = false;
n.isolate = true;
n.knockout = Knockout::On;
n.appearance.set_stroke(Paint::solid(Color::rgb(0.0, 0.5, 0.0)));
let mut e = n.extras_mut();
e.mask = Some(Box::new(OpacityMask::new(mask, true)));
e.graphic_style = Some(7);
Ok(())
})
.unwrap();
node(s, g)
}
/// Asserts that `after` is `before` with other children and Gap Options.
fn same_group_but_children(before: &Node, after: &Node) {
assert_eq!((after.locked, after.visible, after.isolate), (true, false, true));
assert_eq!(after.appearance, before.appearance, "the group's own appearance");
assert_eq!(after.extras().mask, before.extras().mask, "the opacity mask");
assert_eq!(after.extras().graphic_style, Some(7), "the graphic style link");
let mut want = before.clone();
*want.children_mut().unwrap() = after.children().unwrap().clone();
want.extras_mut().live_paint_gaps = after.extras().live_paint_gaps.clone();
assert_eq!(after, &want);
}
#[test]
fn live_paint_merge_keeps_the_groups_lock_visibility_mask_and_graphic_style() {
let mut s = session();
let g = live(&mut s);
let before = dress_live_group(&mut s, g);
let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0);
let r = s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
assert_eq!(NodeId(r["id"].as_u64().unwrap()), g);
let after = node(&s, g);
same_group_but_children(&before, &after);
assert_eq!(vectorcraft_tools::builder::sources(&after).unwrap().children().unwrap().len(), 3, "the rectangle joined the sources");
// One undo step brings back the group as it was, and the rectangle beside it.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g), before);
assert_eq!(layer_children(&s), 2);
}
#[test]
fn live_paint_make_and_fill_with_a_live_paint_group_selected_add_the_paths_to_it() {
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
let fill_at = |s: &Session, g: NodeId, x: f64, y: f64| {
vectorcraft_tools::builder::face_at(&node(s, g), vectorcraft_geom::Point::new(x, y)).map(|f| f.appearance.fill_paint())
};
// Make, and the Live Paint Bucket on a path beside the group, each keep the group, its
// properties and the paint of its faces.
for (cmd, params) in [("livePaint.make", json!({})), ("livePaint.fill", json!({"point": [170, 30], "color": "#0000ff"}))] {
let mut s = session();
let g = live(&mut s);
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap();
let before = dress_live_group(&mut s, g);
let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0);
let mut p = params;
p["ids"] = json!([g.0, c.0]);
let r = s.execute(cmd, &p).unwrap();
let id = r.get("id").or_else(|| r.get("group")).and_then(Value::as_u64);
assert_eq!(id, Some(g.0), "{cmd}");
same_group_but_children(&before, &node(&s, g));
assert_eq!(fill_at(&s, g, 25.0, 50.0), Some(green.clone()), "{cmd}");
assert_eq!(layer_children(&s), 1, "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, g), before, "{cmd}");
}
}
/// A Live Paint group of one open path round the square 0..100, its end 2 pt short of its start.
fn live_square_with_a_gap(s: &mut Session) -> NodeId {
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y}));
+3
View File
@@ -1283,6 +1283,9 @@ already walks Image Trace. Short agent entry points:
pixels; 4). With `selectStrokes` off, Alt-click selects the face under the pointer as a click does; with
`selectFills` off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click selects
no face or edge.
`livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity
mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as
`livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces.
`dataset.select`, `dataset.next` and `dataset.prev` reply `{name, applied, skipped}`. They add `overflow: [id…]`
when area type or type on a path whose text they rewrote doesn't fit, as `text.setText` does (see
+1
View File
@@ -674,6 +674,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756).
- Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged.
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.
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)).
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"}`).