Merge pull request #1092 from IHAGI-c/feat/graph-marker-designs
Graph designs and marker designs: Object › Graph › Design… and Marker…
This commit is contained in:
commit
c71f57941e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"{new?: name|true (New Design: the selected art becomes a graph design, named \"New Design\" when true; put a rectangle at the back to set the size it draws at), rename?: name, to?: new name (the graphs drawing it follow), paste?: name (Paste Design: a copy of the design's art into the document, selected, to edit and save again), delete?: name|[names] (graphs drawing them go back to their default marks)}; no params or selectUnused → {designs: [names], unused: [the names no graph draws with]}",
|
||||
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,236 @@ 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(())
|
||||
}
|
||||
|
||||
/// The designs no graph's series draw with (Select Unused).
|
||||
fn unused_designs(d: &Document) -> Vec<String> {
|
||||
let mut used = std::collections::HashSet::new();
|
||||
d.walk(|n| {
|
||||
if let Some(g) = n.graph.as_deref() {
|
||||
used.extend(g.series_markers.iter().flatten().cloned());
|
||||
}
|
||||
});
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
|
||||
}
|
||||
|
||||
/// Design names from param `key`: one name or a list of names.
|
||||
fn names_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Vec<String>>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(vec![n.clone()])),
|
||||
Some(Value::Array(a)) if !a.is_empty() => a
|
||||
.iter()
|
||||
.map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(cmd, format!("`{key}` is a design name or a list of them"))))
|
||||
.collect::<Result<_>>()
|
||||
.map(Some),
|
||||
Some(_) => Err(bad(cmd, format!("`{key}` is a design name or a list of them"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.design";
|
||||
for key in ["paste", "rename", "to"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_string()) {
|
||||
return Err(bad(C, format!("`{key}` is a design name")));
|
||||
}
|
||||
}
|
||||
let taken = |s: &Session, name: &str| s.doc().is_ok_and(|st| st.doc.graph_designs.iter().any(|x| x.name == name));
|
||||
// New Design: the selected art, named `new` or "New Design" (numbered when that's taken).
|
||||
if let Some(v) = p.get("new") {
|
||||
let name = match v {
|
||||
Value::String(n) => GraphDesign::clean_name(n).ok_or_else(|| bad(C, "a design needs a name"))?,
|
||||
Value::Bool(true) | Value::Null => super::unique_name("New Design", |n| taken(s, n)),
|
||||
_ => return Err(bad(C, "`new` is a design name, or true")),
|
||||
};
|
||||
if taken(s, &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("New 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 }));
|
||||
}
|
||||
// Rename: the graphs drawing the design follow it to its new name.
|
||||
if let Some(old) = str_param(p, "rename") {
|
||||
if !taken(s, old) {
|
||||
return Err(bad(C, format!("no design named `{old}`")));
|
||||
}
|
||||
let to = str_param(p, "to").and_then(GraphDesign::clean_name).ok_or_else(|| bad(C, "`to` is the design's new name"))?;
|
||||
if to == old {
|
||||
return Ok(json!({ "name": to }));
|
||||
}
|
||||
if taken(s, &to) {
|
||||
return Err(bad(C, format!("there is already a design named `{to}`")));
|
||||
}
|
||||
let (old, new) = (old.to_string(), to.clone());
|
||||
s.edit("Rename Design", |d, _| {
|
||||
if let Some(x) = d.graph_designs.iter_mut().find(|x| x.name == old) {
|
||||
x.name = new.clone();
|
||||
}
|
||||
let mut graphs = vec![];
|
||||
d.walk(|n| {
|
||||
if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
|
||||
graphs.push(n.id);
|
||||
}
|
||||
});
|
||||
for id in graphs {
|
||||
if let Some(g) = d.node_mut(id).and_then(|n| n.graph.as_deref_mut()) {
|
||||
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
|
||||
*m = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({ "name": to }));
|
||||
}
|
||||
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(names) = names_param(p, "delete", C)? {
|
||||
if let Some(n) = names.iter().find(|n| !taken(s, n)) {
|
||||
return Err(bad(C, format!("no design named `{n}`")));
|
||||
}
|
||||
s.edit("Delete Design", |d, _| {
|
||||
d.graph_designs.retain(|x| !names.contains(&x.name));
|
||||
for name in &names {
|
||||
refresh_graphs_using(d, name)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
// The list, with the designs no graph draws with (Select Unused picks those).
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({ "designs": design_names(d), "unused": unused_designs(d) }))
|
||||
}
|
||||
|
||||
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 +1422,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 +2554,277 @@ 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!({"new": 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!({"new": "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!({"new": "Pill"})).is_err(), "the name is taken");
|
||||
assert!(s.execute("graph.design", &json!({"new": " "})).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!({"new": "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!({"new": "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!({"new": "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!({"new": 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!({"new": "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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_design_rename_delete_and_select_unused() {
|
||||
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("select.set", &json!({"ids": [r]})).unwrap();
|
||||
// New Design names it "New Design", numbered when that's taken.
|
||||
assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design");
|
||||
assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2");
|
||||
let id = graph(&mut s, "line");
|
||||
s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap();
|
||||
let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap();
|
||||
assert_eq!(v["unused"], json!(["New Design 2"]));
|
||||
// Renaming a design the graph draws with: the graph follows, still drawing it.
|
||||
s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]);
|
||||
assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken");
|
||||
assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err());
|
||||
// Delete several at once.
|
||||
s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap();
|
||||
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([]));
|
||||
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
//! Object › Graph › Design… and Marker…: the document's graph designs in a list.
|
||||
//!
|
||||
//! Graph Design: the designs, a preview of the selected one, and New Design (the selected art as a
|
||||
//! design named "New Design"), Rename (to the name typed), Delete Design (the selected designs),
|
||||
//! Paste Design (a copy of the selected design into the document, selected, to edit) and Select
|
||||
//! Unused (the designs no graph draws with), each run at once by `graph.design`; Close closes.
|
||||
//! Marker: the design the selected graph series draw their data points with; OK runs
|
||||
//! `graph.marker`.
|
||||
//!
|
||||
//! Fields: `selected` (the selected designs' names; Marker: one name) and, in Graph Design, `name`
|
||||
//! (the name Rename gives).
|
||||
|
||||
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";
|
||||
|
||||
/// The preview's size.
|
||||
const PREVIEW: f32 = 120.0;
|
||||
|
||||
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: 420.0,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
pub(super) const MARKER_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Graph Marker").into(),
|
||||
body: marker_body,
|
||||
confirm: |app, d| {
|
||||
let design = d.str("selected");
|
||||
if design.is_empty() {
|
||||
app.ui.dialog = None;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
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: Vec<String> = names.first().cloned().into_iter().collect();
|
||||
let name = first.first().cloned().unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new(DESIGN, json!({ "selected": first, "name": name })));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Open Marker for the selected graph series, on the design they draw with (else the first).
|
||||
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().map(str::to_string).or_else(|| names(app).ok()?.first().cloned()).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())
|
||||
}
|
||||
|
||||
/// Graph Design's selected designs.
|
||||
fn selected(d: &Dialog) -> Vec<String> {
|
||||
d.fields.get("selected").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn select(d: &mut Dialog, names: Vec<String>) {
|
||||
d.fields.insert("name".into(), json!(names.first().cloned().unwrap_or_default()));
|
||||
d.fields.insert("selected".into(), json!(names));
|
||||
}
|
||||
|
||||
/// A design's art drawn in `r` (cached by the art's identity).
|
||||
fn preview(ui: &egui::Ui, app: &VectorcraftApp, name: &str, r: egui::Rect) {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
static CACHE: crate::graphics::TexCache<HashMap<usize, egui::TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(art) = st.doc.graph_designs.iter().find(|x| x.name == name).map(|x| x.art.clone()) else { return };
|
||||
let key = std::sync::Arc::as_ptr(&art) as usize;
|
||||
let px = (r.width() * ui.ctx().pixels_per_point()).round() as u32;
|
||||
let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| {
|
||||
let img = RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail(&st.doc, &art, px.max(16), Some([255, 255, 255, 255])))?;
|
||||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let tex = ui.ctx().load_texture(format!("graph-design-{key}"), color, egui::TextureOptions::LINEAR);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() > 200 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, tex.clone());
|
||||
});
|
||||
Some(tex)
|
||||
});
|
||||
if let Some(t) = tex {
|
||||
ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
fn design_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let names = names(app).unwrap_or_default();
|
||||
let chosen: Vec<String> = selected(d).into_iter().filter(|n| names.contains(n)).collect();
|
||||
let one = chosen.first().cloned();
|
||||
let has_art = app.session.active().is_some_and(|st| !st.selection.is_empty());
|
||||
let mut run = None;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(220.0);
|
||||
// Click picks a design; Cmd/Ctrl-click adds or removes one.
|
||||
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);
|
||||
for n in &names {
|
||||
if ui.selectable_label(chosen.contains(n), n.as_str()).clicked() {
|
||||
let mut now = chosen.clone();
|
||||
if ui.input(|i| i.modifiers.command) {
|
||||
if let Some(k) = now.iter().position(|x| x == n) {
|
||||
now.remove(k);
|
||||
} else {
|
||||
now.push(n.clone());
|
||||
}
|
||||
} else {
|
||||
now = vec![n.clone()];
|
||||
}
|
||||
select(d, now);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Name:"));
|
||||
form::text(ui, d, "name", 150.0);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover());
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(160)), egui::StrokeKind::Inside);
|
||||
if let Some(n) = &one {
|
||||
preview(ui, app, n, r.shrink(4.0));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let button =
|
||||
|ui: &mut egui::Ui, on: bool, label: &str| ui.add_enabled_ui(on, |ui| widgets::flat_button(ui, label, PREVIEW)).inner.clicked();
|
||||
if button(ui, has_art, tl!("New Design")) {
|
||||
run = Some(json!({ "new": true }));
|
||||
}
|
||||
let to = d.str("name");
|
||||
if button(ui, chosen.len() == 1 && !to.trim().is_empty(), tl!("Rename")) {
|
||||
run = one.clone().map(|n| json!({ "rename": n, "to": to }));
|
||||
}
|
||||
if button(ui, !chosen.is_empty(), tl!("Delete Design")) {
|
||||
run = Some(json!({ "delete": chosen }));
|
||||
}
|
||||
if button(ui, chosen.len() == 1, tl!("Paste Design")) {
|
||||
run = one.clone().map(|n| json!({ "paste": n }));
|
||||
}
|
||||
if button(ui, !names.is_empty(), tl!("Select Unused")) {
|
||||
run = Some(json!({ "selectUnused": true }));
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(p) = run {
|
||||
match app.run("graph.design", p.clone()) {
|
||||
Ok(r) => {
|
||||
if let Some(n) = r["name"].as_str() {
|
||||
select(d, vec![n.to_string()]);
|
||||
}
|
||||
if let Some(unused) = r["unused"].as_array().filter(|_| p.get("selectUnused").is_some()) {
|
||||
select(d, unused.iter().filter_map(|v| v.as_str().map(str::to_string)).collect());
|
||||
}
|
||||
// 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();
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("graph-markers").min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.set_min_height(160.0);
|
||||
for n in &names {
|
||||
if ui.selectable_label(d.str("selected") == *n, n.as_str()).clicked() {
|
||||
d.fields.insert("selected".into(), json!(n));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
fn app_with_design() -> VectorcraftApp {
|
||||
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!({"new": true})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graph_menu_opens_both_dialogs_and_they_draw_with_a_preview() {
|
||||
let mut app = app_with_design();
|
||||
crate::menus::invoke(&mut app, "graph.design", json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.kind, super::DESIGN);
|
||||
assert_eq!((super::selected(d), d.str("name")), (vec!["New Design".to_string()], "New Design".to_string()));
|
||||
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 marker_lists_the_designs_and_applies_the_one_picked() {
|
||||
let mut app = app_with_design();
|
||||
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 d: Dialog = app.ui.dialog.take().unwrap();
|
||||
assert_eq!(d.str("selected"), "New Design", "the first design");
|
||||
(super::MARKER_SPEC.confirm)(&mut app, &d).unwrap();
|
||||
assert_eq!(app.session.execute("graph.marker", &json!({})).unwrap()["design"], "New Design");
|
||||
super::open_marker(&mut app).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("selected"), "New Design");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@
|
|||
# tests): the product name, language names, OpenType, the built-in workspace names and the
|
||||
# bracketed perspective grid preset names. Keep placeholders, ellipses and the leading/trailing
|
||||
# spaces of fragments when editing.
|
||||
New Design Nový návrh
|
||||
Delete Design Odstranit návrh
|
||||
Paste Design Vložit návrh
|
||||
Select Unused Vybrat nepoužité
|
||||
Glowing Edges… Zářící okraje…
|
||||
About VectorCraft O aplikaci VectorCraft
|
||||
Align to Path Zarovnat k cestě
|
||||
|
|
@ -223,6 +227,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.
|
|
|
@ -1104,6 +1104,12 @@
|
|||
Graph Diagramm
|
||||
Graph Data Diagrammdaten
|
||||
Graph Type Diagrammtyp
|
||||
Graph Design Diagrammdesign
|
||||
Graph Marker Diagrammmarkierung
|
||||
New Design Neues Design
|
||||
Delete Design Design löschen
|
||||
Paste Design Design einfügen
|
||||
Select Unused Nicht verwendete auswählen
|
||||
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.
|
|
|
@ -1121,6 +1121,12 @@
|
|||
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
|
||||
New Design Nuevo diseño
|
||||
Delete Design Eliminar diseño
|
||||
Paste Design Pegar diseño
|
||||
Select Unused Seleccionar no utilizados
|
||||
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.
|
|
|
@ -1120,6 +1120,12 @@
|
|||
Graph Graphe
|
||||
Graph Data Données du graphe
|
||||
Graph Type Type de graphe
|
||||
Graph Design Conception de graphe
|
||||
Graph Marker Marqueur de graphe
|
||||
New Design Nouvelle conception
|
||||
Delete Design Supprimer la conception
|
||||
Paste Design Coller la conception
|
||||
Select Unused Sélectionner les éléments inutilisés
|
||||
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.
|
|
|
@ -1120,6 +1120,12 @@
|
|||
Graph Grafico
|
||||
Graph Data Dati grafico
|
||||
Graph Type Tipo di grafico
|
||||
Graph Design Design del grafico
|
||||
Graph Marker Indicatore del grafico
|
||||
New Design Nuovo design
|
||||
Delete Design Elimina design
|
||||
Paste Design Incolla design
|
||||
Select Unused Seleziona non utilizzati
|
||||
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.
|
|
|
@ -2984,6 +2984,12 @@
|
|||
Gradient (.) グラデーション (.)
|
||||
Graph Data グラフデータ
|
||||
Graph Type グラフの種類
|
||||
Graph Design グラフのデザイン
|
||||
Graph Marker グラフのマーカー
|
||||
New Design 新規デザイン
|
||||
Delete Design デザインを削除
|
||||
Paste Design デザインをペースト
|
||||
Select Unused 未使用を選択
|
||||
Graphic Style Libraries Menu グラフィックスタイルライブラリメニュー
|
||||
Graphics グラフィックス
|
||||
Greek & Cyrillic ギリシャ文字とキリル文字
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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.
|
|
|
@ -1118,6 +1118,12 @@
|
|||
Graph Диаграмма
|
||||
Graph Data Данные диаграммы
|
||||
Graph Type Тип диаграммы
|
||||
Graph Design Дизайн диаграммы
|
||||
Graph Marker Маркер диаграммы
|
||||
New Design Новый дизайн
|
||||
Delete Design Удалить дизайн
|
||||
Paste Design Вставить дизайн
|
||||
Select Unused Выбрать неиспользуемые
|
||||
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.
|
|
|
@ -1114,6 +1114,12 @@
|
|||
Graph Діаграма
|
||||
Graph Data Дані діаграми
|
||||
Graph Type Тип діаграми
|
||||
Graph Design Дизайн діаграми
|
||||
Graph Marker Маркер діаграми
|
||||
New Design Новий дизайн
|
||||
Delete Design Видалити дизайн
|
||||
Paste Design Вставити дизайн
|
||||
Select Unused Вибрати невикористані
|
||||
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.
|
|
|
@ -1071,6 +1071,12 @@
|
|||
Graph 图表
|
||||
Graph Data 图表数据
|
||||
Graph Type 图表类型
|
||||
Graph Design 图表设计
|
||||
Graph Marker 图表标记
|
||||
New Design 新建设计
|
||||
Delete Design 删除设计
|
||||
Paste Design 粘贴设计
|
||||
Select Unused 选择未使用项
|
||||
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.
|
|
|
@ -1095,6 +1095,12 @@
|
|||
Graph 圖表
|
||||
Graph Data 圖表資料
|
||||
Graph Type 圖表類型
|
||||
Graph Design 圖表設計
|
||||
Graph Marker 圖表標記
|
||||
New Design 新增設計
|
||||
Delete Design 刪除設計
|
||||
Paste Design 貼上設計
|
||||
Select Unused 選取未使用項目
|
||||
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.
|
|
|
@ -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 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 |
|
||||
|
|
|
|||
|
|
@ -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 lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. 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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue