mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 23:05:39 +00:00
Graphs: aligned paint and stroke rows given with a design's art are rows of its stack
paint.setFill, paint.setStroke, paint.invert and paint.complement with an item, stroke.set, stroke.setAdvanced, paint.editGradient, paint.setGradientGeom and the paint.freeform edits read an item given with aligned: true and the art of a graph design in a series first as a row of the art's stack, as appearance.setItem and transparency.set do. Each other object edits the row equal to it and an object without one is left as it is. A row the art does not have, a row of the other kind, or a row no other object has is an error, also when the art is the only object given, and the document and its undo history stay as they are. Without stroke, the gradient commands edit the kind of the art's row. The objects and ids the commands report, a gradient spanning the selection, and the last color and last gradient count only the objects whose row was edited.
This commit is contained in:
@@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result<Opti
|
||||
Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id)))
|
||||
}
|
||||
|
||||
/// The [`design_reference`] of an edit aimed at `item`: none for the Appearance panel's active row, which is a row of the
|
||||
/// first selected object's stack.
|
||||
pub(crate) fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
/// `ids` after `of` (see [`design_reference`]), when there is one.
|
||||
pub(crate) fn with_reference(of: Option<NodeId>, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
of.into_iter().chain(ids.iter().copied()).collect()
|
||||
@@ -606,16 +615,22 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on
|
||||
/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and
|
||||
/// when `f` leaves every target as it was, the document and its undo history stay as they are.
|
||||
/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which
|
||||
/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to
|
||||
/// it, a target without one is left as it is, and no target with one is an error. Returns the
|
||||
/// targets `f` was aimed at, in the order of `ids`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn edit_items(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
item: ItemTarget,
|
||||
of: Option<NodeId>,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
fill: bool,
|
||||
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
|
||||
) -> Result<Vec<NodeId>> {
|
||||
edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
|
||||
}
|
||||
|
||||
/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and
|
||||
@@ -627,17 +642,29 @@ pub(crate) fn edit_items_then(
|
||||
s: &mut Session,
|
||||
ids: &[NodeId],
|
||||
item: ItemTarget,
|
||||
of: Option<NodeId>,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
fill: bool,
|
||||
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
|
||||
mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
|
||||
) -> Result<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
) -> Result<Vec<NodeId>> {
|
||||
let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone()));
|
||||
let per = match (item, art) {
|
||||
// Checked even when the art is the only object given, which leaves `ids` empty.
|
||||
(ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => {
|
||||
item.resolve(&art, fill, cmd)?;
|
||||
let per = item.per_target_of(s, of, ids);
|
||||
if per.iter().all(Option::is_none) {
|
||||
return Err(bad(cmd, format!("no appearance item {index}")));
|
||||
}
|
||||
per
|
||||
}
|
||||
_ if ids.is_empty() => return Ok(Vec::new()),
|
||||
_ => item.per_target(s, ids),
|
||||
};
|
||||
let aimed: Vec<NodeId> = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect();
|
||||
let before = s.doc()?.doc.clone();
|
||||
let per = item.per_target(s, ids);
|
||||
super::overprint::edit_counted(s, label, |d| {
|
||||
let mut edited = Vec::new();
|
||||
// The objects whose rows `f` edited: on type, no row means its characters.
|
||||
@@ -656,7 +683,13 @@ pub(crate) fn edit_items_then(
|
||||
let labels = super::graph::capture_label_rows(d, &rows);
|
||||
Ok(count_changed(d, &before, &edited) + usize::from(more || labels))
|
||||
})?;
|
||||
Ok(())
|
||||
Ok(aimed)
|
||||
}
|
||||
|
||||
/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it
|
||||
/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is.
|
||||
pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option<NodeId>, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> {
|
||||
ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?)))
|
||||
}
|
||||
|
||||
/// How many of `ids` differ in `d` from `before`.
|
||||
@@ -665,8 +698,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
|
||||
}
|
||||
|
||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or
|
||||
/// null is an error of command `cmd`.
|
||||
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than
|
||||
/// true, false or null is an error of command `cmd`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
|
||||
check_switches(p, &["stroke"], cmd)?;
|
||||
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
||||
@@ -674,8 +707,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
|
||||
}
|
||||
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
let holder = match reference_of(s, p, item, cmd)? {
|
||||
Some(of) => Some(of),
|
||||
None => {
|
||||
let ids = drop_design_art(d, appearance_targets(s, p)?);
|
||||
ids.get(item.anchor(s, &ids)).copied()
|
||||
}
|
||||
};
|
||||
Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
|
||||
}
|
||||
|
||||
/// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`].
|
||||
|
||||
@@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference,
|
||||
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of,
|
||||
with_reference,
|
||||
};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
@@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
|
||||
/// have.
|
||||
const APPENDED: usize = usize::MAX;
|
||||
|
||||
/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a
|
||||
/// row of the first selected object's stack.
|
||||
fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
|
||||
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
|
||||
return Ok(None);
|
||||
}
|
||||
design_reference(s, p, cmd)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn edit_effects(
|
||||
s: &mut Session,
|
||||
|
||||
@@ -201,7 +201,9 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
first.get_or_insert(r);
|
||||
Ok(())
|
||||
};
|
||||
edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &ids);
|
||||
edit_items(s, &ids, item, of, cmd, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -222,7 +224,9 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
|
||||
})?;
|
||||
let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
|
||||
// The edited gradient is the last one used (`paint.lastGradient`).
|
||||
let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()));
|
||||
let shown = shown_at.and_then(|(id, item)| {
|
||||
s.doc().ok()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()))
|
||||
});
|
||||
if let Some(p) = shown {
|
||||
s.remember_paint(&p);
|
||||
}
|
||||
|
||||
@@ -580,7 +580,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model)
|
||||
.map_err(|e| bad(C, e))?;
|
||||
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
let shown_at = super::appearance::first_edited(s, item, of, &ids);
|
||||
// The objects returned: with a design's art, those with a row equal to the art's.
|
||||
let edited = edit_items(s, &ids, item, of, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -616,9 +619,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.gradient_mode = m;
|
||||
}
|
||||
// The last gradient is the one the first object now shows (the defaults' without objects).
|
||||
let shown = match ids.first() {
|
||||
Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())),
|
||||
None => Some(new_default.clone()),
|
||||
let shown = match shown_at {
|
||||
Some((id, item)) => {
|
||||
s.doc()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten()))
|
||||
}
|
||||
None if ids.is_empty() => Some(new_default.clone()),
|
||||
None => None,
|
||||
};
|
||||
if let Some(shown) = shown {
|
||||
s.remember_paint(&shown);
|
||||
@@ -628,7 +634,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.paint.fill = new_default;
|
||||
}
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
Ok(json!({"ids": edited.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// A painted stroke on a type run without a weight gets 1 pt.
|
||||
@@ -766,7 +772,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets);
|
||||
let model = super::paint::new_color_model(s, &Value::Null);
|
||||
edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
// The objects returned: with a design's art, those with a row equal to the art's.
|
||||
let targets = edit_items(s, &targets, item, of, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
|
||||
@@ -859,6 +859,173 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() {
|
||||
assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]);
|
||||
}
|
||||
|
||||
/// An `item` given with `aligned: true` and the art of a graph design in a series first is a row of the art's stack in the
|
||||
/// paint and stroke commands: each other object edits the row equal to it, and a row the art does not have as a fill or
|
||||
/// stroke as edited, or that no other object has, is an error that leaves the document and its undo history as they are.
|
||||
#[test]
|
||||
fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
|
||||
// The art's row 1 is the blue fill the mark has as row 0, and its row 2 the black stroke the mark has as row 1.
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
let black = rows(&s, mark).items[1].clone();
|
||||
s.edit("Stroke", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items.push(black.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
let fill = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(s, mark);
|
||||
assert_eq!(rows(s, art), art_was, "{cmd}");
|
||||
assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's blue fill");
|
||||
assert_eq!(now.items[1], mark_was.items[1], "{cmd} leaves the mark's stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step");
|
||||
now
|
||||
};
|
||||
let ids = [art.0, mark.0];
|
||||
let now = fill(&mut s, "paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}));
|
||||
assert_eq!(now.items[0].paint(), &green);
|
||||
let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true}));
|
||||
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's");
|
||||
let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}));
|
||||
let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) };
|
||||
assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient");
|
||||
|
||||
let stroke = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(s, mark);
|
||||
assert_eq!(rows(s, art), art_was, "{cmd}");
|
||||
assert_eq!(now.items[0], mark_was.items[0], "{cmd} leaves the mark's fill");
|
||||
assert_ne!(now.items[1], mark_was.items[1], "{cmd} edits the mark's stroke");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step");
|
||||
now
|
||||
};
|
||||
let now = stroke(&mut s, "paint.setStroke", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}));
|
||||
assert_eq!(now.items[1].paint(), &green);
|
||||
let now = stroke(&mut s, "stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}));
|
||||
assert_eq!(now.stroke_at(Some(1)).unwrap().width, 5.0);
|
||||
let now = stroke(&mut s, "stroke.setAdvanced", json!({"ids": ids, "item": 2, "aligned": true, "arrowScale": [200, 200]}));
|
||||
assert_eq!(now.stroke_at(Some(1)).unwrap().arrow_scale, (200.0, 200.0));
|
||||
// Without `stroke`, the kind of the art's row is edited.
|
||||
let now = stroke(&mut s, "paint.editGradient", json!({"ids": ids, "item": 2, "aligned": true, "kind": "linear"}));
|
||||
assert!(matches!(now.items[1].paint(), Paint::Gradient(_)));
|
||||
fill(&mut s, "paint.complement", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
|
||||
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
for (cmd, p, said) in [
|
||||
("stroke.set", json!({"ids": ids, "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}), "appearance item 2 is not a fill"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 0, "aligned": true, "color": "#00ff00"}), "no appearance item 0"),
|
||||
("paint.setFill", json!({"ids": ids, "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"),
|
||||
// The art alone: no other object has the row.
|
||||
("paint.setFill", json!({"ids": [art.0], "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"),
|
||||
("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"),
|
||||
("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"),
|
||||
("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"),
|
||||
] {
|
||||
let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient));
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(said), "{cmd}: {e}");
|
||||
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
|
||||
assert_eq!(format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)), defaults, "{cmd}");
|
||||
}
|
||||
|
||||
// An object with no row equal to the art's is left out of what the commands report as edited.
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let rect = *s.doc().unwrap().selection.subjects().first().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap();
|
||||
let rect_was = rows(&s, rect);
|
||||
let ids = [art.0, mark.0, rect.0];
|
||||
for (cmd, p, key, said) in [
|
||||
("paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}), "objects", json!(1)),
|
||||
("stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}), "objects", json!(1)),
|
||||
("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}), "ids", json!([mark.0])),
|
||||
("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]}), "ids", json!([mark.0])),
|
||||
] {
|
||||
if cmd == "paint.setGradientGeom" {
|
||||
s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": "linear"})).unwrap();
|
||||
let gradient = rows(&s, mark).items[0].clone();
|
||||
s.edit("Gradient", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items[1] = gradient.clone();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(r[key], said, "{cmd}: {r}");
|
||||
assert_eq!(rows(&s, rect), rect_was, "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
// A gradient spanning the selection spans only the objects that take it: a distant object without an equal row does
|
||||
// not move it.
|
||||
s.execute("shape.rectangle", &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).unwrap();
|
||||
let far = *s.doc().unwrap().selection.subjects().first().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [far.0], "color": "#ff00ff"})).unwrap();
|
||||
let far_was = rows(&s, far);
|
||||
let gradient = json!({"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]});
|
||||
let mut placed = Vec::new();
|
||||
for ids in [vec![art.0, mark.0], vec![art.0, mark.0, far.0]] {
|
||||
s.execute("paint.setFill", &json!({"ids": ids, "item": 1, "aligned": true, "gradient": gradient, "span": "selection"})).unwrap();
|
||||
placed.push(rows(&s, mark).items[0].clone());
|
||||
assert_eq!(rows(&s, far), far_was);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(matches!(placed[0].paint(), Paint::Gradient(_)));
|
||||
assert_eq!(placed[1], placed[0]);
|
||||
}
|
||||
|
||||
/// The gradient and freeform gradient commands read an `item` given with `aligned: true` and the art of a graph design in
|
||||
/// a series first as a row of the art's stack, as the paint commands do.
|
||||
#[test]
|
||||
fn aligned_gradient_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
|
||||
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
|
||||
for kind in ["linear", "freeform"] {
|
||||
let (mut s, _, art, mark) = aligned_design_art();
|
||||
// The mark's fill and the art's equal row become the same gradient.
|
||||
s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": kind})).unwrap();
|
||||
let gradient = rows(&s, mark).items[0].clone();
|
||||
// A yellow fill above the mark's gradient is the mark's row 1, as the gradient is the art's row 1.
|
||||
let mut yellow = gradient.clone();
|
||||
*yellow.paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(1.0, 1.0, 0.0));
|
||||
s.edit("Gradient", |d, _| {
|
||||
d.node_mut(art).unwrap().appearance.items[1] = gradient.clone();
|
||||
d.node_mut(mark).unwrap().appearance.items.insert(1, yellow.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let ids = [art.0, mark.0];
|
||||
let edits = if kind == "linear" {
|
||||
vec![
|
||||
("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]})),
|
||||
("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "radial"})),
|
||||
]
|
||||
} else {
|
||||
vec![("paint.freeform.addPoint", json!({"ids": ids, "item": 1, "aligned": true, "at": [5, 5]}))]
|
||||
};
|
||||
for (cmd, p) in edits {
|
||||
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let now = rows(&s, mark);
|
||||
assert_eq!(rows(&s, art), art_was, "{cmd}");
|
||||
assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's gradient fill");
|
||||
assert_eq!(now.items[1..], mark_was.items[1..], "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// The edited gradient is the last gradient (paint.setGradientGeom does not set it).
|
||||
if cmd != "paint.setGradientGeom" {
|
||||
let Paint::Gradient(g) = now.items[0].paint() else { panic!("{cmd}") };
|
||||
assert_eq!(&s.last_gradient, &**g, "{cmd}: the mark's edited gradient is the last gradient");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item
|
||||
/// included.
|
||||
#[test]
|
||||
|
||||
@@ -538,7 +538,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
let paint = if span {
|
||||
// With a design's art, the gradient spans only the objects with a row equal to the art's, which take it.
|
||||
let spanned: Vec<NodeId> = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect();
|
||||
placed_across(&s.doc()?.doc, &spanned, paint, p, fill)
|
||||
} else {
|
||||
paint
|
||||
};
|
||||
// The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just those points of their meshes.
|
||||
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
|
||||
@@ -555,10 +562,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
// The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep
|
||||
// for their characters; a row edit is kept with the label's rows.
|
||||
let characters = std::cell::RefCell::new(Vec::new());
|
||||
edit_items_then(
|
||||
// The objects counted: with a design's art, those with a row equal to the art's.
|
||||
let ids = edit_items_then(
|
||||
s,
|
||||
&ids,
|
||||
item,
|
||||
of,
|
||||
cmd,
|
||||
label,
|
||||
fill,
|
||||
@@ -1004,26 +1013,34 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
return Ok(json!({ "changed": changed as usize }));
|
||||
}
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||||
let (r, shown_item) = match item {
|
||||
let (r, shown_at) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
|
||||
let of = super::appearance::reference_of(s, p, item, cmd)?;
|
||||
// With design art first, the row shown is the one edited in the first object that has it.
|
||||
let shown_at = match of {
|
||||
Some(_) => super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it {
|
||||
ItemTarget::Item { index, .. } => Some((id, Some(index))),
|
||||
ItemTarget::Top => None,
|
||||
}),
|
||||
None => ids.first().map(|id| (*id, Some(index))),
|
||||
};
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index))
|
||||
(json!({ "changed": changed }), shown_at)
|
||||
}
|
||||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None)
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None)))
|
||||
}
|
||||
};
|
||||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||||
if let Some(shown) = shown_at.and_then(|(id, at)| s.doc().ok()?.doc.node(id).map(|n| proxy_paint(n, stroke, at))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
Ok(r)
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -303,7 +303,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.profile = pr.clone();
|
||||
}
|
||||
};
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
// The objects counted: with a design's art, those with a row equal to the art's.
|
||||
let ids = edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -372,7 +374,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
}
|
||||
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
|
||||
let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" };
|
||||
edit_items(s, &ids, item, c, label, false, |n, index| {
|
||||
edit_items(s, &ids, item, None, c, label, false, |n, index| {
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -535,7 +537,8 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
arrows_and_profile(st);
|
||||
}
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
let of = super::appearance::reference_of(s, p, item, C)?;
|
||||
edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| {
|
||||
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
|
||||
arrows_and_profile(st);
|
||||
if let Some(b) = &brush {
|
||||
|
||||
+7
-3
@@ -829,9 +829,13 @@ given), `threeD.mapGraphic`,
|
||||
do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with
|
||||
`aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`,
|
||||
`transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit,
|
||||
move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as
|
||||
it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do
|
||||
for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and
|
||||
move or remove an effect, `paint.setFill`, `paint.setStroke`, `paint.invert` and `paint.complement` with an `item`,
|
||||
`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and the `paint.freeform.*` commands
|
||||
that edit points and lines: each other object edits the row or effect equal to it, and an object without one is left
|
||||
as it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they
|
||||
do for a row no object has, and the other commands are an error. The paint, stroke and gradient commands are also an
|
||||
error when the art's row is not the kind of row they edit, and `paint.editGradient`, `paint.setGradientGeom` and the
|
||||
`paint.freeform.*` commands without `stroke` edit the kind of the art's row. `appearance.moveItem`, `effect.move` and
|
||||
`appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and
|
||||
`appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the
|
||||
art. To change the art, edit the design with `graph.design`.
|
||||
|
||||
Reference in New Issue
Block a user