mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Swatches: global swatch edits also update graph designs and the art of brushes
Squashed from #4489 onto main.
This commit is contained in:
@@ -108,9 +108,10 @@ impl Document {
|
||||
Some(list.remove(i))
|
||||
}
|
||||
|
||||
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
|
||||
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// Visit every color that can link to a swatch (solid paints and gradient stops of fills,
|
||||
/// strokes and text runs) in the art (opacity masks included), symbol definitions, graph
|
||||
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles
|
||||
/// and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// `f(colour, link, tint)` may change them and returns true when it did
|
||||
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
|
||||
/// copied. Returns the number of changed paints.
|
||||
@@ -119,7 +120,10 @@ impl Document {
|
||||
for s in &mut self.symbols {
|
||||
n += map_tree(&mut s.art, f);
|
||||
}
|
||||
for p in &mut self.patterns {
|
||||
for g in &mut self.graph_designs {
|
||||
n += map_tree(&mut g.art, f);
|
||||
}
|
||||
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
|
||||
n += map_trees(&mut p.art, f);
|
||||
}
|
||||
for gs in &mut self.graphic_styles {
|
||||
@@ -307,6 +311,19 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::map_solid_paints`] for art a document keeps outside its trees, such as the art of a
|
||||
/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only
|
||||
/// the nodes holding a changed paint are copied. Returns the number of changed paints.
|
||||
pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
*n = new;
|
||||
count
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
@@ -400,6 +417,25 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_reaches_graph_designs_and_the_tile_that_pattern_cancel_puts_back() {
|
||||
let (mut d, a, _) = doc();
|
||||
let art = d.node_arc(a).cloned().unwrap();
|
||||
d.graph_designs.push(crate::GraphDesign { name: "Bar".into(), art: art.clone() });
|
||||
d.pattern_edit =
|
||||
Some(crate::PatternEdit { pattern: "Tile".into(), layer: NodeId(0), original: Some(crate::PatternDef::new("Tile", vec![art])) });
|
||||
let mut loose = d.node(a).unwrap().clone();
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
|
||||
assert_eq!(d.map_solid_paints(&mut to_blue), 3, "the layer's rectangle, the design and the Cancel tile");
|
||||
assert_eq!(d.graph_designs[0].art.appearance.fill_paint(), linked(blue, "Brand"));
|
||||
let tile = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0];
|
||||
assert_eq!(tile.appearance.fill_paint(), linked(blue, "Brand"));
|
||||
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 1);
|
||||
assert_eq!(loose.appearance.fill_paint(), linked(blue, "Brand"));
|
||||
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_within_ids_visits_each_paint_once() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_doc::pattern::pattern_paint;
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors};
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
|
||||
use super::paint::paint_from;
|
||||
@@ -257,7 +257,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for n in &names {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut unlink))
|
||||
Ok(map_swatch_links(d, &mut unlink))
|
||||
})?;
|
||||
map_default_paints(s, &mut unlink);
|
||||
Ok(json!({"deleted": names, "unlinked": unlinked}))
|
||||
@@ -529,6 +529,130 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
|
||||
/// the brushes in its library, which is stored again only when a brush's paint changed. A Hue Shift
|
||||
/// key that was the color of a recolored paint takes the paint's new color. Returns the number of
|
||||
/// changed paints. Stored entries are stored again as they were read, except that a brush whose
|
||||
/// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does
|
||||
/// not read are kept at every level.
|
||||
pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
|
||||
use vectorcraft_brush::{Brush, BrushKind, Colorization};
|
||||
let mut n = d.map_solid_paints(f);
|
||||
// Each stored entry, its brush when it parses, the brush as written before the edit (only
|
||||
// when its paint changed), and whether its paint changed.
|
||||
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
|
||||
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
|
||||
None => {
|
||||
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
|
||||
}
|
||||
};
|
||||
let mut in_brushes = 0;
|
||||
for (_, b, base, changed) in lib.iter_mut() {
|
||||
let Some(b) = b else { continue };
|
||||
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
|
||||
let key = match b.kind.colorization() {
|
||||
Colorization::HueShift { key } => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
let before = key.map(|_| art_paint_colors(b.art()));
|
||||
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::<usize>();
|
||||
*changed = in_brush > 0;
|
||||
if *changed {
|
||||
*base = before_edit;
|
||||
}
|
||||
in_brushes += in_brush;
|
||||
let (Some(key), Some(before)) = (key, before) else { continue };
|
||||
// Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them.
|
||||
let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex();
|
||||
let after = art_paint_colors(b.art());
|
||||
// The key moves when the art's fills and strokes (not opacity masks) had the key color and
|
||||
// none has it now, to the first new color of a paint that had it.
|
||||
let moved = before.iter().any(|c| same(c, &key)) && !after.iter().any(|c| same(c, &key));
|
||||
let new_key = before.iter().zip(&after).find(|(o, _)| same(o, &key)).map(|(_, n)| *n);
|
||||
if let Some(key) = new_key.filter(|_| moved) {
|
||||
let c = match &mut b.kind {
|
||||
BrushKind::Art(a) => &mut a.colorization,
|
||||
BrushKind::Scatter(s) => &mut s.colorization,
|
||||
BrushKind::Pattern(p) => &mut p.colorization,
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue,
|
||||
};
|
||||
*c = Colorization::HueShift { key };
|
||||
}
|
||||
}
|
||||
if in_brushes > 0 {
|
||||
let lib = lib
|
||||
.into_iter()
|
||||
.map(|(raw, b, base, changed)| match b.filter(|_| changed) {
|
||||
Some(b) => merge_stored(raw, &base, serde_json::to_value(b).unwrap_or_default()),
|
||||
None => raw,
|
||||
})
|
||||
.collect();
|
||||
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib));
|
||||
n += in_brushes;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that
|
||||
/// `base` (the definition as read from `raw` and written before the edit) does not hold. An object
|
||||
/// keeps the fields of `raw` that `base` leaves out, and drops the fields that `base` holds and
|
||||
/// `new` leaves out. An array takes the length of `new`, each element merged with the element at
|
||||
/// the same index in `raw`.
|
||||
fn merge_stored(raw: Value, base: &Value, new: Value) -> Value {
|
||||
match (raw, new) {
|
||||
(Value::Object(mut raw), Value::Object(new)) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in new {
|
||||
let v = match raw.remove(&k) {
|
||||
Some(r) => merge_stored(r, base.get(k.as_str()).unwrap_or(&Value::Null), v),
|
||||
None => v,
|
||||
};
|
||||
out.insert(k, v);
|
||||
}
|
||||
out.extend(raw.into_iter().filter(|(k, _)| base.get(k.as_str()).is_none()));
|
||||
Value::Object(out)
|
||||
}
|
||||
(Value::Array(raw), Value::Array(new)) => {
|
||||
let mut raw = raw.into_iter();
|
||||
let merged = new.into_iter().enumerate().map(|(i, v)| match raw.next() {
|
||||
Some(r) => merge_stored(r, base.get(i).unwrap_or(&Value::Null), v),
|
||||
None => v,
|
||||
});
|
||||
Value::Array(merged.collect())
|
||||
}
|
||||
(_, new) => new,
|
||||
}
|
||||
}
|
||||
|
||||
/// The solid colors of the fills and strokes under `nodes` (type runs' fills and strokes included,
|
||||
/// opacity masks left out), in tree order and with repeats, the paints [`vectorcraft_brush::art_colors`]
|
||||
/// counts.
|
||||
fn art_paint_colors(nodes: Vec<&vectorcraft_doc::Node>) -> Vec<Color> {
|
||||
fn walk(n: &vectorcraft_doc::Node, out: &mut Vec<Color>) {
|
||||
for it in &n.appearance.items {
|
||||
let (visible, paint) = match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => (f.visible, &f.paint),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => (s.visible, &s.paint),
|
||||
};
|
||||
out.extend(paint.color().filter(|_| visible));
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
|
||||
for r in &t.runs {
|
||||
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
|
||||
out.extend([Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color));
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(c, out);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
walk(n, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.edit";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
@@ -592,7 +716,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t)))
|
||||
Ok(map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t)))
|
||||
})?;
|
||||
relink.defaults(s);
|
||||
Ok(json!({"name": to, "relinked": relinked}))
|
||||
@@ -747,7 +871,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for n in merged {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut relink))
|
||||
Ok(map_swatch_links(d, &mut relink))
|
||||
})?;
|
||||
map_default_paints(s, &mut relink);
|
||||
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
|
||||
@@ -803,7 +927,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|w| w.spot && matches!(w.paint, Paint::Solid { color: Color::Lab { .. }, swatch: None, .. }))
|
||||
.filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?)))
|
||||
.collect();
|
||||
let relinked = d.map_solid_paints(&mut follow_bases(&bases));
|
||||
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
|
||||
Ok((relinked, bases))
|
||||
})?;
|
||||
map_default_paints(s, &mut follow_bases(&bases));
|
||||
@@ -880,7 +1004,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
|
||||
let g = &mut d.swatch_groups[gi];
|
||||
(g.name, g.swatches) = (name.clone(), swatches);
|
||||
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
|
||||
let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
|
||||
let relinked = if relinks.is_empty() { 0 } else { map_swatch_links(d, &mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
|
||||
Ok(GroupEdit { name, swatches: names, relinks, relinked })
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatc
|
||||
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
|
||||
use super::menucmds::squash;
|
||||
use super::package::free_name;
|
||||
use super::swatch::{map_default_paints, str_list, swatch_json};
|
||||
use super::swatch::{map_default_paints, map_swatch_links, str_list, swatch_json};
|
||||
use super::{Color, CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -644,7 +644,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(d.swatches, d.swatch_groups) = (defaults.into(), groups.into());
|
||||
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
|
||||
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
|
||||
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));
|
||||
map_swatch_links(d, &mut |_, l, _| unlink_dead(&live, l));
|
||||
return Ok(added);
|
||||
}
|
||||
for (i, w) in defaults.into_iter().enumerate() {
|
||||
|
||||
@@ -528,3 +528,203 @@ fn global_swatch_edits_reach_opacity_mask_art() {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
|
||||
}
|
||||
|
||||
/// The fills of graph design "Bar" and of the art of brush "Holder", as `place: color, link`.
|
||||
fn fills_outside_the_layers(s: &Session) -> Vec<String> {
|
||||
let d = doc(s);
|
||||
let mut out = vec![];
|
||||
let mut tree = |place: &str, n: &vectorcraft_doc::Node| {
|
||||
n.walk(&mut |m| {
|
||||
if let Paint::Solid { color, swatch, .. } = m.appearance.fill_paint() {
|
||||
out.push(format!("{place}: {}, {}", color.to_hex(), swatch.as_deref().unwrap_or("-")));
|
||||
}
|
||||
})
|
||||
};
|
||||
tree("design", &d.graph_designs.iter().find(|g| g.name == "Bar").unwrap().art);
|
||||
vectorcraft_brush::find(d, "Holder").unwrap().art().into_iter().for_each(|n| tree("brush art", n));
|
||||
out
|
||||
}
|
||||
|
||||
/// A global swatch edit that recolors a Hue Shift brush's key color in its art moves the key to the
|
||||
/// new color, so strokes still paint that art with the stroke color.
|
||||
#[test]
|
||||
fn global_swatch_edits_move_a_hue_shift_key() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Key", "ids": [id.0], "params": {"colorization": {"method": "hueShift"}}}));
|
||||
let key = |s: &mut Session| run(s, "brush.get", json!({"name": "Key"}))["colorization"]["key"].clone();
|
||||
let (red, blue) = (json!(Color::rgb(1.0, 0.0, 0.0)), json!(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(key(&mut s), red);
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
assert_eq!(key(&mut s), blue);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(key(&mut s), red);
|
||||
|
||||
// A paint in the key color that only an opacity mask uses leaves the key where it is.
|
||||
run(&mut s, "swatch.new", json!({"name": "MaskTone", "color": "#ff0000", "global": true}));
|
||||
let obj = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [obj.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]}));
|
||||
let m = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "MaskTone", "ids": [m.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Masked", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}}));
|
||||
let masked = |s: &mut Session| run(s, "brush.get", json!({"name": "Masked"}))["colorization"]["key"].clone();
|
||||
assert_eq!(masked(&mut s), red);
|
||||
run(&mut s, "swatch.edit", json!({"name": "MaskTone", "color": "#0000ff"}));
|
||||
assert_eq!(masked(&mut s), red);
|
||||
|
||||
// With the art and its mask on one swatch, the key takes the art's new color, not the mask's.
|
||||
run(&mut s, "swatch.new", json!({"name": "Both", "color": "#ffffff", "global": true}));
|
||||
let obj = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Both", "ids": [obj.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]}));
|
||||
let m = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Both", "tint": 50, "ids": [m.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Both", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}}));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Both", "color": "#0000ff"}));
|
||||
assert_eq!(run(&mut s, "brush.get", json!({"name": "Both"}))["colorization"]["key"], blue);
|
||||
}
|
||||
|
||||
/// A global swatch edit that recolors brush art keeps the stored library entries that do not parse
|
||||
/// as brushes.
|
||||
#[test]
|
||||
fn global_swatch_edits_keep_unreadable_brush_entries() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]}));
|
||||
let junk = json!({"name": "Unreadable", "kind": 7});
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") };
|
||||
entries.push(junk.clone());
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
|
||||
assert!(stored.as_array().is_some_and(|a| a.contains(&junk)), "{stored}");
|
||||
assert!(vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").is_some());
|
||||
}
|
||||
|
||||
/// A global swatch edit that recolors brush art keeps the fields of stored brush entries that the
|
||||
/// brush definition does not read, both on the brush it recolors and on the brushes it leaves alone,
|
||||
/// and inside the art too. A swatch delete that unlinks the art drops the link and keeps those fields.
|
||||
#[test]
|
||||
fn global_swatch_edits_keep_unread_brush_fields() {
|
||||
// Adds `"extra": 8` to every object under `v` that links a swatch.
|
||||
fn tag_links(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.contains_key("swatch") {
|
||||
o.insert("extra".into(), json!(8));
|
||||
}
|
||||
o.values_mut().for_each(tag_links);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(tag_links),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The objects under `v` that carry `"extra": 8`.
|
||||
fn tagged(v: &Value) -> Vec<&Value> {
|
||||
match v {
|
||||
Value::Object(o) => o.get("extra").filter(|e| **e == json!(8)).map(|_| v).into_iter().chain(o.values().flat_map(tagged)).collect(),
|
||||
Value::Array(a) => a.iter().flat_map(tagged).collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]}));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") };
|
||||
for e in entries.iter_mut() {
|
||||
if let Some(o) = e.as_object_mut() {
|
||||
o.insert("extra".into(), json!(7));
|
||||
}
|
||||
tag_links(e);
|
||||
}
|
||||
let links = tagged(&doc.unknown[vectorcraft_brush::DOC_KEY]).len();
|
||||
assert!(links > 0, "{}", doc.unknown[vectorcraft_brush::DOC_KEY]);
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
|
||||
let entries = stored.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
assert!(entries.len() > 1, "{stored}");
|
||||
assert!(entries.iter().all(|e| e["extra"] == json!(7)), "{stored}");
|
||||
assert_eq!(tagged(stored).len(), links, "{stored}");
|
||||
let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap();
|
||||
let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex()));
|
||||
assert_eq!(fill, Some(Some("#0000ff".to_string())));
|
||||
|
||||
run(&mut s, "swatch.delete", json!({"name": "Tone"}));
|
||||
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
|
||||
let unlinked = tagged(stored);
|
||||
assert_eq!(unlinked.len(), links, "{stored}");
|
||||
assert!(unlinked.iter().all(|o| o.get("swatch").is_none()), "{stored}");
|
||||
let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap();
|
||||
let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex()));
|
||||
assert_eq!(fill, Some(Some("#0000ff".to_string())));
|
||||
}
|
||||
|
||||
/// A global swatch edit, rename, delete and Default Swatches with replace change the art of graph
|
||||
/// designs and brushes: the art follows the swatch's color and name, or keeps its color unlinked,
|
||||
/// each in one undo step.
|
||||
#[test]
|
||||
fn global_swatch_edits_reach_graph_designs_and_brush_art() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
for (cmd, p) in [("graph.design", json!({"new": "Bar"})), ("brush.new", json!({"type": "art", "name": "Holder"}))] {
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, cmd, p);
|
||||
run(&mut s, "edit.clear", json!({"ids": [id.0]}));
|
||||
}
|
||||
let both = |fill: &str| vec![format!("design: {fill}"), format!("brush art: {fill}")];
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone"));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
|
||||
let r = run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
assert_eq!(r["relinked"], 2, "{r}");
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#0000ff, Tone"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone"));
|
||||
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "newName": "Hue"}));
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Hue"));
|
||||
// Default Swatches with replace drops the links to the swatches it removes.
|
||||
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let r = run(&mut s, "swatch.delete", json!({"name": "Hue"}));
|
||||
assert_eq!(r["unlinked"], 2, "{r}");
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2);
|
||||
}
|
||||
|
||||
/// A global swatch edited while a pattern is being edited also changes the tile that pattern
|
||||
/// editing's Cancel puts back.
|
||||
#[test]
|
||||
fn global_swatch_edits_reach_the_tile_that_pattern_cancel_puts_back() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let pattern = run(&mut s, "object.pattern.make", json!({"edit": false}))["name"].as_str().unwrap().to_string();
|
||||
run(&mut s, "object.pattern.edit", json!({"name": pattern}));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
run(&mut s, "object.pattern.cancel", json!({}));
|
||||
let mut fills = vec![];
|
||||
for n in &doc(&s).pattern(&pattern).unwrap().art {
|
||||
n.walk(&mut |m| fills.push(m.appearance.fill_paint()));
|
||||
}
|
||||
assert_eq!(fills, [Paint::Solid { color: Color::rgb(0.0, 0.0, 1.0), swatch: Some("Tone".into()), tint: 1.0 }]);
|
||||
}
|
||||
|
||||
+2
-1
@@ -504,7 +504,8 @@ Errors (unknown tool, bad arguments, a disabled or failing command) come back as
|
||||
|
||||
Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour;
|
||||
`swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a
|
||||
global or spot swatch follow its colour and name, as one undo step); `swatch.delete {names, unlink?}` deletes
|
||||
global or spot swatch follow its colour and name, as one undo step, in the art, opacity masks, symbols, graph
|
||||
designs, pattern tiles, graphic styles and the art of brushes); `swatch.delete {names, unlink?}` deletes
|
||||
swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked).
|
||||
`swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient
|
||||
or pattern; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected
|
||||
|
||||
@@ -872,6 +872,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Paint and appearance (M3):**
|
||||
- **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too.
|
||||
- **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them.
|
||||
|
||||
Reference in New Issue
Block a user