diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 570dc855..d8214a04 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, …}", + "{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …}", has_doc, info ), @@ -43,16 +43,15 @@ fn paint_kind(p: &Paint) -> Option<&'static str> { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let d = &st.doc; - let roots: Vec<&Node> = if bool_or(p, "selectionOnly", false) { - st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() - } else { - d.layers.iter().map(|l| &**l).collect() - }; + let selection_only = bool_or(p, "selectionOnly", false); + let roots: Vec<&Node> = + if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() }; let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut fonts = BTreeSet::new(); let mut images: BTreeMap = BTreeMap::new(); let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); + let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; for r in roots { r.walk(&mut |n| { let k = match &n.kind { @@ -104,6 +103,12 @@ fn info(s: &mut Session, p: &Value) -> Result { if !n.appearance.effects.is_empty() { *counts.entry("liveEffects").or_default() += 1; } + if selection_only + && let Some(g) = super::style::linked_style(d, n) + && !styles.contains(&g.name.as_str()) + { + styles.push(&g.name); + } }); } Ok(json!({ @@ -115,6 +120,7 @@ fn info(s: &mut Session, p: &Value) -> Result { "spotColors": spot, "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), + "graphicStyleNames": styles, "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), "patterns": d.patterns.len(), diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index a84b6fa0..086bdfb0 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -75,6 +75,24 @@ pub fn specs() -> Vec { cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n .path_data() .is_some_and(|p| !p.is_closed()))), + cmd!( + "select.same.graphicStyle", + "Graphic Style", + ["Select", "Same"], + None, + "{} the objects linked to the graphic style the first selected object is linked to", + has_selection, + same_graphic_style + ), + cmd!( + "select.same.appearanceAttribute", + "Appearance Attribute", + ["Select", "Same"], + None, + "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill", + has_selection, + same_attribute + ), ] } @@ -224,20 +242,68 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result { fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result { let st = s.doc()?; let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; + select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn)) +} + +/// Select the visible, unlocked objects in visible, unlocked layers that `f` accepts (not looking +/// inside accepted ones); Select > Reselect repeats `cmd` with `p`. +fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result { + fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec) { + if n.visible && !n.locked && !n.is_layer() && f(d, n) { + return ids.push(n.id); + } + for c in n.children().into_iter().flatten() { + visit(d, c, f, ids); + } + } + let st = s.doc()?; let mut ids = vec![]; for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) { - l.walk(&mut |n| { - if !n.is_container() && n.visible && !n.locked && eq(n, &refn) { - ids.push(n.id); - } - }); + visit(&st.doc, l, &f, &mut ids); } - let c = cmd.to_string(); s.select(|_, sel| sel.set(ids.iter().copied()))?; - s.doc_mut()?.last_selection_cmd = Some((c, json!({}))); + s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone())); Ok(json!({ "count": ids.len() })) } +fn same_graphic_style(s: &mut Session, _: &Value) -> Result { + let cmd = "select.same.graphicStyle"; + let st = s.doc()?; + let id = st + .selection + .objects + .first() + .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) + .map(|g| g.id) + .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; + select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) +} + +/// An appearance attribute Select > Same > Appearance Attribute matches. +enum Attribute { + Item(vectorcraft_doc::AppearanceItem), + Effect(vectorcraft_doc::Effect), +} + +fn same_attribute(s: &mut Session, p: &Value) -> Result { + let cmd = "select.same.appearanceAttribute"; + let st = s.doc()?; + let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let ap = &refn.appearance; + let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item()); + let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) { + (Some(it), _) => Attribute::Item(it.clone()), + (None, Some(e)) => Attribute::Effect(e.clone()), + (None, None) => { + Attribute::Item(ap.items.iter().rev().find(|i| i.is_fill()).cloned().ok_or_else(|| bad(cmd, "the object has no fill, stroke or effect"))?) + } + }; + select_where(s, cmd, p, |_, n| match &attr { + Attribute::Item(it) => n.appearance.items.contains(it), + Attribute::Effect(e) => n.appearance.effects.iter().any(|x| x.id == e.id && x.params == e.params), + }) +} + fn same_layers(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let layers: BTreeSet = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect(); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index fcfa609e..b1a9ba1c 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -198,10 +198,14 @@ fn deleting_a_style_unlinks_and_renaming_keeps_links() { let (_, b, c) = linked_three(&mut s); run(&mut s, "graphicStyle.rename", json!({"name": "S", "to": "T"})); assert_eq!(linked(&mut s, "T").len(), 3); + run(&mut s, "select.set", json!({"ids": [b.0]})); + assert_eq!(run(&mut s, "select.same.graphicStyle", json!({}))["count"], 3); let look = node(&s, c).appearance; run(&mut s, "graphicStyle.delete", json!({"name": "T"})); assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None)); assert_eq!(node(&s, c).appearance, look); + run(&mut s, "select.set", json!({"ids": [b.0]})); + assert!(s.execute("select.same.graphicStyle", &json!({})).is_err()); } #[test] @@ -218,3 +222,32 @@ fn unused_styles_and_sort_by_name() { let names: Vec = s.doc().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect(); assert_eq!(names, ["Default Graphic Style", "Black Outline", "Heavy Ink", "S", "Sunshine"]); } + +#[test] +fn select_same_appearance_attribute() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + let b = rect(&mut s, 100.0); + set_fill(&mut s, b, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + let c = rect(&mut s, 200.0); + // The shared effect. + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + // The shared stroke (item 1). + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({"item": 1})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b, c]); +} + +#[test] +fn document_info_lists_style_names() { + let mut s = session(); + let (a, ..) = linked_three(&mut s); + let i = run(&mut s, "document.info", json!({})); + assert_eq!(i["graphicStyleNames"].as_array().unwrap().len(), 5); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert_eq!(run(&mut s, "document.info", json!({"selectionOnly": true}))["graphicStyleNames"], json!(["S"])); +} diff --git a/crates/ui-egui/src/dialogs/graphic_style_options.rs b/crates/ui-egui/src/dialogs/graphic_style_options.rs new file mode 100644 index 00000000..33d0982d --- /dev/null +++ b/crates/ui-egui/src/dialogs/graphic_style_options.rs @@ -0,0 +1,97 @@ +//! Graphic Style Options: the style's name. Opened on a style it renames it (`graphicStyle.rename`, +//! linked objects stay linked); opened for New Graphic Style it names the style made from the +//! selection (`graphicStyle.new`). +//! +//! Fields: `name` and `__style` (the style being renamed; empty for a new style). + +use serde_json::{Value, json}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +/// The dialog kind of Graphic Style Options. +pub const KIND: &str = "graphicStyleOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Graphic Style Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open Graphic Style Options for style `name`, or (`None`) to name a new style from the selection. +pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result { + let d = &app.session.active().ok_or("no document open")?.doc; + let name = match name { + Some(n) => d.graphic_style(n).ok_or_else(|| format!("no graphic style `{n}`"))?.name.clone(), + None => d.new_graphic_style_name(), + }; + let style = if d.graphic_style(&name).is_some() { name.clone() } else { String::new() }; + app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name, "__style": style }))); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + grid(ui, |ui| { + label(ui, "Style Name:"); + form::text(ui, d, "name", 190.0); + ui.end_row(); + }); + false +} + +/// OK: name the new style or rename the style. A name another style has keeps the dialog open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (name, style) = (d.str("name"), d.str("__style")); + let r = match style.as_str() { + "" => app.run("graphicStyle.new", json!({ "name": name })), + s if s == name => Ok(Value::Null), + s => app.run("graphicStyle.rename", json!({ "name": s, "to": name })), + }; + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app + } + + fn set(app: &mut VectorcraftApp, v: &str) { + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(v)); + } + + fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() + } + + #[test] + fn names_a_new_style_then_renames_it() { + let mut app = app(); + app.run("ui.graphicStyleOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Graphic Style 5"); + // Drawn headlessly in the shared frame. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + set(&mut app, "Glow"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && names(&app).last().unwrap() == "Glow"); + + app.run("ui.graphicStyleOptions", json!({"name": "Glow"})).unwrap(); + set(&mut app, "Sunshine"); + assert!(super::super::confirm(&mut app).is_err(), "names are unique"); + assert!(app.ui.dialog.is_some(), "a taken name keeps the dialog open"); + set(&mut app, "Halo"); + super::super::confirm(&mut app).unwrap(); + assert_eq!(names(&app).last().unwrap(), "Halo"); + assert!(app.run("ui.graphicStyleOptions", json!({"name": "nope"})).is_err()); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 5d207293..ad932134 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -17,6 +17,7 @@ mod effect; mod export_for_screens; mod form; mod gradient_stop; +pub mod graphic_style_options; pub mod new_color_group; mod new_document; pub mod new_swatch; @@ -140,6 +141,7 @@ registry! { NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, + GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f09c495..ca5e7473 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -189,6 +189,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke", ), + ( + "ui.graphicStyleOptions", + "Graphic Style Options…", + "", + "{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -524,6 +530,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_color_picker(app, p), + "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), _ => return None, }; Some(r) @@ -687,6 +694,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), + "ui.graphicStyleOptions" => app.session.active().is_some(), _ => true, } } @@ -1077,14 +1085,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Same", vec![ c("Appearance", "select.same.appearance"), - todo("Appearance Attribute"), + c("Appearance Attribute", "select.same.appearanceAttribute"), c("Blending Mode", "select.same.blendingMode"), c("Fill & Stroke", "select.same.fillAndStroke"), c("Fill Color", "select.same.fillColor"), c("Opacity", "select.same.opacity"), c("Stroke Color", "select.same.strokeColor"), c("Stroke Weight", "select.same.strokeWeight"), - todo("Graphic Style"), + c("Graphic Style", "select.same.graphicStyle"), c("Shape", "select.same.shapeType"), c("Symbol Instance", "select.same.symbolInstance"), Sep, diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 05ccd07d..066f5d37 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -175,12 +175,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); let mixed = mixed_appearances(app); let label = if mixed { "Mixed Appearances" } else { object_label(app) }; + // A linked object names its graphic style ("Rectangle: Sunshine"). + let label = match app.session.selection_graphic_style() { + Some((g, true)) if !mixed => std::borrow::Cow::Owned(format!("{label}: {}", g.name)), + _ => std::borrow::Cow::Borrowed(label), + }; if !hide_thumb { let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); chip(ui, th, &fill); } - text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), label, true); + text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true); match &node { // Objects that differ have no common stack to list. Some(_) if mixed => {} @@ -582,7 +587,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { set_pstate(ui.ctx(), "ap-hide-thumb", !hide); } ui.separator(); - menu_item(ui, "Redefine Graphic Style", false, false); + let style = app.session.selection_graphic_style().map(|(g, _)| g.name.clone()); + let redefine = style.as_ref().map_or_else(|| "Redefine Graphic Style".into(), |n| format!("Redefine Graphic Style \u{201c}{n}\u{201d}")); + if menu_item(ui, &redefine, style.is_some(), false) { + app.run("graphicStyle.redefine", json!({})).ok(); + } menu_item(ui, "Show All Hidden Attributes", false, false); } diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index fc21c292..ab959fd9 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -1,5 +1,5 @@ //! Document Info panel: the document's setup and what it contains (objects by kind, fonts, -//! images, symbols, spot colours…), for the whole document or the selection only. +//! images, symbols, spot colours, graphic styles…), for the whole document or the selection only. use egui::Ui; use serde_json::{Value, json}; @@ -71,7 +71,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, "None"); } widgets::divider(ui); - for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors")] { + for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors"), ("graphicStyleNames", "Graphic Styles")] { let list: Vec<&str> = i[key].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); widgets::subheader(ui, &format!("{title} ({})", list.len())); for f in list { @@ -87,7 +87,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); for (key, label) in [ ("swatches", "Swatches"), - ("graphicStyles", "Graphic Styles"), ("characterStyles", "Character Styles"), ("paragraphStyles", "Paragraph Styles"), ("patterns", "Pattern Swatches"), diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 38ed6692..7226a414 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -241,6 +241,9 @@ mod tests { show(&mut app, ui); menu(&mut app, ui); }); + // The Appearance panel names the style the object is linked to. + let shown = frame(&ctx, |ui| super::super::appearance::show(&mut app, ui)); + assert!(shown.iter().any(|t| t == "Rectangle: Mine"), "{shown:?}"); assert!(texts.iter().any(|t| t == "Mine") && texts.iter().any(|t| t == "Sunshine"), "{texts:?}"); // Select All Unused picks every style but the one the rectangle is linked to. let unused: Vec = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4aacf46a..d8a38197 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,3 +36,7 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern `swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`. The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. + +Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and +`ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the +dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`). diff --git a/docs/mcp.md b/docs/mcp.md index 345bdde9..b1e3f21a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,3 +196,20 @@ plain group and keeps the clipping path, unpainted. {"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}} {"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}} ``` + +## Graphic styles + +A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout. +`graphicStyle.new {name?, id?}` captures an object (a group without its own fills or strokes lends its topmost +object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the objects the style and links them; +`add: true` adds the style on top of the existing appearance instead. Linked objects stay linked while they keep the +style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}` +updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the +style of the first selected object; `select.same.graphicStyle` selects an object's fellow users. + +```json +{"name":"run_command","arguments":{"command":"graphicStyle.new","params":{"id":12,"name":"Glow"}}} +{"name":"run_command","arguments":{"command":"graphicStyle.apply","params":{"name":"Glow","ids":[15,20]}}} +{"name":"run_command","arguments":{"command":"graphicStyle.redefine","params":{"id":12}}} +{"name":"run_command","arguments":{"command":"graphicStyle.list","params":{}}} +```