diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 43074a4e80..d561663524 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,6 +363,14 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, + /// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own + /// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub fill_unit_box: bool, + /// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes + /// it on its stroke's box (the geometric bounds grown by half the weight). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_unit_box: bool, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..b47fd0e5e3 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index a789304b24..69510652b9 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_opacity}; +use super::series::{series_marks, with_series_gradients, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -1006,7 +1006,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, with_series_opacity(g, s, items)); + let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items))); self.b.out.push(grp); } self.b.out.append(&mut self.on_top); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 51a7ead550..aeaceb9e91 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -2,7 +2,7 @@ use vectorcraft_color::BlendMode; use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_para, label_style}; -use super::{Arc, 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, Rect, Result, Session, Value, bad}; // ---------- generation ---------- @@ -229,7 +229,8 @@ pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, b continue; } let (paint, width) = shown_paint(node, *fill); - captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width))); + let width = (!*fill).then_some(width); + captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width))); } store_series_paints(doc, captures) } @@ -281,11 +282,129 @@ fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool changed } -/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs. +const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX; + +/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the +/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept. +/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box. +fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option) -> Option { + let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) }; + let Some(Paint::Gradient(g)) = kept else { return None }; + if !unit { + return Some(Paint::Gradient(g.clone())); + } + let on = on?; + let mut g = g.clone(); + g.rebase(UNIT_BOX, on); + Some(Paint::Gradient(g)) +} + +/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the +/// rounding a move or a placement on another box leaves). +fn same_paint_placed(a: &Paint, b: &Paint) -> bool { + let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b }; + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6; + let mut a = (**a).clone(); + match (&mut a.geom, &b.geom) { + (Some(ag), Some(bg)) => { + let focal = match (ag.focal, bg.focal) { + (Some(p), Some(q)) => near(p, q), + (p, q) => p.is_none() && q.is_none(), + }; + if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) { + return false; + } + (ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal); + } + (None, None) => {} + _ => return false, + } + match (&mut a.freeform, &b.freeform) { + (Some(af), Some(bf)) => { + if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) { + return false; + } + for (p, q) in af.points.iter_mut().zip(&bf.points) { + p.at = q.at; + } + } + (None, None) => {} + _ => return false, + } + a == **b +} + +/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown +/// by half the weight for a stroke. The box can have no width or no height. +fn paint_box(node: &Node, width: Option) -> Option { + let b = node.geometric_bounds()?; + Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w))) +} + +/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place +/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's +/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved. +fn unit_box_paint(paint: Paint, from: Option) -> (Paint, bool) { + match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) { + (Paint::Gradient(mut g), Some(from)) => { + g.rebase(from, UNIT_BOX); + (Paint::Gradient(g), true) + } + (paint, _) => (paint, false), + } +} + +/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the +/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in +/// document coordinates (from files saved before the unit box) are left as they are. +pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { + let Some(slot) = g.series_paints.get(i) else { return items }; + fn kept(p: &Option, unit: bool) -> Option<&Paint> { + p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_))) + } + let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box)); + if fill.is_none() && stroke.is_none() { + return items; + } + // A Filled Lines band takes the kept stroke as its fill when no fill is kept. + let band_fill = if slot.fill.is_none() { stroke } else { None }; + let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p); + let stroke_from_store = |p: &Paint| stroke == Some(p); + for item in &mut items { + if !matches!(item.kind, NodeKind::Path { .. }) { + continue; + } + let Some(b) = item.geometric_bounds() else { continue }; + let ap = &item.appearance; + if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) { + continue; + } + let ap = &mut Arc::make_mut(item).appearance; + if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint)) + && let Paint::Gradient(gp) = &mut f.paint + { + gp.rebase(UNIT_BOX, b); + } + if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) { + let to = st.paint_bounds(b); + if let Paint::Gradient(gp) = &mut st.paint { + gp.rebase(UNIT_BOX, to); + } + } + } + items +} + +/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's +/// [`paint_box`]. +type Capture = (NodeId, usize, bool, Paint, Option, Option); + +/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a /// series' captured fill or stroke differs from what the graph stored before the first capture. -fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { +fn store_series_paints(doc: &mut Document, captures: Vec) -> bool { let mut before = SeriesPaintsBefore::new(); - for (graph, index, fill, paint, width) in captures { + for (graph, index, fill, paint, width, from) 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 }; before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). @@ -293,10 +412,17 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + // A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is. + let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint)); + let (paint, unit) = unit_box_paint(paint, from); if fill { - slot.fill = Some(paint); + if !drawn { + (slot.fill, slot.fill_unit_box) = (Some(paint), unit); + } } else { - slot.stroke = Some(paint); + if !drawn { + (slot.stroke, slot.stroke_unit_box) = (Some(paint), unit); + } slot.stroke_width = width; } } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..45ce3e22e9 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -2754,10 +2754,39 @@ fn a_gradient_edit_on_a_series_mark_is_kept() { series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); } -/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and -/// every mark of the series has that fill, placement included, after the graph is drawn again. +/// Where gradient `paint` sits on box `b`: its vector's start and end, then each freeform point, as fractions of the box. +fn gradient_place(paint: &Paint, b: vectorcraft_geom::Rect) -> Vec { + let Paint::Gradient(g) = paint else { panic!("not a gradient: {paint:?}") }; + let points = g.geom.iter().flat_map(|geom| [geom.start, geom.end]).chain(g.freeform.iter().flat_map(|f| f.points.iter().map(|p| p.at))); + points.flat_map(|p| [(p.x - b.x0) / b.width(), (p.y - b.y0) / b.height()]).collect() +} + +/// The box a gradient on `mark`'s fill (`stroke` false) or stroke is placed on. +fn paint_box_of(mark: &vectorcraft_doc::Node, stroke: bool) -> vectorcraft_geom::Rect { + let b = mark.geometric_bounds().unwrap(); + if stroke { vectorcraft_doc::appearance::stroke_paint_bounds(b, mark.appearance.stroke_width()) } else { b } +} + +/// The fill (`stroke` false) or stroke of every path of series 0, the legend swatch included, has a gradient at `place` on +/// its own box, and the gradients are not all at one place in the document. Returns how many paths were checked. +fn series_0_gradients_at(s: &Session, id: NodeId, stroke: bool, place: &[f64]) -> usize { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let paths: Vec<_> = series(&n, 0).children().unwrap().iter().filter(|m| matches!(m.kind, NodeKind::Path { .. })).cloned().collect(); + let paints: Vec = paths.iter().map(|m| if stroke { m.appearance.stroke_paint() } else { m.appearance.fill_paint() }).collect(); + for (m, paint) in paths.iter().zip(&paints) { + let at = gradient_place(paint, paint_box_of(m, stroke)); + assert_eq!(at.len(), place.len(), "{at:?}"); + assert!(at.iter().zip(place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}"); + } + assert!(paints.windows(2).any(|w| w[0] != w[1]), "every mark has the gradient at one place: {paints:?}"); + paths.len() +} + +/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored relative to the mark's box, and every +/// mark of the series has it at the same place on its own box after the graph is drawn again, also after a data change +/// moves and resizes the marks and after the graph is moved. #[test] -fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { +fn a_gradient_placed_on_a_series_mark_takes_the_same_place_on_every_mark() { let (mut s, id, _, marks) = column_marks(); // `paint.setFill` stores the gradient before it is placed. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); @@ -2766,26 +2795,258 @@ fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); let placed = fill_of(&s, marks[0]); assert_ne!(placed, fill, "the command places the gradient"); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let place = gradient_place(&placed, paint_box_of(&mark, false)); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + assert!(series_0_gradients_at(&s, id, false, &place) >= 4); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 50, "dy": 30})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [5, 4], [1, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); } -/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +/// A gradient placed on a series mark's stroke is kept relative to the mark's stroke box, and every mark of the series has +/// it at the same place on its own stroke box after the graph is drawn again. +#[test] +fn a_gradient_placed_on_a_series_marks_stroke_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let place = gradient_place(&mark.appearance.stroke_paint(), paint_box_of(&mark, true)); + assert!(spec(&s, id).series_paints[0].stroke_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, true, &place); +} + +/// A placed gradient picked up with the Eyedropper from art outside the graph is kept relative to the series mark it is +/// dropped on, and every mark of the series has it at the same place on its own box. +#[test] +fn a_placed_gradient_dropped_on_a_series_mark_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [marks[2].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone(); + let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false)); + assert!(spec(&s, id).series_paints[0].fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); +} + +/// A graphic style with a placed gradient applied to a series mark is kept relative to that mark's box, and every mark of +/// the series has the gradient at the same place on its own box. +#[test] +fn a_graphic_style_gradient_applied_to_a_series_mark_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Placed"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Placed", "ids": [marks[2].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone(); + let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false)); + assert!(spec(&s, id).series_paints[0].fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph, +/// and every mark of the series has the gradient's points at the same place on its own box after the graph is drawn again. #[test] fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { let (mut s, id, _, marks) = column_marks(); let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); // `paint.setFill` stores the gradient before its point is recolored. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); - let fill = fill_of(&s, marks[0]); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); let edited = fill_of(&s, marks[0]); - assert_ne!(edited, fill, "the command recolors the point"); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); + let Paint::Gradient(g) = &edited else { panic!("{edited:?}") }; + assert_eq!(g.freeform.as_ref().unwrap().points[1].color.to_hex(), "#00ff00"); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let place = gradient_place(&edited, paint_box_of(&mark, false)); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); + series_0_gradients_at(&s, id, false, &place); +} + +/// A gradient a graph kept in document coordinates (a file saved before gradients were kept relative to the unit box) is +/// drawn on every mark as it was kept. +#[test] +fn a_gradient_kept_in_document_coordinates_is_drawn_as_kept() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let placed = fill_of(&s, marks[0]); + s.edit("Kept in document coordinates", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.fill, slot.fill_unit_box) = (Some(placed.clone()), false); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); + // The same gradient kept for the fill relative to the unit box leaves the stroke kept in document coordinates as it is. + s.edit("Kept fill and stroke", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.fill, slot.fill_unit_box) = (Some(placed.clone()), true); + (slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0)); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [3, 4]]})).unwrap(); + let drawn = marks_of(&s, id, 0); + assert!(drawn.iter().all(|m| m.1 == placed), "{drawn:?}"); + series_0_gradients_at(&s, id, false, &gradient_place(&placed, vectorcraft_doc::GraphicStyle::UNIT_BOX)); +} + +/// A gradient placed on a series path with no height (a flat line's fill) is kept in document coordinates, and every +/// path of the series that takes the kept fill has it at that place in the document. +#[test] +fn a_gradient_placed_on_a_series_path_with_no_height_is_kept_in_document_coordinates() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "line"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 2], [3, 4], [3, 6]]})).unwrap(); + let flat = |s: &Session| { + let parts = series_parts(s, id, 0); + parts.into_iter().find(|m| m.appearance.fill_paint().is_none() && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)).unwrap().id + }; + let line = flat(&s); + s.execute("paint.setFill", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "start": [150, 120], "end": [300, 280]})).unwrap(); + let placed = fill_of(&s, line); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(!kept.fill_unit_box); + assert_eq!(kept.fill, Some(placed.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1], [3, 5], [3, 2]]})).unwrap(); + let fills: Vec = series_parts(&s, id, 0).iter().map(|m| m.appearance.fill_paint()).filter(|p| matches!(p, Paint::Gradient(_))).collect(); + assert!(fills.len() >= 2 && fills.iter().all(|p| *p == placed), "{fills:?}"); +} + +/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series +/// keeps relative to the unit box, leaves the kept gradient, the document and its undo history as they are, on every mark. +#[test] +fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1.3, 2], [3.7, 4], [6.1, 1]]})).unwrap(); + let was = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).collect(); + assert!(drawn.len() >= 4, "{}", drawn.len()); + for mark in drawn { + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + // A mark moved with its gradient shows the kept gradient on its moved box, up to rounding. + let kept = spec(&s, id).series_paints[0].clone(); + for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() { + s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap(); + let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap(); + assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id); + let undo = s.doc().unwrap().history.undo.len(); + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + +/// Writing the fill of a series mark with no height (a column for a value of 0) as it is, after the graph drew the mark's +/// fill from a gradient the series keeps relative to the unit box, leaves the kept gradient and its unit-box flag as they +/// are. +#[test] +fn rewriting_a_drawn_gradient_fill_on_a_series_mark_with_no_height_keeps_the_kept_gradient() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[0, 2], [3, 4], [6, 1]]})).unwrap(); + let flat = series_parts(&s, id, 0) + .into_iter() + .find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_)) && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)) + .unwrap() + .id; + let changed = s.edit("Write the fill as it is", |d, _| Ok(super::capture_series_top_paints(d, &[(flat, true)]))).unwrap(); + assert!(!changed); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series keeps +/// in document coordinates (a file saved before the unit box), leaves the kept gradient and its unit-box flag as they are +/// and records no undo step. +#[test] +fn rewriting_a_series_marks_stroke_drawn_from_a_gradient_kept_in_document_coordinates_keeps_it() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 2})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + let placed = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.stroke_paint(); + s.edit("Kept in document coordinates", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0)); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let undo = s.doc().unwrap().history.undo.len(); + let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| m.appearance.stroke_paint() == placed).collect(); + assert!(drawn.len() >= 3, "{}", drawn.len()); + for mark in drawn { + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + +/// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the +/// band, which takes the kept stroke as its fill, has it at the same place on its own box. +#[test] +fn a_gradient_kept_for_a_lines_stroke_takes_the_same_place_on_its_filled_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "line"); + let line = series_parts(&s, id, 0).into_iter().find(|m| m.appearance.fill_paint().is_none()).unwrap().id; + s.execute("paint.setStroke", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "stroke": true, "start": [150, 120], "end": [300, 280]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.stroke_unit_box && kept.fill.is_none()); + let place = gradient_place(kept.stroke.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = series_parts(&s, id, 0).into_iter().find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_))).unwrap(); + let at = gradient_place(&band.appearance.fill_paint(), band.geometric_bounds().unwrap()); + assert!(at.iter().zip(&place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}"); +} + +/// A series' kept unit-box flags are read from and written to a file, and a file without them reads as document +/// coordinates. +#[test] +fn a_series_paints_unit_box_flags_round_trip() { + let kept = vectorcraft_doc::SeriesPaint { fill_unit_box: true, stroke_unit_box: true, ..Default::default() }; + let text = serde_json::to_string(&kept).unwrap(); + assert_eq!(text, r#"{"fillUnitBox":true,"strokeUnitBox":true}"#); + assert_eq!(serde_json::from_str::(&text).unwrap(), kept); + let old: vectorcraft_doc::SeriesPaint = serde_json::from_str("{}").unwrap(); + assert!(!old.fill_unit_box && !old.stroke_unit_box); } /// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, diff --git a/docs/mcp.md b/docs/mcp.md index b4dfcea774..4507bfdbf3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -835,9 +835,13 @@ them sets what the graph stores. While Global Edit is on, a path directly in a s match (a graph's paths are matches only while the graph is isolated) has what changed on it stored for its series: the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and the object's opacity and blend mode. `addon.arrange.randomizeOpacity` and `addon.arrange.sort` with `apply: "opacity"` store the object opacity, with the -blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement, and -every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's -dashes, caps, joins, arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement relative +to the box of the path it was stored from (a stroke's box is the path's bounds grown by half the weight), and every path +of the series, the legend swatch included, takes it at the same place on its own box when the graph is drawn again, also +after the data changes or the graph moves. The graph saves this as `fillUnitBox` and `strokeUnitBox` in its series +paints. A gradient stored from a path with no width or no height is drawn at the place in the document where it was +stored. So is a gradient a graph stored without these flags. The graph does not store a stroke's dashes, caps, joins, +arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again with the paint they had. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index af05ebad29..a5b3d62925 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1131,7 +1131,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **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), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); 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, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. 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. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. 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, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, 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, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. 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, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. 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. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. 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, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, 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, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. 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). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **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.