Graph designs and marker designs: Object › Graph › Design… and Marker…

Graph designs are named art a graph draws in place of its default
marks. Marker designs follow
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/apply-marker-designs-to-graphs.html
and the Graph Design dialog
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/reuse-graph-designs.html

- graph.design saves the selected art as a named design (Save Design),
  pastes a copy of a design into the document, selected, to edit and
  save again (Paste Design), and deletes one; graphs drawing a deleted
  design go back to their default marks.
- graph.marker draws the data points and legend swatches of the series
  picked with Group Selection (or listed, or every series) with a
  design: its backmost object is scaled to fill the default marker's
  square, strokes, effects and pattern tiles scaling with it. Line,
  scatter and radar series; null goes back to the default square.
- Object › Graph › Design… and Marker… open dialogs listing the
  document's designs.

Own choices, as the pages don't say: the backmost object's bounds, not
only a rectangle's, set the size (a single object uses its own bounds);
Delete Design manages the list; a design keeps its own paint, so
painting a series doesn't recolour it.

Designs are stored in a new `graphDesigns` list and series designs in
`seriesMarkers`, written only when present. A graph saved as a design is
kept as the plain art it draws. A design holds at most 2,000 objects, a
document 1,000 designs, and a graph whose designs would draw more than
200,000 objects draws default squares; a file's designs are tidied the
same way on open. Global swatch edits, image relinking and placed
documents see design art like symbol art. Graphs without marker designs
draw the same art as before.
This commit is contained in:
IHAGI-c 2026-10-11 01:01:57 +09:00 committed by bflatastic
parent 2ac3ba2b86
commit 3979872283
25 changed files with 860 additions and 64 deletions

View File

@ -80,6 +80,59 @@ impl GraphKind {
}
}
/// A graph design (Object › Graph › Design…): named art that graphs draw in place of their default marks.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphDesign {
pub name: String,
pub art: std::sync::Arc<crate::Node>,
}
impl GraphDesign {
/// Most designs a document keeps, objects in one design's art, and characters in a design's name.
pub const MAX: usize = 1000;
pub const MAX_NODES: usize = 2000;
pub const MAX_NAME: usize = 64;
/// A design name cleaned up: trimmed, without control characters, at most [`Self::MAX_NAME`] long; `None` when
/// nothing is left.
pub fn clean_name(name: &str) -> Option<String> {
let n: String = name.trim().chars().filter(|c| !c.is_control()).take(Self::MAX_NAME).collect();
Some(n.trim().to_string()).filter(|n| !n.is_empty())
}
/// `art` as plain design art: a graph inside it becomes the plain group it draws as, so a marker never holds a
/// graph of its own (that its commands would then edit).
pub fn plain_art(art: &crate::Node) -> crate::Node {
let mut n = art.clone();
n.graph = None;
n.series_index = None;
if let Some(ch) = n.children_mut() {
let old = std::mem::take(ch);
*ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect();
}
n
}
}
impl crate::Document {
/// The graph designs a file gave, made safe to list and draw: at most [`GraphDesign::MAX`], each with a clean,
/// unique name, art of at most [`GraphDesign::MAX_NODES`] objects and no graph inside.
pub fn tidy_graph_designs(&mut self) {
let mut kept: Vec<GraphDesign> = vec![];
for d in std::mem::take(&mut self.graph_designs) {
if kept.len() >= GraphDesign::MAX {
break;
}
let Some(name) = GraphDesign::clean_name(&d.name) else { continue };
if kept.iter().any(|k| k.name == name) || d.art.count() > GraphDesign::MAX_NODES {
continue;
}
kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) });
}
self.graph_designs = kept;
}
}
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -200,6 +253,10 @@ pub struct GraphSpec {
pub right_axis_min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_max: Option<f64>,
/// Object › Graph › Marker: the graph design each series' data points (and legend swatch) are drawn with, by
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_markers: Vec<Option<String>>,
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
/// length, and tick marks per division (0 and 1 = one, at the labels).
#[serde(skip_serializing_if = "TickLength::is_short")]
@ -302,6 +359,7 @@ impl Default for GraphSpec {
right_ticks: 0,
right_axis_min: None,
right_axis_max: None,
series_markers: vec![],
tick_length: TickLength::Short,
tick_marks: 1,
right_tick_length: TickLength::Short,

View File

@ -69,7 +69,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
pub use graph::{GraphDesign, GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
@ -563,6 +563,9 @@ pub struct Document {
pub text_threads: Vec<Vec<NodeId>>,
#[serde(default)]
pub symbols: Vec<Symbol>,
/// Object › Graph › Design…: art graphs can draw as markers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_designs: Vec<GraphDesign>,
#[serde(default)]
pub guides: Vec<Guide>,
/// View → New View… (up to 25, like Illustrator).
@ -706,6 +709,7 @@ impl Document {
para_styles: vec![],
text_threads: vec![],
symbols: vec![],
graph_designs: vec![],
guides: vec![],
views: vec![],
saved_selections: vec![],

View File

@ -127,7 +127,12 @@ impl Document {
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
/// opacity-mask art included.
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art));
let roots = self
.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| &p.art));
for root in roots {
walk_all(root, &mut |n| {
if let NodeKind::Image(im) = &n.kind {
@ -276,8 +281,12 @@ impl Document {
walk(child, f);
}
}
let roots =
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
walk(root, &mut f);
}
@ -308,8 +317,12 @@ impl Document {
update(c, pick, f);
}
}
let roots =
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}

View File

@ -129,11 +129,19 @@ fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
/// The roots of `d`'s art: layers, symbol definitions and pattern swatches.
fn roots(d: &Document) -> impl Iterator<Item = &Arc<Node>> {
d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art))
d.layers
.iter()
.chain(d.symbols.iter().map(|s| &s.art))
.chain(d.graph_designs.iter().map(|g| &g.art))
.chain(d.patterns.iter().flat_map(|p| &p.art))
}
fn roots_mut(d: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
d.layers
.iter_mut()
.chain(d.symbols.iter_mut().map(|s| &mut s.art))
.chain(d.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(d.patterns.iter_mut().flat_map(|p| &mut p.art))
}
impl Document {
@ -495,7 +503,10 @@ impl Document {
let prefix = prefix(&p.key);
add_resources(&mut d, &e.doc, &prefix);
// The placed documents in that art, under their names here.
let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art));
let inner = std::iter::once(&e.art)
.chain(e.doc.symbols.iter().map(|s| &s.art))
.chain(e.doc.graph_designs.iter().map(|g| &g.art))
.chain(e.doc.patterns.iter().flat_map(|p| &p.art));
for root in inner {
walk_all(root, &mut |n| {
if let NodeKind::PlacedDocument(q) = &n.kind {

View File

@ -163,7 +163,12 @@ impl Document {
}
})
};
self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n));
self.layers
.iter()
.chain(self.symbols.iter().map(|s| &s.art))
.chain(self.graph_designs.iter().map(|g| &g.art))
.chain(self.patterns.iter().flat_map(|p| &p.art))
.for_each(|n| tree(n));
self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it)));
}
}

View File

@ -302,7 +302,7 @@ fn brush_set_current(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The selection as one art node (a group for several objects), ids kept.
fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
pub(crate) fn selection_art(s: &Session, p: &Value) -> Result<Option<Node>> {
let d = &s.doc()?.doc;
let ids = roots(s, p)?;
let nodes: Vec<Node> = ids.iter().filter_map(|id| d.node(*id).cloned()).collect();

View File

@ -11,7 +11,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
Appearance, CharStyle, Document, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
Appearance, CharStyle, Document, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
@ -47,6 +47,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
set_type
),
cmd!(
"graph.design",
"Design…",
["Object", "Graph"],
None,
"{save?: name (the selected art becomes a graph design; put a rectangle at the back to set the size it draws at), paste?: name (a copy of the design's art into the document, selected, to edit and save again), delete?: name (graphs drawing it go back to their default marks)}; no params → {designs: [names]}",
has_doc,
design
),
cmd!(
"graph.marker",
"Marker…",
["Object", "Graph"],
None,
"{id?, seriesIndexes?: [index], design?: name|null} draw the data points and legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, scaled so its backmost object fills the default marker's square; null = the default marker. Line, scatter and radar series. No design → {designs: [names], design: the selected series' design or null}",
has_selection,
set_marker
),
]
}
@ -294,6 +312,13 @@ impl Gen<'_> {
g.name = Some(name.into());
Arc::new(g)
}
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
match design.and_then(|a| fit_design(self.d, a, cell)) {
Some(n) => n,
None => self.path(shapes::rectangle(cell), fill, stroke, w),
}
}
/// A series group. Empty series are omitted by the caller; the index is stored on the group
/// so later paint edits find the series without counting siblings.
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
@ -324,13 +349,52 @@ fn polyline(pts: &[Point], closed: bool) -> PathData {
PathData::from_bezpath(&bp)
}
fn marker(p: Point, s: f64) -> PathData {
shapes::rectangle(Rect::from_center_size(p, (s, s)))
/// Most objects a graph's marker designs may add (each data point and legend swatch draws a copy of its design).
const MARKER_BUDGET: usize = 200_000;
/// Each series' marker design art, when the document has the design.
fn marker_designs(d: &Document, g: &GraphSpec) -> Vec<Option<Arc<Node>>> {
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
(0..nser)
.map(|s| {
let name = g.series_markers.get(s)?.as_deref()?;
d.graph_designs.iter().find(|x| x.name == name).map(|x| x.art.clone())
})
.collect()
}
/// How many objects the marker designs `designs` add to graph `g`: a copy per data point and legend swatch.
fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
let points = g.rows.len().min(vectorcraft_doc::MAX_GRAPH_CATEGORIES) + 1;
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
}
/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is
/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it.
/// Fresh ids. `None` for art with no area.
fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
let group = matches!(art.kind, NodeKind::Group { .. });
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
let frame = backmost.or_else(|| art.geometric_bounds().filter(usable))?;
let a = Affine::translate(cell.origin().to_vec2())
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
* Affine::translate(-frame.origin().to_vec2());
// Strokes, effects and pattern tiles scale with the art.
let art = super::place::transformed(art.clone(), a);
Some(Arc::new(d.reid(&art)))
}
/// Build the graph's children.
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width);
// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all
// when the designs would draw more than MARKER_BUDGET objects.
let mut designs = marker_designs(d, g);
if marker_cost(g, &designs) > MARKER_BUDGET {
designs.clear();
}
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
let mut b = Gen { d, out: vec![] };
let r = g.rect;
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
@ -411,8 +475,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
if g.mark_points {
for p in pts {
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
items.push(b.path(marker(p, 4.0), fill, stroke, w));
let paint = mark(s, true, Paint::None, 0.0);
items.push(b.marker(design(s), Rect::from_center_size(p, (4.0, 4.0)), paint));
}
}
}
@ -652,8 +716,9 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
if g.mark_points {
for p in pts.iter().flatten() {
let (fill, stroke, w) = mark(si, true, Paint::None, 0.0);
series[si].push(b.path(marker(*p, 5.0), fill, stroke, w));
let paint = mark(si, true, Paint::None, 0.0);
let n = b.marker(design(si), Rect::from_center_size(*p, (5.0, 5.0)), paint);
series[si].push(n);
}
}
}
@ -741,8 +806,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
if g.mark_points {
for p in pts.iter().flatten() {
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
let n = b.path(marker(*p, 5.0), fill, stroke, w);
let paint = mark(s, true, Paint::None, 0.0);
let n = b.marker(design(s), Rect::from_center_size(*p, (5.0, 5.0)), paint);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
@ -762,8 +827,10 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
continue;
}
let y = r.y0 + s as f64 * (LABEL_SIZE * 1.8);
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
items.push(b.path(shapes::rectangle(Rect::new(x, y, x + 8.0, y + 8.0)), fill, stroke, w));
// Series drawn with markers show their marker design in the legend.
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line;
let paint = mark(s, true, Paint::None, 0.0);
items.push(b.marker(design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
labels.push(b.text(Point::new(x + 12.0, y + 7.5), &label, Justify::Left));
}
}
@ -901,13 +968,16 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
}
let mut out = Vec::new();
for id in ids {
match members.get(id).filter(|r| r.group == *id).and_then(|_| doc.node(*id)) {
Some(group) => group.walk(&mut |m| {
if matches!(m.kind, NodeKind::Path { .. }) {
out.push(m.id);
// The series' own marks: the art of a marker 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 => {
if let Some(group) = doc.node(*id) {
out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id));
}
}),
None => out.push(*id),
}
Some(r) if doc.parent_of(*id) != Some(r.group) => {}
_ => out.push(*id),
}
}
out.sort_unstable();
@ -926,7 +996,8 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
let captures: Vec<_> = ids
.iter()
.filter_map(|id| {
let r = members.get(id)?;
// Only a series' own marks set its paint, not the art of a marker design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id)?;
let paint = node.appearance.paint_at(None, fill)?.clone();
let width = (!fill).then(|| node.appearance.stroke_width());
@ -964,25 +1035,14 @@ fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.setType";
let id = target(s, p, C)?;
let mut spec = spec_of(s, id)?;
// The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group
// Selection). Anything else selected, the graph itself for one, is the whole graph.
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
// A file's list of right-axis series, kept to series the graph has, at most once each.
spec.right_series.retain(|i| *i < count);
spec.right_series.sort_unstable();
spec.right_series.dedup();
let picked: Vec<usize> = match p.get("seriesIndexes") {
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
Ok(match p.get("seriesIndexes") {
Some(v) => {
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(C, "`seriesIndexes` is a non-empty list of series indexes"))?;
if str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`"));
}
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`seriesIndexes` is a non-empty list of series indexes"))?;
a.iter()
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(C, format!("no series {i}"))))
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(cmd, format!("no series {i}"))))
.collect::<Result<_>>()?
}
None if p.get("id").is_none() => {
@ -995,7 +1055,167 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
out
}
None => vec![],
})
}
/// Select the series groups `indexes` of graph `id` (they are new nodes after a regeneration).
fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeId, indexes: &[u32]) {
let groups: Vec<NodeId> = d
.node(id)
.and_then(Node::children)
.into_iter()
.flatten()
.filter(|c| c.series_index.is_some_and(|i| indexes.contains(&i)))
.map(|c| c.id)
.collect();
sel.set(groups);
}
/// The document's graph design names, in order.
fn design_names(d: &Document) -> Vec<String> {
d.graph_designs.iter().map(|x| x.name.clone()).collect()
}
/// Regenerate the graphs whose series use design `name` (it was added or removed).
fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> {
let mut ids = vec![];
d.walk(|n| {
if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
ids.push(n.id);
}
});
for id in ids {
if let Some(spec) = d.node(id).and_then(|n| n.graph.as_deref().cloned()) {
regenerate(d, id, spec)?;
}
}
Ok(())
}
fn design(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.design";
for key in ["save", "paste", "delete"] {
if p.get(key).is_some_and(|v| !v.is_string()) {
return Err(bad(C, format!("`{key}` is a design name")));
}
}
if let Some(name) = str_param(p, "save") {
let name = GraphDesign::clean_name(name).ok_or_else(|| bad(C, "a design needs a name"))?;
if s.doc()?.doc.graph_designs.iter().any(|x| x.name == name) {
return Err(bad(C, format!("there is already a design named `{name}`")));
}
if s.doc()?.doc.graph_designs.len() >= GraphDesign::MAX {
return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX)));
}
let art = super::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?;
if art.count() > GraphDesign::MAX_NODES {
return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES)));
}
// A graph saved as a design is kept as the art it draws.
let art = GraphDesign::plain_art(&art);
let saved = name.clone();
s.edit("Graph Design", |d, _| {
let art = d.reid(&art);
d.graph_designs.push(GraphDesign { name: saved.clone(), art: Arc::new(art) });
refresh_graphs_using(d, &saved)
})?;
return Ok(json!({ "name": name }));
}
if let Some(name) = str_param(p, "paste") {
let art = s
.doc()?
.doc
.graph_designs
.iter()
.find(|x| x.name == name)
.map(|x| x.art.clone())
.ok_or_else(|| bad(C, format!("no design named `{name}`")))?;
let parent = s.doc()?.insertion_parent();
let id = s.edit("Paste Design", |d, sel| {
let n = d.reid(&art);
let id = n.id;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})?;
return Ok(json!({ "id": id.0 }));
}
if let Some(name) = str_param(p, "delete") {
if !s.doc()?.doc.graph_designs.iter().any(|x| x.name == name) {
return Err(bad(C, format!("no design named `{name}`")));
}
let name = name.to_string();
s.edit("Delete Design", |d, _| {
d.graph_designs.retain(|x| x.name != name);
refresh_graphs_using(d, &name)
})?;
return ok();
}
Ok(json!({ "designs": design_names(&s.doc()?.doc) }))
}
fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.marker";
let id = target(s, p, C)?;
let mut spec = spec_of(s, id)?;
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
let picked = picked_series(s, p, id, count, C)?;
let names = design_names(&s.doc()?.doc);
let Some(v) = p.get("design") else {
// The design the picked series (all of them with none picked) share, or null.
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked };
// A design deleted since doesn't count: those series draw the default marker.
let used: Vec<Option<&String>> =
all.iter().map(|i| spec.series_markers.get(*i).and_then(Option::as_ref).filter(|n| names.contains(n))).collect();
let shared = used.first().copied().flatten().filter(|d| used.iter().all(|x| *x == Some(*d)));
return Ok(json!({ "designs": names, "design": shared }));
};
let design = match v {
Value::Null => None,
Value::String(n) if names.contains(n) => Some(n.clone()),
Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))),
_ => return Err(bad(C, "`design` is a design name, or null for the default marker")),
};
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
let targets: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked };
spec.series_markers.truncate(count);
spec.series_markers.resize(count, None);
for i in targets {
if let Some(m) = spec.series_markers.get_mut(i) {
*m = design.clone();
}
}
while spec.series_markers.last().is_some_and(Option::is_none) {
spec.series_markers.pop();
}
if marker_cost(&spec, &marker_designs(&s.doc()?.doc, &spec)) > MARKER_BUDGET {
return Err(bad(C, "the design is too big to draw at every data point of this graph"));
}
s.edit("Graph Marker", |d, sel| {
regenerate(d, id, spec)?;
if !reselect.is_empty() {
reselect_series(d, sel, id, &reselect);
}
Ok(())
})?;
Ok(json!({ "id": id.0 }))
}
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.setType";
let id = target(s, p, C)?;
let mut spec = spec_of(s, id)?;
// The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group
// Selection). Anything else selected, the graph itself for one, is the whole graph.
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
// A file's list of right-axis series, kept to series the graph has, at most once each.
spec.right_series.retain(|i| *i < count);
spec.right_series.sort_unstable();
spec.right_series.dedup();
if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`"));
}
let picked = picked_series(s, p, id, count, C)?;
let keys = [
"type",
"seriesIndexes",
@ -1133,15 +1353,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Graph Type", |d, sel| {
regenerate(d, id, spec)?;
if !reselect.is_empty() {
let groups: Vec<NodeId> = d
.node(id)
.and_then(Node::children)
.into_iter()
.flatten()
.filter(|c| c.series_index.is_some_and(|i| reselect.contains(&i)))
.map(|c| c.id)
.collect();
sel.set(groups);
reselect_series(d, sel, id, &reselect);
}
if let Some(n) = d.node_mut(id)
&& n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph"))
@ -2273,4 +2485,253 @@ mod tests {
assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len());
assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked");
}
/// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`.
fn save_design(s: &mut Session, name: &str) {
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r, e]})).unwrap();
s.execute("graph.design", &json!({"save": name})).unwrap();
}
/// The marks of series `index` (legend swatch last) and their bounds.
fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_doc::Node> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect()
}
#[test]
fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
let id = graph(&mut s, "line");
let before = series_parts(&s, id, 1);
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
let after = series_parts(&s, id, 1);
assert_eq!(after.len(), before.len(), "a line, three markers and the swatch");
for (old, new) in before.iter().zip(&after).skip(1) {
let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap());
assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art");
// The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide.
assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}");
assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}");
}
// The series stays selected; the other series keeps its squares; the query names the design.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill");
assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]);
// Back to the default square.
s.execute("graph.marker", &json!({"design": null})).unwrap();
assert!(spec(&s, id).series_markers.is_empty());
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err());
assert!(s.execute("graph.marker", &json!({"design": 3})).is_err());
}
#[test]
fn marker_designs_apply_to_markers_not_to_columns() {
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, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
// Every series (none picked): the line series' markers and swatch; the columns and their swatch stay.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
let line = series_parts(&s, id, 1);
assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch");
let swatch = line.last().unwrap().geometric_bounds().unwrap();
assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}");
// Scatter and radar graphs draw them too.
for ty in ["scatter", "radar"] {
let g = graph(&mut s, ty);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}");
}
}
#[test]
fn graph_designs_are_saved_pasted_and_deleted() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
assert!(s.execute("graph.design", &json!({"save": "Pill"})).is_err(), "nothing selected");
save_design(&mut s, "Pill");
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
assert!(s.execute("graph.design", &json!({"save": "Pill"})).is_err(), "the name is taken");
assert!(s.execute("graph.design", &json!({"save": " "})).is_err(), "no name");
// Paste Design: a copy of the art, selected, with ids of its own.
let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]);
let art = s.doc().unwrap().doc.graph_designs[0].art.clone();
let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone();
assert_ne!(copy.id, art.id);
assert_eq!(copy.geometric_bounds(), art.geometric_bounds());
assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err());
// Deleting a design draws its graphs' default markers again; undo brings both back.
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
s.execute("graph.design", &json!({"delete": "Pill"})).unwrap();
assert!(s.doc().unwrap().doc.graph_designs.is_empty());
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
s.execute("edit.undo", &json!({})).unwrap();
assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })));
assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err());
}
#[test]
fn designs_and_markers_survive_save_and_open() {
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!({"seriesIndexes": [0], "design": "Pill"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]);
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none());
// A design whose art has no area draws the default square.
let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() };
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) });
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
}
#[test]
fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() {
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!({"design": "Pill"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.design", &json!({"save": "Chart"})).unwrap();
let art = s.doc().unwrap().doc.graph_designs[1].art.clone();
let mut graphs = 0;
art.walk(&mut |n| graphs += usize::from(n.graph.is_some() || n.series_index.is_some()));
assert_eq!(graphs, 0, "no graph inside a design");
// The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit.
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap();
s.execute("graph.design", &json!({"delete": "Pill"})).unwrap();
let mut found = 0;
s.doc().unwrap().doc.walk(|n| found += usize::from(n.graph.is_some()));
assert_eq!(found, 1);
// A deleted design reads as none in the query, so the dialog opens on the default marker.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap();
let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap();
assert!(v["design"].is_null(), "{v}");
}
#[test]
fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 1,500 objects at each of 200 points is past the budget.
let ids: Vec<u64> = (0..1500)
.map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap())
.collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"save": "Big"})).unwrap();
let rows: Vec<Vec<f64>> = (0..200).map(|i| vec![i as f64]).collect();
let id = NodeId(
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err());
assert!(spec(&s, id).series_markers.is_empty());
// A file asking for it anyway draws the default squares.
let mut g = spec(&s, id);
g.series_markers = vec![Some("Big".into())];
let mut d = (*s.doc().unwrap().doc).clone();
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Too many objects for one design.
let ids: Vec<u64> = (0..2001)
.map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap())
.collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
assert!(s.execute("graph.design", &json!({"save": "Huge"})).is_err());
}
#[test]
fn a_files_designs_are_tidied() {
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![]));
let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]);
graphy.graph = Some(Box::new(GraphSpec::default()));
for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] {
let a = art(&mut d);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a });
}
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) });
d.tidy_graph_designs();
let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect();
assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]);
assert!(d.graph_designs[2].art.graph.is_none());
}
#[test]
fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() {
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!({"design": "Pill"})).unwrap();
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
// The design's paths keep the design's paint, before and after the graph is drawn again.
let design_fill = |s: &Session| {
let parts = series_parts(s, id, 0);
let mut fills = vec![];
parts[1].walk(&mut |n| {
if let NodeKind::Path { .. } = n.kind {
fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex()));
}
});
fills
};
let painted = design_fill(&s);
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(design_fill(&s), painted);
}
#[test]
fn graph_design_params_are_names() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for p in [json!({"save": 5}), json!({"paste": null}), json!({"delete": ["a"]})] {
assert!(s.execute("graph.design", &p).is_err(), "{p}");
}
}
#[test]
fn document_wide_passes_see_design_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("graph.design", &json!({"save": "Blue"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let mut seen = false;
s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456"));
assert!(seen, "the colour is used by the design");
}
}

View File

@ -303,7 +303,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
}
/// `node` moved and scaled by `m` as a whole: strokes, effects and pattern tiles go along.
fn transformed(mut node: Node, m: Affine) -> Node {
pub(crate) fn transformed(mut node: Node, m: Affine) -> Node {
node.transform(m, Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), ..Default::default() });
adopt::each_mut(&mut node, &mut |n| vectorcraft_doc::pattern::transform_pattern_paints(n, m));
node

View File

@ -473,6 +473,7 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
doc.fix_next_id();
// Untrusted: capped, named and pointing at objects the file has.
doc.tidy_saved_selections();
doc.tidy_graph_designs();
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, icc.bytes(tail)?))).collect();
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}

View File

@ -0,0 +1,199 @@
//! Object › Graph › Design… and Marker…: the document's graph designs in a list.
//!
//! Graph Design: Save (Save Design) makes the selected art a design under the name typed, Paste (Paste Design) puts
//! a copy of the selected design into the document, Delete Design removes it (`graph.design`); each
//! runs at once and Close closes. Marker: the design (or the default marker) the selected graph
//! series draw their data points with; OK runs `graph.marker`.
//!
//! Fields: `selected` (a design's name; empty for the default marker) and, in Graph Design, `name`.
use serde_json::{Value, json};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kinds.
pub const DESIGN: &str = "graphDesign";
pub const MARKER: &str = "graphMarker";
pub(super) const DESIGN_SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Graph Design").into(),
body: design_body,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: None,
min_width: 380.0,
..DialogSpec::FORM
};
pub(super) const MARKER_SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Graph Marker").into(),
body: marker_body,
confirm: |app, d| {
let design = Some(d.str("selected")).filter(|s| !s.is_empty());
run_and_close(app, "graph.marker", json!({ "design": design }))
},
min_width: 300.0,
..DialogSpec::FORM
};
/// Open Graph Design.
pub fn open_design(app: &mut VectorcraftApp) -> Result<Value, String> {
let names = names(app)?;
let first = names.first().cloned().unwrap_or_default();
app.ui.dialog = Some(Dialog::new(DESIGN, json!({ "selected": first, "name": "" })));
Ok(Value::Null)
}
/// Open Marker for the selected graph series, on the design they draw with.
pub fn open_marker(app: &mut VectorcraftApp) -> Result<Value, String> {
let v = app.session.execute("graph.marker", &json!({})).map_err(|e| e.to_string())?;
let current = v["design"].as_str().unwrap_or_default();
app.ui.dialog = Some(Dialog::new(MARKER, json!({ "selected": current })));
Ok(Value::Null)
}
/// The document's design names, read from the document (the dialogs draw every frame).
fn names(app: &VectorcraftApp) -> Result<Vec<String>, String> {
let st = app.session.active().ok_or("no document open")?;
Ok(st.doc.graph_designs.iter().map(|d| d.name.clone()).collect())
}
/// The designs as a list to pick from (with the default marker first when `default` is given).
fn list(ui: &mut egui::Ui, d: &mut Dialog, names: &[String], default: Option<&str>) {
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("graph-designs").min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(160.0);
let entries = default.map(|label| ("", label)).into_iter().chain(names.iter().map(|n| (n.as_str(), n.as_str())));
for (value, label) in entries {
if ui.selectable_label(d.str("selected") == value, label).clicked() {
d.fields.insert("selected".into(), json!(value));
}
}
});
});
}
fn design_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let names = names(app).unwrap_or_default();
list(ui, d, &names, None);
let selected = d.str("selected").to_string();
let chosen = names.contains(&selected);
let mut run = None;
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if ui.add_enabled_ui(chosen, |ui| widgets::flat_button(ui, tl!("Paste"), 64.0)).inner.clicked() {
run = Some(json!({ "paste": selected }));
}
if ui.add_enabled_ui(chosen, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() {
run = Some(json!({ "delete": selected }));
}
});
ui.add_space(10.0);
let has_art = app.session.active().is_some_and(|st| !st.selection.is_empty());
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Name:"));
form::text(ui, d, "name", 170.0);
let named = !d.str("name").trim().is_empty();
let save = ui.add_enabled_ui(has_art && named, |ui| widgets::flat_button(ui, tl!("Save"), 64.0)).inner;
if save.clicked() {
run = Some(json!({ "save": d.str("name") }));
}
});
if let Some(p) = run {
match app.run("graph.design", p.clone()) {
Ok(r) => {
if let Some(n) = r["name"].as_str() {
d.fields.insert("selected".into(), json!(n));
d.fields.insert("name".into(), json!(""));
}
// Paste Design leaves the copy selected to edit, as the dialog closes.
if p.get("paste").is_some() {
return true;
}
}
Err(e) => app.status(e),
}
}
false
}
fn marker_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let names = names(app).unwrap_or_default();
list(ui, d, &names, Some(tl!("Default")));
false
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::VectorcraftApp;
use crate::state::Dialog;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn the_graph_menu_opens_both_dialogs_and_they_draw() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let r = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [r]})).unwrap();
app.run("graph.design", json!({"save": "Box"})).unwrap();
crate::menus::invoke(&mut app, "graph.design", json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::DESIGN));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("selected"), "Box");
frame(&mut app);
frame(&mut app);
assert!(app.ui.dialog.is_some());
app.ui.dialog = None;
// Marker needs a graph.
crate::menus::invoke(&mut app, "graph.marker", json!({}));
assert!(app.ui.dialog.is_none());
let g = app.run("graph.create", json!({"type": "line", "x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [g]})).unwrap();
crate::menus::invoke(&mut app, "graph.marker", json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::MARKER));
frame(&mut app);
assert!(app.ui.dialog.is_some());
}
#[test]
fn graph_design_saves_and_marker_applies_a_design() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let r = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [r]})).unwrap();
super::open_design(&mut app).unwrap();
let mut d = app.ui.dialog.take().unwrap();
d.fields.insert("name".into(), json!("Box"));
// What Save Design runs.
app.run("graph.design", json!({"save": d.str("name")})).unwrap();
let g = app.run("graph.create", json!({"type": "line", "x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [g]})).unwrap();
super::open_marker(&mut app).unwrap();
let mut d: Dialog = app.ui.dialog.take().unwrap();
assert_eq!(d.str("selected"), "", "the default marker");
d.fields.insert("selected".into(), json!("Box"));
(super::MARKER_SPEC.confirm)(&mut app, &d).unwrap();
let v = app.session.execute("graph.marker", &json!({})).unwrap();
assert_eq!(v["design"], "Box");
// Back to the default.
super::open_marker(&mut app).unwrap();
let mut d: Dialog = app.ui.dialog.take().unwrap();
assert_eq!(d.str("selected"), "Box");
d.fields.insert("selected".into(), json!(""));
(super::MARKER_SPEC.confirm)(&mut app, &d).unwrap();
assert!(app.session.execute("graph.marker", &json!({})).unwrap()["design"].is_null());
}
}

View File

@ -35,6 +35,7 @@ pub mod flattener_presets;
mod form;
pub mod freehand;
mod gradient_stop;
pub mod graph_design;
pub mod graphic_style_options;
pub mod halftone;
pub mod import_pdf;
@ -225,6 +226,8 @@ registry! {
Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC,
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
GraphDesign: [graph_design::DESIGN] => graph_design::DESIGN_SPEC,
GraphMarker: [graph_design::MARKER] => graph_design::MARKER_SPEC,
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
ColorPicker: [color_picker::KIND] => color_picker::SPEC,
GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC,

View File

@ -223,6 +223,8 @@
Type… Typ…
Data… Údaje…
Design… Návrh…
Graph Design Návrh grafu
Graph Marker Značka grafu
Column… Sloupec…
Marker… Značka…
Collect for Export Shromáždit pro export

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1104,6 +1104,8 @@
Graph Diagramm
Graph Data Diagrammdaten
Graph Type Diagrammtyp
Graph Design Diagrammdesign
Graph Marker Diagrammmarkierung
Graphic Style Grafikstil
Graphic Style Libraries Grafikstil-Bibliotheken
Graphic Style Libraries Menu Menü „Grafikstil-Bibliotheken“

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1121,6 +1121,8 @@
Graph Gráfico
Graph Data Datos del gráfico
Graph Type Tipo de gráfico
Graph Design Diseño de gráfico
Graph Marker Marcador de gráfico
Graphic Style Estilo gráfico
Graphic Style Libraries Bibliotecas de estilos gráficos
Graphic Style Libraries Menu Menú Bibliotecas de estilos gráficos

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1120,6 +1120,8 @@
Graph Graphe
Graph Data Données du graphe
Graph Type Type de graphe
Graph Design Conception de graphe
Graph Marker Marqueur de graphe
Graphic Style Style graphique
Graphic Style Libraries Bibliothèques de styles graphiques
Graphic Style Libraries Menu Menu Bibliothèques de styles graphiques

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1120,6 +1120,8 @@
Graph Grafico
Graph Data Dati grafico
Graph Type Tipo di grafico
Graph Design Design del grafico
Graph Marker Indicatore del grafico
Graphic Style Stile grafico
Graphic Style Libraries Librerie stili grafici
Graphic Style Libraries Menu Menu Librerie stili grafici

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2984,6 +2984,8 @@
Gradient (.) グラデーション (.)
Graph Data グラフデータ
Graph Type グラフの種類
Graph Design グラフのデザイン
Graph Marker グラフのマーカー
Graphic Style Libraries Menu グラフィックスタイルライブラリメニュー
Graphics グラフィックス
Greek & Cyrillic ギリシャ文字とキリル文字

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -595,6 +595,8 @@
Description: Descrição:
Deselect Desmarcar
Design… Projeto…
Graph Design Projeto do gráfico
Graph Marker Marcador do gráfico
Destination: Destino:
Detail: Detalhe:
Devices Dispositivos

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1118,6 +1118,8 @@
Graph Диаграмма
Graph Data Данные диаграммы
Graph Type Тип диаграммы
Graph Design Дизайн диаграммы
Graph Marker Маркер диаграммы
Graphic Style Графический стиль
Graphic Style Libraries Библиотеки графических стилей
Graphic Style Libraries Menu Меню «Библиотеки графических стилей»

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1114,6 +1114,8 @@
Graph Діаграма
Graph Data Дані діаграми
Graph Type Тип діаграми
Graph Design Дизайн діаграми
Graph Marker Маркер діаграми
Graphic Style Графічний стиль
Graphic Style Libraries Бібліотеки графічних стилів
Graphic Style Libraries Menu Меню "Бібліотеки графічних стилів"

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -1071,6 +1071,8 @@
Graph 图表
Graph Data 图表数据
Graph Type 图表类型
Graph Design 图表设计
Graph Marker 图表标记
Graphic Style 图形样式
Graphic Style Libraries 图形样式库
Graphic Style Libraries Menu 图形样式库菜单

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1095,6 +1095,8 @@
Graph 圖表
Graph Data 圖表資料
Graph Type 圖表類型
Graph Design 圖表設計
Graph Marker 圖表標記
Graphic Style 繪圖樣式
Graphic Style Libraries 繪圖樣式資料庫
Graphic Style Libraries Menu 繪圖樣式資料庫選單

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2333,7 +2333,16 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
c("Fit to Selected Art", "artboard.fitToSelection"),
],
),
sub("Graph", vec![c("Type…", "graph.setType"), c("Data…", "graph.setData"), todo("Design…"), todo("Column…"), todo("Marker…")]),
sub(
"Graph",
vec![
c("Type…", "graph.setType"),
c("Data…", "graph.setData"),
c("Design…", "graph.design"),
todo("Column…"),
c("Marker…", "graph.marker"),
],
),
sub(
"Collect for Export",
vec![
@ -3105,6 +3114,14 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
return;
}
// Object → Graph → Design… / Marker…: the document's graph designs.
if matches!(id, "graph.design" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = if id == "graph.design" { crate::dialogs::graph_design::open_design(app) } else { crate::dialogs::graph_design::open_marker(app) };
if let Err(e) = r {
app.status(e);
}
return;
}
// New View… / Edit Views…: name dialogs.
if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) {
let n = app.session.active().map_or(0, |d| d.doc.views.len()) + 1;

View File

@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@ -59,7 +59,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; graph Design/Column/Marker designs pending | 6–10 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | M12: Object › Graph › Design… and Marker… (graph designs, marker designs); column designs remain. Scores/hours unchanged. |
| 2026-10-12 | minor | M14: native macOS save Format menu (#866); filename regression tests, actual SVG save and PDF selection/cancel verified; estimates unchanged |
| 2026-10-12 | minor | M6: compound paths inherit the backmost appearance; selected members read and change the compound fill rule (#1197); estimates unchanged |
| 2026-10-11 | minor | M9: PDF raster-effect resolution-cap warnings verified with real capped/uncapped exports; scores and hours unchanged |

View File

@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@ -39,7 +39,7 @@ tests and the CI gate. See [Calibration](#calibration).
- **From the code (measured, origin/main `01e165af`):**
- Menu tree (`crates/ui-egui/src/menus.rs`, laid out in Illustrator's menu order): 347 items bound to
commands, 27 stubs (`todo(…)`/`todos(…)`), so 92.8% of menu items do something (348 and 27 after the
Revolve merge). The stubs: Effect › 3D and Materials (4), Effect › SVG Filters, Edit › Spelling (3), Graph › Design/Column/Marker (3), Live Paint
Revolve merge; 350 and 25, 93.3%, after Graph › Design and Marker). The stubs: Effect › 3D and Materials (4), Effect › SVG Filters, Edit › Spelling (3), Graph › Column, Live Paint
Gap Options, Optical Margin Alignment, Start Global Edit, global and video rulers (2), Snap to Glyph,
Window › Arrange (4), SVG Interactivity, the library placeholders (3), Other Script…, Help.
- Engine commands: ~710 ids registered with `cmd!` across `crates/engine/src/cmd` (plus macro-generated
@ -79,7 +79,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
| Layers, artboards, document setup | 5 | 78% | 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export | 5–8 | estimated (lowered from 82%: target moved) |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated |
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides, global/video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved) |
@ -308,7 +308,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 |
@ -414,7 +414,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **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).
- **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 are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. 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 Design, Column and Marker artwork remain open.
- **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 are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. 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 saves the selected art as a named design, pastes a copy back to edit and deletes it, 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. Column designs remain open.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
@ -467,6 +467,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Graph designs and marker designs (Object › Graph › Design… and Marker…): menu stubs 27 → 25; the graphs row's missing items are column designs and Image Trace depth. Scores unchanged within rounding |
| 2026-10-12 | minor | UI chrome: native macOS save sheets gain a Format menu (#866), with extension regression tests and actual save/cancel verification; score and hours unchanged (estimated) |
| 2026-10-12 | minor | Compound-path appearance and fill-rule edits through directly selected members (#1197) verified with rendered holes and Undo/Redo; scores and hours unchanged |
| 2026-10-11 | minor | Type core: Middle Eastern digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; `{digits}`) leave the missing list; score and hours unchanged at this rounding (estimated) |