M3.59: tints of global and spot colours, tint swatches and Global colour balance

A colour linked to a global or spot swatch keeps a tint (Paint::Solid.tint, 1 by default and
left out of files at 100 %): paint.setFill/setStroke {swatch, tint} apply the swatch at that
percentage, swatch edits recolour every linked colour at its own tint, and Color::tinted
resolves it (CMYK and Gray inks scale, RGB mixes toward white). swatch.new saves a tint as a
tint swatch ("Ink 40%") linked to its base. Spot tints separate as that percentage of their
plate (Separations Preview, PDF Separation value) instead of an ink-ratio guess. Adjust Color
Balance's Global mode (tint) shifts the tints of linked colours. The Color panel shows Tint
mode (the swatch chip, its name and a T slider) for linked colours; the Swatches panel
highlights the tint swatch in use. Gradient stops get the same link and tint fields (the
swatch walks cover them), so GradientStop is no longer Copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 02:29:17 +07:00
co-authored by Claude Opus 5.5
parent e94748dc17
commit b2ececc7b4
42 changed files with 918 additions and 277 deletions
+67 -35
View File
@@ -2,7 +2,7 @@
//! three Blend commands. They recolour the selected objects and everything inside them through the
//! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text
//! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are
//! unlinked when they change.
//! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}. Global mode (tints of global and spot colours) isn't available yet",
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}",
has_selection,
adjust_balance
),
@@ -131,34 +131,37 @@ pub(crate) fn to_gray(c: Color) -> Color {
c.in_model(Model::Gray)
}
/// Apply `f` to a paint; returns whether anything changed.
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
match p {
Paint::Solid { color, swatch } => {
let n = f(*color);
if n != *color {
*color = n;
*swatch = None;
return true;
}
false
}
Paint::Gradient(g) => {
let mut ch = false;
for st in &mut g.gradient.stops {
let n = f(st.color);
if n != st.color {
st.color = n;
ch = true;
}
}
if ch {
g.swatch = None;
}
ch
}
_ => false,
/// What a recolouring pass applies to a colour that can link to a swatch (a solid colour or a
/// gradient stop): `(colour, link, tint)`, true when it changed them.
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient
/// that changes is no longer its gradient swatch's. Returns whether anything changed.
fn map_links(p: &mut Paint, f: &LinkMap) -> bool {
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
if changed && let Paint::Gradient(g) = p {
g.swatch = None;
}
changed
}
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
/// anything changed.
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
map_links(p, &|c, link, _| {
let n = f(*c);
if n == *c {
return false;
}
*c = n;
*link = None;
true
})
}
/// The identity colour map (a pass that only maps linked colours).
fn same(c: Color) -> Color {
c
}
/// What a recolouring pass covers: fills (with the points of gradient meshes), strokes, the pixels
@@ -191,6 +194,8 @@ impl Scope {
/// change are kept as copies.
pub(crate) struct Recolor<'a> {
f: &'a dyn Fn(Color) -> Color,
/// Maps solid colours and gradient stops with their link and tint in place of `f`.
links: Option<&'a LinkMap<'a>>,
scope: Scope,
/// Image key → the key of its recoloured copy (`None`: unchanged).
images: HashMap<String, Option<String>>,
@@ -203,7 +208,13 @@ pub(crate) struct Recolor<'a> {
impl<'a> Recolor<'a> {
pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self {
Self { f, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
Self { f, links: None, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
}
/// A pass that maps only solid colours and gradient stops, with their swatch link and tint
/// (`links`); meshes and images keep their colours.
pub(crate) fn links(scope: Scope, links: &'a LinkMap<'a>) -> Self {
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
@@ -294,7 +305,10 @@ impl<'a> Recolor<'a> {
fn paint(&mut self, d: &Document, p: &mut Paint) {
let changed = match p {
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
_ => map_paint(p, self.f),
_ => match self.links {
Some(f) => map_links(p, f),
None => map_paint(p, self.f),
},
};
self.changed += changed as usize;
}
@@ -348,12 +362,16 @@ impl<'a> Recolor<'a> {
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
}
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step.
fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Value> {
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let scope = Scope::of(p);
let n = s.edit(label, |d, _| Ok(Recolor::new(scope, f).run(d, &ids)))?;
let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?;
Ok(json!({ "changed": n }))
}
@@ -376,7 +394,8 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
None if given(&["c", "m", "y", "k"]) => "cmyk".into(),
None if given(&["gray"]) => "gray".into(),
None if given(&["r", "g", "b"]) => "rgb".into(),
None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k or gray adjustments")),
None if given(&["tint"]) => "global".into(),
None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k, gray or tint adjustments")),
};
let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
let cl = |v: f32| v.clamp(0.0, 1.0);
@@ -403,7 +422,20 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
})
}
"global" => {
return Err(bad(C, "mode `global` shifts the tints of global and spot colours, which aren't supported yet: use rgb, cmyk or gray"));
// Each linked colour moves to the shifted tint of its global swatch's current colour.
let d = &s.doc()?.doc;
let bases: HashMap<String, Color> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
let dt = g("tint");
let shift = move |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let Some(base) = link.as_ref().and_then(|l| bases.get(l)) else { return false };
let t = cl(*tint + dt);
if t == *tint {
return false;
}
(*tint, *c) = (t, base.tinted(t));
true
};
return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
}
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
};
+9
View File
@@ -268,6 +268,15 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
}
// Tints are their converted swatch's colour at their tint (exact ink percentages).
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
Some((_, base)) if *tint < 1.0 => {
*c = base.tinted(*tint);
true
}
_ => false,
});
Ok(())
})?;
Ok(json!({ "changed": changed }))
+1 -1
View File
@@ -218,7 +218,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let stop = |offset: f32, opacity: f32| GradientStop { offset, color: vectorcraft_color::Color::WHITE, opacity, midpoint: 0.5 };
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 });
+2
View File
@@ -124,6 +124,8 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
color,
opacity: opacity.clamp(0.0, 1.0) as f32,
midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32,
swatch: None,
tint: 1.0,
})
})
.collect::<Parsed<Vec<_>>>()?;
+38 -12
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), 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?, 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)} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, 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 fits each object, keeping its aspect), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), 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?, 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)} sets the selection's fill and the default (new art fits a gradient to itself)",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -304,19 +304,18 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
let st = s.doc()?;
let d = &s.doc()?.doc;
// A pattern definition works as its swatch even without a swatch entry.
if st.doc.swatch(name).is_none() && st.doc.pattern(name).is_some() {
if d.swatch(name).is_none() && d.pattern(name).is_some() {
return Ok(Some(vectorcraft_doc::pattern::pattern_paint(name)));
}
let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let mut paint = sw.paint.clone();
if sw.global
&& let Paint::Solid { swatch, .. } = &mut paint
{
*swatch = Some(name.to_string());
}
return Ok(Some(paint));
let sw = d.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let tint = tint_param(p, "paint")?;
return match &sw.paint {
Paint::Solid { .. } => swatch_solid(d, name, tint).map(Some).map_err(|e| bad("paint", e)),
_ if tint.is_some() => Err(bad("paint", "`tint` applies to global and spot colours")),
other => Ok(Some(other.clone())),
};
}
if let Some(g) = p.get("gradient") {
let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?;
@@ -330,8 +329,35 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
Ok(None)
}
/// The `tint` param (a percentage) as 0..1.
pub(crate) fn tint_param(p: &Value, cmd: &str) -> Result<Option<f32>> {
p.get("tint")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().map(|t| (t / 100.0).clamp(0.0, 1.0) as f32).ok_or_else(|| bad(cmd, "`tint` must be a number (%)")))
.transpose()
}
/// The solid paint colour swatch `name` applies at `tint` (0..1): a global or spot colour links to
/// itself (default 100%), a tint swatch to its base (default: its own tint), a process colour is
/// just its colour (it has no tints).
pub(crate) fn swatch_solid(d: &Document, name: &str, tint: Option<f32>) -> std::result::Result<Paint, String> {
let sw = d.swatch(name).ok_or_else(|| format!("no swatch `{name}`"))?;
if sw.paint.color().is_none() {
return Err(format!("`{name}` isn't a colour swatch"));
}
match (d.swatch_link(name), tint) {
// A tint swatch whose base is gone keeps its colour.
(Some((base, own)), _) => Ok(d.tint_paint(&base, tint.unwrap_or(own)).unwrap_or_else(|| sw.paint.clone())),
(None, None) => Ok(sw.paint.clone()),
(None, Some(_)) => Err(format!("`{name}` is a process colour: tints apply to global and spot colours")),
}
}
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
if p.get("tint").is_some() && p.get("swatch").is_none() {
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
}
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
apply_paint(s, p, paint, fill)
}
+61 -29
View File
@@ -1,9 +1,9 @@
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::swatches::{color_name, node_colors};
use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors};
use vectorcraft_doc::{Document, NodeId};
use super::paint::paint_from;
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Swatch",
["Window", "Swatches"],
None,
"{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}",
"{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}",
has_doc,
swatch_new
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Options",
["Window", "Swatches"],
None,
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}",
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
has_doc,
swatch_edit
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatches",
[],
None,
"{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, gradient?, pattern?}], groups: [{name, swatches: [names]}]}",
"{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, tintOf?: base swatch of a tint swatch, tint?: its %, gradient?, pattern?}], groups: [{name, swatches: [names]}]}",
has_doc,
swatch_list
),
@@ -129,7 +129,17 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
}
(None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)],
(None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))),
(None, None) => vec![paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone())],
(None, None) => {
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
// `tint` alone: that tint of the global colour the current fill links to.
match (super::paint::tint_param(p, C)?, &paint) {
(Some(t), Paint::Solid { swatch: Some(l), .. }) if p.get("swatch").is_none() => {
vec![s.doc()?.doc.tint_paint(l, t).ok_or_else(|| bad(C, format!("`{l}` isn't a global or spot colour")))?]
}
(Some(_), _) if p.get("swatch").is_none() => return Err(bad(C, "`tint` needs a global or spot colour")),
_ => vec![paint],
}
}
};
let spot = bool_or(p, "spot", false);
let global = spot || bool_or(p, "global", false);
@@ -138,6 +148,8 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paints = paints
.into_iter()
.map(|paint| match paint {
// A tint stays linked to its base: a tint swatch.
Paint::Solid { swatch: Some(_), tint, .. } if tint < 1.0 && mode.is_none() && !global => Ok(paint),
// The swatch holds the colour itself, not a link to the swatch it may have come from.
Paint::Solid { color, .. } => Ok(Paint::solid(match mode {
Some(m) => convert_to_mode(color, m, C)?,
@@ -157,7 +169,9 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
Some(n) => d.free_swatch_name(n),
None => d.new_swatch_name(&paint),
};
let swatch = Swatch { name: name.clone(), paint, global, spot };
// A tint swatch's kind is its base's.
let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. });
let swatch = Swatch { name: name.clone(), paint, global: global && !tint, spot: spot && !tint };
match group {
Some(g) => {
d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch)
@@ -204,7 +218,8 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
if !bool_or(p, "unlink", true) {
global.clear();
}
let mut unlink = |_: &mut Color, link: &mut Option<String>| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
let mut unlink =
|_: &mut Color, link: &mut Option<String>, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
let unlinked = s.edit("Delete Swatch", |d, _| {
d.swatch_groups.retain(|g| !names.contains(&g.name));
for n in &names {
@@ -244,7 +259,7 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
return 0;
}
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
d.map_solid_paints_in(ids, &mut |c, link| {
d.map_solid_paints_in(ids, &mut |c, link, _| {
if link.as_ref().is_some_and(|l| live.contains(l)) {
return false;
}
@@ -416,15 +431,15 @@ struct Relink {
from: String,
/// The swatch's name after the edit.
to: String,
/// The new colour of a global swatch (linked paints take it).
/// The new colour of a global swatch (linked paints take it, at their tint).
color: Option<Color>,
/// false: drop the links (the swatch stopped being global); paints keep their colour.
keep: bool,
}
impl Relink {
/// Apply to one paint; true when it changed.
fn apply(&self, c: &mut Color, link: &mut Option<String>) -> bool {
/// Apply to one linked colour; true when it changed.
fn apply(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32) -> bool {
if link.as_deref() != Some(self.from.as_str()) {
return false;
}
@@ -436,28 +451,27 @@ impl Relink {
if renamed {
*link = Some(self.to.clone());
}
let recolored = self.color.is_some_and(|n| n != *c);
if let Some(n) = self.color {
let new = self.color.map(|n| n.tinted(*tint));
let recolored = new.is_some_and(|n| n != *c);
if let Some(n) = new {
*c = n;
}
renamed || recolored
}
fn defaults(&self, s: &mut Session) {
map_default_paints(s, &mut |c, l| self.apply(c, l));
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
}
}
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
/// preview (Cancel rolls the document back, not the session).
pub(super) fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) {
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
if s.in_interaction() {
return;
}
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
if let Paint::Solid { color, swatch } = p {
f(color, swatch);
}
p.map_links(f);
}
}
@@ -474,6 +488,11 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) {
return Err(bad(C, "colour, mode and spot apply to solid-colour swatches only"));
}
if let Some((base, _)) = sw.tint_of()
&& ["color", "mode", "paint", "global", "spot"].iter().any(|k| given(k))
{
return Err(bad(C, format!("`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name)")));
}
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
let paint = match p.get("paint").filter(|v| !v.is_null()) {
Some(v) => match (paint_from(s, v)?, &sw.paint) {
@@ -510,7 +529,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l)))
Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t)))
})?;
relink.defaults(s);
Ok(json!({"name": to, "relinked": relinked}))
@@ -520,10 +539,14 @@ pub(super) fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value {
let mut v = json!({"name": sw.name, "group": group, "global": sw.global, "spot": sw.spot});
match &sw.paint {
Paint::None => v["kind"] = json!("none"),
Paint::Solid { color, .. } => {
Paint::Solid { color, swatch, tint } => {
v["kind"] = json!("color");
v["color"] = json!(color);
v["hex"] = json!(color.to_hex());
if let Some(base) = swatch {
v["tintOf"] = json!(base);
v["tint"] = json!(tint_percent(*tint));
}
}
Paint::Gradient(g) => {
v["kind"] = json!("gradient");
@@ -592,20 +615,28 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]);
// Links with their tints, so a tint swatch is used where its tint of its base is.
let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]);
let mut color = |c: &Color, link: Option<&String>, tint: f32| match link {
Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)),
_ => colors.push(*c),
};
d.visit_paints(&mut |p| match p {
Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()),
Paint::Solid { color, .. } => colors.push(*color),
Paint::Solid { color: c, swatch, tint } => color(c, swatch.as_ref(), *tint),
Paint::Gradient(g) => {
links.extend(g.swatch.clone());
colors.extend(g.gradient.stops.iter().map(|s| s.color));
g.gradient.stops.iter().for_each(|s| color(&s.color, s.swatch.as_ref(), s.tint));
gradient_links.extend(g.swatch.clone());
gradients.push(g.gradient.clone());
}
Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()),
Paint::None => {}
});
let used = |w: &Swatch| {
links.contains(&w.name)
gradient_links.contains(&w.name)
|| links.iter().any(|(l, t)| match w.tint_of() {
Some((base, tint)) => l == base && *t == tint,
None => *l == w.name,
})
|| match &w.paint {
Paint::None => true,
Paint::Solid { color, .. } => colors.contains(color),
@@ -632,11 +663,12 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?;
let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone()));
let mut relink = |c: &mut Color, l: &mut Option<String>| {
// Tints keep their tint (of the kept colour).
let mut relink = |c: &mut Color, l: &mut Option<String>, t: &mut f32| {
if !l.as_ref().is_some_and(|l| merged.contains(l)) {
return false;
}
*c = color;
*c = color.tinted(*t);
*l = link.clone();
true
};
+2 -2
View File
@@ -397,7 +397,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
(d.swatches, d.swatch_groups) = (defaults, groups);
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));
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));
return Ok(added);
}
for (i, w) in defaults.into_iter().enumerate() {
@@ -427,7 +427,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
Ok(added)
})?;
if replace {
map_default_paints(s, &mut |_, l| unlink_dead(&live, l));
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
}
Ok(json!({ "added": added }))
}
+1 -1
View File
@@ -355,7 +355,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut stops = g.gradient.stops;
let n = stops.len();
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color;
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.set_color(color, None);
("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke }))
}
};