M3.14: lossless, validated gradient params — paint.setFill/setStroke, swatch.new and appearance.setItem keep kind (freeform too), midpoints, stop opacity, aspect, vector and swatch link; a shared parse_stops rejects fewer than two or malformed stops, clamps offsets and normalises 0..100 opacity; an aspect without a vector places the gradient on each object's (stroke-inflated) bounds; panels write gradients through one params writer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb8c41d8e3
commit
61ddc1ce34
|
|
@ -15,6 +15,10 @@ pub enum GradientKind {
|
|||
}
|
||||
|
||||
impl GradientKind {
|
||||
/// Parse a kind name (`linear`, `radial`, `freeform`; any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GradientKind::Linear => "Linear",
|
||||
|
|
|
|||
|
|
@ -213,6 +213,11 @@ impl StrokeLayer {
|
|||
effects: vec![],
|
||||
}
|
||||
}
|
||||
/// The box an unplaced gradient on this stroke fits to: the object's geometric bounds grown by
|
||||
/// half the weight (as the renderers resolve it).
|
||||
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
|
||||
geometric.inflate(self.width / 2.0, self.width / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the appearance stack.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Appearance Item",
|
||||
[],
|
||||
None,
|
||||
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
|
||||
"{index, opacity?, blend?, visible?, weight?, color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of the selection's appearance stack",
|
||||
has_selection,
|
||||
set_item
|
||||
),
|
||||
|
|
@ -101,18 +101,20 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.edit("Appearance", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let bounds = n.geometric_bounds();
|
||||
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
|
||||
let (pp, op, bl, vis) = match item {
|
||||
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible),
|
||||
let (pp, op, bl, vis, bounds) = match item {
|
||||
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible, bounds),
|
||||
AppearanceItem::Stroke(st) => {
|
||||
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
|
||||
st.width = w.max(0.0);
|
||||
}
|
||||
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible)
|
||||
let bounds = bounds.map(|b| st.paint_bounds(b));
|
||||
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible, bounds)
|
||||
}
|
||||
};
|
||||
if let Some(pa) = &paint {
|
||||
*pp = pa.clone();
|
||||
*pp = super::gradient::place_paint(pa, p, bounds);
|
||||
}
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
|
||||
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2), angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
|
|
@ -31,6 +31,84 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
type Parsed<T> = std::result::Result<T, String>;
|
||||
|
||||
/// Parse a kind name.
|
||||
fn parse_kind(k: &str) -> Parsed<GradientKind> {
|
||||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 {
|
||||
return Err("a gradient needs at least two stops".into());
|
||||
}
|
||||
let mut out = arr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, st)| {
|
||||
let num = |k: &str| st.get(k).map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number"))).transpose();
|
||||
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);
|
||||
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,
|
||||
})
|
||||
})
|
||||
.collect::<Parsed<Vec<_>>>()?;
|
||||
out.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse a gradient paint object (the `gradient` param of `paint.setFill`). Lossless for everything
|
||||
/// `vectorcraft_tools::params::gradient_params` writes.
|
||||
pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
if !g.is_object() {
|
||||
return Err("`gradient` must be an object".into());
|
||||
}
|
||||
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
|
||||
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
|
||||
let mut gp = GradientPaint::new(Gradient { kind, stops });
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
||||
let aspect = aspect_param(g)?;
|
||||
let point = |k: &str| g.get(k).map(|_| point_param(g, k).ok_or_else(|| format!("`{k}` must be [x, y]"))).transpose();
|
||||
match (point("start")?, point("end")?) {
|
||||
(Some(start), Some(end)) => {
|
||||
let geom = GradientGeom { start, end, aspect: aspect.unwrap_or(1.0) };
|
||||
gp.angle = geom.angle_deg();
|
||||
gp.geom = Some(geom);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => return Err("give both `start` and `end` (or neither)".into()),
|
||||
}
|
||||
Ok(gp)
|
||||
}
|
||||
|
||||
/// The `aspect` param (a percentage) as a ratio.
|
||||
fn aspect_param(p: &Value) -> Parsed<Option<f64>> {
|
||||
p.get("aspect")
|
||||
.map(|v| v.as_f64().map(|a| (a / 100.0).clamp(0.005, 327.67)).ok_or_else(|| "`aspect` must be a number (%)".to_string()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// `paint` as applied to an object with `bounds`: a gradient given an `aspect` but no vector is
|
||||
/// placed on the bounds so the aspect sticks.
|
||||
pub(crate) fn place_paint(paint: &Paint, p: &Value, bounds: Option<vectorcraft_geom::Rect>) -> Paint {
|
||||
match (paint, p.get("gradient").and_then(|g| g.get("aspect"))) {
|
||||
(Paint::Gradient(g), Some(a)) if g.geom.is_none() => {
|
||||
apply_gradient_edit(paint, &json!({ "aspect": a }), bounds).unwrap_or_else(|_| paint.clone())
|
||||
}
|
||||
_ => paint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the gradient edits in `p` to `paint` (pure; unit-tested).
|
||||
pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vectorcraft_geom::Rect>) -> std::result::Result<Paint, String> {
|
||||
let mut gp = match paint {
|
||||
|
|
@ -38,37 +116,14 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vecto
|
|||
_ => GradientPaint::new(Gradient::default()),
|
||||
};
|
||||
if let Some(k) = str_param(p, "kind") {
|
||||
let kind = match k.to_ascii_lowercase().as_str() {
|
||||
"linear" => GradientKind::Linear,
|
||||
"radial" => GradientKind::Radial,
|
||||
"freeform" => GradientKind::Freeform,
|
||||
_ => return Err(format!("unknown gradient kind `{k}`")),
|
||||
};
|
||||
let kind = parse_kind(k)?;
|
||||
if kind != gp.gradient.kind {
|
||||
gp.gradient.kind = kind;
|
||||
gp.geom = None;
|
||||
}
|
||||
}
|
||||
if let Some(stops) = p.get("stops") {
|
||||
let arr = stops.as_array().ok_or("`stops` must be an array")?;
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for st in arr {
|
||||
let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?;
|
||||
let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?;
|
||||
let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0);
|
||||
let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5);
|
||||
out.push(GradientStop {
|
||||
offset: offset.clamp(0.0, 1.0) as f32,
|
||||
color,
|
||||
opacity: opacity.clamp(0.0, 1.0) as f32,
|
||||
midpoint: midpoint.clamp(0.13, 0.87) as f32,
|
||||
});
|
||||
}
|
||||
if out.len() < 2 {
|
||||
return Err("a gradient needs at least two stops".into());
|
||||
}
|
||||
gp.gradient.stops = out;
|
||||
gp.gradient.sort();
|
||||
gp.gradient.stops = parse_stops(stops)?;
|
||||
gp.swatch = None;
|
||||
}
|
||||
if bool_or(p, "reverse", false) {
|
||||
|
|
@ -96,8 +151,7 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option<vecto
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(asp) = p.get("aspect").and_then(Value::as_f64) {
|
||||
let asp = (asp / 100.0).clamp(0.005, 327.67);
|
||||
if let Some(asp) = aspect_param(p)? {
|
||||
if gp.geom.is_none()
|
||||
&& let Some(b) = bounds
|
||||
{
|
||||
|
|
@ -141,7 +195,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let b = n.geometric_bounds();
|
||||
let ap = &mut n.appearance;
|
||||
let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() };
|
||||
let (cur, b) = if stroke { (ap.stroke_paint(), b.zip(ap.stroke()).map(|(b, st)| st.paint_bounds(b))) } else { (ap.fill_paint(), b) };
|
||||
match apply_gradient_edit(&cur, p, b) {
|
||||
Ok(np) => {
|
||||
if stroke {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, NodeKind};
|
||||
|
||||
use super::gradient::place_paint;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, 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; both or neither), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, ids?} sets selection fill and the default",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
|
|
@ -62,27 +63,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
|||
return Ok(Some(paint));
|
||||
}
|
||||
if let Some(g) = p.get("gradient") {
|
||||
let kind = match str_param(g, "kind") {
|
||||
Some("radial") => GradientKind::Radial,
|
||||
_ => GradientKind::Linear,
|
||||
};
|
||||
let mut grad = Gradient { kind, ..Default::default() };
|
||||
if let Some(stops) = g.get("stops").and_then(Value::as_array) {
|
||||
grad.stops = stops
|
||||
.iter()
|
||||
.filter_map(|st| {
|
||||
Some(vectorcraft_color::GradientStop {
|
||||
offset: st.get("offset")?.as_f64()? as f32,
|
||||
color: color_value(st.get("color")?)?,
|
||||
opacity: st.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
|
||||
midpoint: 0.5,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
grad.sort();
|
||||
}
|
||||
let mut gp = GradientPaint::new(grad);
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?;
|
||||
return Ok(Some(Paint::Gradient(Box::new(gp))));
|
||||
}
|
||||
if let Some(c) = p.get("color") {
|
||||
|
|
@ -120,10 +101,14 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
let b = n.geometric_bounds();
|
||||
if fill {
|
||||
n.appearance.set_fill(paint.clone());
|
||||
n.appearance.set_fill(place_paint(&paint, p, b));
|
||||
} else {
|
||||
n.appearance.set_stroke(paint.clone());
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.paint = place_paint(&paint, p, b.map(|b| st.paint_bounds(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -10,7 +10,15 @@ use crate::EngineError;
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new),
|
||||
cmd!(
|
||||
"swatch.new",
|
||||
"New Swatch",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{name?, color?|gradient? (as paint.setFill), global?} (default: current fill)",
|
||||
has_doc,
|
||||
swatch_new
|
||||
),
|
||||
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete),
|
||||
cmd!(
|
||||
"swatch.newGroup",
|
||||
|
|
|
|||
|
|
@ -721,6 +721,8 @@ mod tests_draw2;
|
|||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! Gradients through commands: lossless, validated params (M3.14).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> &vectorcraft_doc::Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap()
|
||||
}
|
||||
|
||||
fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
|
||||
match node(s, id).appearance.fill_paint() {
|
||||
Paint::Gradient(g) => *g,
|
||||
p => panic!("expected a gradient fill, got {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 mut g = GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Freeform,
|
||||
stops: vec![
|
||||
stop(0.0, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.3),
|
||||
stop(0.4, Color::cmyk(0.1, 0.2, 0.3, 0.4), 0.5, 0.5),
|
||||
stop(1.0, Color::gray(0.25), 0.75, 0.5),
|
||||
],
|
||||
});
|
||||
g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5 });
|
||||
g.angle = g.geom.unwrap().angle_deg();
|
||||
g
|
||||
}
|
||||
|
||||
fn gradient_param(g: &GradientPaint) -> Value {
|
||||
json!({ "gradient": vectorcraft_tools::params::gradient_params(g) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_params_round_trip_through_fill_and_swatches() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let g = three_stops();
|
||||
s.execute("paint.setFill", &gradient_param(&g)).unwrap();
|
||||
let got = fill_gradient(&s, id);
|
||||
assert_eq!(got.gradient.kind, GradientKind::Freeform);
|
||||
assert_eq!(got.gradient.stops, g.gradient.stops);
|
||||
let (a, b) = (got.geom.unwrap(), g.geom.unwrap());
|
||||
assert!(a.start.distance(b.start) < 1e-9 && a.end.distance(b.end) < 1e-9 && (a.aspect - b.aspect).abs() < 1e-9, "{a:?}");
|
||||
// The default proxy keeps it too, and saving it as a swatch is lossless.
|
||||
assert_eq!(s.paint.fill, Paint::Gradient(Box::new(got.clone())));
|
||||
let mut q = gradient_param(&g);
|
||||
q["name"] = json!("Three");
|
||||
s.execute("swatch.new", &q).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.swatch("Three").unwrap().paint, Paint::Gradient(Box::new(got)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_gradients_are_rejected_without_editing() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let before = node(&s, id).appearance.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"stops": [{"offset": 0, "color": "#ff0000"}]}),
|
||||
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1}]}),
|
||||
json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"color": "#000000"}]}),
|
||||
json!({"stops": [{"offset": "a", "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}),
|
||||
json!({"stops": "red"}),
|
||||
json!({"kind": "conic"}),
|
||||
json!({"start": [0, 0]}),
|
||||
json!({"aspect": "wide"}),
|
||||
] {
|
||||
assert!(s.execute("paint.setFill", &json!({ "gradient": bad })).is_err(), "accepted {bad}");
|
||||
// `start` isn't a paint.editGradient param (it would just apply the gradient).
|
||||
if bad.get("start").is_none() {
|
||||
assert!(s.execute("paint.editGradient", &bad).is_err(), "editGradient accepted {bad}");
|
||||
}
|
||||
}
|
||||
assert_eq!(node(&s, id).appearance, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_opacity_and_offsets_are_normalised() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient": {"stops": [{"offset": 1.5, "color": "#000000", "opacity": 50}, {"offset": -1, "color": "#ffffff", "opacity": 0.25, "midpoint": 0.99}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let g = fill_gradient(&s, id);
|
||||
// Sorted, offsets clamped, 0..100 opacity normalised, midpoint clamped to the diamond's range.
|
||||
let st = &g.gradient.stops;
|
||||
assert_eq!((st[0].offset, st[0].opacity, st[0].midpoint), (0.0, 0.25, 0.87));
|
||||
assert_eq!((st[1].offset, st[1].opacity), (1.0, 0.5));
|
||||
assert!(g.geom.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_and_aspect_in_one_call() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "start": [200, 150], "end": [260, 150], "aspect": 40}})).unwrap();
|
||||
let geom = fill_gradient(&s, id).geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(260.0, 150.0)));
|
||||
assert!((geom.aspect - 0.4).abs() < 1e-9);
|
||||
// An aspect without a vector places the gradient on the object's bounds so the aspect sticks.
|
||||
s.execute("paint.setStroke", &json!({"gradient": {"kind": "radial", "aspect": 25}})).unwrap();
|
||||
let Paint::Gradient(sg) = node(&s, id).appearance.stroke_paint() else { panic!() };
|
||||
let geom = sg.geom.unwrap();
|
||||
assert_eq!(geom.start, Point::new(200.0, 150.0));
|
||||
assert!((geom.aspect - 0.25).abs() < 1e-9 && (geom.length() - 100.5).abs() < 1e-9, "fits the stroke-inflated bounds: {geom:?}");
|
||||
// appearance.setItem takes the same gradient object.
|
||||
let g = three_stops();
|
||||
let mut q = gradient_param(&g);
|
||||
q["index"] = json!(0);
|
||||
s.execute("appearance.setItem", &q).unwrap();
|
||||
assert_eq!(fill_gradient(&s, id).gradient, g.gradient);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod draw2;
|
|||
pub mod extra;
|
||||
pub mod guides;
|
||||
pub mod meshblend;
|
||||
pub mod params;
|
||||
pub mod pen;
|
||||
pub mod select;
|
||||
pub mod shape;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
//! Command params written by tools and panels: the inverse of the engine's paint parsers, so a
|
||||
//! paint read from the document and sent back through a command arrives unchanged.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, GradientPaint, GradientStop};
|
||||
|
||||
/// A colour as a `color` param in its own model: `[r,g,b]`, `{c,m,y,k}` or `{gray}`.
|
||||
pub fn color_json(c: &Color) -> Value {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => json!([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}),
|
||||
Color::Gray { k } => json!({"gray": k}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint.
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A gradient paint as the `gradient` param of `paint.setFill` / `swatch.new`: kind, stops (with
|
||||
/// opacity and midpoint), angle, the linked swatch and, once placed, the vector and aspect (%).
|
||||
pub fn gradient_params(g: &GradientPaint) -> Value {
|
||||
let mut v = json!({
|
||||
"kind": g.gradient.kind.label().to_lowercase(),
|
||||
"angle": g.angle,
|
||||
"stops": stops_json(&g.gradient.stops),
|
||||
});
|
||||
if let Some(geom) = g.geom {
|
||||
v["start"] = json!([geom.start.x, geom.start.y]);
|
||||
v["end"] = json!([geom.end.x, geom.end.y]);
|
||||
v["aspect"] = json!(geom.aspect * 100.0);
|
||||
}
|
||||
if let Some(s) = &g.swatch {
|
||||
v["swatch"] = json!(s);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
#[test]
|
||||
fn gradient_params_carry_placement_only_when_placed() {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Default::default() });
|
||||
let v = gradient_params(&g);
|
||||
assert_eq!(v["kind"], "radial");
|
||||
assert!(v.get("start").is_none() && v.get("aspect").is_none());
|
||||
assert_eq!(v["stops"][0]["midpoint"], json!(0.5));
|
||||
g.geom = Some(GradientGeom { start: Point::new(1.0, 2.0), end: Point::new(3.0, 4.0), aspect: 0.5 });
|
||||
g.swatch = Some("Sky".into());
|
||||
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_eq!(color_json(&Color::gray(0.25)), json!({"gray": 0.25}));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
|
||||
use super::{active_paint, color_json, live_run, pstate, set_pstate};
|
||||
use super::{active_paint, live_run, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, Live, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -88,11 +88,7 @@ pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f
|
|||
}
|
||||
|
||||
/// Stops as `paint.editGradient` JSON.
|
||||
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(),
|
||||
)
|
||||
}
|
||||
pub use vectorcraft_tools::params::stops_json;
|
||||
|
||||
// ---------- UI ----------
|
||||
|
||||
|
|
|
|||
|
|
@ -266,25 +266,15 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
|
||||
/// A colour as command JSON, keeping its model.
|
||||
pub(crate) fn color_json(c: &Color) -> Value {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => json!([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => json!({"c": c, "m": m, "y": y, "k": k}),
|
||||
Color::Gray { k } => json!({"gray": k}),
|
||||
}
|
||||
}
|
||||
pub(crate) use vectorcraft_tools::params::color_json;
|
||||
|
||||
/// Paint as command params (`{color}`, `{none}` or `{gradient}`).
|
||||
/// Paint as command params (`{color}`, `{none}` or `{gradient}`, lossless for gradients).
|
||||
pub(crate) fn paint_params(p: &Paint) -> Value {
|
||||
match p {
|
||||
Paint::None => json!({"none": true}),
|
||||
Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}),
|
||||
Paint::Solid { color, .. } => json!({"color": color_json(color)}),
|
||||
Paint::Gradient(g) => json!({"gradient": {
|
||||
"kind": g.gradient.kind.label().to_lowercase(),
|
||||
"angle": g.angle,
|
||||
"stops": g.gradient.stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity})).collect::<Vec<_>>()
|
||||
}}),
|
||||
Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}),
|
||||
Paint::Pattern { .. } => json!({"none": true}),
|
||||
}
|
||||
}
|
||||
|
|
@ -321,5 +311,6 @@ mod tests {
|
|||
let p = paint_params(&g);
|
||||
assert_eq!(p["gradient"]["kind"], "linear");
|
||||
assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue