diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 75d5852ea2..ac3c780c11 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -472,7 +472,7 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let points = super::paint::picked_mesh_points_of(self, &ids); + let points = self.mesh_points_picked(); TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), @@ -483,7 +483,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let points = super::paint::picked_mesh_points_of(s, &ids); + // The points `transparency.set` with the same `ids` (or none) fades. + let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) }; let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::>(), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 3ec28e7ce7..33ef3ad5fc 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}", always, proxies ), @@ -179,9 +179,13 @@ impl Session { /// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the /// first selected object's own stack) for the proxy of its kind, else the first selected /// object's (a group's first painted object's; type: its first run's), else the defaults for - /// new art. + /// new art. When the object whose fill the proxy shows is a gradient mesh with points picked + /// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid + /// fill color changes. pub fn proxy_paints(&self) -> (Paint, Paint) { + let points = self.fill_mesh_points(); let show = |stroke: bool| match self.proxy_source(stroke) { + Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c), Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), @@ -273,6 +277,13 @@ impl DocState { /// each selected object's own stack is compared. One walk of the document, so callers that ask /// every frame cache it by revision. pub fn proxy_mixed(&self, item: Option) -> (bool, bool) { + self.proxy_mixed_with(item, &[]) + } + + /// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors + /// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of + /// its picked points instead of its fill. + fn proxy_mixed_with(&self, item: Option, points: &[(NodeId, Vec)]) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } @@ -291,7 +302,10 @@ impl DocState { let differ = |stroke: bool| { let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke)); let set = if item.is_some() { &selected } else { &nodes }; - let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item)); + let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) { + c if c.is_empty() => vec![proxy_paint(n, stroke, item)], + c => c.into_iter().map(Paint::solid).collect(), + }); let Some(p0) = paints.next() else { return false }; paints.any(|p| !same_in_proxy(&p0, &p)) }; @@ -313,8 +327,9 @@ impl DocState { impl Session { /// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item. + /// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors. pub fn proxy_mixed(&self) -> (bool, bool) { - self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item())) + self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points())) } } @@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill - // recolours just those points of their meshes. + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid + // fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`). let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { @@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option)]) -> Vec { + match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) { + (NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(), + _ => vec![], + } +} + +impl Session { + /// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or + /// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are + /// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's + /// active item is a fill row or an object of a symbol instance is selected, and a mesh with + /// points picked counts even when other objects are selected with it. + pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec)> { + let Some(st) = self.active() else { return vec![] }; + // Only gradient meshes have points to pick: a selection with no mesh and no container that + // could hold one skips reading the tool's options. + let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container()); + if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) { + return vec![]; + } + let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] }; + if item != ItemTarget::Top { + return vec![]; + } + let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] }; + picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected)) + } + + /// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set` + /// without `ids` or `item`) fades instead of their objects: `None` unless every target is a + /// gradient mesh with points picked, and while the Appearance panel's active item takes the + /// opacity (outside opacity mask editing). + pub fn mesh_points_picked(&self) -> Option)>> { + let st = self.active()?; + let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?; + // Only gradient meshes have points to pick: other targets skip reading the tool's options. + if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) { + return None; + } + if st.doc.mask_edit.is_none() && self.appearance_item().is_some() { + return None; + } + picked_mesh_points_of(self, &ids) + } +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { @@ -1084,8 +1148,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity - // themselves, so only the mesh around them fades (#4206, #4170, #4208). + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the + // opacity themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) && let Some(points) = picked_mesh_points_of(s, &ids) diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index f507b765c6..1a41488285 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() { assert_eq!(red, top, "only the points inside the loop are recoloured"); } +/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows +/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked +/// points' colors differ, the object's fill again once no point is picked. +#[test] +fn the_fill_proxy_shows_the_picked_mesh_points_color() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let object_fill = s.proxy_paints().0; + // Round the top row of points. + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let shown = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}"); + assert_eq!(shown["fillMixed"], json!(false)); + assert_eq!(shown["fill"], json!(s.proxy_paints().0)); + // One picked point takes another color: the points differ. + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + let first = m.idx(0, 0); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points' colors differ"); + // Picking none (the Selection tool) shows the object's fill again. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.proxy_paints().0, object_fill); + assert!(!s.proxy_mixed().0); +} + +/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh +/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and +/// the proxy shows the row. +#[test] +fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items; + let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row"); + let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row"); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked"); + let point_color = |s: &Session| { + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + m.points[m.idx(0, 0)].color.to_hex() + }; + + s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0); + + s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap(); + assert!(s.fill_mesh_points().is_empty()); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into())); +} + +/// A gradient mesh with points picked selected with another object: a solid fill recolors the +/// points and fills the other object, and the Fill proxy and its "?" count the points' colors. +#[test] +fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked"); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0, "the points and the other object are the same color"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ"); +} + /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 71019385b6..8923c6f0ce 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -54,7 +54,7 @@ pub mod variables; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{LiveCorners, Node, StrokeLayer}; +use vectorcraft_doc::{LiveCorners, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_engine::inspect::StrokeMixed; use vectorcraft_geom::shapes::CornerKind; @@ -322,11 +322,13 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per -/// document revision and Appearance panel item. +/// document revision, Appearance panel item and picked gradient mesh points (picking points +/// changes no revision). pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) { + type Key = (u64, u64, Option, Vec<(NodeId, Vec)>); let Some(st) = app.session.active() else { return (false, false) }; - let key = (st.uid, st.revision, app.session.appearance_item()); - match pstate::), (bool, bool))>>(ctx, "proxy-mixed") { + let key: Key = (st.uid, st.revision, app.session.appearance_item(), app.session.fill_mesh_points()); + match pstate::>(ctx, "proxy-mixed") { Some((k, mixed)) if k == key => mixed, _ => { let mixed = app.session.proxy_mixed(); @@ -365,13 +367,23 @@ pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeM } /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance -/// panel's active item's, else the first selected object's. +/// panel's active item's, else the first picked gradient mesh point's opacity (which +/// `transparency.set` changes then) with its object's blend mode, else the first selected object's. pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { let n = first_node(app)?; - Some(match app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { - Some(it) => (it.opacity(), it.blend()), - None => (n.opacity, n.blend), - }) + if let Some(it) = app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) { + return Some((it.opacity(), it.blend())); + } + let picked = app.session.mesh_points_picked().and_then(|points| { + let (id, v) = points.first()?; + let d = &app.session.active()?.doc; + let m = match &d.node(*id)?.kind { + NodeKind::Mesh(m) => m, + _ => return None, + }; + Some((m.points.get(*v.first()?)?.opacity, d.node(*id)?.blend)) + }); + Some(picked.unwrap_or((n.opacity, n.blend))) } /// Is the active proxy "?" (the selected objects' paints differ)? diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index cd7b88cdd0..bd605eef01 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -949,6 +949,60 @@ fn a_mixed_selection_has_no_colour_to_edit() { assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false), "a new revision recomputes it"); } +/// With gradient mesh points picked, the panel edits and shows their color (a solid fill +/// recolors just them), not the mesh object's fill, so the sliders keep the value they set, +/// through a drag too; "?" when the picked points differ, which picking alone (no new revision) +/// updates. The Control bar's opacity is the picked point's. +#[test] +fn picked_mesh_points_are_the_color_the_panel_shows_and_edits() { + use vectorcraft_doc::NodeKind; + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("paint.setFill", json!({"color": "#ff8500"})).unwrap(); + app.run("object.mesh.create", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("object.mesh.setPointColor", json!({"id": id.0, "index": 0, "color": "#0000ff", "opacity": 0.5})).unwrap(); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64, shift: bool| { + for k in [PointerKind::Down, PointerKind::Up] { + let mut ev = PointerEvent::new(k, x, y); + ev.mods.shift = shift; + app.session.pointer(&ev, v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.fill_mesh_points().into_iter().flat_map(|(_, v)| v).collect::>(); + // The top-left corner point (index 0). + click(&mut app, 10.0, 10.0, false); + assert_eq!(picked(&app), vec![0], "the click picks the top-left point"); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + let tgt = target_with(&app, false); + assert_eq!(tgt.color().map(|c| c.to_hex()), Some("#0000ff".into()), "the picked point's color, not the object's fill"); + assert_eq!(crate::panels::current_transparency(&app).map(|t| t.0), Some(0.5), "the picked point's opacity"); + // A slider drag: each step shows the color it set, and the release keeps it. + for c in [Color::rgb8(250, 0, 120), Color::rgb8(240, 10, 140)] { + let tgt = target_with(&app, false); + apply(&mut app, &tgt, c, Live::Dragging, false); + assert_eq!(target_with(&app, false).color(), Some(c), "the drag shows the color it set"); + } + let orange = Color::rgb8(255, 128, 0); + let tgt = target_with(&app, false); + apply(&mut app, &tgt, orange, Live::Released, false); + assert_eq!(target_with(&app, false).color(), Some(orange), "the panel keeps the color it set"); + let NodeKind::Mesh(m) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("still a gradient mesh") }; + assert_eq!(m.points[0].color, orange); + let middle = m.idx(0, 1); + assert_ne!(m.points[middle].color, orange, "only the picked point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (false, false)); + // Shift-click the top middle point (still the object's color): the two differ. + click(&mut app, 35.0, 10.0, true); + assert_eq!(picked(&app), vec![0, middle], "the Shift-click adds the top middle point"); + assert_eq!(crate::panels::mixed_paints(&app, &ctx), (true, false), "picking recomputes it"); +} + #[test] fn rgb_roundtrip() { let c = Color::rgb8(230, 120, 40); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 229a23c411..4f23266e97 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -20,12 +20,12 @@ pub(super) const MASK_THUMB: &str = "tr-mask-thumb"; /// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target /// (the selection, or the object whose mask is being edited), recomputed only when the document -/// or the selection changes. +/// or the selection changes, or other gradient mesh points are picked. fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option) { - type Key = (u64, u64, usize, Option, Option); + type Key = (u64, u64, usize, Option, Option, Option)>>); let Some(st) = app.session.active() else { return Default::default() }; let sel = &st.selection.objects; - let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); + let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied(), app.session.mesh_points_picked()); let id = egui::Id::unique("tr-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option))>(id)) && k == key @@ -351,6 +351,43 @@ mod tests { assert!(app.session.active().unwrap().doc.mask_edit.is_none()); } + /// Picking other gradient mesh points (which gives the document no new revision) shows their + /// opacity, the one `transparency.set` changes then. + #[test] + fn picking_mesh_points_shows_their_opacity() { + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "object.mesh.create", json!({"rows": 2, "cols": 2})); + run(&mut app, "object.mesh.setPointColor", json!({"id": a, "index": 0, "opacity": 0.3})); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + let v = ViewInfo::default(); + app.session.select_tool("mesh", v).unwrap(); + let click = |app: &mut VectorcraftApp, x: f64, y: f64| { + for k in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + }; + let picked = |app: &VectorcraftApp| app.session.mesh_points_picked().map(|p| p.into_iter().flat_map(|(_, v)| v).collect::>()); + let ctx = egui::Context::default(); + click(&mut app, 35.0, 10.0); + assert_ne!(picked(&app), Some(vec![0]), "the click picks the top middle point"); + assert_eq!(picked(&app).map(|v| v.len()), Some(1)); + assert_eq!(state(&app, &ctx).0.opacity, Some(100.0)); + click(&mut app, 10.0, 10.0); + assert_eq!(picked(&app), Some(vec![0]), "the click picks the top-left point"); + assert_eq!(state(&app, &ctx).0.opacity, Some(30.0), "the newly picked point's opacity"); + // An active Appearance row takes the opacity, so the values are the object's again. + let rows = app.session.active().unwrap().selection.objects.first().map(|id| app.session.active().unwrap().doc.node(*id).unwrap().appearance.items.len()); + assert!(rows.is_some_and(|n| n > 0), "the mesh has its stroke row"); + run(&mut app, "appearance.setActiveItem", json!({"index": 0})); + assert_eq!(picked(&app), None); + assert_eq!(app.session.transparency_info().opacity, Some(100.0), "the object's opacity, not the point's"); + assert_eq!(run(&mut app, "transparency.info", json!({}))["opacity"], json!(100.0)); + } + #[test] fn mixed_values_show_blank() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..fec3a36e96 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10573,7 +10573,12 @@ and the arrow keys nudge them by the keyboard increment. The active tool lists t (`tool.setOption {}` returns them as `meshPoints: [{id, index}…]`). On gradient meshes a solid fill colour (`paint.setFill`, from the Color panel, Swatches or the Eyedropper) recolours just the picked points, and when every target of `transparency.set` is a gradient mesh with points picked, its `opacity` fades those points instead of the objects -(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step. +(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step, and neither goes to the +points while the Appearance panel's active row takes the fill or the opacity. When the first selected object is a +gradient mesh with points picked, the Fill proxy, the Color panel, the Color Wheel and `paint.proxies` show its first +picked point's color, and `fillMixed` compares the picked points' colors with each other and with the fills of the other +selected objects. The Transparency panel shows the picked points' opacity (blank when it differs), and the Control +bar shows the first picked point's opacity. `object.mesh.movePoints {id, points: [index…], dx, dy}` (or `{items: [{id, points}…], dx, dy}` over several meshes) moves points by (dx, dy), their handles with them; `object.mesh.setPointColor` takes `points` or `items` the same diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..f41e1a4631 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1445,6 +1445,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **A point on a mesh's outline is on the mesh:** a point exactly on the outline of a gradient mesh (within a millionth of a point) locates on the edge it lies on, on all four sides alike and with no tolerance, as it already did on the top and left edges, so `object.mesh.addLine` and Create Gradient Mesh `at` a point on the bottom or right edge add their lines there instead of reporting the point outside, and the Eyedropper samples the mesh's color there; a point just outside still needs `tolerance`. Tests: `mesh_locate_takes_a_point_on_any_side_of_the_outline` in `crates/doc/src/live.rs`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. +- **Picked mesh points in the panels** (VectorCraft's own rule): with points of a gradient mesh picked by the Mesh tool, Direct Selection or the Lasso, the Fill proxy, the Color panel and the Color Wheel show the first picked point's color, so their sliders keep the color a drag sets, and "?" shows when the picked points' colors differ. The Control bar shows the first picked point's opacity. Tests: `the_fill_proxy_shows_the_picked_mesh_points_color`, `the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active` and `the_fill_proxy_counts_picked_mesh_points_beside_other_objects` in `crates/engine/src/tests/xform.rs`, `picked_mesh_points_are_the_color_the_panel_shows_and_edits` in `crates/ui-egui/src/panels/color/tests.rs`, `picking_mesh_points_shows_their_opacity` in `crates/ui-egui/src/panels/transparency.rs`. Scores and hours unchanged. - **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. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo flat-art check with named colors:** with `logoColors` named, whether the image is flat art is judged by its own colors, as many as are named, with white, or as the Colors setting gives, whichever is more. Named colors 20 levels off a flat badge's colors still trace it, a hatch on a light ground gives the same result with close and far named colors, a gradient's refusal score does not depend on how far the named colors are from it, and areas of colors that were not named are traced in the named colors instead of being refused; code-drawn tests in `crates/trace`.