From 0d26724a7cac408e6b924b6e93525aefb1474929 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:50:26 -0800 Subject: [PATCH] Graphs: Appearance and effect commands leave a design's art in a series out A graph draws the art of a column or marker design inside a series again from the stored design. Rows, effects and graphic styles that a command gave that art were lost the next time the graph's data, type or size changed. The paint commands already left the art out. - The appearance.* row and whole-appearance commands, effect.apply, appearance.addEffect and the effect.* commands that edit, move or remove an effect, threeD.mapGraphic, graphicStyle.apply, graphicStyle.addFromLibrary with apply, transparency.set with a fill or stroke item, and appearance.copyFrom (as the target) leave out any object inside a series group other than a path directly in it, given by id, selected or reached through target: "contents". They edit the other objects given, and a command whose objects are all design art is an error. - appearance.transfer with design art as its target, or as its source without copy, is an error. - A Pathfinder effect no longer groups selected design art. - The paint commands given a design's copy with a fill or stroke item leave it out as well. docs/mcp.md states the rule, and the Graphs line in docs/target-app-parity.md names it. --- crates/engine/src/cmd/appearance.rs | 37 ++++- crates/engine/src/cmd/effectcmd.rs | 13 +- crates/engine/src/cmd/effectcmd/graphics.rs | 4 +- crates/engine/src/cmd/graph.rs | 5 +- crates/engine/src/cmd/graph/series.rs | 33 +++-- crates/engine/src/cmd/graph/tests.rs | 146 +++++++++++++++++++- crates/engine/src/cmd/style.rs | 2 +- crates/engine/src/cmd/stylelib.rs | 10 +- docs/mcp.md | 10 ++ docs/target-app-parity.md | 2 +- 10 files changed, 224 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index ca0202e858..7a4fa80a1c 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -419,6 +419,27 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) } } +/// The refusal of an appearance edit whose targets are all the art of graph designs drawn in graph series. +const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn again from the design: edit the design to change it"; + +/// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series +/// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets +/// are all such art is refused. +pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result> { + let ids = appearance_targets(s, p)?; + without_design_art(&s.doc()?.doc, ids, "appearance") +} + +/// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else. +pub(crate) fn without_design_art(d: &Document, mut ids: Vec, cmd: &str) -> Result> { + let given = ids.len(); + ids.retain(|id| !super::graph::is_design_art(d, *id)); + if given > 0 && ids.is_empty() { + return Err(bad(cmd, DESIGN_ART)); + } + Ok(ids) +} + /// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside /// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: /// the Appearance panel lists those rows and edits them by index. @@ -453,17 +474,17 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n) } -/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing -/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. +/// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the +/// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { - let ids = appearance_targets(s, p)?; + let ids = edit_targets(s, p)?; Ok(rowless_series_marks(&s.doc()?.doc, &ids)) } /// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, /// which can hold effects and a graphic style link of its own. pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { - let own = appearance_targets(s, p)?; + let own = edit_targets(s, p)?; let mut ids = rowless_series_marks(&s.doc()?.doc, &own); let groups = stood_for(&own, &ids); ids.extend(groups); @@ -863,6 +884,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`source` and `target` are the same object")); } let copy = switch_or(p, "copy", false, C)?; + // The art of a graph design in a series is read as a source, but neither takes an appearance nor gives one up. + without_design_art(&s.doc()?.doc, vec![target], C)?; + if !copy { + without_design_art(&s.doc()?.doc, vec![source], C)?; + } // A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a // move clears them all; as the target, each of them other than a source takes the appearance. let sources = look_holders(&s.doc()?.doc, source); @@ -1239,7 +1265,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { // A graph series group's own Contents row, not its paths, which have none. - let ids = appearance_targets(s, p)?; + let ids = edit_targets(s, p)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } @@ -1441,6 +1467,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { (Some(clicked), leaf_targets(s, &selected)?) }; targets.retain(|id| Some(*id) != source); + let mut targets = without_design_art(&s.doc()?.doc, targets, C)?; let node = match source { Some(id) => Arc::new(s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone()), None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 17fcb63fe2..6ee3905d95 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at}; +use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at}; use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; @@ -49,7 +49,7 @@ fn effect_specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", has_doc, apply ), @@ -298,7 +298,7 @@ fn ids_json(ids: &[NodeId]) -> Value { /// edits its equal item, or its equal object-level effect, each target matched on its own whatever /// the other targets have; in a target without them, `item` and `index` stand for themselves. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p)?; let d = &s.doc()?.doc; let per = item.per_target(s, &roots); let first = roots.first().and_then(|f| d.node(*f)); @@ -453,7 +453,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - let targets = appearance_targets(s, p)?; + let targets = edit_targets(s, p)?; for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; @@ -492,8 +492,9 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -606,7 +607,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let src = item_target_at(s, p, "fromItem", C)?; let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = switch_or(p, "copy", false, C)?; - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p)?; // Where an aligned effect would go in each of several different stacks isn't one place. if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 3f95ae0518..9f4b89d2f4 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{Effect, Node}; use vectorcraft_render::effects; -use super::super::appearance::{appearance_targets, index_param, item_target}; +use super::super::appearance::{edit_targets, index_param, item_target}; use super::super::brushsym::{normalize, selection_art, store_symbol}; use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_doc, has_selection, str_param}; use super::{effect_targets, ids_json}; @@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { effect_targets(s, p, item, index, C)? } else { let mut out = vec![]; - for id in appearance_targets(s, p)? { + for id in edit_targets(s, p)? { let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; out.push((id, item, index)); } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e283afd752..20332742bd 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,8 +46,9 @@ use generate::generate; use series::default_series_color; pub(crate) use series::{ LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, - capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs, - paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint, + capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art, + label_object_opacity_differs, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, + shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index bfde9e881a..51a7ead550 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -131,7 +131,7 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { } let mut out = Vec::new(); for id in ids { - // The series' own marks: the art of a marker design inside it keeps the design's paint (painting it would + // The series' own marks: the art of a graph design inside it keeps the design's paint (painting it would // last only until the graph is drawn again), so edit the design to change it. match members.get(id) { Some(r) if r.group == *id => { @@ -139,7 +139,7 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id)); } } - Some(r) if doc.parent_of(*id) != Some(r.group) => {} + Some(r) if doc.parent_of(*id) != Some(r.group) || doc.node(*id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. })) => {} _ => out.push(*id), } } @@ -157,18 +157,11 @@ pub(crate) fn series_group_marks(doc: &Document, ids: &[NodeId]) -> Vec /// [`series_group_marks`] for the series groups `stands` accepts; the others pass through. pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: impl Fn(&Node) -> bool) -> Vec { - // A series group is a child of a graph with one of the graph's series indexes ([`series_members`]). Each id is - // looked up through its parent, with no pass over the document: the panels call this while they draw. - let is_series_group = |n: &Node| { - let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false }; - let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref()); - graph.is_some_and(|spec| index < series_count(spec)) - }; let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for id in ids { match doc.node(*id) { - Some(group) if is_series_group(group) && stands(group) => { + Some(group) if is_series_group(doc, group) && stands(group) => { let marks: Vec = group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id).collect(); if marks.is_empty() { out.push(*id) } else { out.extend(marks) } @@ -180,6 +173,26 @@ pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: i out } +/// Whether `n` is a graph series group: a child of a graph with one of the graph's series indexes ([`series_members`]). +/// It is looked up through its parent, with no pass over the document: the panels call this while they draw. +fn is_series_group(doc: &Document, n: &Node) -> bool { + let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false }; + let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref()); + graph.is_some_and(|spec| index < series_count(spec)) +} + +/// Whether `id` is the art of a graph design drawn in a graph series (Object › Graph › Column or Marker): an object +/// inside a series group other than a path directly in it. The graph draws that art again from the design. +pub(crate) fn is_design_art(doc: &Document, id: NodeId) -> bool { + let Some(chain) = doc.ancestry(id) else { return false }; + let Some(at) = chain.iter().position(|a| doc.node(*a).is_some_and(|n| is_series_group(doc, n))) else { return false }; + match chain.len() - at { + 1 => false, + 2 => doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. })), + _ => true, + } +} + /// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint). /// With no such row: no paint. pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index bcd6f406ed..7c085fe11f 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -373,8 +373,8 @@ fn removing_a_series_marks_fill_forgets_its_kept_opacity() { assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); } -/// An opacity set on a row added to a graph design's copy inside a series is not kept for the series: the graph draws -/// the design again from the design itself. +/// A row or a row opacity is not given to a graph design's copy inside a series, and the graph keeps no opacity for the +/// series: the graph draws the design again from the design itself. #[test] fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() { let mut s = Session::new(); @@ -384,11 +384,143 @@ fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() { s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); assert!(matches!(copy.kind, NodeKind::Group { .. })); - s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap(); - s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).unwrap(); + assert!(s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).is_err()); + assert!(s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).is_err()); assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none())); } +/// A column graph whose series 0 draws the design "Square": the graph, series 0's first copy of the design, a path +/// inside that copy, and the first mark of series 1, drawn plain. +fn design_art() -> (Session, NodeId, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Square"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let copy = series(&n, 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + let mut art = vec![]; + copy.walk(&mut |m| { + if matches!(m.kind, NodeKind::Path { .. }) { + art.push(m.id); + } + }); + let mark = series(&n, 1).children().unwrap()[0].clone(); + assert!(matches!(mark.kind, NodeKind::Path { .. })); + (s, id, copy.id, art[0], mark.id) +} + +/// `cmd` with `p` is refused as an edit of graph design art, and the document keeps its undo history. +fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) { + let steps = s.doc().unwrap().history.undo.len(); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("drawn again from the design"), "{cmd}: {e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); +} + +/// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by +/// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design. +#[test] +fn appearance_commands_refuse_a_graph_designs_art_in_a_series() { + let (mut s, _, copy, art, _) = design_art(); + let (rect, _) = blue_style(&mut s, "Blue", false); + let graphic = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [graphic]})).unwrap(); + let fill = top_row(&s, art, true); + for ids in [json!([copy.0]), json!([art.0])] { + refused_on_design_art( + &mut s, + "graphicStyle.addFromLibrary", + json!({"library": "painted-sketched", "name": "Wet Ink", "apply": true, "ids": ids}), + ); + for cmd in ["appearance.addFill", "appearance.addStroke", "appearance.clear", "appearance.reduceToBasic", "appearance.showAllHidden"] { + refused_on_design_art(&mut s, cmd, json!({"ids": ids})); + } + refused_on_design_art(&mut s, "appearance.moveItem", json!({"ids": ids, "from": "contents", "to": 0})); + refused_on_design_art(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": ids})); + refused_on_design_art(&mut s, "appearance.addEffect", json!({"effect": "stylize.dropShadow", "ids": ids})); + refused_on_design_art(&mut s, "effect.apply", json!({"effect": "threeD.extrude", "item": null, "ids": ids})); + refused_on_design_art(&mut s, "effect.move", json!({"ids": ids, "from": 0, "to": 0})); + refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0})); + refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"})); + refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids})); + } + for cmd in ["appearance.removeItem", "appearance.duplicateItem"] { + refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill})); + } + refused_on_design_art(&mut s, "appearance.setItem", json!({"ids": [art.0], "index": fill, "color": "#00ff00"})); + refused_on_design_art(&mut s, "appearance.moveItem", json!({"ids": [art.0], "from": fill, "to": 0})); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "item": fill, "opacity": 40})); + refused_on_design_art(&mut s, "appearance.copyFrom", json!({"source": rect.0, "ids": [art.0]})); + refused_on_design_art(&mut s, "appearance.transfer", json!({"source": rect.0, "target": art.0, "copy": true})); + refused_on_design_art(&mut s, "appearance.transfer", json!({"source": art.0, "target": rect.0})); + // Selected, and through the contents of the design's copy. + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + refused_on_design_art(&mut s, "appearance.addFill", json!({})); + refused_on_design_art(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink", "apply": true})); + refused_on_design_art(&mut s, "appearance.addStroke", json!({"ids": [copy.0], "target": "contents"})); + // A Pathfinder effect does not group the selected paths of the design's copy. + let mut paths = vec![]; + s.doc().unwrap().doc.node(copy).unwrap().walk(&mut |m| { + if matches!(m.kind, NodeKind::Path { .. }) { + paths.push(m.id.0); + } + }); + assert_eq!(paths.len(), 2); + s.execute("select.set", &json!({"ids": paths})).unwrap(); + refused_on_design_art(&mut s, "effect.apply", json!({"effect": "pathfinder.add"})); + // Read as a source, the art gives its look. + s.execute("appearance.transfer", &json!({"source": art.0, "target": rect.0, "copy": true})).unwrap(); +} + +/// Given the art of a graph design in a series with other objects, the commands edit the other objects only. +#[test] +fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() { + let (mut s, id, copy, art, mark) = design_art(); + let (rect, _) = blue_style(&mut s, "Blue", false); + let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + let (copy_was, art_was, mark_was) = (rows(&s, copy), rows(&s, art), rows(&s, mark)); + s.execute("appearance.addFill", &json!({"ids": [copy.0, mark.0]})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone())); + assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0]})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": rect.0, "ids": [art.0, mark.0]})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_ne!(rows(&s, mark), mark_was); + s.execute("edit.undo", &json!({})).unwrap(); + // The graph's contents: series 1's marks take the fill, the design's art does not. + s.execute("appearance.addFill", &json!({"ids": [id.0], "target": "contents"})).unwrap(); + assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was)); + assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); +} + +/// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item +/// included. +#[test] +fn paint_targets_leave_a_graph_designs_copy_out() { + let (s, _, copy, art, mark) = design_art(); + let d = &s.doc().unwrap().doc; + assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]); +} + +/// The art of a marker design in a series is refused too. +#[test] +fn effect_commands_refuse_a_marker_designs_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + save_design(&mut s, "Pill"); + let id = graph(&mut s, "line"); + s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap(); + let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone(); + assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art"); + refused_on_design_art(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [marker.id.0]})); +} + /// A series' kept opacity goes on its generated marks only: a column design made of one path keeps its own opacity. #[test] fn a_column_design_keeps_its_own_opacity_under_a_series_opacity() { @@ -1627,8 +1759,8 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); assert!(matches!(copy.kind, NodeKind::Group { .. })); - s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap(); - s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).unwrap(); + assert!(s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).is_err()); + assert!(s.execute("appearance.setItem", &json!({"ids": [copy.id.0], "index": 0, "color": "#0000ff"})).is_err()); assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); // A path inside the design's copy is a member of the series too, and its paint is not kept either. let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); @@ -1639,7 +1771,7 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { } }); let art = paths[0]; - s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).unwrap(); + assert!(s.execute("appearance.setItem", &json!({"ids": [art.0], "index": top_row(&s, art, true), "color": "#00ff00"})).is_err()); assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill.is_none())); let mut s = Session::new(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 1c41006b3f..370664f795 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); let chars = s.prefs.override_char_color; - let own = super::appearance::appearance_targets(s, p)?; + let own = super::appearance::edit_targets(s, p)?; let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own); // The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves, // and a style added on top unlinks them. diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 2a9f480e89..09b478e8c7 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -161,11 +161,13 @@ fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "graphicStyle.addFromLibrary"; let (info, lib) = library_param(s, p, C)?; let (apply, add) = (switch_or(p, "apply", false, C)?, switch_or(p, "add", false, C)?); - if p.get("ids").is_some_and(|v| !v.is_null()) { + let ids_given = p.get("ids").is_some_and(|v| !v.is_null()); + if ids_given { checked_ids_param(s, p, "ids", C)?; - } else if apply { - // A selection that can't take a style is refused before any style is added. - super::appearance::appearance_targets(s, &json!({}))?; + } + if apply { + // Objects that can't take a style are refused before any style is added. + super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/docs/mcp.md b/docs/mcp.md index 0b636bfd61..5039cfc5be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -795,6 +795,16 @@ The other commands given a series group's id edit the group itself, and the grap `appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. +The graph draws the art of a column or marker design in a series again from the design, so the commands that add, +change, move or remove fill and stroke rows, effects or graphic styles leave that art out: the `appearance.*` commands +listed above, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, move or remove an +effect, `graphicStyle.apply` (and `graphicStyle.addFromLibrary` with `apply`), `transparency.set` with a fill or stroke +`item`, and `appearance.copyFrom` as the target. The art is any object inside a series group other than a path directly +in it, whether given by id, selected, or reached through `target: "contents"`. The commands edit the other objects they +are given, and a command whose objects are all design art is an error. `appearance.transfer` with design art as its +`target`, or as its `source` without `copy`, is an error. The paint commands leave the art out as well, without an +error. To change the art, edit the design with `graph.design`. + The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient made, placed or edited with `paint.editGradient`, `paint.setGradientGeom`, `paint.freeform.addPoint`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ae723dc35..2cdf8734f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1097,7 +1097,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.