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:
bflatastic
2026-10-04 12:22:49 +07:00
co-authored by Claude Opus 5.5
parent fb8c41d8e3
commit 61ddc1ce34
12 changed files with 321 additions and 78 deletions
+7 -5
View File
@@ -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;
+83 -29
View File
@@ -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 {
+9 -24
View File
@@ -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(())
+9 -1
View File
@@ -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",