diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 56ff8a636..abc828b31 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; 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 ), diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index b0d839220..43234dcc9 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -1807,6 +1807,71 @@ fn live_paint_make_builds_group_with_hidden_sources() { assert_eq!(info["sources"], 2); } +/// Make and Expand preserve the colors of compound paths sharing a curved boundary. +#[test] +fn live_paint_make_and_expand_keep_abutting_compound_colors() { + use kurbo::Shape as _; + use vectorcraft_geom::shapes; + + let mut s = session(); + let mut ring = shapes::ellipse(Rect::new(0.0, 0.0, 1.0, 1.0)); + let mut disk = shapes::ellipse(Rect::new(0.2, 0.2, 0.8, 0.8)); + let mut hole = disk.clone(); + hole.reverse(); + ring.subpaths.extend(hole.subpaths); + disk.subpaths.extend(shapes::rectangle(Rect::new(1.0, 0.0, 1.3, 1.0)).subpaths); + let compound = |s: &mut Session, path: &PathData, color: &str| { + let id = s.execute("path.create", &json!({"d": path.to_bezpath().to_svg()})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"id": id, "color": color})).unwrap(); + s.execute("paint.setStroke", &json!({"id": id, "none": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let ink = compound(&mut s, &ring, "#000000"); + let blue = compound(&mut s, &disk, "#2878f0"); + s.execute("select.set", &json!({"ids": [ink.0, blue.0]})).unwrap(); + let history = s.doc().unwrap().history.undo.len(); + let made = s.execute("livePaint.make", &json!({})).unwrap(); + assert_eq!(made["faces"], 3); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + let group = NodeId(made["id"].as_u64().unwrap()); + let paint_at = |n: &Node, p: Point| { + n.children() + .unwrap() + .iter() + .filter(|c| c.visible && !c.appearance.fill_paint().is_none()) + .filter(|c| c.path_data().is_some_and(|path| path.to_bezpath().winding(p) != 0)) + .map(|c| c.appearance.fill_paint()) + .collect::>() + }; + let check_colors = |n: &Node| { + assert_eq!(paint_at(n, Point::new(0.1, 0.5)), vec![Paint::solid(Color::BLACK)]); + let blue = Paint::solid(Color::from_hex("#2878f0").unwrap()); + assert_eq!(paint_at(n, Point::new(0.5, 0.5)), vec![blue.clone()]); + assert_eq!(paint_at(n, Point::new(1.15, 0.5)), vec![blue]); + }; + check_colors(&node(&s, group)); + let info = s.execute("livePaint.info", &json!({"group": group.0})).unwrap(); + assert_eq!(info["sources"], 2); + assert_eq!(info["faces"].as_array().unwrap().len(), 3); + s.execute("livePaint.expand", &json!({})).unwrap(); + let expanded = node(&s, group); + assert!(!vectorcraft_tools::builder::is_live_paint(&expanded)); + assert_eq!(expanded.children().unwrap().len(), 3); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 2); + check_colors(&expanded); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(vectorcraft_tools::builder::is_live_paint(&node(&s, group))); + check_colors(&node(&s, group)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_children(&s), 2); + assert!(matches!(node(&s, ink).kind, NodeKind::Compound { .. })); + assert!(matches!(node(&s, blue).kind, NodeKind::Compound { .. })); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + check_colors(&node(&s, group)); +} + #[test] fn live_paint_fill_assigns_the_face_under_the_point() { let mut s = session(); diff --git a/crates/pathops/src/planar/map.rs b/crates/pathops/src/planar/map.rs index ec8b0b76c..3abf7cfea 100644 --- a/crates/pathops/src/planar/map.rs +++ b/crates/pathops/src/planar/map.rs @@ -299,11 +299,16 @@ fn sweep_graph(inputs: &[(BezPath, usize)], eps: f64, precision: f64) -> Option< if ends[0] == ends[1] { continue; } + // Source attribution belongs to the sweep piece: pinning its endpoints can move the + // middle off the original outlines even though it still separates the same faces. + let mut sources = mid(&segs).map(|m| outlines.sources_at(m)).unwrap_or_default(); // Every edge at a vertex starts or ends exactly on it, so faces meeting there share its // coordinates: merging them (a union) then cancels their common edge, which it can't when // each face carries its own rounding of the corner (#893). pin_ends(&mut segs, p0, p1); - let sources = mid(&segs).map(|m| outlines.sources_at(m)).unwrap_or_default(); + if sources.is_empty() { + sources = mid(&segs).map(|m| outlines.sources_at(m)).unwrap_or_default(); + } let Some(&source) = sources.last() else { continue }; // Coincident pieces the sweep left apart are one edge (the front-most input's), lying on // the inputs of both. diff --git a/crates/pathops/src/planar/tests.rs b/crates/pathops/src/planar/tests.rs index 3ca7a4814..d1d05d4df 100644 --- a/crates/pathops/src/planar/tests.rs +++ b/crates/pathops/src/planar/tests.rs @@ -230,6 +230,48 @@ fn curves_and_a_circle_crossed_by_a_line() { assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs"); } +/// Coincident curved boundaries retain the fill coverage of the regions they separate. +#[test] +fn coincident_curves_keep_their_source_faces() { + fn check(diameter: f64, offset: f64, rule: FillRule, reverse: bool, swap: bool) { + let mut ring = circle(0.5 * diameter, 0.5 * diameter, 0.5 * diameter, 0); + let mut hole = circle(0.5 * diameter, 0.5 * diameter, 0.3 * diameter, 1).path; + hole.reverse(); + ring.path.subpaths.extend(hole.subpaths); + ring.rule = rule; + let mut disk = circle(0.5 * diameter + offset, 0.5 * diameter, 0.3 * diameter, 1); + disk.rule = rule; + if reverse { + ring.path.reverse(); + disk.path.reverse(); + } + let shapes = if swap { vec![disk, ring] } else { vec![ring, disk] }; + let (faces, _) = live_paint(&shapes); + let label = format!("diameter={diameter}, offset={offset}, rule={rule:?}, reverse={reverse}, swap={swap}"); + for (x, source) in [(0.1 * diameter, usize::from(swap)), (0.5 * diameter, usize::from(!swap))] { + let covering = at(&faces, x, 0.5 * diameter); + assert_eq!(covering.len(), 1, "{label}: {covering:?}"); + assert_eq!(covering[0].sources, [source], "{label}"); + } + if offset == 0.0 { + assert_eq!(faces.len(), 2, "{label}"); + let ring_face = at(&faces, 0.1 * diameter, 0.5 * diameter)[0]; + assert_eq!(ring_face.path.subpaths.len(), 2, "{label}: the ring keeps its hole"); + } + } + for diameter in [1.0, 0.5, 2.0, 100.0] { + for offset in [0.0, 0.025, -0.025] { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + for reverse in [false, true] { + for swap in [false, true] { + check(diameter, offset, rule, reverse, swap); + } + } + } + } + } +} + #[test] fn a_line_ending_on_an_edge_splits_the_shape() { let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)]; diff --git a/docs/mcp.md b/docs/mcp.md index 57abddff1..5dc19c665 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1841,7 +1841,9 @@ already walks Image Trace. Short agent entry points: `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, - color?, closeGaps?}` (Object › Live Paint › Gap Options). With `detect: true`, paint stops at the gaps that run + color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most + covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. + With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. The options are saved with the group, and the reply and `livePaint.info` list the gaps up to `size` wide that no path closes, as `[x0, y0, x1, y1]`. `livePaint.merge` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7cc40f69..b79671899 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -996,6 +996,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. + Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). 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"}`).