From 61ddc1ce348cec5807652966b07cd752533df96c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 12:22:49 +0700 Subject: [PATCH] =?UTF-8?q?M3.14:=20lossless,=20validated=20gradient=20par?= =?UTF-8?q?ams=20=E2=80=94=20paint.setFill/setStroke,=20swatch.new=20and?= =?UTF-8?q?=20appearance.setItem=20keep=20kind=20(freeform=20too),=20midpo?= =?UTF-8?q?ints,=20stop=20opacity,=20aspect,=20vector=20and=20swatch=20lin?= =?UTF-8?q?k;=20a=20shared=20parse=5Fstops=20rejects=20fewer=20than=20two?= =?UTF-8?q?=20or=20malformed=20stops,=20clamps=20offsets=20and=20normalise?= =?UTF-8?q?s=200..100=20opacity;=20an=20aspect=20without=20a=20vector=20pl?= =?UTF-8?q?aces=20the=20gradient=20on=20each=20object's=20(stroke-inflated?= =?UTF-8?q?)=20bounds;=20panels=20write=20gradients=20through=20one=20para?= =?UTF-8?q?ms=20writer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/color/src/gradient.rs | 4 + crates/doc/src/appearance.rs | 5 + crates/engine/src/cmd/appearance.rs | 12 ++- crates/engine/src/cmd/gradient.rs | 112 ++++++++++++++++------ crates/engine/src/cmd/paint.rs | 33 ++----- crates/engine/src/cmd/swatch.rs | 10 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_gradient.rs | 133 ++++++++++++++++++++++++++ crates/tools/src/lib.rs | 1 + crates/tools/src/params.rs | 62 ++++++++++++ crates/ui-egui/src/panels/gradient.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 17 +--- 12 files changed, 321 insertions(+), 78 deletions(-) create mode 100644 crates/engine/src/tests_gradient.rs create mode 100644 crates/tools/src/params.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 3554ba16..dd45c9bf 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -15,6 +15,10 @@ pub enum GradientKind { } impl GradientKind { + /// Parse a kind name (`linear`, `radial`, `freeform`; any case). + pub fn parse(s: &str) -> Option { + [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", diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 91ef1b84..fe029fe1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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. diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 10f24bad..f3e6f506 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "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 { 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; diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 01b0d0e7..f41f077d 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "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 { ] } +type Parsed = std::result::Result; + +/// Parse a kind name. +fn parse_kind(k: &str) -> Parsed { + 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> { + 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::>>()?; + 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 { + 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> { + 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) -> 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) -> std::result::Result { let mut gp = match paint { @@ -38,37 +116,14 @@ pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option 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 Result { } 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 { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 712d0af5..5af3ab35 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -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 { "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> { 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 { } 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(()) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7b5793d9..eea07861 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -10,7 +10,15 @@ use crate::EngineError; pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..b435ad68 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs new file mode 100644 index 00000000..00951b98 --- /dev/null +++ b/crates/engine/src/tests_gradient.rs @@ -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); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 440ee67c..f1a481f7 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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; diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs new file mode 100644 index 00000000..faee9a18 --- /dev/null +++ b/crates/tools/src/params.rs @@ -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})); + } +} diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index a789e066..d336afd8 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -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 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 ---------- diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0b163e51..523e007d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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::>() - }}), + 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)); } }