Graphs: series marks keep a fill or stroke opacity when the graph is drawn again

A graph keeps the paint and stroke weight given to a series' marks, but not
the opacity of their fill or stroke row. An opacity set with
`transparency.set {item}` or `appearance.setItem {opacity}` on a column,
bar, line or other series path was lost the next time the graph was drawn
(a data, type or option change).

- `SeriesPaint` gains `fillOpacity` and `strokeOpacity`. A setItem opacity
  on the topmost fill or stroke of a series path stores it, in the same
  undo step, and the generator puts it on the topmost fill and stroke of
  every path directly in that series' group. Graph designs keep their own
  opacity. A value read from a file is held to 0..1.
- The Appearance commands that change the opacity of a series path's
  topmost fill or stroke (add, remove, duplicate and move a row, Clear
  Appearance, Reduce to Basic Appearance, `appearance.transfer` and the
  Eyedropper) update the stored opacity. They do not change the stored
  paint, which only `paint.setFill` and `paint.setStroke` set.
- An edit that leaves a series path fully opaque stores nothing for a
  series the graph keeps nothing for.
This commit is contained in:
dotCooCoo
2026-11-04 11:45:16 +07:00
committed by bflatastic
parent 0cdcf3039b
commit c8128ee65b
7 changed files with 362 additions and 15 deletions
+7
View File
@@ -314,6 +314,13 @@ pub struct SeriesPaint {
/// Stroke weight in points, kept with [`Self::stroke`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_width: Option<f64>,
/// Opacity (0..1) of the topmost fill of each path directly in the series group (graph designs keep their own),
/// taken from the topmost fill row edited on one of them. Absent: fully opaque.
#[serde(skip_serializing_if = "Option::is_none")]
pub fill_opacity: Option<f32>,
/// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_opacity: Option<f32>,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
+47 -10
View File
@@ -531,6 +531,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
}
_ => vec![None; ids.len()],
};
let before = s.doc()?.doc.clone();
let mut inserted = None;
// Where the new fill went in on the active item's object, when it went above the active row.
let mut above = None;
@@ -577,6 +578,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
}
}
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(())
})?;
// A fill added above the active row becomes the active row; otherwise the rows above the new
@@ -595,12 +597,28 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
clear(n);
}
}
Ok(count_changed(d, &before, &ids))
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
s.active_appearance_item = None;
ok()
}
/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an
/// edit that replaces whole stacks.
fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
}
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than
/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0)
};
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
}
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
/// group keeps no fill or stroke rows of its own.
fn clear(n: &mut Node) {
@@ -636,7 +654,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
}
Ok(count_changed(d, &before, &[source, target]))
let changed = if copy { vec![target] } else { vec![source, target] };
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -689,7 +709,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(count_changed(d, &before, &ids))
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
s.active_appearance_item = None;
ok()
@@ -711,9 +732,14 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &[idx], aligned(p));
super::overprint::edit_counted(s, "Appearance", |d| {
let mut edited = Vec::new();
for (id, at) in ids.iter().zip(&per) {
let Some(&idx) = at.first() else { continue };
let Some(n) = d.node_mut(*id) else { continue };
let items = &n.appearance.items;
if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) {
edited.push((*id, fill));
}
if p.get("weight").is_none()
&& let Some(pa) = &paint
{
@@ -744,7 +770,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
*vis = v;
}
}
Ok(count_changed(d, &before, &ids))
// The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again.
let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited);
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
if let Some(pa) = &paint {
s.remember_paint(pa);
@@ -787,7 +815,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(count_changed(d, &before, &ids))
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
ok()
@@ -813,7 +842,8 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
}
let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p));
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &idx, aligned(p));
s.edit("Duplicate Item", |d, _| {
let mut any = false;
for (id, idx) in ids.iter().zip(&per) {
@@ -851,7 +881,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) }
if !any {
return Err(bad(C, format!("no item at index {}", idx[0])));
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(())
})?;
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
let m = idx.len();
@@ -910,7 +944,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
n.appearance.set_contents_at(k);
}
}
Ok(count_changed(d, &before, &ids))
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
// The moved row stays the active one; the rows it passed shift by one.
s.remap_appearance_item(&ids, |a| {
@@ -953,7 +988,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<V
n.appearance.set_contents_at(k);
}
}
Ok(count_changed(d, &before, ids))
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
Ok(count_changed(d, &before, ids) + usize::from(kept))
})?;
let m = idx.len();
let landed = first_landed.unwrap_or(to);
@@ -1090,7 +1126,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
picked.apply(n, &attrs, append);
}
}
Ok(count_changed(d, &before, &targets))
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets));
Ok(count_changed(d, &before, &targets) + usize::from(kept))
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
+1 -1
View File
@@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv};
use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{capture_label_styles, capture_series_paints, paint_targets};
pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets};
#[cfg(test)]
mod tests;
+2 -2
View File
@@ -5,7 +5,7 @@ use super::labels::{
FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::series_marks;
use super::series::{series_marks, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -1002,7 +1002,7 @@ impl<'a> Plot<'a> {
}
}
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
let grp = self.b.series(&name, s, items);
let grp = self.b.series(&name, s, with_series_opacity(g, s, items));
self.b.out.push(grp);
}
self.b.out.append(&mut self.on_top);
+71 -1
View File
@@ -1,5 +1,5 @@
use super::labels::{Role, label_style};
use super::{CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
// ---------- generation ----------
@@ -25,6 +25,31 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P
(fill, stroke, width)
}
/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their
/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is
/// held to 0..1, and one that is not a number is left out.
pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
let Some(slot) = g.series_paints.get(i) else { return items };
let kept = |o: Option<f32>| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0));
let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity));
if fill.is_none() && stroke.is_none() {
return items;
}
for item in &mut items {
if !matches!(item.kind, NodeKind::Path { .. }) {
continue;
}
let ap = &mut Arc::make_mut(item).appearance;
if let (Some(o), Some(f)) = (fill, ap.fill_mut()) {
f.opacity = o;
}
if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) {
st.opacity = o;
}
}
items
}
/// Where a node inside a graph's generated series art belongs: the graph, the series group and
/// the series index stored on that group.
#[derive(Clone, Copy)]
@@ -119,6 +144,51 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
changed
}
/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`:
/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind
/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity
/// changed.
pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
if edited.is_empty() {
return false;
}
let members = series_members(doc);
if members.is_empty() {
return false;
}
let captures: Vec<_> = edited
.iter()
.filter_map(|(id, fill)| {
// Only the paths the graph draws again, not the copy of a graph design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?;
let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity);
Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity)))
})
.collect();
let mut changed = false;
for (graph, index, fill, kept) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
// A fully opaque row on a series the graph keeps nothing for leaves the graph as it is.
if kept.is_none() && spec.series_paints.len() <= index {
continue;
}
let was = spec.series_paints.clone();
// `index` is below the graph's series count (`series_members`).
if spec.series_paints.len() <= index {
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
if fill {
slot.fill_opacity = kept;
} else {
slot.stroke_opacity = kept;
}
changed |= spec.series_paints != was;
}
changed
}
/// Graph Data › Transpose row/column: categories become series and series categories.
pub(super) fn transpose(g: &mut GraphSpec) {
let cells = g.cells();
+233
View File
@@ -289,6 +289,239 @@ fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() {
assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green)));
}
/// An opacity set on the fill or stroke item of one series mark is kept by the graph: every path of that series has
/// it after the graph is drawn again, the other series stay opaque, 100% forgets it, and undo takes it back.
#[test]
fn an_item_opacity_on_a_series_mark_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let opacities = |s: &Session, index: u32| -> Vec<(f32, f32)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, index).children().unwrap().to_vec();
marks.iter().map(|m| (m.appearance.fill().unwrap().opacity, m.appearance.stroke().unwrap().opacity)).collect()
};
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), Some(0.6)));
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap();
assert_eq!(restored, spec(&s, id));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(opacities(&s, 0).iter().all(|o| *o == (0.4, 0.6)), "{:?}", opacities(&s, 0));
assert!(opacities(&s, 1).iter().all(|o| *o == (1.0, 1.0)), "{:?}", opacities(&s, 1));
// Back to 100%: the graph keeps no opacity for the fill.
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
s.execute("graph.setType", &json!({"type": "bar"})).unwrap();
assert!(opacities(&s, 0).iter().all(|o| *o == (1.0, 0.6)), "{:?}", opacities(&s, 0));
// Undo goes back through the type change, the 100% and the data edit, then takes the stroke opacity away with its
// edit.
for _ in 0..4 {
s.execute("edit.undo", &json!({})).unwrap();
}
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), None));
}
/// Clear Appearance and Reduce to Basic Appearance on a series mark take its fill and stroke opacity away, and the
/// graph keeps none, so the series stays opaque when it is drawn again.
#[test]
fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() {
for cmd in ["appearance.clear", "appearance.reduceToBasic"] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap();
s.execute(cmd, &json!({"ids": [bar.0]})).unwrap();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill_opacity, kept.stroke_opacity), (None, None), "{cmd}");
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0), "{cmd}");
}
}
/// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque
/// (a blend mode alone) keeps nothing for its series.
#[test]
fn removing_a_series_marks_fill_forgets_its_kept_opacity() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap();
assert!(spec(&s, id).series_paints.is_empty());
s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4));
s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": 0})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
// An opaque fill added over the kept one takes it away too.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None);
}
/// An opacity set on a row added to a graph design's copy inside a series is not kept for the series: the graph draws
/// the design again from the design itself.
#[test]
fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
save_design(&mut s, "Square");
s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap();
let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone();
assert!(matches!(copy.kind, NodeKind::Group { .. }));
s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap();
s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
}
/// A series' kept opacity goes on its generated marks only: a column design made of one path keeps its own opacity.
#[test]
fn a_column_design_keeps_its_own_opacity_under_a_series_opacity() {
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let mut look = vectorcraft_doc::Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
look.fill_mut().unwrap().opacity = 0.5;
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
let art = vectorcraft_doc::Node::path(d.alloc_id(), rect, look);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Square".into(), art: std::sync::Arc::new(art) });
let options = ColumnDesign { design: "Square".into(), column_type: ColumnType::Vertical, ..ColumnDesign::default() };
let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(0.2), ..Default::default() }];
let g = GraphSpec {
rows: vec![vec![35.0]],
axis_min: Some(0.0),
axis_max: Some(50.0),
series_columns: vec![Some(options)],
series_paints: paints,
..GraphSpec::default()
};
let out = super::generate(&mut d, &g);
let group = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
let mut fills = vec![];
group.walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { .. }) {
fills.push(n.appearance.fill().unwrap().opacity);
}
});
assert!(!fills.is_empty() && fills.iter().all(|o| *o == 0.5), "{fills:?}");
}
/// An opacity set on an item that the graph does not draw again (a second fill added to a mark) is not kept.
#[test]
fn an_opacity_on_an_added_item_of_a_series_mark_is_not_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
let items = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.clone();
let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
assert!(items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap() != lower);
s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()));
}
/// The fill positions (lowest, topmost) in a mark's appearance stack.
fn fill_rows(s: &Session, mark: NodeId) -> (usize, usize) {
let items = s.doc().unwrap().doc.node(mark).unwrap().appearance.items.clone();
let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
let upper = items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap();
assert!(lower < upper);
(lower, upper)
}
/// Moving a translucent fill to the top of a series mark's stack, or placing a copy of it there, makes the graph keep
/// its opacity.
#[test]
fn moving_or_duplicating_a_series_marks_fill_to_the_top_keeps_its_opacity() {
for cmd in ["appearance.moveItem", "appearance.duplicateItem"] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id;
s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap();
let (lower, upper) = fill_rows(&s, bar);
s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap();
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()), "{cmd}");
let p = if cmd == "appearance.moveItem" {
json!({"ids": [bar.0], "from": lower, "to": upper})
} else {
json!({"ids": [bar.0], "index": lower, "to": upper + 1})
};
s.execute(cmd, &p).unwrap();
let top = fill_rows(&s, bar).1;
assert_eq!(s.doc().unwrap().doc.node(bar).unwrap().appearance.items[top].opacity(), 0.4, "{cmd}");
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4), "{cmd}");
}
}
/// Moving or copying a translucent mark's appearance onto a mark of another series (the Layers panel's target
/// circle, or the Eyedropper) makes the graph keep the opacity for that series; a move leaves the source series with
/// none.
#[test]
fn moving_or_copying_an_appearance_onto_a_series_mark_keeps_its_opacity() {
for (cmd, source_keeps) in [("move", None), ("copy", Some(0.4)), ("eyedropper", Some(0.4))] {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap();
match cmd {
"eyedropper" => s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(),
_ => s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": cmd == "copy"})).unwrap(),
};
let paints = spec(&s, id).series_paints;
assert_eq!(paints.get(1).and_then(|p| p.fill_opacity), Some(0.4), "{cmd}");
assert_eq!(paints[0].fill_opacity, source_keeps, "{cmd}");
}
}
/// A line graph keeps a stroke opacity set on a series' line, and puts it on the line drawn again.
#[test]
fn a_stroke_opacity_on_a_series_line_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let lines = |s: &Session| -> Vec<vectorcraft_doc::Node> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let marks = series(&n, 0).children().unwrap().to_vec();
marks.iter().filter(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).map(|m| (**m).clone()).collect()
};
let line = lines(&s).first().unwrap().clone();
let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap();
s.execute("transparency.set", &json!({"ids": [line.id.0], "item": row, "opacity": 60})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.6));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4], [2, 5]]})).unwrap();
let after = lines(&s);
assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6));
}
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
#[test]
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(-1.0), stroke_opacity: Some(f32::NAN), ..Default::default() }];
let paints = [paints, vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(40.0), ..Default::default() }]].concat();
let g = GraphSpec { rows: vec![vec![35.0, 20.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_paints: paints, ..GraphSpec::default() };
let out = super::generate(&mut d, &g);
for (index, want) in [(0, 0.0), (1, 1.0)] {
let group = out.iter().find(|n| n.extras().series_index == Some(index)).unwrap();
let marks: Vec<_> = group.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
assert!(!marks.is_empty());
for m in marks {
assert_eq!(m.appearance.fill().unwrap().opacity, want);
assert!(m.appearance.stroke().is_none_or(|st| st.opacity == 1.0));
}
}
}
/// A series index read from a file past the graph's series is not a series: painting its art
/// paints the art alone and stores nothing (no allocation sized by the file).
#[test]
+1 -1
View File
@@ -772,7 +772,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke 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. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the opacity set on a mark's top fill or stroke row, 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. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.
- **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.