Merge pull request #7643 from dotCooCoo/fix/aligned-paint-rows-of-design-art

Graphs: aligned paint and stroke rows given with a design's art are rows of its stack
This commit is contained in:
BFlat
2026-11-15 12:37:29 +07:00
committed by GitHub
10 changed files with 342 additions and 64 deletions
+52 -12
View File
@@ -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()
@@ -610,16 +619,23 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// ([`capture_series_changes`], [`super::graph::capture_label_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| {
) -> Result<Vec<NodeId>> {
edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| {
let series = capture_series_changes(before, d, edited);
// The rows `f` edited are kept by `edit_items_then`.
let labels = super::graph::capture_label_changes(before, d, edited, false);
@@ -636,17 +652,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.
@@ -665,7 +693,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`.
@@ -674,8 +708,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) {
@@ -683,8 +717,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`].
+2 -10
View File
@@ -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,
+11 -7
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (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; the points on a label's characters are not stored → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (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; the points on a label's characters are not stored → {index}",
has_doc,
add_point
),
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Freeform Point",
[],
None,
"{index? (default: the selected point), at?: [x,y] (document coordinates), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
"{index? (default: the selected point), at?: [x,y] (document coordinates), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
has_doc,
set_point
),
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Freeform Point",
[],
None,
"{index? (default: the selected point), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
"{index? (default: the selected point), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
has_doc,
delete_point
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Line",
[],
None,
"{points: [index, …] (at least two), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} join points with a smooth line through them, in order → {line}",
"{points: [index, …] (at least two), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} join points with a smooth line through them, in order → {line}",
has_doc,
add_line
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Split Freeform Line",
[],
None,
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.editGradient), target?: \"object\"|\"contents\"} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
has_doc,
split_line
),
@@ -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);
}
+16 -8
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient 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, and on its characters their gradient without its placement, fitted to each label",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), aligned?: bool (as paint.setFill; without stroke, the kind of the row edited), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient 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, and on its characters their gradient without its placement, fitted to each label",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 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, and on its characters their gradient without the placement this command sets, 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), aligned?: bool (as paint.editGradient), 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, and on its characters their gradient without the placement this command sets, fitted to each label",
has_doc,
set_gradient_geom
),
@@ -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
{
+182
View File
@@ -869,6 +869,188 @@ 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.lastColor", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
assert_eq!(Some(s.last_solid), now.items[0].paint().color());
// Without `aligned`, the art is left out and the item is a row of the mark.
let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 0}));
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's inverted color is the last color, not the art's");
s.last_solid = vectorcraft_color::Color::rgb(0.5, 0.5, 0.5);
let now = fill(&mut s, "paint.complement", json!({"ids": ids, "item": 0}));
assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's complement 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 now = fill(&mut s, "paint.lastGradient", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false}));
assert!(matches!(now.items[0].paint(), Paint::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"),
// The weight steps take no row: they step each object's active or top stroke.
("stroke.weightUp", json!({"ids": ids, "item": 2}), "unknown parameter `item`"),
("stroke.weightUp", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"),
("stroke.weightDown", json!({"ids": ids, "item": 2}), "unknown parameter `item`"),
("stroke.weightDown", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"),
] {
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 Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as
/// the rows are not.
#[test]
+30 -19
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), 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), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke",
[],
None,
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
"{…as paint.setFill, item?: the stroke item to set, aligned?: bool (as paint.setFill)} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
has_doc,
|s, p| { set_paint(s, p, false) }
),
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Complement",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
),
@@ -114,7 +114,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Apply Last Color",
[],
Some(","),
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
has_doc,
|s, p| {
let paint = Paint::solid(s.last_solid);
@@ -126,7 +126,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Apply Last Gradient",
[],
Some("."),
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, aligned?: bool (as paint.setFill), target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
has_doc,
|s, p| {
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
@@ -553,7 +553,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, Direct Selection or the Lasso: a solid
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
@@ -570,10 +577,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,
@@ -1070,30 +1079,32 @@ 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, edited) = match item {
ItemTarget::Item { index, .. } => {
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
let (r, shown_at) = match item {
ItemTarget::Item { .. } => {
let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
let of = super::appearance::reference_of(s, p, item, cmd)?;
// The row shown is the one edited in the first object that has it: an aligned row maps to each object's equal row.
let shown_at = super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it {
ItemTarget::Item { index, .. } => Some((id, Some(index))),
ItemTarget::Top => None,
});
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), Some(ids))
(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, None)
(super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None)))
}
};
// The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none.
let shown_ids = match edited {
Some(ids) => ids,
None => 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)
+8 -5
View File
@@ -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, and on its characters the stroke options they take → {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, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
[],
None,
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?|id?, target?: \"object\"|\"contents\"} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)",
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, aligned?: bool (as stroke.set), ids?|id?, target?: \"object\"|\"contents\"} Stroke panel extras (with nothing selected, all but brush set up the next object drawn)",
has_doc,
stroke_advanced
),
@@ -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 {
+33
View File
@@ -52,6 +52,39 @@ fn invert_and_complement_recolour_the_active_item() {
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
}
/// With a mixed selection, Invert on the Appearance panel's active row remembers the color of the row it recolors in the
/// first object, the row equal to the active one, which need not have the active row's index.
#[test]
fn invert_of_the_active_row_remembers_the_equal_row_it_recolors() {
let mut s = session();
let rect = |s: &mut Session, x: f64| {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
NodeId(r["id"].as_u64().unwrap())
};
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 100.0));
// `a` paints first: [red Fill, Stroke, blue Fill]. `b`, selected first: [green Fill, red Fill, Stroke].
let look = Appearance::basic(Paint::solid(Color::from_hex("#ff0000").unwrap()), Paint::solid(Color::BLACK), 1.0);
let (mut a_look, mut b_look) = (look.clone(), look.clone());
let (mut green, mut blue) = (look.items[0].clone(), look.items[0].clone());
*green.paint_mut() = Paint::solid(Color::from_hex("#00ff00").unwrap());
*blue.paint_mut() = Paint::solid(Color::from_hex("#0000ff").unwrap());
b_look.items.insert(0, green);
a_look.items.push(blue);
s.edit("Looks", |d, _| {
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Shared::new(a_look.clone());
d.node_mut(b).unwrap().appearance = vectorcraft_doc::Shared::new(b_look.clone());
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
s.last_solid = Color::from_hex("#808080").unwrap();
s.execute("paint.invert", &json!({})).unwrap();
let hexes = (item_hex(&s, a, 0), item_hex(&s, a, 2), item_hex(&s, b, 0), item_hex(&s, b, 1));
assert_eq!(hexes, ("#00ffff".into(), "#0000ff".into(), "#00ff00".into(), "#00ffff".into()));
assert_eq!(s.last_solid.to_hex(), "#00ffff", "the last color is the inverted red, not row 1 of the first object");
}
#[test]
fn the_mixed_proxy_compares_the_active_item() {
let mut s = session();
+7 -3
View File
@@ -846,9 +846,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`, `paint.complement`, `paint.lastColor` and
`paint.lastGradient` 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`.
+1
View File
@@ -1185,6 +1185,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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.
The transparency, paint, stroke, gradient and overprint commands and Expand Appearance also leave the art of a column or marker design inside a series unchanged. The graph draws that art again from the design (`docs/mcp.md`).
When that art is given first with `aligned: true`, the paint, stroke and gradient commands read the `item` as a row of the art's stack, and each other object edits its equal row.
- **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.
While a drag rotates the view, a dial at the center of the canvas shows the angle.
- **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation).