Files
vectorcraft/crates/engine/src/cmd/freeform.rs
T
bflatasticandClaude Opus 5.5 dec085e1e5 Review: width-point removal reuses point_indices; ROADMAP (#1079)
point_indices moves from freeform.rs to the cmd param helpers so
stroke.widthPoint.remove parses `index` and `indices` with it, and a
malformed entry reports the translated "no such width point".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:43:14 +07:00

346 lines
16 KiB
Rust

//! Freeform gradients: the points and lines of the freeform gradient behind the active proxy (the
//! Gradient panel's freeform section, the Color panel and the Gradient tool edit them through
//! these). Points are given in document coordinates; type runs keep them in text space.
use serde_json::{Value, json};
use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Freeform, FreeformMode, FreeformPoint, GradientKind, Paint};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Affine, Rect};
use super::appearance::{edit_items, edits_stroke, index_param, item_target};
use super::gradient::{StopOwner, item_paint_bounds, run_paint_mut};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paint.freeform.addPoint",
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), opacity? 0..1 (or 0..100), spread? 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
has_doc,
add_point
),
cmd!(
"paint.freeform.setPoint",
"Edit Freeform Point",
[],
None,
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, opacity? 0..1 (or 0..100), spread? 0..1 (or 0..100 %), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} move or recolour a point of the freeform gradient → {index}",
has_doc,
set_point
),
cmd!(
"paint.freeform.deletePoint",
"Delete Freeform Point",
[],
None,
"{index? (default: the selected point), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} remove a point (never the last one); lines through it close up around it and lines left with one point go → {index}",
has_doc,
delete_point
),
cmd!(
"paint.freeform.addLine",
"Add Freeform Line",
[],
None,
"{points: [index, …] (at least two), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} join points with a smooth line through them, in order → {line}",
has_doc,
add_line
),
cmd!(
"paint.freeform.splitLine",
"Split Freeform Line",
[],
None,
"{line: line index, segment: the segment from the line's point `segment` to the next, t?: 0..1 along it (default 0.5), ids?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind)} add a point on a line there (colour, opacity and spread between the segment's ends) and select it → {index}",
has_doc,
split_line
),
cmd!(
"paint.freeform.selectPoint",
"Select Freeform Point",
[],
None,
"{index: point index | null to clear} select a point of the freeform gradient behind the active proxy (the first selected object's): the point the Gradient tool, the Gradient and Color panels and Delete act on → {index}",
has_doc,
select_point
),
cmd!(
query "paint.freeform.get",
"Freeform Gradient",
[],
None,
"{stroke?: bool (default: the active proxy)} the freeform gradient behind the proxy of the first selected object → {mode: points|lines, points: [{at: [x,y] (document coordinates), color, opacity, spread}], lines: [[index, …]], selected: index|null}",
has_doc,
get
),
]
}
const NOT_FREEFORM: &str = "the paint is not a freeform gradient (apply one with paint.editGradient {kind: \"freeform\"})";
impl Session {
/// The selected freeform point (`paint.freeform.selectPoint`), while the gradient it was
/// selected on is still the one behind the active proxy. Callers check it against the point
/// count (an undo can remove points).
pub fn selected_freeform_point(&self) -> Option<usize> {
self.freeform_point.filter(|(_, owner)| *owner == StopOwner::of(self)).map(|(i, _)| i)
}
/// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in
/// document coordinates, and the length its spreads are fractions of there (see
/// [`Node::proxy_freeform`]).
pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> {
let (n, item) = self.proxy_source(stroke)?;
n.proxy_freeform(stroke, item)
}
fn select_freeform_point(&mut self, i: Option<usize>) {
self.freeform_point = i.map(|i| (i, StopOwner::of(self)));
}
}
/// Where an edited paint lives: the map from the document to its space and the length its spreads
/// are fractions of.
#[derive(Clone, Copy)]
struct Space {
to_paint: Affine,
scale: f64,
}
/// The `opacity` / `spread` convention: 0..1, or a percentage above 1.
fn fraction(v: &Value, key: &str) -> std::result::Result<f32, String> {
let x = v.as_f64().ok_or_else(|| format!("`{key}` must be a number"))?;
Ok((if x > 1.0 { x / 100.0 } else { x }).clamp(0.0, 1.0) as f32)
}
/// Apply the `at` (mapped by `to_paint`), `color`, `opacity` and `spread` fields of `v` to `pt`.
fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::result::Result<(), String> {
if v.get("at").is_some() {
pt.at = to_paint * point_param(v, "at").ok_or("`at` must be [x, y]")?;
}
if let Some(c) = v.get("color") {
pt.color = color_value(c).ok_or("`color` must be a colour")?;
}
if let Some(o) = v.get("opacity") {
pt.opacity = fraction(o, "opacity")?;
}
if let Some(s) = v.get("spread") {
pt.spread = fraction(s, "spread")?;
}
Ok(())
}
/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}],
/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for
/// `vectorcraft_tools::params::freeform_json`.
pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String> {
let points = v.get("points").and_then(Value::as_array).ok_or("`freeform.points` must be an array")?;
let mut f = Freeform::default();
for (i, pv) in points.iter().enumerate() {
let (Some(at), Some(color)) = (point_param(pv, "at"), pv.get("color").and_then(color_value)) else {
return Err(format!("freeform point {i} needs `at` [x, y] and a valid `color`"));
};
let mut pt = FreeformPoint::new(at, color);
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
f.points.push(pt);
}
if let Some(lines) = v.get("lines") {
let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?;
for (i, l) in lines.iter().enumerate() {
let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?;
f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?;
}
}
if let Some(m) = str_param(v, "mode") {
f.mode = parse_mode(m)?;
}
Ok(f)
}
pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)"))
}
/// Run `f` on the freeform gradient behind the proxy of each target (the selection, or `ids`),
/// as one undo step; returns what it returned for the first. An unplaced gradient gets the
/// automatic points it shows first, and the stops follow the points' colours.
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
let item = item_target(s, p, cmd)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select an object with a freeform gradient"));
}
let mut first = None;
let mut apply = |paint: &mut Paint, b: Rect, to_doc: Affine| -> Result<()> {
let Paint::Gradient(g) = paint else { return Err(bad(cmd, NOT_FREEFORM)) };
if g.gradient.kind != GradientKind::Freeform {
return Err(bad(cmd, NOT_FREEFORM));
}
let to_paint = if to_doc == Affine::IDENTITY { to_doc } else { to_doc.inverse() };
let mut ff = g.freeform_on(b).into_owned();
let r = f(&mut ff, Space { to_paint, scale: spread_scale(b) }).map_err(|e| bad(cmd, e))?;
g.set_freeform(ff);
g.swatch = None;
first.get_or_insert(r);
Ok(())
};
edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
{
let (xf, lb) = (t.xf, t.local_bounds());
if xf.determinant().abs() < 1e-12 {
return Err(bad(cmd, "the type object is flattened to nothing"));
}
for r in &mut t.runs {
let (paint, b) = run_paint_mut(r, stroke, lb);
apply(paint, b, xf)?;
}
return Ok(());
}
let b = item_paint_bounds(n, index, !stroke).ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
let paint =
if stroke { n.appearance.stroke_at_mut(index).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(index).map(|l| &mut l.paint) };
apply(paint.ok_or_else(|| bad(cmd, NOT_FREEFORM))?, b, Affine::IDENTITY)
})?;
let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?;
// The edited gradient is the last one used (`paint.lastGradient`).
let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten()));
if let Some(p) = shown {
s.remember_paint(&p);
}
Ok(r)
}
/// The `index` param, else the selected point.
fn point_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
match p.get("index") {
Some(v) => v.as_u64().map(|i| i as usize).ok_or_else(|| bad(cmd, "`index` must be a point index")),
None => s.selected_freeform_point().ok_or_else(|| bad(cmd, "no point selected (give `index`)")),
}
}
fn check_index(f: &Freeform, i: usize) -> std::result::Result<(), String> {
if i < f.points.len() { Ok(()) } else { Err(format!("no point {i} (the gradient has {})", f.points.len())) }
}
fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.addPoint";
let at = point_param(p, "at").ok_or_else(|| bad(C, "missing `at` [x, y]"))?;
let join = match p.get("line") {
None | Some(Value::Bool(false)) => None,
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
let (color, opacity) = f.sample(q, sp.scale);
let mut pt = FreeformPoint { opacity, ..FreeformPoint::new(q, color) };
point_fields(p, &mut pt, sp.to_paint)?;
let i = f.add_point(pt);
if let Some(from) = join {
f.connect(from, i);
}
Ok(i)
})?;
s.select_freeform_point(Some(index));
Ok(json!({ "index": index }))
}
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
edit(s, p, C, |f, sp| {
check_index(f, i)?;
point_fields(p, &mut f.points[i], sp.to_paint)
})?;
Ok(json!({ "index": i }))
}
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.deletePoint";
let i = point_index(s, p, C)?;
edit(s, p, C, |f, _| {
check_index(f, i)?;
if f.remove_point(i) { Ok(()) } else { Err("a freeform gradient keeps at least one point".into()) }
})?;
// The points after it moved down one.
let sel = s.selected_freeform_point().and_then(|j| match j.cmp(&i) {
std::cmp::Ordering::Less => Some(j),
std::cmp::Ordering::Equal => None,
std::cmp::Ordering::Greater => Some(j - 1),
});
s.select_freeform_point(sel);
Ok(json!({ "index": i }))
}
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.addLine";
let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
Ok(json!({ "line": line }))
}
fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.splitLine";
let line = index_param(p, "line", C)?;
let segment = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5);
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
s.select_freeform_point(Some(index));
Ok(json!({ "index": index }))
}
fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.selectPoint";
let index = match p.get("index") {
None => return Err(bad(C, "missing `index` (a point index, or null to clear)")),
Some(Value::Null) => None,
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize;
let (f, _) = s.proxy_freeform(!s.fill_active).ok_or_else(|| bad(C, NOT_FREEFORM))?;
check_index(&f, i).map_err(|e| bad(C, e))?;
Some(i)
}
};
s.select_freeform_point(index);
Ok(json!({ "index": index }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.get";
let stroke = bool_or(p, "stroke", !s.fill_active);
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
let mut v = vectorcraft_tools::params::freeform_json(&f);
v["selected"] = json!(selected);
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
/// A line with an index that isn't a point's is refused, not shortened to the indices that are.
#[test]
fn malformed_freeform_lines_are_not_silently_shortened() {
let points = json!([
{"at": [0, 0], "color": "#ff0000"},
{"at": [50, 0], "color": "#00ff00"},
{"at": [100, 0], "color": "#0000ff"}
]);
for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] {
let value = json!({"points": points, "lines": lines, "mode": "lines"});
assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}");
}
let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap();
assert_eq!(good.lines, vec![vec![0, 1, 2]]);
}
}