From dc390aba37f15a15e4a76741a7c8f48ef9bd9b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Wed, 14 Oct 2026 12:55:36 +0100 Subject: [PATCH 1/3] G6: graph variables (CSV data merge) VariableKind::Graph binds a graph root; dataset rows supply CSV as graph.setData. Make Graph Dynamic, Variables dialog, capture/apply, and pt-br for the new strings. Co-authored-by: Cursor --- crates/doc/src/graph.rs | 26 ++++++++++++++++ crates/doc/src/variables.rs | 14 +++++++-- crates/engine/src/cmd/graph.rs | 39 ++++++++++-------------- crates/engine/src/cmd/variablecmds.rs | 40 +++++++++++++++++++------ crates/engine/src/tests_variables.rs | 35 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/variables.rs | 35 +++++++++++++++------- crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/panels/variables.rs | 16 ++++++++-- docs/gaps.md | 3 ++ docs/mcp.md | 3 ++ docs/target-app-parity.md | 2 ++ 11 files changed, 167 insertions(+), 47 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 5dc018401..37329ec62 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -422,6 +422,32 @@ impl GraphSpec { cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect(); self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect(); } + + /// CSV of this graph's data (header row with series names, then category + values), as Graph + /// Data and `graph.setData` use. Always writes a header so a blank first category isn't + /// misread as one. + pub fn to_csv(&self) -> String { + let cells = self.cells(); + let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len()); + let mut out = String::from(","); + out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::>().join(",")); + out.push('\n'); + let labelled = !self.categories.is_empty(); + for (i, r) in cells.iter().enumerate() { + if labelled { + let c = self.categories.get(i).map(String::as_str).unwrap_or_default(); + if c.trim().is_empty() || c.trim().parse::().is_ok() || c != c.trim() { + out.push_str(&format!("\"{c}\"")); + } else { + out.push_str(c); + } + out.push(','); + } + out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(",")); + out.push('\n'); + } + out + } } impl Default for GraphSpec { diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 3a5f3d908..1147268cd 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -19,16 +19,19 @@ pub enum VariableKind { Visibility, /// A linked image's file path (the pixels swap; the object's bounds stay). Image, + /// A graph object's data (CSV of categories and series, as Graph Data uses). + Graph, } impl VariableKind { /// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted - /// as the same thing, as the panel and the docs call it both), or `image`. + /// as the same thing, as the panel and the docs call it both), `image` or `graph`. pub fn parse(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "text" => Some(Self::Text), "visibility" | "visible" => Some(Self::Visibility), "image" => Some(Self::Image), + "graph" => Some(Self::Graph), _ => None, } } @@ -39,6 +42,7 @@ impl VariableKind { Self::Text => "text", Self::Visibility => "visibility", Self::Image => "image", + Self::Graph => "graph", } } } @@ -58,6 +62,8 @@ pub enum DataValue { Visible(bool), /// Absolute path of the image file an image variable should show. Image(String), + /// CSV of a graph's categories and series (same shape as Graph Data / `graph.setData`). + Graph(String), } impl DataValue { @@ -66,7 +72,10 @@ impl DataValue { pub fn fits(&self, kind: VariableKind) -> bool { matches!( (self, kind), - (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility) | (Self::Image(_), VariableKind::Image) + (Self::Text(_), VariableKind::Text) + | (Self::Visible(_), VariableKind::Visibility) + | (Self::Image(_), VariableKind::Image) + | (Self::Graph(_), VariableKind::Graph) ) } } @@ -196,6 +205,7 @@ impl Document { (VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())), (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), (VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())), + (VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())), (VariableKind::Text | VariableKind::Image, _) => None, } } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 94c950807..4e8086393 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -127,29 +127,22 @@ fn parse_csv(csv: &str) -> (Vec, Vec, Vec>>) { } fn to_csv(g: &GraphSpec) -> String { - // Always a header row (empty first cell), so a first data row starting with a blank isn't read as one. - let cells = g.cells(); - let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len()); - let mut out = String::from(","); - out.push_str(&(0..width).map(|s| g.series.get(s).cloned().unwrap_or_default()).collect::>().join(",")); - out.push('\n'); - let labelled = !g.categories.is_empty(); - for (i, r) in cells.iter().enumerate() { - if labelled { - // An empty label, one that reads as a number or one with spaces around it goes in quotes, so it reads back - // as the same label. - let c = g.categories.get(i).map(String::as_str).unwrap_or_default(); - if c.trim().is_empty() || c.trim().parse::().is_ok() || c != c.trim() { - out.push_str(&format!("\"{c}\"")); - } else { - out.push_str(c); - } - out.push(','); - } - out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(",")); - out.push('\n'); - } - out + g.to_csv() +} + +/// Apply CSV graph data to the graph root `id` and rebuild its art (Variables data merge). +pub(crate) fn apply_csv(d: &mut Document, id: NodeId, csv: &str) -> Result<()> { + let Some(node) = d.node(id) else { + return Err(EngineError::NoNode(id)); + }; + let Some(mut spec) = node.extras().graph.as_deref().cloned() else { + return Err(bad("graph.applyCsv", format!("node {} is not a graph", id.0))); + }; + let (series, categories, rows) = parse_csv(csv); + spec.series = series; + spec.categories = categories; + spec.set_cells(rows); + regenerate(d, id, spec) } fn apply_data(g: &mut GraphSpec, p: &Value) -> bool { diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index df578ed79..31cfff228 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -1,11 +1,12 @@ //! Variables (data merge): named values bound to objects, applied per dataset. //! //! One template document produces many variants: bind a type object to a `text` -//! variable, anything to a `visibility` variable, and a linked image to an `image` -//! variable, put a row of values in each dataset, and `dataset.select` swaps the -//! whole document to that row in one undo step. `dataset.import` / `dataset.export` -//! move those rows as CSV (header: `name` then variable names). Agents reach every -//! command through `run_command`, like all engine commands. +//! variable, anything to a `visibility` variable, a linked image to an `image` +//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in +//! each dataset, and `dataset.select` swaps the whole document to that row in one undo +//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name` +//! then variable names). Agents reach every command through `run_command`, like all +//! engine commands. use std::collections::{BTreeMap, BTreeSet}; @@ -19,6 +20,8 @@ use super::*; /// The longest variable or dataset name, in characters (names arrive from MCP and files). const MAX_NAME: usize = 255; +/// Cap on a graph variable's CSV (MCP and files are untrusted). +const MAX_GRAPH_CSV: usize = 256 * 1024; /// Caps on a datasets CSV (paths and MCP payloads are untrusted). const MAX_CSV_BYTES: usize = 1_048_576; @@ -71,6 +74,9 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl Some(n) if kind == VariableKind::Image && !matches!(n.kind, NodeKind::Image(_)) => { return Err(bad(cmd, format!("node {} is not an image", id.0))); } + Some(n) if kind == VariableKind::Graph && n.extras().graph.is_none() => { + return Err(bad(cmd, format!("node {} is not a graph", id.0))); + } Some(_) => {} } } @@ -97,6 +103,11 @@ fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result< VariableKind::Visibility => v.as_bool().map(DataValue::Visible), // An image value is a path string; empty is not a file to open. VariableKind::Image => v.as_str().map(str::trim).filter(|t| !t.is_empty()).map(|t| DataValue::Image(t.to_string())), + VariableKind::Graph => v + .as_str() + .map(str::trim) + .filter(|t| !t.is_empty()) + .and_then(|t| if t.len() > MAX_GRAPH_CSV { None } else { Some(DataValue::Graph(t.to_string())) }), } .ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind"))) } @@ -118,7 +129,8 @@ fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result Result { const C: &str = "variable.define"; let name = var_name(p, C)?; - let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility` or `image`"))?; + let kind = + str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?; if s.doc()?.doc.variables.variable(&name).is_some() { return Err(bad(C, format!("variable `{name}` is already defined"))); } @@ -161,7 +173,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"from": from, "name": to})) } -/// Make Text / Visibility / Image Dynamic: define a variable of that kind and bind the +/// Make Text / Visibility / Image / Graph Dynamic: define a variable of that kind and bind the /// selection to it in one step, naming it after what it drives — the panel's buttons. fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result { // Errors name the command that ran: `variable.make{stem}Dynamic`. @@ -479,6 +491,7 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { }), // Swap the linked file; keep the object's bounds. A missing path skips. (VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path).is_ok(), + (VariableKind::Graph, DataValue::Graph(csv)) => super::graph::apply_csv(d, id, csv).is_ok(), _ => false, }; if done { @@ -740,7 +753,7 @@ pub fn specs() -> Vec { "New Variable…", ["Window", "Variables"], None, - "{name, kind: text|visibility|image} define a variable", + "{name, kind: text|visibility|image|graph} define a variable (graph: CSV data on a graph root)", has_doc, define ), @@ -790,12 +803,21 @@ pub fn specs() -> Vec { has_selection, |s, _| make_dynamic(s, VariableKind::Image, "Image") ), + cmd!( + "variable.makeGraphDynamic", + "Make Graph Dynamic", + ["Window", "Variables"], + None, + "{} define a graph variable over the selected graph root(s), named after it and bound to it; each dataset row supplies CSV as graph.setData", + has_selection, + |s, _| make_dynamic(s, VariableKind::Graph, "Graph") + ), cmd!( "variable.bind", "Bind Variable", ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", has_doc, bind ), diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 90e48b3f5..05b3cbc19 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -947,3 +947,38 @@ fn datasets_csv_round_trips_and_rejects_bad_input() { // One undo undoes the last import's replacements. s.execute("edit.undo", &json!({})).unwrap(); } + +#[test] +fn graph_variable_applies_csv_and_captures() { + let mut s = session(); + let id = s + .execute( + "graph.create", + &json!({ + "type": "column", + "x": 10, + "y": 10, + "width": 120, + "height": 80, + "csv": ",Sales\nA,1\nB,2" + }), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("variable.makeGraphDynamic", &json!({})).unwrap(); + let name = s.execute("variable.list", &json!({})).unwrap()["variables"][0]["name"].as_str().unwrap().to_string(); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"][0]["kind"], "graph"); + s.execute("dataset.new", &json!({"name": "Alt", "values": {name.clone(): ",Sales\nA,10\nB,20"}})).unwrap(); + let v = s.execute("dataset.select", &json!({"name": "Alt"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0))); + let csv = s.doc().unwrap().doc.variable_value(NodeId(id), vectorcraft_doc::VariableKind::Graph).unwrap(); + match csv { + vectorcraft_doc::DataValue::Graph(c) => assert!(c.contains("10") && c.contains("20"), "{c}"), + other => panic!("expected graph csv, got {other:?}"), + } + // Binding a non-graph is refused. + let r = rect(&mut s); + assert!(s.execute("variable.bind", &json!({"variable": name, "ids": [r.0]})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index 918bad157..ee30d81a8 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -3,7 +3,7 @@ //! //! One dialog covers all of them: what it shows is what the command takes. A variable is a //! name and a kind; a dataset is a name and one field per variable — a text field for a -//! `text` or `image` variable (path for image), a checkbox for a `visibility` one. +//! `text`, `image` (a path) or `graph` (CSV) variable, a checkbox for a `visibility` one. //! `dataset.set` opens with the row's current values, so editing a row is editing what it //! already says. @@ -20,11 +20,11 @@ use crate::widgets; pub const KIND: &str = "variables"; /// The kinds a variable can have, as `variable.define` names them. -const KINDS: [&str; 3] = ["text", "visibility", "image"]; +const KINDS: [&str; 4] = ["text", "visibility", "image", "graph"]; /// What the dialog shows for each of [`KINDS`]. -fn kind_labels() -> [&'static str; 3] { - [tl!("Text"), tl!("Visibility"), tl!("Image")] +fn kind_labels() -> [&'static str; 4] { + [tl!("Text"), tl!("Visibility"), tl!("Image"), tl!("Graph")] } pub(super) const SPEC: DialogSpec = @@ -125,9 +125,11 @@ fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap json!(t), (Some(DataValue::Visible(b)), _) => json!(b), (Some(DataValue::Image(p)), _) => json!(p), - // Without a value in the row a text/image field starts empty (no value in this row) - // and a visibility one starts shown: its false is a value, so it is never "unset". - (None, VariableKind::Text | VariableKind::Image) => json!(""), + (Some(DataValue::Graph(c)), _) => json!(c), + // Without a value in the row a text, image or graph field starts empty (no value in + // this row) and a visibility one starts shown: its false is a value, so it is never + // "unset". + (None, VariableKind::Text | VariableKind::Image | VariableKind::Graph) => json!(""), (None, VariableKind::Visibility) => json!(true), }; out.insert(value_key(&v.name), value); @@ -207,6 +209,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { VariableKind::Text | VariableKind::Image => { text(ui, d, &key, 220.0); } + VariableKind::Graph => { + text_area(ui, d, &key, 220.0); + } VariableKind::Visibility => checkbox(ui, d, &key), } ui.end_row(); @@ -250,14 +255,15 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], widt } } -/// The `values` object the dialog's fields mean: a text or image variable whose field is empty -/// carries no value in this row, and a visibility checkbox is always a value — its false, too. +/// The `values` object the dialog's fields mean: a text, image or graph variable whose field is +/// empty carries no value in this row, and a visibility checkbox is always a value — its false, +/// too. fn values_of(d: &Dialog, vars: &Variables) -> Value { let mut out = serde_json::Map::new(); for v in &vars.variables { let key = value_key(&v.name); match v.kind { - VariableKind::Text | VariableKind::Image => { + VariableKind::Text | VariableKind::Image | VariableKind::Graph => { let text = d.str(&key); if !text.is_empty() { out.insert(v.name.clone(), json!(text)); @@ -271,6 +277,15 @@ fn values_of(d: &Dialog, vars: &Variables) -> Value { Value::Object(out) } +/// A multiline field for graph CSV. +fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) { + let mut s = d.str(key); + let r = ui.add(egui::TextEdit::multiline(&mut s).desired_width(width).desired_rows(4)); + if r.changed() { + d.fields.insert(key.into(), json!(s)); + } +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let cmd = d.str("__command"); let name = d.str("name"); diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 125a9f836..f33beaf0c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3802,6 +3802,7 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + Make Graph Dynamic Tornar gráfico dinâmico Registration: Registro: # Dimension tool (linked dimension annotations) Dimension Tool Ferramenta Cota diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 84990d9a5..48d0c24ba 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -74,6 +74,8 @@ struct Picked { single_type: bool, /// Make Image Dynamic needs exactly one image object. single_image: bool, + /// Make Graph Dynamic needs a selection of graph roots only. + selection_graph: bool, } fn picked(st: &vectorcraft_engine::DocState) -> Picked { @@ -81,7 +83,8 @@ fn picked(st: &vectorcraft_engine::DocState) -> Picked { let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id))); let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))); let single_image = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Image(_)))); - Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type, single_image } + let selection_graph = !selected.is_empty() && selected.iter().all(|id| st.doc.node(NodeId(*id)).is_some_and(|n| n.extras().graph.is_some())); + Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type, single_image, selection_graph } } /// Delete Variable: the highlighted variable only (the command's default); the rest stay. @@ -131,7 +134,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }) .collect(); let active = sets.iter().find(|s| s.active); - let Picked { selected, has_selection, selection_bound, single_type, single_image } = picked(st); + let Picked { selected, has_selection, selection_bound, single_type, single_image, selection_graph } = picked(st); let highlighted = highlighted_row(st); // The current data set across the top: its name, and the arrows that step through them. @@ -167,6 +170,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { VariableKind::Text => "type", VariableKind::Visibility => "eye", VariableKind::Image => "image", + VariableKind::Graph => "chart-column", }; ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0))); // The object a variable drives is named beside it, as the reference panel does. @@ -212,6 +216,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button_enabled(ui, "image", tl!("Make Image Dynamic"), false, single_image, 24.0).clicked() { app.run("variable.makeImageDynamic", json!({})).ok(); } + if widgets::icon_button_enabled(ui, "chart-column", tl!("Make Graph Dynamic"), false, selection_graph, 24.0).clicked() { + app.run("variable.makeGraphDynamic", json!({})).ok(); + } if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() { crate::menus::invoke(app, "variable.define", json!({})); } @@ -246,7 +253,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); let highlighted = highlighted_row(st); let highlighted_bound: Vec = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default(); - let Picked { selected, has_selection, selection_bound, single_type, single_image } = picked(st); + let Picked { selected, has_selection, selection_bound, single_type, single_image, selection_graph } = picked(st); if widgets::menu_item(ui, tl!("New Variable…"), true, false) { crate::menus::invoke(app, "variable.define", json!({})); @@ -265,6 +272,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::menu_item(ui, tl!("Make Image Dynamic"), single_image, false) { app.run("variable.makeImageDynamic", json!({})).ok(); } + if widgets::menu_item(ui, tl!("Make Graph Dynamic"), selection_graph, false) { + app.run("variable.makeGraphDynamic", json!({})).ok(); + } if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) { app.run("variable.unbind", json!({"ids": selected})).ok(); } diff --git a/docs/gaps.md b/docs/gaps.md index 87f1007bb..d5dcb2e96 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -265,6 +265,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G6 Automation and scripting +- **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image variable kind and dataset import/export (graph variables land separately). Actions, Variables (text, + visibility, graph) and full agent control over MCP are done. +- **Estimate:** 8–14 h. - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, graph variable kinds. Actions, Variables (text, visibility and image), dataset CSV import/export and full agent control over kinds, dataset import/export. Actions, Variables (text, visibility and image) and full agent control over diff --git a/docs/mcp.md b/docs/mcp.md index a98423c52..83f431f1d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -986,6 +986,9 @@ already walks Image Trace. Short agent entry points: `symbol.replace`, `symbol.edit`, `symbol.duplicate`, `symbol.selectInstances {name}`. - Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`, `brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`. +- Variables: `variable.list`, `variable.define {name, kind: text|visibility|graph}`, `variable.bind {variable, ids?}`, + `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeGraphDynamic`, `variable.unbind`, + `dataset.new` / `dataset.set` / `dataset.select` (graph values are CSV as `graph.setData`). - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.unbind`, `dataset.new` / `dataset.set` / `dataset.select` (image values are file path strings; apply keeps object bounds), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 773e4be6c..e7abe1783 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -89,6 +89,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | +| Automation (Actions, Variables, scripting, batch) | 3 | 68% | a scripting surface (Illustrator: JavaScript/ExtendScript); Batch, image variables and dataset CSV open as PRs; graph variables done | 5–10 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 65% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables | 4–8 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables, dataset import/export | 6–12 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | @@ -337,6 +338,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | +| Automation | 3 | 70% | scripting surface, batch, image variables and dataset CSV (open as PRs); Variables: text, visibility and graph (CSV) bound to objects, dataset rows applied in one undo step | 4–8 | | Automation | 3 | 72% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, CSV import/export, panel and Window › Variables with dialogs; graph kinds open) | 4–7 | | Automation | 3 | 68% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; graph kinds and dataset import/export open) | 5–9 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | From 568008dd47b03168eaea560552f5c82734c64358 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 04:29:58 +0700 Subject: [PATCH 2/3] Variables: graph values go through the dataset CSV too (#1941) --- crates/engine/src/cmd/variablecmds.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 31cfff228..c3cc152fa 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -129,8 +129,7 @@ fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result Result { const C: &str = "variable.define"; let name = var_name(p, C)?; - let kind = - str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?; + let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?; if s.doc()?.doc.variables.variable(&name).is_some() { return Err(bad(C, format!("variable `{name}` is already defined"))); } @@ -598,13 +597,15 @@ fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result Ok(Some(match kind { VariableKind::Text => DataValue::Text(t.to_string()), VariableKind::Image => DataValue::Image(t.to_string()), + VariableKind::Graph if t.len() <= MAX_GRAPH_CSV => DataValue::Graph(t.to_string()), + VariableKind::Graph => return Err(bad(cmd, format!("value for `{var}` is longer than {MAX_GRAPH_CSV} bytes"))), VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, cmd)?), })) } fn value_to_cell(v: &DataValue) -> String { match v { - DataValue::Text(t) | DataValue::Image(t) => t.clone(), + DataValue::Text(t) | DataValue::Image(t) | DataValue::Graph(t) => t.clone(), DataValue::Visible(b) => if *b { "true" } else { "false" }.to_string(), } } From b3e484702f2fbe42c4e7c06e18af98ac6b96b789 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 04:37:14 +0700 Subject: [PATCH 3/3] i18n: catalog rows for the new strings (#1941) --- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 14 files changed, 14 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3ba58aba1..6b804dc9c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4914,3 +4914,4 @@ mark-style Japanese Japonské Make Image Dynamic Nastavit obraz jako dynamický Import Data Sets… Importovat sady dat… Export Data Sets… Exportovat sady dat… + Make Graph Dynamic Nastavit graf jako dynamický diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f1cfc0c00..131e64a43 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4450,3 +4450,4 @@ theme Appearance Erscheinungsbild Make Image Dynamic Bild dynamisch machen Import Data Sets… Datensätze importieren… Export Data Sets… Datensätze exportieren… + Make Graph Dynamic Diagramm dynamisch machen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c3edf0638..f792ca030 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4856,3 +4856,4 @@ mark-style Japanese Japonesas Make Image Dynamic Hacer imagen dinámica Import Data Sets… Importar conjuntos de datos… Export Data Sets… Exportar conjuntos de datos… + Make Graph Dynamic Hacer gráfico dinámico diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f545f3cb5..628bb75e9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4913,3 +4913,4 @@ theme Appearance Apparence Make Image Dynamic Rendre l'image dynamique Import Data Sets… Importer des jeux de données… Export Data Sets… Exporter des jeux de données… + Make Graph Dynamic Rendre le graphe dynamique diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 66958ee39..6313145f6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4881,3 +4881,4 @@ imageTrace Outline Körvonal Make Image Dynamic Kép dinamikussá tétele Import Data Sets… Adatkészletek importálása… Export Data Sets… Adatkészletek exportálása… + Make Graph Dynamic Diagram dinamikussá tétele diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 632a28554..394e03fdc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4490,3 +4490,4 @@ lighting Light Luce Make Image Dynamic Rendi immagine dinamica Import Data Sets… Importa set di dati… Export Data Sets… Esporta set di dati… + Make Graph Dynamic Rendi grafico dinamico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 189661b4b..9c99e4836 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4485,3 +4485,4 @@ lighting Light ライト Make Image Dynamic 画像を動的にする Import Data Sets… データセットを読み込み… Export Data Sets… データセットを書き出し… + Make Graph Dynamic グラフを動的にする diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index beecbcd93..9cd201176 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4454,3 +4454,4 @@ lighting Light 광원 Make Image Dynamic 이미지를 동적으로 만들기 Import Data Sets… 데이터 세트 가져오기… Export Data Sets… 데이터 세트 내보내기… + Make Graph Dynamic 그래프를 동적으로 만들기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3dcb72816..73ceace12 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4758,3 +4758,4 @@ mark-style Japanese Japońskie Make Image Dynamic Uczyń obraz dynamicznym Import Data Sets… Importuj zestawy danych… Export Data Sets… Eksportuj zestawy danych… + Make Graph Dynamic Uczyń wykres dynamicznym diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4021e4e65..8dac1596f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4486,3 +4486,4 @@ lighting Light Свет Make Image Dynamic Сделать изображение динамическим Import Data Sets… Импортировать наборы данных… Export Data Sets… Экспортировать наборы данных… + Make Graph Dynamic Сделать диаграмму динамической diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7f63ec4f7..68695a5a3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4470,3 +4470,4 @@ lighting Light Світло Make Image Dynamic Зробити зображення динамічним Import Data Sets… Імпортувати набори даних… Export Data Sets… Експортувати набори даних… + Make Graph Dynamic Зробити діаграму динамічною diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f0194d76d..52df84888 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4470,3 +4470,4 @@ theme Appearance Giao diện Make Image Dynamic Tạo hình ảnh động Import Data Sets… Nhập tập dữ liệu… Export Data Sets… Xuất tập dữ liệu… + Make Graph Dynamic Tạo biểu đồ động diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 79d4d9afc..7e5a99ba4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4031,3 +4031,4 @@ lighting Light 光源 Make Image Dynamic 使图像动态化 Import Data Sets… 导入数据集… Export Data Sets… 导出数据集… + Make Graph Dynamic 使图表动态化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 053e7b656..232fc4834 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3818,3 +3818,4 @@ lighting Light 光源 Make Image Dynamic 使影像動態化 Import Data Sets… 匯入資料集… Export Data Sets… 匯出資料集… + Make Graph Dynamic 使圖表動態化