Merge pull request #45 from storytold/pr/m3-tints-linked-stops
M3: tints of global and spot colours, gradient stops linked to swatches (M3.59, M3.60)
This commit is contained in:
commit
3675156c4e
|
|
@ -37,9 +37,8 @@ fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
|
|||
}
|
||||
|
||||
fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
|
||||
if let Paint::Solid { color, swatch } = p {
|
||||
*color = map_color(color, mode, key);
|
||||
*swatch = None;
|
||||
if let Paint::Solid { color, .. } = p {
|
||||
*p = Paint::solid(map_color(color, mode, key));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,9 +82,8 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
|||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Solid { color: c, swatch } = p {
|
||||
*c = c.lerp(color, amount);
|
||||
*swatch = None;
|
||||
if let Paint::Solid { color: c, .. } = p {
|
||||
*p = Paint::solid(c.lerp(color, amount));
|
||||
}
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ impl Freeform {
|
|||
/// swatch chips, exports without freeform shading and re-seeding on other art use.
|
||||
pub fn stops(&self) -> Vec<GradientStop> {
|
||||
let n = self.points.len();
|
||||
let stop = |offset: f32, p: &FreeformPoint| GradientStop { offset, color: p.color, opacity: p.opacity, midpoint: 0.5 };
|
||||
let stop = |offset: f32, p: &FreeformPoint| GradientStop { opacity: p.opacity, ..GradientStop::new(offset, p.color) };
|
||||
match n {
|
||||
0 => Gradient::default().stops,
|
||||
1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])],
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ impl GradientKind {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// 0..=1 along the gradient.
|
||||
pub offset: f32,
|
||||
|
|
@ -41,6 +41,27 @@ pub struct GradientStop {
|
|||
/// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5.
|
||||
#[serde(default = "half")]
|
||||
pub midpoint: f32,
|
||||
/// The global or spot swatch this stop's colour is linked to (edits to the swatch recolour it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swatch: Option<String>,
|
||||
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
|
||||
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
|
||||
pub tint: f32,
|
||||
}
|
||||
|
||||
impl GradientStop {
|
||||
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
|
||||
pub fn new(offset: f32, color: Color) -> Self {
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
|
||||
}
|
||||
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
|
||||
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
|
||||
self.color = color;
|
||||
(self.swatch, self.tint) = match link {
|
||||
Some((n, t)) => (Some(n), t),
|
||||
None => (None, 1.0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
|
|
@ -59,13 +80,7 @@ pub struct Gradient {
|
|||
impl Default for Gradient {
|
||||
/// Illustrator's default "White, Black" gradient.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
}
|
||||
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +152,7 @@ pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
|
|||
let offset = offset.clamp(0.0, 1.0);
|
||||
let (color, opacity) = g.sample(offset);
|
||||
let mut stops = g.stops.clone();
|
||||
let i = place_stop(&mut stops, GradientStop { offset, color, opacity, midpoint: 0.5 });
|
||||
let i = place_stop(&mut stops, GradientStop { opacity, ..GradientStop::new(offset, color) });
|
||||
(stops, i)
|
||||
}
|
||||
|
||||
|
|
@ -166,19 +181,22 @@ pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<Gradient
|
|||
/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index.
|
||||
pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
|
||||
let mut v = stops.to_vec();
|
||||
let Some(&s) = v.get(i) else { return (v, i) };
|
||||
let Some(s) = v.get(i).cloned() else { return (v, i) };
|
||||
let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s });
|
||||
(v, ni)
|
||||
}
|
||||
|
||||
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another); offsets, opacities
|
||||
/// and midpoints stay. Out-of-range indices leave the stops unchanged.
|
||||
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another), with their swatch
|
||||
/// links and tints; offsets, opacities and midpoints stay. Out-of-range indices leave the stops
|
||||
/// unchanged.
|
||||
pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec<GradientStop> {
|
||||
let mut v = stops.to_vec();
|
||||
if a < v.len() && b < v.len() {
|
||||
let c = v[a].color;
|
||||
v[a].color = v[b].color;
|
||||
v[b].color = c;
|
||||
if a < v.len() && b < v.len() && a != b {
|
||||
let (lo, hi) = v.split_at_mut(a.max(b));
|
||||
let (x, y) = (&mut lo[a.min(b)], &mut hi[0]);
|
||||
std::mem::swap(&mut x.color, &mut y.color);
|
||||
std::mem::swap(&mut x.swatch, &mut y.swatch);
|
||||
std::mem::swap(&mut x.tint, &mut y.tint);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
|
@ -517,7 +535,7 @@ mod tests {
|
|||
|
||||
fn g3() -> Gradient {
|
||||
let mut g = Gradient::default();
|
||||
g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 });
|
||||
g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
|
||||
g
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -240,6 +240,22 @@ impl Color {
|
|||
}
|
||||
}
|
||||
}
|
||||
/// Tint `t` (0..1) of this colour, the colour a tint of a global or spot swatch shows: CMYK and
|
||||
/// Gray inks scale by `t`, RGB mixes toward white (`t` = 0 is paper white, 1 the colour itself).
|
||||
pub fn tinted(self, t: f32) -> Color {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 1.0 {
|
||||
return self;
|
||||
}
|
||||
match self {
|
||||
Color::Cmyk { c, m, y, k } => Color::Cmyk { c: c * t, m: m * t, y: y * t, k: k * t },
|
||||
Color::Gray { k } => Color::Gray { k: k * t },
|
||||
Color::Rgb { r, g, b } => {
|
||||
let w = |v: f32| v + (1.0 - v) * (1.0 - t);
|
||||
Color::Rgb { r: w(r), g: w(g), b: w(b) }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn model_name(&self) -> &'static str {
|
||||
match self {
|
||||
Color::Rgb { .. } => "RGB",
|
||||
|
|
@ -262,10 +278,15 @@ pub enum Paint {
|
|||
#[default]
|
||||
None,
|
||||
Solid {
|
||||
/// The colour shown: with a link, the swatch's colour at `tint` ([`Color::tinted`]).
|
||||
color: Color,
|
||||
/// Name of the global swatch this colour is linked to (edits to the swatch update it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
swatch: Option<String>,
|
||||
/// Tint of the linked swatch (0..1; 1 without a link): a spot tint prints this much of its
|
||||
/// plate, and swatch edits keep it.
|
||||
#[serde(default = "full_tint", skip_serializing_if = "is_full_tint")]
|
||||
tint: f32,
|
||||
},
|
||||
Gradient(Box<GradientPaint>),
|
||||
Pattern {
|
||||
|
|
@ -281,9 +302,22 @@ fn is_identity(a: &kurbo::Affine) -> bool {
|
|||
*a == kurbo::Affine::IDENTITY
|
||||
}
|
||||
|
||||
pub(crate) fn full_tint() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
pub(crate) fn is_full_tint(t: &f32) -> bool {
|
||||
*t == 1.0
|
||||
}
|
||||
|
||||
/// A tint as a whole percentage ("40" for 0.4).
|
||||
pub fn tint_percent(t: f32) -> f32 {
|
||||
(t.clamp(0.0, 1.0) * 100.0).round()
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn solid(c: Color) -> Self {
|
||||
Paint::Solid { color: c, swatch: None }
|
||||
Paint::Solid { color: c, swatch: None, tint: 1.0 }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
matches!(self, Paint::None)
|
||||
|
|
@ -297,12 +331,30 @@ impl Paint {
|
|||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Paint::None => "None".into(),
|
||||
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
|
||||
Paint::Solid { color, swatch: Some(n), tint } if *tint < 1.0 => format!("{n} {}% ({})", tint_percent(*tint), color.to_hex()),
|
||||
Paint::Solid { color, swatch: Some(n), .. } => format!("{n} ({})", color.to_hex()),
|
||||
Paint::Solid { color, .. } => color.to_hex(),
|
||||
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
|
||||
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
|
||||
}
|
||||
}
|
||||
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
|
||||
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
|
||||
/// left without a link gets tint 1. Returns whether anything changed.
|
||||
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
|
||||
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
|
||||
let changed = f(c, link, tint);
|
||||
if link.is_none() {
|
||||
*tint = 1.0;
|
||||
}
|
||||
changed
|
||||
};
|
||||
match self {
|
||||
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -376,6 +428,53 @@ mod tests {
|
|||
assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tints_scale_inks_and_mix_rgb_toward_white() {
|
||||
let close = |a: Color, b: Color| {
|
||||
let v = |c: Color| match c {
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
Color::Gray { k } => vec![k],
|
||||
};
|
||||
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
};
|
||||
assert!(close(Color::cmyk(0.5, 1.0, 0.0, 0.2).tinted(0.4), Color::cmyk(0.2, 0.4, 0.0, 0.08)));
|
||||
assert!(close(Color::gray(0.5).tinted(0.5), Color::gray(0.25)));
|
||||
assert!(close(Color::rgb(1.0, 0.0, 0.5).tinted(0.4), Color::rgb(1.0, 0.6, 0.8)), "RGB mixes toward white");
|
||||
assert_eq!(Color::rgb(0.2, 0.4, 0.6).tinted(1.0), Color::rgb(0.2, 0.4, 0.6));
|
||||
assert_eq!(Color::cmyk(0.3, 0.3, 0.3, 0.3).tinted(0.0), Color::cmyk(0.0, 0.0, 0.0, 0.0));
|
||||
assert_eq!(Color::rgb(0.0, 0.0, 0.0).tinted(-1.0).to_hex(), "#ffffff", "clamped");
|
||||
assert_eq!(tint_percent(0.404), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tints_serialize_only_below_full_and_read_old_files() {
|
||||
let full = serde_json::to_string(&Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 }).unwrap();
|
||||
assert!(!full.contains("tint"), "{full}");
|
||||
let old: Paint = serde_json::from_str(r#"{"type":"solid","color":{"model":"gray","k":1.0},"swatch":"Ink"}"#).unwrap();
|
||||
assert_eq!(old, Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 });
|
||||
let t = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
|
||||
let s = serde_json::to_string(&t).unwrap();
|
||||
assert!(s.contains("\"tint\":0.4"), "{s}");
|
||||
assert_eq!(serde_json::from_str::<Paint>(&s).unwrap(), t);
|
||||
assert_eq!(t.label(), "Ink 40% (#999999)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_links_resets_the_tint_of_unlinked_colours() {
|
||||
let mut p = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
|
||||
assert!(p.map_links(&mut |_, l, _| l.take().is_some()));
|
||||
assert_eq!(p, Paint::solid(Color::gray(0.4)));
|
||||
let mut g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
g.map_links(&mut |_, l, t| {
|
||||
*l = Some("Ink".into());
|
||||
*t = 0.5;
|
||||
true
|
||||
});
|
||||
let Paint::Gradient(g) = g else { unreachable!() };
|
||||
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_serde() {
|
||||
let p = Paint::solid(Color::rgb8(10, 20, 30));
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
|
|||
|
||||
/// A gradient swatch through `stops` of (offset, colour, opacity).
|
||||
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
|
||||
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { offset, color, opacity, midpoint: 0.5 }).collect();
|
||||
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
|
||||
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -241,8 +241,8 @@ mod tests {
|
|||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::cmyk(0.1, 0.2, 0.3, 0.0), opacity: 1.0, midpoint: 0.4 },
|
||||
GradientStop { offset: 1.0, color: Color::rgb8(255, 0, 0), opacity: 0.5, midpoint: 0.5 },
|
||||
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
|
||||
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
|
||||
],
|
||||
})));
|
||||
SwatchLibrary {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ pub struct Swatch {
|
|||
pub spot: bool,
|
||||
}
|
||||
|
||||
impl Swatch {
|
||||
/// A tint swatch's base swatch and tint: its colour links to another (global) swatch.
|
||||
pub fn tint_of(&self) -> Option<(&str, f32)> {
|
||||
match &self.paint {
|
||||
Paint::Solid { swatch: Some(base), tint, .. } => Some((base, *tint)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SwatchGroup {
|
||||
pub name: String,
|
||||
|
|
@ -101,10 +111,7 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
|||
name: name.into(),
|
||||
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
|
||||
kind,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
stops: vec![GradientStop::new(0.0, Color::from_hex(a).unwrap()), GradientStop::new(1.0, Color::from_hex(b).unwrap())],
|
||||
}))),
|
||||
global: false,
|
||||
spot: false,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ impl OverprintBlack {
|
|||
/// [`Self::spot`]), or a gradient whose every stop is.
|
||||
fn black(&self, p: &Paint, spots: &[String]) -> bool {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
(self.spot || !swatch.as_ref().is_some_and(|s| spots.contains(s))) && is_black_ink(color, self.min_k, self.rich)
|
||||
}
|
||||
Paint::Gradient(g) => !g.gradient.stops.is_empty() && g.gradient.stops.iter().all(|s| is_black_ink(&s.color, self.min_k, self.rich)),
|
||||
|
|
@ -159,7 +159,7 @@ mod tests {
|
|||
assert!(!n.appearance.fill().unwrap().overprint, "a gradient with a white stop is not black");
|
||||
|
||||
// Spot-linked blacks only with `spot`; type runs too.
|
||||
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()) };
|
||||
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()), tint: 1.0 };
|
||||
let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() };
|
||||
let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style))));
|
||||
OverprintBlack::default().apply(&mut t, &["Ink".to_string()]);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
//! Swatch lookups across colour groups and the walks over the paints that link to swatches.
|
||||
//!
|
||||
//! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every
|
||||
//! lookup here covers both. Solid paints link to a global swatch by name (`Paint::Solid.swatch`);
|
||||
//! [`Document::map_solid_paints`] rewrites those links (and colours) wherever art can hold them,
|
||||
//! copying only the nodes that change.
|
||||
//! lookup here covers both. Solid paints and gradient stops link to a global swatch by name, at a
|
||||
//! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a
|
||||
//! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites
|
||||
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
|
||||
//! change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_color::{Color, Paint, Swatch, tint_percent};
|
||||
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
|
||||
|
|
@ -25,6 +27,29 @@ impl Document {
|
|||
pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> {
|
||||
self.swatches_iter_mut().find(|s| s.name == name)
|
||||
}
|
||||
/// The colour of global (or spot) swatch `name`, which its tints scale; `None` when `name`
|
||||
/// isn't a global solid colour of its own (a tint swatch isn't).
|
||||
pub fn global_color(&self, name: &str) -> Option<Color> {
|
||||
match &self.swatch(name).filter(|w| w.global)?.paint {
|
||||
Paint::Solid { color, swatch: None, .. } => Some(*color),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The link a colour taken from swatch `name` gets: a global solid colour links to itself at
|
||||
/// 100 %, a tint swatch to its base at its tint; `None` for other swatches.
|
||||
pub fn swatch_link(&self, name: &str) -> Option<(String, f32)> {
|
||||
let w = self.swatch(name)?;
|
||||
match (w.tint_of(), &w.paint) {
|
||||
(Some((base, tint)), _) => Some((base.to_string(), tint)),
|
||||
(None, Paint::Solid { .. }) if w.global => Some((name.to_string(), 1.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Tint `tint` (0..1) of global swatch `name` as a solid paint linked to it.
|
||||
pub fn tint_paint(&self, name: &str, tint: f32) -> Option<Paint> {
|
||||
let tint = tint.clamp(0.0, 1.0);
|
||||
Some(Paint::Solid { color: self.global_color(name)?.tinted(tint), swatch: Some(name.to_string()), tint })
|
||||
}
|
||||
/// Index of the colour group holding swatch `name` (`None` when ungrouped or missing).
|
||||
pub fn swatch_group_of(&self, name: &str) -> Option<usize> {
|
||||
self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name))
|
||||
|
|
@ -49,6 +74,7 @@ impl Document {
|
|||
/// "New Pattern Swatch 1"…
|
||||
pub fn new_swatch_name(&self, paint: &Paint) -> String {
|
||||
let base = match paint {
|
||||
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.free_swatch_name(&format!("{n} {}%", tint_percent(*tint))),
|
||||
Paint::Solid { color, .. } => return self.free_swatch_name(&color_name(*color)),
|
||||
Paint::Gradient(_) => "New Gradient Swatch",
|
||||
_ => "New Pattern Swatch",
|
||||
|
|
@ -66,11 +92,12 @@ impl Document {
|
|||
Some(list.remove(i))
|
||||
}
|
||||
|
||||
/// Visit every solid paint (fills, strokes and text runs) in the art, symbol definitions,
|
||||
/// pattern tiles and graphic styles. `f(colour, link)` may change both and returns true when it
|
||||
/// did. Only nodes holding a changed paint (and the paths to them) are copied. Returns the
|
||||
/// number of changed paints.
|
||||
pub fn map_solid_paints(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
|
||||
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
|
||||
/// strokes and text runs — in the art, symbol definitions, pattern tiles, 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.
|
||||
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
|
||||
let mut n = map_trees(&mut self.layers, f);
|
||||
for s in &mut self.symbols {
|
||||
n += map_tree(&mut s.art, f);
|
||||
|
|
@ -80,15 +107,18 @@ impl Document {
|
|||
}
|
||||
for gs in &mut self.graphic_styles {
|
||||
for it in &mut gs.appearance.items {
|
||||
n += usize::from(map_solid(item_paint_mut(it), f));
|
||||
n += usize::from(item_paint_mut(it).map_links(f));
|
||||
}
|
||||
}
|
||||
for w in self.swatches_iter_mut() {
|
||||
n += usize::from(w.paint.map_links(f));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// [`Document::map_solid_paints`] limited to the subtrees of `ids` (each paint visited once even
|
||||
/// when an id is inside another).
|
||||
pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
|
||||
/// [`Document::map_solid_paints`] limited to the art in the subtrees of `ids` (each paint
|
||||
/// visited once even when an id is inside another).
|
||||
pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut LinkFn) -> usize {
|
||||
let roots: Vec<NodeId> =
|
||||
ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect();
|
||||
let mut n = 0;
|
||||
|
|
@ -135,14 +165,18 @@ pub fn color_name(c: Color) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text
|
||||
/// runs (with their link), then gradient stops and mesh points (unlinked), in paint order.
|
||||
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
|
||||
/// true when it changed them.
|
||||
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
|
||||
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
|
||||
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
|
||||
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
|
||||
n.walk(&mut |m| {
|
||||
for p in node_paints(m) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => f(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, None)),
|
||||
Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -184,24 +218,17 @@ fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
|||
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
|
||||
}
|
||||
|
||||
/// Apply `f` to a solid paint; true when it changed.
|
||||
fn map_solid(p: &mut Paint, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> bool {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => f(color, swatch),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with `f` applied to its subtree's solid paints, or `None` when nothing changed (then nothing
|
||||
/// was copied). Also returns the number of changed paints.
|
||||
fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> Option<(Node, usize)> {
|
||||
// Try each own paint on a scratch copy first, so unchanged nodes are never cloned.
|
||||
let mut changes: Vec<(usize, Color, Option<String>)> = vec![];
|
||||
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
|
||||
/// nothing was copied). Also returns the number of changed paints.
|
||||
fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
// Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never
|
||||
// cloned.
|
||||
let mut changes: Vec<(usize, Paint)> = vec![];
|
||||
for (i, p) in node_paints(n).enumerate() {
|
||||
if let Paint::Solid { color, swatch } = p {
|
||||
let (mut c, mut s) = (*color, swatch.clone());
|
||||
if f(&mut c, &mut s) {
|
||||
changes.push((i, c, s));
|
||||
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) {
|
||||
let mut q = p.clone();
|
||||
if q.map_links(f) {
|
||||
changes.push((i, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -212,10 +239,9 @@ fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool
|
|||
let mut changes = changes.into_iter().peekable();
|
||||
for (i, p) in node_paints_mut(&mut m).enumerate() {
|
||||
if changes.peek().is_some_and(|c| c.0 == i)
|
||||
&& let (Some((_, c, s)), Paint::Solid { color, swatch }) = (changes.next(), p)
|
||||
&& let Some((_, q)) = changes.next()
|
||||
{
|
||||
*color = c;
|
||||
*swatch = s;
|
||||
*p = q;
|
||||
}
|
||||
}
|
||||
out = Some(m);
|
||||
|
|
@ -234,7 +260,7 @@ fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool
|
|||
out.map(|m| (m, count))
|
||||
}
|
||||
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
*n = Arc::new(new);
|
||||
|
|
@ -244,7 +270,7 @@ fn map_tree(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut Color, &mut Option<String>
|
|||
}
|
||||
}
|
||||
|
||||
fn map_trees(v: &mut [Arc<Node>], f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
|
||||
fn map_trees(v: &mut [Arc<Node>], f: &mut LinkFn) -> usize {
|
||||
v.iter_mut().map(|n| map_tree(n, f)).sum()
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +283,7 @@ mod tests {
|
|||
use crate::Appearance;
|
||||
|
||||
fn linked(color: Color, name: &str) -> Paint {
|
||||
Paint::Solid { color, swatch: Some(name.into()) }
|
||||
Paint::Solid { color, swatch: Some(name.into()), tint: 1.0 }
|
||||
}
|
||||
|
||||
/// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one.
|
||||
|
|
@ -309,7 +335,7 @@ mod tests {
|
|||
let (mut d, a, b) = doc();
|
||||
let before = d.clone();
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let n = d.map_solid_paints(&mut |c, s| {
|
||||
let n = d.map_solid_paints(&mut |c, s, _| {
|
||||
if s.as_deref() != Some("Brand") {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -323,7 +349,7 @@ mod tests {
|
|||
assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied");
|
||||
// Nothing to change: the tree is untouched.
|
||||
let again = d.clone();
|
||||
assert_eq!(d.map_solid_paints(&mut |_, _| false), 0);
|
||||
assert_eq!(d.map_solid_paints(&mut |_, _, _| false), 0);
|
||||
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +358,7 @@ mod tests {
|
|||
let (mut d, a, b) = doc();
|
||||
let layer = d.layers[0].id;
|
||||
let mut seen = 0;
|
||||
let n = d.map_solid_paints_in(&[layer, a], &mut |_, s| {
|
||||
let n = d.map_solid_paints_in(&[layer, a], &mut |_, s, _| {
|
||||
seen += 1;
|
||||
s.take().is_some()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)"))),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_tools::params::color_json;
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target};
|
||||
use super::edit::selected_roots;
|
||||
use super::paint::swatch_solid;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
|
|
@ -18,7 +20,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|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), 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), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
|
|
@ -103,9 +105,42 @@ fn parse_kind(k: &str) -> Parsed<GradientKind> {
|
|||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
||||
}
|
||||
|
||||
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
|
||||
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
|
||||
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
|
||||
/// the colour it gives.
|
||||
pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
|
||||
let mut v = v.clone();
|
||||
let Some(stops) = v.get_mut("stops").and_then(Value::as_array_mut) else { return Ok(v) };
|
||||
for (i, st) in stops.iter_mut().enumerate() {
|
||||
let Some(name) = st.get("swatch").and_then(Value::as_str).map(str::to_string) else { continue };
|
||||
if d.swatch(&name).is_none() && st.get("color").is_some() {
|
||||
continue;
|
||||
}
|
||||
let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
|
||||
continue;
|
||||
};
|
||||
let Some(o) = st.as_object_mut() else { continue };
|
||||
o.insert("color".into(), color_json(&color));
|
||||
match swatch {
|
||||
Some(n) => {
|
||||
o.insert("swatch".into(), json!(n));
|
||||
o.insert("tint".into(), json!(tint * 100.0));
|
||||
}
|
||||
None => {
|
||||
o.remove("swatch");
|
||||
o.remove("tint");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid
|
||||
/// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the
|
||||
/// diamond's range. The result is sorted by offset.
|
||||
/// diamond's range; `swatch` and `tint` (%) keep a stop's link as given (resolve them first with
|
||||
/// [`link_stops`]). The result is sorted by offset.
|
||||
pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
||||
let arr = v.as_array().ok_or("`stops` must be an array")?;
|
||||
if arr.len() < 2 {
|
||||
|
|
@ -119,11 +154,15 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
|
|||
let offset = num("offset")?.ok_or_else(|| format!("stop {i} needs `offset`"))?;
|
||||
let color = st.get("color").and_then(color_value).ok_or_else(|| format!("stop {i} needs a valid `color`"))?;
|
||||
let opacity = num("opacity")?.map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
|
||||
let swatch = str_param(st, "swatch").map(str::to_string);
|
||||
let tint = num("tint")?.filter(|_| swatch.is_some()).map_or(1.0, |t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
Ok(GradientStop {
|
||||
offset: offset.clamp(0.0, 1.0) as f32,
|
||||
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,
|
||||
tint,
|
||||
})
|
||||
})
|
||||
.collect::<Parsed<Vec<_>>>()?;
|
||||
|
|
@ -299,6 +338,8 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// Stops naming swatches take their colours from them.
|
||||
let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?;
|
||||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
|
||||
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||||
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 is recorded as the gradient's swatch and 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, 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, 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,23 +304,24 @@ 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")),
|
||||
Paint::Gradient(g) => Ok(Some(Paint::Gradient(Box::new(GradientPaint { swatch: Some(name.to_string()), ..(**g).clone() })))),
|
||||
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))?;
|
||||
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
|
||||
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
||||
let mut gp = super::gradient::parse_gradient(&g).map_err(|e| bad("paint", e))?;
|
||||
gp.gradient.stops.iter_mut().filter(|st| st.swatch.is_none()).for_each(|st| st.color = new_color(st.color));
|
||||
return Ok(Some(Paint::Gradient(Box::new(gp))));
|
||||
}
|
||||
if let Some(c) = p.get("color") {
|
||||
|
|
@ -330,8 +331,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -805,6 +805,8 @@ mod tests_knockout;
|
|||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
|
|
@ -847,4 +849,6 @@ mod tests_textedit;
|
|||
#[cfg(test)]
|
||||
mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ fn swatch_links_survive_mode_conversion() {
|
|||
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap();
|
||||
s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
let Paint::Solid { color, swatch } = fill_of(&s, a) else { panic!() };
|
||||
let Paint::Solid { color, swatch, .. } = fill_of(&s, a) else { panic!() };
|
||||
assert_eq!(swatch.as_deref(), Some("Brand"));
|
||||
let sw = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Brand").unwrap().paint.color().unwrap();
|
||||
assert!(matches!(color, Color::Cmyk { .. }));
|
||||
|
|
|
|||
|
|
@ -52,21 +52,6 @@ fn adjust_balance_modes() {
|
|||
assert!(s.execute("edit.colors.adjustBalance", &json!({"mode": "lab"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_mode_needs_tints_and_says_so() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Brand", "color": "#3366cc", "global": true})).unwrap();
|
||||
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute("edit.colors.adjustBalance", &json!({"mode": "global"})).unwrap_err().to_string();
|
||||
assert!(e.contains("global and spot"), "{e}");
|
||||
assert_eq!(*s.doc().unwrap().doc, *before, "nothing changed");
|
||||
let p = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint();
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(ref n), .. } if n == "Brand"), "the link is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_previews_and_commits_one_step() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
|
|||
|
||||
/// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector.
|
||||
fn three_stops() -> GradientPaint {
|
||||
let stop = |offset, color, opacity, midpoint| GradientStop { offset, color, opacity, midpoint };
|
||||
let stop = |offset, color, opacity, midpoint| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) };
|
||||
let mut g = GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Freeform,
|
||||
stops: vec![
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
//! Gradient swatches and gradient stops linked to global and spot swatches: the recorded links,
|
||||
//! stops following swatch edits, spot stops on their plate and as Separation shadings in PDF.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_render::proof;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &vectorcraft_doc::Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
const INK: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 };
|
||||
|
||||
/// A document with spot swatch "Ink" and a selected rectangle.
|
||||
fn setup() -> (Session, NodeId) {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Ink", "color": INK, "spot": true}));
|
||||
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn gradient(s: &Session, id: NodeId) -> GradientPaint {
|
||||
match doc(s).node(id).unwrap().appearance.fill_paint() {
|
||||
Paint::Gradient(g) => *g,
|
||||
p => panic!("not a gradient: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink at `tint` % → white.
|
||||
fn ink_to_white(tint: f64) -> Value {
|
||||
json!({"stops": [{"offset": 0, "swatch": "Ink", "tint": tint}, {"offset": 1, "color": "#ffffff"}]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_gradient_swatch_records_the_link_until_the_stops_change() {
|
||||
let (mut s, id) = setup();
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Sunset"}));
|
||||
assert_eq!(gradient(&s, id).swatch.as_deref(), Some("Sunset"));
|
||||
let used = run(&mut s, "swatch.unused", json!({}));
|
||||
assert!(!used["names"].as_array().unwrap().contains(&json!("Sunset")));
|
||||
run(&mut s, "paint.editGradient", json!({"angle": 45}));
|
||||
assert_eq!(gradient(&s, id).swatch.as_deref(), Some("Sunset"), "moving the vector keeps it");
|
||||
run(&mut s, "paint.editGradient", ink_to_white(100.0));
|
||||
assert_eq!(gradient(&s, id).swatch, None, "new stops are another gradient");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_link_to_swatches_and_follow_their_edits() {
|
||||
let (mut s, id) = setup();
|
||||
run(&mut s, "paint.editGradient", ink_to_white(50.0));
|
||||
let st = gradient(&s, id).gradient.stops[0].clone();
|
||||
assert_eq!((st.swatch.as_deref(), st.tint, st.color), (Some("Ink"), 0.5, INK.tinted(0.5)), "the colour comes from the swatch");
|
||||
assert_eq!(gradient(&s, id).gradient.stops[1].swatch, None);
|
||||
// A tint swatch links the stop to its base at its tint; a process swatch just gives its colour.
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
|
||||
run(&mut s, "swatch.new", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "swatch": "Ink 40%"}, {"offset": 1, "swatch": "Black"}]}}));
|
||||
let stops = gradient(&s, id).gradient.stops;
|
||||
assert_eq!((stops[0].swatch.as_deref(), stops[0].tint), (Some("Ink"), 0.4));
|
||||
assert_eq!((stops[1].swatch.as_deref(), stops[1].tint), (None, 1.0));
|
||||
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "swatch": "Nope"}, {"offset": 1, "color": "#fff"}]})).is_err());
|
||||
// Editing the swatch recolours the stops (and the tint swatch) as one undo step.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 1, "m": 0, "y": 0, "k": 0}}));
|
||||
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
||||
assert_eq!(gradient(&s, id).gradient.stops[0].color, cyan.tinted(0.4));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Global mode of Adjust Color Balance shifts stop tints too.
|
||||
run(&mut s, "edit.colors.adjustBalance", json!({"tint": 60}));
|
||||
assert_eq!(gradient(&s, id).gradient.stops[0].color, cyan);
|
||||
// Recolouring a stop unlinks it; deleting the swatch unlinks the rest, which keep their colour.
|
||||
run(&mut s, "paint.editGradient", ink_to_white(25.0));
|
||||
run(&mut s, "swatch.delete", json!({"name": "Ink"}));
|
||||
let st = gradient(&s, id).gradient.stops[0].clone();
|
||||
assert_eq!((st.swatch, st.tint, st.color), (None, 1.0, cyan.tinted(0.25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_swatches_keep_their_stop_links_and_follow_edits() {
|
||||
let (mut s, id) = setup();
|
||||
run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": ink_to_white(100.0)}));
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Glow"}));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 0, "m": 0, "y": 1, "k": 0}}));
|
||||
let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0);
|
||||
let Paint::Gradient(sw) = doc(&s).swatch("Glow").unwrap().paint.clone() else { panic!() };
|
||||
assert_eq!(sw.gradient.stops[0].color, yellow, "the gradient swatch's stop follows");
|
||||
let g = gradient(&s, id);
|
||||
assert_eq!((g.gradient.stops[0].color, g.swatch.as_deref()), (yellow, Some("Glow")), "so does the art, still linked to its swatch");
|
||||
assert_eq!(g.gradient, sw.gradient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_stops_separate_on_their_plate() {
|
||||
let (mut s, id) = setup();
|
||||
run(&mut s, "paint.editGradient", ink_to_white(60.0));
|
||||
let mut node = doc(&s).node(id).unwrap().clone();
|
||||
let mut links = vec![];
|
||||
proof::map_node_colors(&mut node, &mut |c, link| {
|
||||
links.push(link.map(|(n, t)| (n.to_string(), t)));
|
||||
*c
|
||||
});
|
||||
assert!(links.contains(&Some(("Ink".to_string(), 0.6))), "{links:?}");
|
||||
let c = vectorcraft_color::cms::active();
|
||||
let st = gradient(&s, id).gradient.stops[0].clone();
|
||||
let ink = proof::inks(doc(&s), &c, &st.color, Some(("Ink", st.tint)), proof::Intent::RelativeColorimetric);
|
||||
assert_eq!((ink.cmyk, ink.spot), ([0.0; 4], Some(("Ink".to_string(), 0.6))));
|
||||
}
|
||||
|
||||
fn pdf_of(s: &Session) -> (String, Vec<String>) {
|
||||
let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() };
|
||||
let r = vectorcraft_pdf::export_with_report(doc(s), &opts).unwrap();
|
||||
(String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_gradients_export_as_separation_shadings() {
|
||||
let (mut s, _) = setup();
|
||||
run(&mut s, "paint.editGradient", ink_to_white(80.0));
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "an Ink → paper gradient is a Separation shading");
|
||||
// Its function runs over the tints: 80 % (204 / 255) to 0 %.
|
||||
assert!(pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints");
|
||||
// Mixing the spot ink with another colour falls back to process colours, with a warning.
|
||||
run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]}));
|
||||
let (pdf, warnings) = pdf_of(&s);
|
||||
assert!(!pdf.contains("/Separation"), "no Separation for a mixed gradient");
|
||||
assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_stops_round_trip() {
|
||||
let (mut s, id) = setup();
|
||||
run(&mut s, "paint.editGradient", ink_to_white(30.0));
|
||||
let d = doc(&s).clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
|
||||
assert_eq!(back.node(id).unwrap().appearance.fill_paint(), d.node(id).unwrap().appearance.fill_paint());
|
||||
// Stops from before links read as unlinked.
|
||||
let old: GradientStop = serde_json::from_value(json!({"offset": 0.5, "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}})).unwrap();
|
||||
assert_eq!(old, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("tint"));
|
||||
}
|
||||
|
|
@ -107,7 +107,10 @@ fn add_used_colors_skips_colours_that_have_swatches() {
|
|||
run(&mut s, "select.set", json!({"ids": [d.0]}));
|
||||
let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true}));
|
||||
assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1}));
|
||||
assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) });
|
||||
assert_eq!(
|
||||
fill(&s, d),
|
||||
Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()), tint: 1.0 }
|
||||
);
|
||||
assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global);
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +154,7 @@ fn merge_keeps_the_first_and_relinks_the_others_art() {
|
|||
let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]}));
|
||||
assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1}));
|
||||
assert!(doc(&s).swatch("Orange").is_none());
|
||||
assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) });
|
||||
assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()), tint: 1.0 });
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*doc(&s), before);
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ fn fill_of(s: &Session, id: NodeId) -> Paint {
|
|||
}
|
||||
|
||||
fn linked(hex: &str, name: &str) -> Paint {
|
||||
Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()) }
|
||||
Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()), tint: 1.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -310,7 +310,7 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() {
|
|||
let (_, b) = art(&mut s);
|
||||
// A 50% tint of Brand on b's stroke.
|
||||
let tint = Color::from_hex("#95b7d8").unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l| {
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l, _| {
|
||||
let hit = l.as_deref() == Some("Brand");
|
||||
if hit {
|
||||
*c = tint;
|
||||
|
|
@ -322,7 +322,11 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() {
|
|||
run(&mut s, "edit.undo", json!({}));
|
||||
let with = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "includeTints": true}));
|
||||
assert_eq!(with["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0", "R=149 G=183 B=216"]));
|
||||
assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), Paint::Solid { color: tint, swatch: Some("Brand".into()) }, "tints stay linked");
|
||||
assert_eq!(
|
||||
doc(&s).node(b).unwrap().appearance.stroke_paint(),
|
||||
Paint::Solid { color: tint, swatch: Some("Brand".into()), tint: 1.0 },
|
||||
"tints stay linked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() {
|
|||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Mine"}));
|
||||
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()) });
|
||||
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()), tint: 1.0 });
|
||||
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
|
||||
let d = doc(&s);
|
||||
assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>(), defaults);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
//! Tints of global and spot colours: applying a swatch at a tint, tints following swatch edits,
|
||||
//! tint swatches, spot tints on their plate and in PDF, Adjust Color Balance's Global mode.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_render::proof;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &vectorcraft_doc::Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
/// A selected rectangle.
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
|
||||
run(s, "select.set", json!({"ids": [id.0]}));
|
||||
id
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> Paint {
|
||||
doc(s).node(id).unwrap().appearance.fill_paint()
|
||||
}
|
||||
|
||||
const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.2 };
|
||||
|
||||
/// A document with a spot swatch "Ink" (magenta + 20 % black) and a global process "Brand".
|
||||
fn inks() -> Session {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Ink", "color": MAGENTA, "spot": true}));
|
||||
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#3366cc", "global": true}));
|
||||
s
|
||||
}
|
||||
|
||||
fn linked(color: Color, name: &str, tint: f32) -> Paint {
|
||||
Paint::Solid { color, swatch: Some(name.into()), tint }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swatch_applies_at_a_tint_and_the_tint_follows_swatch_edits() {
|
||||
let mut s = inks();
|
||||
let a = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 40}));
|
||||
let brand = Color::from_hex("#3366cc").unwrap();
|
||||
assert_eq!(fill(&s, a), linked(brand.tinted(0.4), "Brand", 0.4));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "color": "#cc3300"}));
|
||||
assert_eq!(r["relinked"], 1);
|
||||
let red = Color::from_hex("#cc3300").unwrap();
|
||||
assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Brand", 0.4), "the 40% tint follows the edit");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Renaming keeps the tint; deleting unlinks it and the art keeps its colour.
|
||||
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Logo"}));
|
||||
assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Logo", 0.4));
|
||||
run(&mut s, "swatch.delete", json!({"name": "Logo"}));
|
||||
assert_eq!(fill(&s, a), Paint::solid(red.tinted(0.4)), "unlinked: tint 100% of its own colour");
|
||||
// The proxies report the tint; process colours have none.
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 25}));
|
||||
assert_eq!(run(&mut s, "paint.proxies", json!({}))["fill"]["tint"], json!(0.25));
|
||||
assert!(s.execute("paint.setFill", &json!({"swatch": "Black", "tint": 50})).unwrap_err().to_string().contains("process"));
|
||||
assert!(s.execute("paint.setFill", &json!({"color": "#ff0000", "tint": 50})).is_err(), "a tint needs a swatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_swatch_saves_a_tint_swatch_linked_to_its_base() {
|
||||
let mut s = inks();
|
||||
let a = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
|
||||
let r = run(&mut s, "swatch.new", json!({}));
|
||||
assert_eq!(r["name"], "Ink 40%");
|
||||
let w = doc(&s).swatch("Ink 40%").unwrap();
|
||||
assert_eq!((w.tint_of(), w.global, w.spot), (Some(("Ink", 0.4)), false, false), "its kind is its base's");
|
||||
let listed = run(&mut s, "swatch.list", json!({}));
|
||||
let row = listed["swatches"].as_array().unwrap().iter().find(|w| w["name"] == "Ink 40%").unwrap().clone();
|
||||
assert_eq!((row["tintOf"].clone(), row["tint"].clone()), (json!("Ink"), json!(40.0)));
|
||||
// `tint` alone: that tint of the fill's swatch.
|
||||
assert_eq!(run(&mut s, "swatch.new", json!({"tint": 70}))["name"], "Ink 70%");
|
||||
assert_eq!(run(&mut s, "swatch.new", json!({"swatch": "Brand", "tint": 10}))["name"], "Brand 10%");
|
||||
// Applying a tint swatch links to the base at that tint.
|
||||
let b = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink 70%"}));
|
||||
assert_eq!(fill(&s, b), linked(MAGENTA.tinted(0.7), "Ink", 0.7));
|
||||
// Editing the base recolours the tint swatch and the art; the tint swatch only takes a name.
|
||||
run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 1, "m": 0, "y": 0, "k": 0}}));
|
||||
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
||||
assert_eq!(doc(&s).swatch("Ink 70%").unwrap().paint, linked(cyan.tinted(0.7), "Ink", 0.7));
|
||||
assert_eq!(fill(&s, a), linked(cyan.tinted(0.4), "Ink", 0.4));
|
||||
assert!(s.execute("swatch.edit", &json!({"name": "Ink 70%", "color": "#ff0000"})).is_err());
|
||||
run(&mut s, "swatch.edit", json!({"name": "Ink 70%", "newName": "Pale Ink"}));
|
||||
// A tint swatch is used where its tint of its base is.
|
||||
let unused = run(&mut s, "swatch.unused", json!({}));
|
||||
let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert!(!names.contains(&"Pale Ink") && !names.contains(&"Ink 40%") && names.contains(&"Brand 10%"), "{names:?}");
|
||||
// A tint asked for in another mode, or as a global colour, is a process copy.
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
let r = run(&mut s, "swatch.new", json!({"global": true}));
|
||||
assert!(doc(&s).swatch(r["name"].as_str().unwrap()).unwrap().tint_of().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_tints_print_their_percentage_of_the_plate() {
|
||||
let mut s = inks();
|
||||
rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
|
||||
let d = doc(&s);
|
||||
let c = vectorcraft_color::cms::active();
|
||||
let Paint::Solid { color, .. } = fill(&s, s.doc().unwrap().selection.objects[0]) else { panic!() };
|
||||
let ink = proof::inks(d, &c, &color, Some(("Ink", 0.4)), proof::Intent::RelativeColorimetric);
|
||||
assert_eq!((ink.cmyk, ink.spot), ([0.0; 4], Some(("Ink".to_string(), 0.4))));
|
||||
// A process colour linked to a global swatch separates into process inks.
|
||||
assert!(proof::inks(d, &c, &color, Some(("Brand", 0.4)), proof::Intent::RelativeColorimetric).spot.is_none());
|
||||
// PDF: the Separation value is the tint (102 / 255 = 0.4).
|
||||
let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap();
|
||||
let pdf = String::from_utf8_lossy(&bytes);
|
||||
assert!(pdf.contains("/Separation/Ink/DeviceCMYK"), "a spot tint is a Separation");
|
||||
assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_mode_shifts_tints_of_linked_colours_only() {
|
||||
let mut s = inks();
|
||||
let a = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 50}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#808080"}));
|
||||
let b = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "edit.colors.adjustBalance", json!({"tint": -30}));
|
||||
assert_eq!(r["changed"], 2, "two linked fills; the grey stroke isn't global");
|
||||
let tint_of = |p: Paint| match p {
|
||||
Paint::Solid { color, swatch, tint } => (color, swatch, tint),
|
||||
_ => panic!("{p:?}"),
|
||||
};
|
||||
let (color, swatch, tint) = tint_of(fill(&s, a));
|
||||
assert!((tint - 0.2).abs() < 1e-6 && swatch.as_deref() == Some("Ink") && color == MAGENTA.tinted(tint), "{tint}");
|
||||
let brand = Color::from_hex("#3366cc").unwrap();
|
||||
let (color, swatch, tint) = tint_of(fill(&s, b));
|
||||
assert!((tint - 0.7).abs() < 1e-6 && swatch.as_deref() == Some("Brand") && color == brand.tinted(tint), "{tint}");
|
||||
assert_eq!(doc(&s).node(a).unwrap().appearance.stroke_paint(), Paint::solid(Color::from_hex("#808080").unwrap()));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// Clamped at 100%; nothing left to change leaves the document alone.
|
||||
let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 100}));
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(fill(&s, a), linked(MAGENTA, "Ink", 1.0));
|
||||
let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 10}));
|
||||
assert_eq!(r["changed"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tints_round_trip_through_the_native_format() {
|
||||
let mut s = inks();
|
||||
rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 35}));
|
||||
run(&mut s, "swatch.new", json!({}));
|
||||
let d = doc(&s).clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(back.node(id).unwrap().appearance.fill_paint(), fill(&s, id));
|
||||
assert_eq!(back.swatch("Ink 35%"), d.swatch("Ink 35%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_the_document_colour_mode_keeps_tints_exact() {
|
||||
let mut s = inks();
|
||||
let a = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 30}));
|
||||
run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"}));
|
||||
let base = doc(&s).global_color("Brand").unwrap();
|
||||
assert!(matches!(base, Color::Cmyk { .. }));
|
||||
assert_eq!(fill(&s, a), linked(base.tinted(0.3), "Brand", 0.3), "30 % of the converted swatch's inks");
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||||
use krilla::color::{cmyk, luma, rgb};
|
||||
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
|
||||
use krilla::geom::{Path, PathBuilder, Size, Transform};
|
||||
|
|
@ -242,19 +243,57 @@ impl Exporter<'_> {
|
|||
color(c)
|
||||
}
|
||||
|
||||
/// A solid paint, as a Separation colour space when it's linked to a spot swatch (the
|
||||
/// swatch's CMYK equivalent is the alternate space).
|
||||
fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color {
|
||||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||||
let spot = swatch.and_then(|n| self.doc.swatch(n).filter(|s| s.spot)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc)));
|
||||
let Some((name, sc)) = spot else { return self.col(c) };
|
||||
/// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate
|
||||
/// space); `None` when `name` isn't a spot colour.
|
||||
fn separation(&self, name: &str) -> Option<SeparationSpace> {
|
||||
let sw = self.doc.swatch(name).filter(|s| s.spot)?;
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let intent = cms.settings().intent;
|
||||
let full = cms.to_cmyk(&sc, intent);
|
||||
let total: f32 = full.iter().sum();
|
||||
let tint = if total <= 1e-4 { 1.0 } else { (cms.to_cmyk(c, intent).iter().sum::<f32>() / total).clamp(0.0, 1.0) };
|
||||
let full = cms.to_cmyk(&sw.paint.color()?, cms.settings().intent);
|
||||
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
|
||||
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into()
|
||||
Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt))
|
||||
}
|
||||
|
||||
/// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch.
|
||||
fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color {
|
||||
match link.and_then(|n| self.separation(n)) {
|
||||
Some(space) => SepColor::new(q(tint), space).into(),
|
||||
None => self.col(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of
|
||||
/// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the
|
||||
/// tint and opacity there are halfway). `None` for other gradients; one that mixes a spot ink
|
||||
/// with other colours is written in process colours (the PDF writer has no DeviceN).
|
||||
fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
|
||||
let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?;
|
||||
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
|
||||
let tints: Option<Vec<f32>> = g
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| match s.swatch.as_deref() {
|
||||
Some(n) if n == ink => Some(s.tint),
|
||||
None if paper(&s.color) => Some(0.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let Some(tints) = tints else {
|
||||
self.warn("gradients mixing a spot color with other colors are exported in process colors");
|
||||
return None;
|
||||
};
|
||||
let space = self.separation(ink)?;
|
||||
let tint = |t: f32| -> krilla::color::Color { SepColor::new(q(t), space.clone()).into() };
|
||||
let mut out = vec![];
|
||||
for (i, s) in g.stops.iter().enumerate() {
|
||||
out.push((s.offset, tint(tints[i]), s.opacity));
|
||||
if let Some(n) = g.stops.get(i + 1)
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
let at = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||
out.push((at, tint((tints[i] + tints[i + 1]) / 2.0), (s.opacity + n.opacity) / 2.0));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect.
|
||||
|
|
@ -275,15 +314,33 @@ impl Exporter<'_> {
|
|||
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
|
||||
match p {
|
||||
Paint::None => None,
|
||||
Paint::Solid { color: c, swatch } => Some(self.solid(c, swatch.as_deref()).into()),
|
||||
Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()),
|
||||
Paint::Gradient(g) => {
|
||||
let geom = g.resolve(bounds);
|
||||
let spot = (g.gradient.kind != GradientKind::Freeform).then(|| self.spot_stops(&g.gradient)).flatten();
|
||||
let colored = match spot {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let stops = g.gradient.expanded_stops();
|
||||
// The PDF writer needs every stop in one colour space: stops of mixed
|
||||
// models (midpoints sample in RGB) go through RGB, or stay CMYK in a CMYK
|
||||
// document, where `col` separates the rest.
|
||||
let mixed = stops.windows(2).any(|w| w[0].1.model() != w[1].1.model());
|
||||
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
|
||||
let one = |c: Color| match c {
|
||||
Color::Cmyk { .. } if cmyk_doc => c,
|
||||
_ if mixed => c.in_model(vectorcraft_color::cms::Model::Rgb),
|
||||
_ => c,
|
||||
};
|
||||
stops.into_iter().map(|(o, c, a)| (o, self.col(&one(c)), a)).collect()
|
||||
}
|
||||
};
|
||||
let mut stops: Vec<Stop> = Vec::new();
|
||||
let mut last = 0.0f32;
|
||||
for (o, c, a) in g.gradient.expanded_stops() {
|
||||
for (o, color, a) in colored {
|
||||
let o = o.clamp(last, 1.0);
|
||||
last = o;
|
||||
stops.push(Stop { offset: norm(o), color: self.col(&c), opacity: norm(a) });
|
||||
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<Gra
|
|||
keep.push(N);
|
||||
let stop_at = |t: f32, offset: f32| -> GradientStop {
|
||||
let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
|
||||
GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }
|
||||
GradientStop::new(offset, color)
|
||||
};
|
||||
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
||||
let (kind, geom, stops) = if *axial {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
//! * **Proof Colors** post-processes the rendered frame: every pixel goes display sRGB → proof
|
||||
//! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT.
|
||||
//! * **Separations Preview** recolours the *document* before rendering: each paint colour is split
|
||||
//! into process inks (colour-managed) plus a spot ink when it's linked to a spot swatch. With one
|
||||
//! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the
|
||||
//! visible inks are composited through the proof profile, spots multiplied on top.
|
||||
//! into process inks (colour-managed), or a spot ink at the colour's tint when it's linked to a
|
||||
//! spot swatch (solid colours and gradient stops alike). With one plate visible the plate
|
||||
//! renders as greyscale ink coverage (black = 100%); with several, the visible inks are
|
||||
//! composited through the proof profile, spots multiplied on top.
|
||||
//! Placed raster images are not separated (limitation).
|
||||
//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
|
||||
//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
|
||||
|
|
@ -72,26 +73,26 @@ fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Separate one colour into inks. Colours linked to a spot swatch print on that plate only, with
|
||||
/// the tint given by the colour's ink total relative to the swatch's.
|
||||
pub fn inks(doc: &Document, c: &Cms, color: &Color, swatch: Option<&str>, intent: Intent) -> Inks {
|
||||
if let Some(name) = swatch
|
||||
&& let Some(sc) = spot_swatch(doc, name)
|
||||
/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it.
|
||||
pub type Link<'a> = Option<(&'a str, f32)>;
|
||||
|
||||
/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at
|
||||
/// its tint.
|
||||
pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks {
|
||||
if let Some((name, tint)) = link
|
||||
&& spot_swatch(doc, name).is_some()
|
||||
{
|
||||
let full: f32 = c.to_cmyk(sc, intent).iter().sum();
|
||||
let this: f32 = c.to_cmyk(color, intent).iter().sum();
|
||||
let tint = if full <= 1e-4 { 1.0 } else { (this / full).clamp(0.0, 1.0) };
|
||||
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint)) };
|
||||
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))) };
|
||||
}
|
||||
Inks { cmyk: c.to_cmyk(color, intent), spot: None }
|
||||
}
|
||||
|
||||
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Link) -> Color) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => *color = f(color, swatch.as_deref()),
|
||||
Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))),
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
s.color = f(&s.color, None);
|
||||
s.color = f(&s.color, s.swatch.as_deref().map(|n| (n, s.tint)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -99,8 +100,8 @@ fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
|||
}
|
||||
|
||||
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
|
||||
/// points), keeping swatch links.
|
||||
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
/// points) given its swatch link and tint, keeping the links.
|
||||
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) => map_paint(&mut l.paint, f),
|
||||
|
|
@ -129,7 +130,7 @@ pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) ->
|
|||
}
|
||||
|
||||
/// Apply `f` to every colour in the document's art and symbol definitions.
|
||||
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) {
|
||||
for l in &mut doc.layers {
|
||||
map_node_colors(Arc::make_mut(l), f);
|
||||
}
|
||||
|
|
@ -162,8 +163,8 @@ fn multiply_overprints(a: &mut Arc<Node>) {
|
|||
}
|
||||
}
|
||||
|
||||
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color {
|
||||
let ink = inks(doc, c, color, swatch, proof.intent);
|
||||
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color {
|
||||
let ink = inks(doc, c, color, link, proof.intent);
|
||||
let rgb = if visible.len() == 1 {
|
||||
let name = &visible[0];
|
||||
let v = match PROCESS_PLATES.iter().position(|p| p == name) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ fn render(d: &Document) -> Image {
|
|||
}
|
||||
|
||||
fn stops(a: Color, b: Color) -> Vec<GradientStop> {
|
||||
vec![GradientStop { offset: 0.0, color: a, opacity: 1.0, midpoint: 0.5 }, GradientStop { offset: 1.0, color: b, opacity: 1.0, midpoint: 0.5 }]
|
||||
vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]
|
||||
}
|
||||
|
||||
fn gradient(kind: GradientKind, a: Color, b: Color, angle: f64) -> Paint {
|
||||
|
|
|
|||
|
|
@ -154,10 +154,7 @@ fn line_and_head_take_the_opacity_once() {
|
|||
|
||||
#[test]
|
||||
fn a_gradient_runs_on_into_the_head() {
|
||||
let stops = vec![
|
||||
GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 },
|
||||
];
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint);
|
||||
let h = head_of(&d);
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ impl Importer {
|
|||
.iter()
|
||||
.map(|s| {
|
||||
let c = s.color();
|
||||
GradientStop { offset: s.offset().get(), color: Color::rgb8(c.red, c.green, c.blue), opacity: s.opacity().get(), midpoint: 0.5 }
|
||||
GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,10 +13,21 @@ pub fn color_json(c: &Color) -> Value {
|
|||
}
|
||||
}
|
||||
|
||||
/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint.
|
||||
/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint (a linked
|
||||
/// stop also carries its `swatch` and `tint` %).
|
||||
pub fn stops_json(stops: &[GradientStop]) -> Value {
|
||||
Value::Array(
|
||||
stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(),
|
||||
stops
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut v = json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint});
|
||||
if let Some(n) = &s.swatch {
|
||||
v["swatch"] = json!(n);
|
||||
v["tint"] = json!(s.tint * 100.0);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +86,11 @@ mod tests {
|
|||
let v = gradient_params(&g);
|
||||
assert_eq!((v["start"].clone(), v["end"].clone(), v["aspect"].clone()), (json!([1.0, 2.0]), json!([3.0, 4.0]), json!(50.0)));
|
||||
assert_eq!(v["swatch"], "Sky");
|
||||
assert!(v["stops"][0].get("swatch").is_none(), "unlinked stops carry no link");
|
||||
g.gradient.stops[1].swatch = Some("Ink".into());
|
||||
g.gradient.stops[1].tint = 0.25;
|
||||
let v = gradient_params(&g);
|
||||
assert_eq!((v["stops"][1]["swatch"].clone(), v["stops"][1]["tint"].clone()), (json!("Ink"), json!(25.0)));
|
||||
assert_eq!(color_json(&Color::gray(0.25)), json!({"gray": 0.25}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! Edit → Edit Colors → Adjust Color Balance: the colour mode (Gray, RGB, CMYK or Global), a slider
|
||||
//! per channel (−100..100 %), Convert, Fill and Stroke, previewed live on the canvas; OK keeps the
|
||||
//! preview as one undo step (`edit.colors.adjustBalance`).
|
||||
//! per channel (−100..100 %; Global has one, Tint, for the tints of global and spot colours),
|
||||
//! Convert, Fill and Stroke, previewed live on the canvas; OK keeps the preview as one undo step
|
||||
//! (`edit.colors.adjustBalance`).
|
||||
//!
|
||||
//! Fields: `mode` (`gray`, `rgb`, `cmyk` or `global`), the channels `r`, `g`, `b`, `c`, `m`, `y`,
|
||||
//! `k`, `gray`, then `convert`, `fill`, `stroke` and `preview`.
|
||||
//! `k`, `gray`, `tint`, then `convert`, `fill`, `stroke` and `preview`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
|
|
@ -33,13 +34,16 @@ const MODES: [Mode; 4] = [
|
|||
Mode { id: "gray", label: "Grayscale", channels: &[("gray", "Black")] },
|
||||
Mode { id: "rgb", label: "RGB", channels: &[("r", "Red"), ("g", "Green"), ("b", "Blue")] },
|
||||
Mode { id: "cmyk", label: "CMYK", channels: &[("c", "Cyan"), ("m", "Magenta"), ("y", "Yellow"), ("k", "Black")] },
|
||||
Mode { id: "global", label: "Global", channels: &[] },
|
||||
Mode { id: "global", label: "Global", channels: &[("tint", "Tint")] },
|
||||
];
|
||||
|
||||
/// Global mode: it shifts the tints of linked colours, which keep their model (no Convert).
|
||||
const GLOBAL: &str = "global";
|
||||
|
||||
/// Open the dialog with every channel at 0, adjusting fills and strokes.
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
let fields = json!({
|
||||
"mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0,
|
||||
"mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0, "tint": 0,
|
||||
"convert": false, "fill": true, "stroke": true, "preview": true,
|
||||
});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
|
|
@ -85,16 +89,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
d.fields.insert("mode".into(), json!(MODES[i].id));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if widgets::check(ui, "Convert", d.bool("convert"), !m.channels.is_empty()) {
|
||||
if widgets::check(ui, "Convert", d.bool("convert"), m.id != GLOBAL) {
|
||||
d.fields.insert("convert".into(), json!(!d.bool("convert")));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let channels = mode(d).channels;
|
||||
if channels.is_empty() {
|
||||
widgets::dim_label(ui, "Global shifts the tints of global and spot colors (not available yet).");
|
||||
let m = mode(d);
|
||||
if m.id == GLOBAL {
|
||||
widgets::dim_label(ui, "Shifts the tints of global and spot colors; other colors stay.");
|
||||
}
|
||||
for (k, label) in channels {
|
||||
for (k, label) in m.channels {
|
||||
form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
|
|
@ -166,15 +170,22 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn global_mode_reports_why_and_stays_open() {
|
||||
fn global_mode_shifts_the_tints_of_global_colours() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap();
|
||||
app.run("ui.colorBalanceDialog", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("global"));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("mode".into(), json!("global"));
|
||||
d.fields.insert("tint".into(), json!(-20));
|
||||
frame(&mut app);
|
||||
let e = super::super::confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains("global"), "{e}");
|
||||
assert!(app.ui.dialog.is_some());
|
||||
assert_eq!(params(app.ui.dialog.as_ref().unwrap())["tint"], json!(-20.0));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
let vectorcraft_color::Paint::Solid { swatch, tint, .. } = n.appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(swatch.as_deref(), Some("Ink"), "the colour stays linked");
|
||||
assert!((tint - 0.3).abs() < 1e-6, "{tint}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the
|
||||
//! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls,
|
||||
//! or a document swatch), opacity and location. It edits the selected stop
|
||||
//! (`gradient.selectStop`) through `paint.editGradient`, and closes on Escape or a click elsewhere.
|
||||
//! or a document swatch, which stays linked when it is a global or spot colour), opacity and
|
||||
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
|
||||
//! closes on Escape or a click elsewhere.
|
||||
//!
|
||||
//! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points,
|
||||
//! from the panel), and `tab` (`color` or `swatches`).
|
||||
|
|
@ -31,9 +32,9 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
|
|||
Some((g.gradient.stops, i))
|
||||
}
|
||||
|
||||
/// `stops` with stop `i` given `color`.
|
||||
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
|
||||
stops[i].color = color;
|
||||
/// `stops` with stop `i` given `color`, linked to `link` (a global swatch and tint) or unlinked.
|
||||
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color, link: Option<(String, f32)>) -> Vec<GradientStop> {
|
||||
stops[i].set_color(color, link);
|
||||
stops
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +85,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let s = stops[i];
|
||||
let s = &stops[i];
|
||||
widgets::dim_label(ui, "Opacity:");
|
||||
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
|
||||
let mut v2 = stops.clone();
|
||||
|
|
@ -124,12 +125,14 @@ fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientSto
|
|||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered());
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
pick = Some(*c);
|
||||
pick = Some((name.clone(), *c));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(c) = pick {
|
||||
set_stops(app, &recolor(stops.to_vec(), i, c), Some(i), Live::Released);
|
||||
// A global or spot colour (or a tint of one) stays linked.
|
||||
if let Some((name, c)) = pick {
|
||||
let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name }));
|
||||
set_stops(app, &recolor(stops.to_vec(), i, c, link), Some(i), Live::Released);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +141,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
|||
let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?;
|
||||
if let Some(hex) = d.fields.get("color").and_then(Value::as_str) {
|
||||
let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?;
|
||||
stops = recolor(stops, i, c);
|
||||
stops = recolor(stops, i, c, None);
|
||||
}
|
||||
if d.fields.contains_key("opacity") {
|
||||
stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
//! New Swatch: the Swatch Options fields (name, colour type, Global, mode, sliders and hex)
|
||||
//! prefilled from the active fill or stroke; a gradient or pattern only takes a name. OK runs
|
||||
//! `swatch.new`.
|
||||
//! prefilled from the active fill or stroke; a gradient, a pattern or a tint of a global colour
|
||||
//! (saved as a tint swatch, "Name 40%") only takes a name. OK runs `swatch.new`.
|
||||
//!
|
||||
//! Fields: `name`, `spot`, `global`, `mode` and `color` as in Swatch Options, `group` (the colour
|
||||
//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient or
|
||||
//! pattern; absent for a colour) and `__auto` (the default name, which follows the colour until
|
||||
//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient,
|
||||
//! pattern or tint; absent for a colour) and `__auto` (the default name, which follows the colour until
|
||||
//! the name is edited).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -26,6 +26,9 @@ pub fn open(app: &mut VectorcraftApp, spot: bool, group: Option<&str>) -> Result
|
|||
let paint = crate::panels::active_paint(app);
|
||||
let name = app.session.active().ok_or("no document open")?.doc.new_swatch_name(&paint);
|
||||
let mut fields = match &paint {
|
||||
Paint::Solid { swatch: Some(_), tint, .. } if *tint < 1.0 => {
|
||||
json!({"__paint": crate::panels::paint_params(&paint), "group": group.unwrap_or_default()})
|
||||
}
|
||||
Paint::Solid { color, .. } => {
|
||||
json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot, "group": group.unwrap_or_default()})
|
||||
}
|
||||
|
|
@ -82,4 +85,18 @@ mod tests {
|
|||
let d = Dialog::new(KIND, json!({"name": "Dots", "__paint": {"pattern": "Dots"}, "group": ""}));
|
||||
assert_eq!(params(&d), json!({"name": "Dots", "pattern": "Dots"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tint_opens_as_a_named_tint_swatch() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
|
||||
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap();
|
||||
open(&mut app, false, None).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(params(&d), json!({"name": "Ink 40%", "swatch": "Ink", "tint": 40.0}));
|
||||
run_and_close(&mut app, "swatch.new", params(&d)).unwrap();
|
||||
let w = app.session.doc().unwrap().doc.swatch("Ink 40%").cloned().unwrap();
|
||||
assert_eq!(w.tint_of(), Some(("Ink", 0.4)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,10 +53,18 @@ fn set_color(d: &mut Dialog, c: Color) {
|
|||
d.fields.insert("color".into(), json!(c));
|
||||
}
|
||||
|
||||
/// Open the editor of swatch `name`: Swatch Options for a colour or a gradient (its name only),
|
||||
/// pattern editing for a pattern.
|
||||
/// Open the editor of swatch `name`: Swatch Options for a colour (a tint swatch's base) or a
|
||||
/// gradient (its name only), pattern editing for a pattern.
|
||||
pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
||||
let sw = app.session.active().and_then(|st| st.doc.swatch(name).cloned()).ok_or_else(|| format!("no swatch `{name}`"))?;
|
||||
let d = app.session.active().map(|st| &st.doc);
|
||||
let sw = d.and_then(|d| d.swatch(name)).ok_or_else(|| format!("no swatch `{name}`"))?;
|
||||
if let Some((base, _)) = sw.tint_of()
|
||||
&& d.is_some_and(|d| d.swatch(base).is_some())
|
||||
{
|
||||
let base = base.to_string();
|
||||
return open(app, &base);
|
||||
}
|
||||
let sw = sw.clone();
|
||||
match &sw.paint {
|
||||
Paint::Solid { color, .. } => {
|
||||
let fields = json!({
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"ui.colorBalanceDialog",
|
||||
"Adjust Color Balance…",
|
||||
"",
|
||||
"{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step",
|
||||
"{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step",
|
||||
),
|
||||
(
|
||||
"ui.saturateDialog",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks,
|
||||
//! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy.
|
||||
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop (on a
|
||||
//! freeform gradient, its selected point).
|
||||
//! freeform gradient, its selected point). A colour (or stop) linked to a global or spot swatch
|
||||
//! shows Tint mode instead: the swatch's chip and name and a T slider (0–100 % of the swatch);
|
||||
//! picking a mode from the menu makes it a process colour.
|
||||
//!
|
||||
//! The mode follows the colour's own model (the last mode picked comes back for colours in its
|
||||
//! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the
|
||||
|
|
@ -259,27 +261,53 @@ fn to32(c: &Color) -> Color32 {
|
|||
super::c32(c)
|
||||
}
|
||||
|
||||
/// What the panel is editing: the active proxy's solid colour, a selected gradient stop or a
|
||||
/// selected freeform point.
|
||||
/// The global or spot swatch a colour is a tint of (Tint mode).
|
||||
struct Tint {
|
||||
swatch: String,
|
||||
spot: bool,
|
||||
/// 0..1.
|
||||
tint: f32,
|
||||
/// The swatch's colour (100 %).
|
||||
base: Color,
|
||||
}
|
||||
|
||||
impl Tint {
|
||||
/// The tint `link` (a swatch name) and `tint` show: `None` unless the swatch is a global colour.
|
||||
fn of(app: &VectorcraftApp, link: Option<&str>, tint: f32) -> Option<Self> {
|
||||
let name = link?;
|
||||
let d = &app.session.active()?.doc;
|
||||
let spot = d.swatch(name)?.spot;
|
||||
Some(Self { swatch: name.to_string(), spot, tint, base: d.global_color(name)? })
|
||||
}
|
||||
}
|
||||
|
||||
/// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with
|
||||
/// the global swatch it is a tint of), or a selected freeform point.
|
||||
enum Target {
|
||||
Paint(Option<Color>),
|
||||
Stop { paint: Paint, index: usize, color: Color },
|
||||
Paint(Option<Color>, Option<Tint>),
|
||||
Stop { paint: Paint, index: usize, color: Color, tint: Option<Tint> },
|
||||
Point { index: usize, color: Color },
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn color(&self) -> Option<Color> {
|
||||
match self {
|
||||
Target::Paint(c) => *c,
|
||||
Target::Paint(c, _) => *c,
|
||||
Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color),
|
||||
}
|
||||
}
|
||||
fn tint(&self) -> Option<&Tint> {
|
||||
match self {
|
||||
Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(),
|
||||
Target::Point { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active proxy's colour or gradient stop; no colour for None, patterns and a "?" proxy.
|
||||
fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
|
||||
if super::active_mixed(app, ui.ctx()) {
|
||||
return Target::Paint(None);
|
||||
return Target::Paint(None, None);
|
||||
}
|
||||
let p = active_paint(app);
|
||||
match &p {
|
||||
|
|
@ -291,11 +319,11 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
|
|||
}
|
||||
Paint::Gradient(g) => {
|
||||
let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
|
||||
let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK);
|
||||
Target::Stop { paint: p.clone(), index: i, color }
|
||||
let (color, tint) = g.gradient.stops.get(i).map_or((Color::BLACK, None), |s| (s.color, Tint::of(app, s.swatch.as_deref(), s.tint)));
|
||||
Target::Stop { paint: p.clone(), index: i, color, tint }
|
||||
}
|
||||
Paint::Solid { color, .. } => Target::Paint(Some(*color)),
|
||||
_ => Target::Paint(None),
|
||||
Paint::Solid { color, swatch, tint } => Target::Paint(Some(*color), Tint::of(app, swatch.as_deref(), *tint)),
|
||||
_ => Target::Paint(None, None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +334,7 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
|
|||
Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => {
|
||||
let mut stops = g.gradient.stops.clone();
|
||||
if let Some(s) = stops.get_mut(*index) {
|
||||
s.color = c;
|
||||
s.set_color(c, None);
|
||||
}
|
||||
super::gradient::set_stops(app, &stops, None, phase);
|
||||
}
|
||||
|
|
@ -322,17 +350,59 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
|
|||
}
|
||||
}
|
||||
|
||||
/// Switch the panel to `mode`, converting the colour to its model.
|
||||
/// Set the target's tint of its swatch to `t` (0..1), keeping the link (the T slider).
|
||||
fn apply_tint(app: &mut VectorcraftApp, tgt: &Target, tint: &Tint, t: f32, phase: Live) {
|
||||
match tgt {
|
||||
Target::Stop { paint: Paint::Gradient(g), index, .. } => {
|
||||
let mut stops = g.gradient.stops.clone();
|
||||
if let Some(s) = stops.get_mut(*index) {
|
||||
s.set_color(tint.base.tinted(t), Some((tint.swatch.clone(), t)));
|
||||
}
|
||||
super::gradient::set_stops(app, &stops, None, phase);
|
||||
}
|
||||
_ => {
|
||||
let cmd = super::proxy_cmd(app, false);
|
||||
live_run(app, "Color", cmd, json!({"swatch": tint.swatch, "tint": t * 100.0}), phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the panel to `mode`, converting the colour to its model (a tint becomes a process
|
||||
/// colour).
|
||||
fn set_mode(app: &mut VectorcraftApp, ctx: &egui::Context, tgt: &Target, mode: Mode) {
|
||||
set_pstate(ctx, "color-mode", Some(mode));
|
||||
if let Some(c) = tgt.color() {
|
||||
let conv = convert_to(mode, &c);
|
||||
if conv != c {
|
||||
if conv != c || tgt.tint().is_some() {
|
||||
apply(app, tgt, conv, Live::Released, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tint mode's rows: the swatch's chip and name, and the T slider and field. Returns a new tint
|
||||
/// (0..1) and its phase.
|
||||
fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f32, Live)> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(tint.base), false, false);
|
||||
super::swatches::global_mark(ui, r, tint.spot);
|
||||
ui.label(egui::RichText::new(&tint.swatch).size(12.5).color(t.text));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("T").size(12.5).color(t.text)));
|
||||
let track = |x: f32| to32(&tint.base.tinted(x));
|
||||
let (nv, phase) = widgets::color_slider(ui, ("color-slider", "tint"), tint.tint, slider_w, &track);
|
||||
let field = widgets::plain_field(ui, ("color-field", "tint"), (tint.tint * 100.0).round() as f64, "%", 0, field_w);
|
||||
match (nv, field) {
|
||||
(Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)),
|
||||
(None, Some(v)) => Some(((v as f32 / 100.0).clamp(0.0, 1.0), Live::Released)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// What happened on the spectrum ramp.
|
||||
enum SpectrumHit {
|
||||
Pick(Color, Live),
|
||||
|
|
@ -391,6 +461,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
// Alt held for the inactive proxy (`behind`).
|
||||
let mut new: Option<(Color, Live, Vec<f32>)> = None;
|
||||
let mut behind: Option<(Color, Live)> = None;
|
||||
let mut tinted: Option<(f32, Live)> = None;
|
||||
let gamut = color.filter(|_| matches!(mode, Mode::Rgb | Mode::Hsb)).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c));
|
||||
ui.horizontal(|ui| {
|
||||
// Left column: proxy, out-of-gamut and out-of-web warnings.
|
||||
|
|
@ -412,6 +483,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let field_w = 48.0;
|
||||
let slider_w = (ui.available_width() - field_w - 24.0).max(60.0);
|
||||
if let Some(tint) = tgt.tint() {
|
||||
tinted = tint_rows(ui, tint, slider_w, field_w);
|
||||
return;
|
||||
}
|
||||
let enabled = color.is_some();
|
||||
for (i, lbl) in mode.labels().iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -489,6 +564,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if let Some((c, phase)) = behind {
|
||||
apply(app, &tgt, c, phase, true);
|
||||
}
|
||||
if let (Some((t, phase)), Some(tint)) = (tinted, tgt.tint()) {
|
||||
apply_tint(app, &tgt, tint, t, phase);
|
||||
}
|
||||
if let Some((c, phase, comps2)) = new {
|
||||
let key = (c.to_hex(), mode as u8);
|
||||
set_pstate(ui.ctx(), "color-comps", Some((key, comps2)));
|
||||
|
|
@ -504,9 +582,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.separator();
|
||||
let tgt = target(app, ui);
|
||||
let color = tgt.color();
|
||||
// Tint mode checks no colour mode: picking one makes the tint a process colour.
|
||||
let cur = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref());
|
||||
for (m, label) in Mode::ALL {
|
||||
if menu_item(ui, label, true, m == cur) {
|
||||
if menu_item(ui, label, true, m == cur && tgt.tint().is_none()) {
|
||||
set_mode(app, ui.ctx(), &tgt, m);
|
||||
}
|
||||
}
|
||||
|
|
@ -531,7 +610,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if menu_item(ui, "Create New Swatch…", color.is_some(), false)
|
||||
&& let Some(c) = color
|
||||
{
|
||||
app.run("swatch.new", json!({"color": color_json(&c)})).ok();
|
||||
// A tint saves a tint swatch ("Name 40%").
|
||||
let p = match tgt.tint() {
|
||||
Some(t) => json!({"swatch": t.swatch, "tint": t.tint * 100.0}),
|
||||
None => json!({"color": color_json(&c)}),
|
||||
};
|
||||
app.run("swatch.new", p).ok();
|
||||
}
|
||||
if menu_item(ui, "Copy Color Value (Hex)", color.is_some(), false)
|
||||
&& let Some(c) = color
|
||||
|
|
@ -691,6 +775,54 @@ mod tests {
|
|||
assert!(d.bool("stroke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_global_colour_shows_tint_mode_and_the_t_slider_sets_its_tint() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
|
||||
let _ = ctx.run_ui(Default::default(), |ui| {
|
||||
let tgt = target(&app, ui);
|
||||
let t = tgt.tint().expect("Tint mode");
|
||||
assert_eq!((t.swatch.as_str(), t.spot, t.tint, t.base), ("Ink", true, 0.5, Color::cmyk(0.0, 1.0, 0.0, 0.0)));
|
||||
});
|
||||
// The chip's name and the T row are drawn; a click on the slider's right part raises the
|
||||
// tint and keeps the link.
|
||||
let texts = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(260.0, 600.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
};
|
||||
let drawn = texts(&mut app, vec![]);
|
||||
let at = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, p)| *p);
|
||||
assert!(at("Ink").is_some(), "the swatch's name: {drawn:?}");
|
||||
let (t_label, field) = (at("T").expect("the T row"), at("50%").expect("the tint field"));
|
||||
let pos = egui::pos2(field.x - 40.0, t_label.y);
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
texts(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]);
|
||||
texts(&mut app, vec![button(false)]);
|
||||
let Paint::Solid { swatch, tint, .. } = crate::panels::current_paints(&app).0 else { panic!() };
|
||||
assert_eq!(swatch.as_deref(), Some("Ink"));
|
||||
assert!(tint > 0.6 && tint < 1.0, "{tint}");
|
||||
// Picking a mode makes it a process colour.
|
||||
let _ = ctx.run_ui(Default::default(), |ui| {
|
||||
let tgt = target(&app, ui);
|
||||
set_mode(&mut app, ui.ctx(), &tgt, Mode::Cmyk);
|
||||
});
|
||||
assert!(matches!(crate::panels::current_paints(&app).0, Paint::Solid { swatch: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_options_leaves_the_proxy_and_spectrum() {
|
||||
let mut app = app();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
//! Alt-dropping it on another stop swaps their colours), drag it off to remove it, double-click it
|
||||
//! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint
|
||||
//! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or
|
||||
//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider.
|
||||
//! recolours the stop it lands on; a global or spot colour (or a tint of one) stays linked, so
|
||||
//! editing the swatch recolours the stop. The thumbnail menu marks the gradient swatch the
|
||||
//! gradient was applied from. Hide Options leaves the thumbnail, the proxy and the slider.
|
||||
//!
|
||||
//! A freeform gradient shows its section instead of the angle, aspect and slider: the Draw
|
||||
//! toggle (Points or Lines: how the Gradient tool adds points) and the selected point's colour
|
||||
|
|
@ -54,17 +56,28 @@ pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 {
|
|||
pub use vectorcraft_tools::params::stops_json;
|
||||
|
||||
/// A colour dropped on the ramp: recolours stop `on` (fully opaque), or adds an opaque stop of
|
||||
/// that colour at `offset`. Returns the stops and the index of the stop that took the colour.
|
||||
fn drop_color(g: &Gradient, on: Option<usize>, offset: f32, c: Color) -> (Vec<GradientStop>, usize) {
|
||||
/// that colour at `offset`, linked to `link` (a global swatch and tint) when given. Returns the
|
||||
/// stops and the index of the stop that took the colour.
|
||||
fn drop_color(g: &Gradient, on: Option<usize>, offset: f32, c: Color, link: Option<(String, f32)>) -> (Vec<GradientStop>, usize) {
|
||||
let (mut v, i) = match on.filter(|i| *i < g.stops.len()) {
|
||||
Some(i) => (g.stops.clone(), i),
|
||||
None => insert_stop(g, offset),
|
||||
};
|
||||
v[i].color = c;
|
||||
v[i].set_color(c, link);
|
||||
v[i].opacity = 1.0;
|
||||
(v, i)
|
||||
}
|
||||
|
||||
/// The swatch link a dropped paint's params give a stop: `{swatch, tint?}` of a global or spot
|
||||
/// colour or a tint swatch (a proxy's tint carries its percentage); none for other colours.
|
||||
pub(crate) fn dropped_link(app: &VectorcraftApp, params: &Value) -> Option<(String, f32)> {
|
||||
let name = params.get("swatch")?.as_str()?;
|
||||
let d = &app.session.active()?.doc;
|
||||
let (base, own) = d.swatch_link(name)?;
|
||||
let tint = params.get("tint").and_then(Value::as_f64).map_or(own, |t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
Some((base, tint))
|
||||
}
|
||||
|
||||
/// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy
|
||||
/// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns
|
||||
/// the stops and the stop to select.
|
||||
|
|
@ -337,7 +350,10 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad:
|
|||
}
|
||||
for (name, grad) in &swatches {
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
// The gradient swatch the gradient was applied from.
|
||||
if g.swatch.as_ref() == Some(name) {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0));
|
||||
|
|
@ -365,13 +381,18 @@ fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: boo
|
|||
let stops = &g.stops;
|
||||
let sel = app.session.selected_stop().filter(|i| *i < stops.len() && is_grad);
|
||||
let mid = selected_mid(app, ui.ctx(), stops.len()).filter(|_| is_grad);
|
||||
let stop = sel.map(|i| stops[i]);
|
||||
let stop = sel.map(|i| &stops[i]);
|
||||
if let (Some(i), Some(s)) = (sel, stop) {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, "Color:");
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(s.color), false, resp.hovered());
|
||||
widgets::dim_label(ui, &s.color.to_hex().to_uppercase());
|
||||
// A linked stop names its swatch and tint.
|
||||
let label = match &s.swatch {
|
||||
Some(n) => format!("{n} {}%", vectorcraft_color::tint_percent(s.tint)),
|
||||
None => s.color.to_hex().to_uppercase(),
|
||||
};
|
||||
widgets::dim_label(ui, &label);
|
||||
if resp.on_hover_text("Edit the stop").clicked() {
|
||||
open_popover(app, i, r.left_bottom() + vec2(0.0, 4.0));
|
||||
}
|
||||
|
|
@ -598,7 +619,11 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
|||
&& let Some(c) = d.color()
|
||||
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
|
||||
{
|
||||
let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c);
|
||||
let link = match &*d {
|
||||
PanelDrag::Paint { params, .. } => dropped_link(app, params),
|
||||
_ => None,
|
||||
};
|
||||
let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c, link);
|
||||
select = Some(i);
|
||||
changed = Some((v, Live::Released));
|
||||
}
|
||||
|
|
@ -666,9 +691,9 @@ mod tests {
|
|||
let mut g = Gradient::default();
|
||||
g.stops[1].opacity = 0.5;
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let (v, i) = drop_color(&g, Some(1), 0.3, red);
|
||||
let (v, i) = drop_color(&g, Some(1), 0.3, red, None);
|
||||
assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0));
|
||||
let (v, i) = drop_color(&g, None, 0.3, red);
|
||||
let (v, i) = drop_color(&g, None, 0.3, red, None);
|
||||
assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0));
|
||||
}
|
||||
|
||||
|
|
@ -879,6 +904,20 @@ mod tests {
|
|||
let last = p.widget(("grad-stop", 2)).center();
|
||||
drop(&mut p, &mut app, last);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]);
|
||||
// A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop.
|
||||
app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true, "focus": false})).unwrap();
|
||||
let ink = app.session.doc().unwrap().doc.swatch("Ink").unwrap().paint.clone();
|
||||
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::Paint { paint: ink, params: json!({"swatch": "Ink"}), rows: None });
|
||||
p.frame(&mut app, vec![Event::PointerMoved(a)], Modifiers::NONE);
|
||||
p.frame(
|
||||
&mut app,
|
||||
vec![Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }],
|
||||
Modifiers::NONE,
|
||||
);
|
||||
let st = fill(&app).gradient.stops[0].clone();
|
||||
assert_eq!((st.swatch.as_deref(), st.tint, st.color.to_hex()), (Some("Ink"), 1.0, "#cc0066".to_string()));
|
||||
app.run("swatch.edit", json!({"name": "Ink", "color": "#0066cc"})).unwrap();
|
||||
assert_eq!(hexes(&app)[0], "#0066cc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -378,10 +378,12 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
/// A colour as command JSON, keeping its model.
|
||||
pub(crate) use vectorcraft_tools::params::color_json;
|
||||
|
||||
/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` (lossless) or `{pattern}`).
|
||||
/// Paint as command params (`{color}`, `{swatch, tint?}`, `{none}`, `{gradient}` (lossless) or
|
||||
/// `{pattern}`).
|
||||
pub(crate) fn paint_params(p: &Paint) -> Value {
|
||||
match p {
|
||||
Paint::None => json!({"none": true}),
|
||||
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => json!({"swatch": n, "tint": tint * 100.0}),
|
||||
Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}),
|
||||
Paint::Solid { color, .. } => json!({"color": color_json(color)}),
|
||||
Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}),
|
||||
|
|
@ -423,6 +425,8 @@ mod tests {
|
|||
assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"}));
|
||||
assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5));
|
||||
let tint = Paint::Solid { color: Color::gray(0.5), swatch: Some("Ink".into()), tint: 0.5 };
|
||||
assert_eq!(paint_params(&tint), json!({"swatch": "Ink", "tint": 50.0}), "a tint keeps its percentage");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -116,7 +116,11 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec<Entry> {
|
|||
let q = query.trim().to_lowercase();
|
||||
let found = |n: &str| q.is_empty() || n.to_lowercase().contains(&q);
|
||||
let mut out = vec![];
|
||||
let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot };
|
||||
// A tint swatch shows its base's kind (global or spot).
|
||||
let sw = |s: &vectorcraft_color::Swatch| {
|
||||
let (global, spot) = s.tint_of().and_then(|(base, _)| d.swatch(base)).map_or((s.global, s.spot), |b| (b.global, b.spot));
|
||||
Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global, spot }
|
||||
};
|
||||
// None first, then Registration, then the rest (the reference app's order).
|
||||
let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none());
|
||||
for s in specials.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) {
|
||||
|
|
@ -616,12 +620,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
|
|||
widgets::subheader(ui, "Swatch Tiles");
|
||||
let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), "Find") } else { String::new() };
|
||||
let items = entries(app, kind, &query);
|
||||
let active = active_paint(app);
|
||||
let active_swatch = match &active {
|
||||
Paint::Solid { swatch: Some(n), .. } => Some(n.clone()),
|
||||
Paint::None => Some("[None]".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let active_swatch = active_swatch(app);
|
||||
let selected = selection(app, ui);
|
||||
// Without a selection the swatch of the active paint is highlighted.
|
||||
let fallback: Vec<String> = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] };
|
||||
|
|
@ -693,6 +692,21 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
|
|||
bottom(app, ui, &selected);
|
||||
}
|
||||
|
||||
/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch
|
||||
/// when there is one), a gradient's gradient swatch, or None.
|
||||
fn active_swatch(app: &VectorcraftApp) -> Option<String> {
|
||||
match active_paint(app) {
|
||||
Paint::Solid { swatch: Some(n), tint, .. } => {
|
||||
let d = &app.session.active()?.doc;
|
||||
let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint)));
|
||||
Some(tint_swatch.map_or(n.clone(), |w| w.name.clone()))
|
||||
}
|
||||
Paint::Gradient(g) => g.swatch,
|
||||
Paint::None => Some("[None]".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder
|
||||
/// (`is_folder`) starts a new row.
|
||||
pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> {
|
||||
|
|
@ -1180,7 +1194,7 @@ mod tests {
|
|||
let doc = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into()));
|
||||
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
|
||||
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()) });
|
||||
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()), tint: 1.0 });
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
|
|
@ -1309,7 +1323,7 @@ mod tests {
|
|||
let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made");
|
||||
assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::<Vec<_>>(), [("R=18 G=52 B=86", true)]);
|
||||
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
|
||||
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) });
|
||||
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()), tint: 1.0 });
|
||||
}
|
||||
|
||||
/// The centre of the tile or row of `name` as laid out by the last frame.
|
||||
|
|
@ -1425,10 +1439,8 @@ mod tests {
|
|||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||
let grad = |c: &str| {
|
||||
let stops = vec![
|
||||
vectorcraft_color::GradientStop { offset: 0.0, color: Color::from_hex(c).unwrap(), opacity: 1.0, midpoint: 0.5 },
|
||||
vectorcraft_color::GradientStop { offset: 1.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
|
||||
];
|
||||
let stops =
|
||||
vec![vectorcraft_color::GradientStop::new(0.0, Color::from_hex(c).unwrap()), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)];
|
||||
Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { kind: GradientKind::Radial, stops })))
|
||||
};
|
||||
let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| {
|
||||
|
|
@ -1470,6 +1482,23 @@ mod tests {
|
|||
assert!(!Kind::Groups.accepts(&solid, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_tint_or_gradient_swatch_is_highlighted_and_a_tint_edits_its_base() {
|
||||
let mut app = app();
|
||||
app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
|
||||
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap();
|
||||
assert_eq!(active_swatch(&app).as_deref(), Some("Ink"), "no tint swatch yet: its base");
|
||||
app.run("swatch.new", json!({})).unwrap();
|
||||
assert_eq!(active_swatch(&app).as_deref(), Some("Ink 40%"));
|
||||
let tint = entries(&app, Kind::Color, "Ink 40").into_iter().find(|e| e.name() == "Ink 40%").unwrap();
|
||||
assert!(matches!(tint, Entry::Swatch { global: true, spot: true, .. }), "a tint swatch shows its base's kind");
|
||||
app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap();
|
||||
assert_eq!(active_swatch(&app).as_deref(), Some("Sunset"), "the applied gradient swatch");
|
||||
app.run("ui.swatchOptions", json!({"name": "Ink 40%"})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("__swatch").as_str()), (KIND, "Ink"), "a tint swatch opens its base's options");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_tiles_match_measured_metrics() {
|
||||
assert_eq!(View::MediumThumb.tile(), (15.5, 17.0));
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches ru
|
|||
(one swatch per colour, one undo step).
|
||||
|
||||
Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/
|
||||
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` from −100 to 100, `convert`, `fill`, `stroke`,
|
||||
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`,
|
||||
`preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the
|
||||
canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.colors.adjustBalance` /
|
||||
`edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global
|
||||
|
|
|
|||
30
docs/mcp.md
30
docs/mcp.md
|
|
@ -428,3 +428,33 @@ fills and strokes become objects among its members, and its members are expanded
|
|||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{"ids":[12]}}}
|
||||
```
|
||||
|
||||
## Tints of global and spot colours
|
||||
|
||||
A colour linked to a global or spot swatch has a tint (the reference app's T slider): `paint.setFill {swatch,
|
||||
tint?: 0..100}` (and `paint.setStroke`) applies the swatch at that percentage, linked, so `swatch.edit` recolours it
|
||||
at its own tint. `paint.proxies` and `document.inspect` show the paint as `{type: "solid", color, swatch, tint}`
|
||||
(`tint` 0..1, left out at 100 %). `swatch.new {tint?}` with the current fill (or `swatch`) saves a tint swatch,
|
||||
"Name 40%": `swatch.list` reports it with `tintOf` and `tint`, applying it links to its base at that tint, and it
|
||||
follows edits of its base. A spot tint prints that percentage of its plate (Separations Preview, PDF Separation
|
||||
value). `edit.colors.adjustBalance {tint: -100..100}` (Global mode) shifts the tints of the selection's linked
|
||||
colours and leaves the rest alone.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.setFill","params":{"swatch":"Ink","tint":40}}}
|
||||
{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"global","tint":-20}}}
|
||||
```
|
||||
|
||||
## Linked gradient stops
|
||||
|
||||
Applying a gradient swatch (`paint.setFill {swatch}`) records it as the gradient's `swatch` (the Swatches panel
|
||||
highlights it; new stops drop the link). A gradient stop can link to a global or spot swatch like a solid colour:
|
||||
give it `swatch` (and `tint` %, default 100 or a tint swatch's own) instead of `color` in `paint.editGradient
|
||||
{stops}` or a `gradient` paint's stops. Editing or deleting the swatch then recolours or unlinks the stop, in art and
|
||||
in gradient swatches. A spot stop separates on its plate; a gradient whose stops are all tints of one spot ink (or
|
||||
paper white, 0 %) exports to PDF as a Separation shading (the writer has no DeviceN, so a gradient mixing a spot
|
||||
ink with other colours is written in process colours, with a warning).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stops":[{"offset":0,"swatch":"Ink"},{"offset":1,"swatch":"Ink","tint":20}]}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue