Commands: a boolean or number given as text is refused

Squashed from #3428 onto main.
This commit is contained in:
bflatastic
2026-10-22 00:10:57 +07:00
parent 14f804cdee
commit e7cdf154ca
32 changed files with 1467 additions and 105 deletions
+1
View File
@@ -548,6 +548,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.setItem")?;
super::paint::check_keep_model(p, "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
+1
View File
@@ -1301,6 +1301,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) {
return Err(bad(C, format!("unknown tool `{tool}`")));
}
super::paint::check_keep_model(p, C)?;
let pts = points(p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
+2
View File
@@ -752,6 +752,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
@@ -781,6 +782,7 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
if !(0.0..=1000.0).contains(&width) {
+1
View File
@@ -494,6 +494,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let x = f64_req(p, "x", "text.create")?;
let y = f64_req(p, "y", "text.create")?;
super::paint::check_keep_model(p, "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
+43 -21
View File
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
@@ -300,7 +300,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
return Err(bad(C, "left/right must be non-negative widths"));
}
let adjust = bool_or(p, "adjustAdjoining", false);
let adjust = switch_or(p, "adjustAdjoining", false, C)?;
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
// history stay as they are.
let mut pos = 0;
@@ -405,7 +405,7 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Array(a)) => {
let mut pts = vec![];
for q in a {
let v: Vec<f64> = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let v: Vec<f64> = q.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()).unwrap_or_default();
if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 {
return Err(bad(C, "points must be [t, left, right] with non-negative widths"));
}
@@ -632,9 +632,17 @@ impl LiquifyStroke {
}
}
/// The number keys of `object.liquify`. [`LiquifyParams::from_json`] reads a value that is not a
/// number as the key left out.
const LIQUIFY_NUMBERS: &[&str] =
&["diameter", "width", "height", "angle", "intensity", "detail", "simplify", "rate", "complexity", "horizontal", "vertical"];
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.liquify";
check_numbers(p, LIQUIFY_NUMBERS, C)?;
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
// `LiquifyParams::from_json` uses a key's default for a value that is not true or false.
check_switches(p, &["usePressure", "simplifyOn", "affectAnchors", "affectIn", "affectOut"], C)?;
let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
@@ -701,9 +709,10 @@ fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Resul
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.puppetWarp";
check_numbers(p, &["expand"], C)?;
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
let rest = bool_or(p, "rest", false);
let rest = switch_or(p, "rest", false, C)?;
if (pins.is_empty() && !rest) || pins.len() != moved.len() {
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
}
@@ -715,7 +724,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(Value::Array(a)) if a.len() == pins.len() && a.iter().all(|v| v.is_null() || v.is_number()) => {
a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect()
}
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
@@ -756,6 +767,7 @@ pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["expand"], "object.puppetWarp.pins")?;
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
@@ -798,9 +810,10 @@ pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> R
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.grid.set";
let old = grid_of(&s.doc()?.doc);
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
let g = old.merged(p).map_err(|e| bad(C, e))?;
let reproject = switch_or(p, "reproject", false, C)?;
// When the document holds this grid already, no object moves, and the document and its undo
// history stay as they are.
if grid_kept(&s.doc()?.doc, &g) {
@@ -823,7 +836,12 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
}
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
let want = p.get("visible").and_then(Value::as_bool);
const C: &str = "perspective.grid.show";
// A missing or null `visible` toggles the grid.
let want = match p.get("visible") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "visible", false, C)?),
};
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
Ok(json!({ "visible": g.visible }))
}
@@ -1037,7 +1055,9 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
check_switches(p, &["snap"], C)?;
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
@@ -1088,13 +1108,13 @@ fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = f64_or(p, "depth", 0.0);
let dz = number_or(p, "depth", 0.0, C)?;
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, C)?;
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
@@ -1126,6 +1146,7 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
check_numbers(p, &["offset", "by"], C)?;
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
let from = old.offset(plane);
@@ -1223,11 +1244,12 @@ fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 };
let copy = switch_or(p, "copy", false, C)?;
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
@@ -1235,10 +1257,7 @@ fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(
s,
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
)
persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy}))
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1247,11 +1266,14 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = bool_or(p, "snap", g.snap);
let snap = switch_or(p, "snap", g.snap, C)?;
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
let map = match point_param(p, "at") {
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
None => None,
let map = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => {
let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?)
}
};
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
+40 -29
View File
@@ -152,24 +152,40 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- helpers ----------
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
/// `[[x,y]…]` or `[{x,y}…]`.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite
/// number.
fn point_entry(v: &Value, cmd: &str) -> Result<Point> {
let xy = match v {
Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)),
Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)),
_ => None,
};
xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers"))
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a
/// number nor null, is an error.
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
.map(|v| {
let pressure = match v {
Value::Array(xy) => xy.get(2),
_ => v.get("pressure"),
};
match pressure {
None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))),
Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))),
}
})
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
.collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
}
@@ -648,7 +664,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
let keep_selected = bool_or(p, "keepSelected", true);
@@ -724,7 +740,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let within = f64_or(rd, "within", 12.0);
let within = number_or(rd, "within", 12.0, C)?;
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
}
@@ -764,16 +780,8 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvature";
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
let pts: Vec<(Point, bool)> = arr
.iter()
.filter_map(|v| {
let pt = match v {
Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?),
_ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),
};
(pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))
})
.collect();
let pts: Vec<(Point, bool)> =
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
if pts.is_empty() {
return Err(bad(C, "need at least one point"));
}
@@ -807,6 +815,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
check_numbers(p, &["x", "y", "t"], C)?;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
@@ -980,9 +989,11 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
check_numbers(p, &["x", "y"], C)?;
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => Some(Point::new(x, y)),
_ => None,
(None, None) => None,
_ => return Err(bad(C, "x and y must be given together")),
};
if target.is_some_and(|q| !within_input_bound(q)) {
return Err(bad(C, "x and y must be finite"));
@@ -1014,8 +1025,8 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
return Err(bad(C, "invalid delta"));
}
@@ -1132,7 +1143,7 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseRegion";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
let size = number_or(p, "size", 10.0, C)?;
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
@@ -1174,7 +1185,7 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseSegments";
let pts = points_param(p, "points", C)?;
let w = f64_or(p, "width", 8.0);
let w = number_or(p, "width", 8.0, C)?;
if !(w.is_finite() && w > 0.0) {
return Err(bad(C, "width must be positive"));
}
@@ -1317,8 +1328,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smoothRegion";
let pts = points_param(p, "points", C)?;
let radius = f64_or(p, "radius", 12.0).max(0.0);
let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0);
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0);
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
@@ -1365,7 +1376,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.joinScrub";
let pts = points_param(p, "points", C)?;
let tol = f64_or(p, "tolerance", 12.0).max(0.0);
let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0);
let st = s.doc()?;
// Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start).
let poly = {
@@ -1464,7 +1475,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
fn blob(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.blob";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
let size = number_or(p, "size", 10.0, C)?;
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
+2
View File
@@ -244,6 +244,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
super::paint::check_keep_model(p, C)?;
let given = given_color(s, p);
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
@@ -266,6 +267,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
super::paint::check_keep_model(p, C)?;
let given = given_color(s, p);
edit(s, p, C, |f, sp| {
check_index(f, i)?;
+1
View File
@@ -381,6 +381,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
};
super::paint::check_keep_model(p, C)?;
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, p);
// Stops naming swatches take their colours from them.
+18 -12
View File
@@ -411,6 +411,7 @@ const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
check_numbers(p, &["value", "steps", "distance"], cmd)?;
let smooth = switch_or(p, "smooth", false, cmd)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
let mode = str_param(p, "spacing");
@@ -428,7 +429,7 @@ fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpa
steps("steps", n)?
} else if let Some(d) = num("distance") {
BlendSpacing::Distance(d)
} else if bool_or(p, "smooth", false) {
} else if smooth {
BlendSpacing::SmoothColor
} else {
current
@@ -643,7 +644,7 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
Some("in") => Some(false),
Some(_) => return Err(bad(C, "handle must be in or out")),
};
let independent = bool_or(p, "independent", false);
let independent = switch_or(p, "independent", false, C)?;
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
if let Some(out) = handle {
@@ -697,12 +698,12 @@ fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?);
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
let perpendicular = bool_or(p, "perpendicular", false);
let perpendicular = switch_or(p, "perpendicular", false, C)?;
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
@@ -991,10 +992,11 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0);
h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0);
v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0);
let given = switch_or(p, "horizontal", horizontal, cmd)?;
horizontal = match str_param(p, "orientation") {
Some("vertical") => false,
Some("horizontal") => true,
_ => bool_or(p, "horizontal", horizontal),
_ => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
}
@@ -1119,7 +1121,7 @@ fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithMesh";
let maintain = bool_or(p, "maintainShape", true);
let maintain = switch_or(p, "maintainShape", true, C)?;
let kinds = reshaped(s, |env, kind| {
let own = match kind {
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
@@ -1150,11 +1152,11 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeO
None => cur.preserve_shape,
};
Ok(EnvelopeOptions {
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?,
preserve_shape,
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?,
distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?,
distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?,
})
}
@@ -1249,6 +1251,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.options";
check_numbers(p, &["fidelity"], C)?;
check_numbers(p, WARP_NUMBERS, C)?;
check_switches(p, &["horizontal"], C)?;
let fid = p.get("fidelity").and_then(Value::as_f64);
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
return Err(bad(C, "fidelity must be 0..100"));
@@ -1330,9 +1333,10 @@ fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
}
fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.editContents";
let envs = selected_of(s, is_envelope);
let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. }));
let want = bool_or(p, "editing", !cur);
let want = switch_or(p, "editing", !cur, C)?;
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
let mut content_ids = vec![];
for e in &envs {
@@ -1535,6 +1539,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.setPointColor";
check_numbers(p, &["opacity"], C)?;
super::paint::check_keep_model(p, C)?;
let index = index_param(p, "index", C)?;
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let opacity = p.get("opacity").and_then(Value::as_f64);
@@ -1573,6 +1578,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
super::paint::check_keep_model(p, C)?;
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
+14 -13
View File
@@ -348,11 +348,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7);
let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7);
let rot = f64_or(p, "rotate", 0.0);
let rx = bool_or(p, "reflectX", false);
let ry = bool_or(p, "reflectY", false);
let random = bool_or(p, "random", false);
let rx = switch_or(p, "reflectX", false, "object.transformEach")?;
let ry = switch_or(p, "reflectY", false, "object.transformEach")?;
let random = switch_or(p, "random", false, "object.transformEach")?;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = bool_or(p, "copy", false);
let copy = switch_or(p, "copy", false, "object.transformEach")?;
let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero"));
@@ -598,7 +598,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
let gray = bool_or(p, "gray", false);
let gray = switch_or(p, "gray", false, C)?;
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
@@ -613,7 +613,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9);
let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9);
let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9));
let delete = bool_or(p, "deleteRaster", false);
let delete = switch_or(p, "deleteRaster", false, C)?;
let (gid, n) = s.edit("Create Object Mosaic", |d, sel| {
let mut tiles = vec![];
for r in 0..rows {
@@ -670,7 +670,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(C, "select an image"))?;
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
if p.get("trim").and_then(Value::as_bool) == Some(true) {
if switch_or(p, "trim", false, C)? {
return trim_image(s, id, (**im).clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
@@ -836,9 +836,9 @@ impl Session {
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.createTrimMarks";
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
let style = match str_param(p, "style") {
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
None => s.crop_mark_style(),
let style = match p.get("style") {
None | Some(Value::Null) => s.crop_mark_style(),
Some(v) => v.as_str().and_then(MarkStyle::parse).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
};
let d = TrimMarks::default();
let marks = TrimMarks {
@@ -849,7 +849,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
weight: f64_or(p, "weight", d.weight).max(0.01),
};
let st = s.doc()?;
let rects: Vec<Rect> = if bool_or(p, "allArtboards", st.selection.is_empty()) {
let rects: Vec<Rect> = if switch_or(p, "allArtboards", st.selection.is_empty(), C)? {
st.doc.artboards.iter().map(|a| a.rect).collect()
} else {
st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect()
@@ -993,9 +993,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
use super::newdoc::ArtboardLayout as L;
// A value given (not null) must be one of the names.
let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default());
let by_column = switch_or(p, "byColumn", false, CMD)?;
let layout = match named("layout") {
Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?,
None if bool_or(p, "byColumn", false) => L::GridByColumn,
None if by_column => L::GridByColumn,
None => L::GridByRow,
};
let rtl = match named("order") {
@@ -1016,7 +1017,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
};
check_numbers(p, &["spacing"], CMD)?;
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let move_art = bool_or(p, "moveArtwork", true);
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
let locked_and_hidden = s.prefs.move_locked_with_artboard;
let d = &s.doc()?.doc;
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
+28 -6
View File
@@ -318,20 +318,42 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
/// Error unless each of `keys` that `p` gives is a number (or null): an optional number read with
/// [`f64_or`] would otherwise fall back to its default for a string such as `"60 mm"` (#1952).
/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other
/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}".
pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// Error unless each of `keys` that `p` gives is a number (or null), as [`number_or`] reads them: an
/// optional number read with [`f64_or`] would otherwise fall back to its default for a string such as
/// `"60 mm"` (#1952).
pub(crate) fn check_numbers(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
for key in keys {
match p.get(*key) {
None | Some(Value::Null) | Some(Value::Number(_)) => {}
Some(v) => return Err(bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
number_or(p, key, 0.0, cmd)?;
}
Ok(())
}
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(default)
}
/// The true or false `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any
/// other value, such as `"false"`, is an error: "`{key}` must be true or false".
pub(crate) fn switch_or(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
}
}
/// Error unless each of `keys` that `p` gives is true or false (or null), as [`switch_or`] reads
/// them.
pub(crate) fn check_switches(p: &Value, keys: &[&str], cmd: &str) -> Result<()> {
for key in keys {
switch_or(p, key, false, cmd)?;
}
Ok(())
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
+23
View File
@@ -356,6 +356,11 @@ pub(crate) fn transforms_objects(p: &Value) -> bool {
bool_or(p, "objects", true)
}
/// The on/off params of the transform commands that [`apply_transform`], [`scaling`],
/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file
/// check them with [`check_switches`] before they change anything.
const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"];
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off.
pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) {
if objects {
@@ -505,6 +510,8 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?;
check_switches(p, &["anchors"], "object.transform")?;
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (outermost(&s.doc()?.doc, v), false),
@@ -515,12 +522,15 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.move")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.move")?;
let ids = selected_roots_to_transform(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["dx", "dy"], "object.nudge")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?;
check_switches(p, &["big"], "object.nudge")?;
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
@@ -549,6 +559,8 @@ fn about(o: Point, a: Affine) -> Affine {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.rotate")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?;
check_switches(p, &["absolute"], "object.rotate")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let mut angle = f64_or(p, "angle", 0.0);
@@ -562,6 +574,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["sy"], "object.scale")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
@@ -573,6 +586,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
@@ -593,6 +607,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], "object.shear")?;
check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?;
let ids = selected_roots_to_transform(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
@@ -605,6 +620,7 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
/// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform
/// Each is repeated by running it again (its transform is per object, about each one's bounds).
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?;
if let Some(again) = s.doc()?.last_perspective.clone() {
return super::distortcmds::transform_again(s, &again);
}
@@ -1138,6 +1154,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
let blend = match str_param(p, "blend") {
@@ -1389,6 +1406,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let preview = preview_bounds(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
check_switches(p, &["patterns"], "object.align")?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
@@ -1607,6 +1625,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distribute")?;
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
@@ -1680,6 +1699,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distributeSpacing")?;
let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
@@ -1768,6 +1788,8 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?;
if transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
@@ -2299,6 +2321,7 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?;
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) {
None => None,
+23 -12
View File
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
always,
|s, p| {
s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active);
s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?;
Ok(json!({ "fillActive": s.fill_active }))
}
),
@@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let paint = Paint::solid(s.last_solid);
apply_to_proxy(s, p, paint)
apply_to_proxy(s, p, paint, "paint.lastColor")
}
),
cmd!(
@@ -117,7 +117,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
apply_to_proxy(s, p, paint)
apply_to_proxy(s, p, paint, "paint.lastGradient")
}
),
cmd!(
@@ -287,8 +287,15 @@ fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
}
/// `stroke` param, defaulting to the proxy that is in front.
fn stroke_param(s: &Session, p: &Value) -> bool {
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
switch_or(p, "stroke", !s.fill_active, cmd)
}
/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must
/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists
/// `keepModel` calls it before it changes anything.
pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> {
check_switches(p, &["keepModel"], cmd)
}
/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
@@ -319,11 +326,13 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
/// model ([`new_color_model`]), converted through the color settings.
/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel`
/// other than true, false or null is an error.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
check_keep_model(p, "paint")?;
let model = new_color_model(s, p);
let new_color = |c: Color| stored_color(model, c);
if bool_or(p, "none", false) {
if switch_or(p, "none", false, "paint")? {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
@@ -391,13 +400,14 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
if p.get("tint").is_some() && p.get("swatch").is_none() {
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
}
check_keep_model(p, cmd)?;
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
apply_paint(s, p, paint, fill)
}
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
let fill = !stroke_param(s, p);
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result<Value> {
let fill = !stroke_param(s, p, cmd)?;
apply_paint(s, p, paint, fill)
}
@@ -406,6 +416,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
let focus = switch_or(p, "focus", true, cmd)?;
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
if p.get("ids").is_none()
@@ -431,7 +442,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
super::typecmd::refresh_bounds(t);
Ok(super::appearance::count_changed(d, &before, &[id]))
})?;
if bool_or(p, "focus", true) {
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
@@ -514,7 +525,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
}
s.paint.stroke = unplaced(&paint);
}
if bool_or(p, "focus", true) {
if focus {
s.fill_active = fill;
}
s.remember_paint(&paint);
@@ -666,7 +677,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
/// default when there are none) and remember the first target's new paint. The Appearance panel's
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let stroke = stroke_param(s, p);
let stroke = stroke_param(s, p, cmd)?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => super::edit::selected_roots(s)?,
+7 -3
View File
@@ -158,7 +158,10 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let h_in = point_param(p, "in");
let h_in = match p.get("in").filter(|h| !h.is_null()) {
None => None,
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
};
let independent = bool_or(p, "independent", false);
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
@@ -178,7 +181,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
// Objects selected whole move as objects, and their pattern tiles move with them when
// `patterns` (default: General › Transform Pattern Tiles) is on.
let whole = s.doc()?.selection.whole();
@@ -725,6 +728,7 @@ fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let tol = f64_or(p, "tol", 3.0).max(0.0);
let radius = p.get("radius").and_then(Value::as_f64);
@@ -773,7 +777,7 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
+12 -8
View File
@@ -366,7 +366,7 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
let base = GridDefinition::default();
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
let replace = bool_or(p, "replace", false);
let replace = switch_or(p, "replace", false, C)?;
let mut imported = vec![];
for mut preset in presets {
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
@@ -390,9 +390,13 @@ fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "imported": imported }))
}
/// Toggle (or set, with `on`) a view option of the grid.
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
let want = p.get("on").and_then(Value::as_bool);
/// Toggle (or set, with `on`) a view option of the grid for the command `cmd`.
fn view_option(s: &mut Session, p: &Value, cmd: &str, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
// A missing or null `on` toggles the option.
let want = match p.get("on") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "on", false, cmd)?),
};
let mut g = silent(s, |g| {
let f = field(g);
*f = want.unwrap_or(!*f);
@@ -401,19 +405,19 @@ fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &m
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.locked)
view_option(s, p, "perspective.grid.lock", |g| &mut g.locked)
}
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.lock_station)
view_option(s, p, "perspective.grid.lockStation", |g| &mut g.lock_station)
}
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.snap)
view_option(s, p, "perspective.grid.snap", |g| &mut g.snap)
}
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
view_option(s, p, |g| &mut g.rulers)
view_option(s, p, "perspective.grid.rulers", |g| &mut g.rulers)
}
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
+7
View File
@@ -137,6 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.new";
let mode = str_param(p, "mode");
super::paint::check_keep_model(p, C)?;
let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) {
(Some(cs), _) => {
let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) };
@@ -302,6 +303,7 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.newGroup";
let mut names = str_list(p, "swatches");
super::paint::check_keep_model(p, C)?;
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
@@ -538,6 +540,10 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(C, format!("`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name)")));
}
super::paint::check_keep_model(p, C)?;
if let Some(v) = p.get("paint") {
super::paint::check_keep_model(v, C)?;
}
// 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 (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) {
@@ -875,6 +881,7 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
.collect::<Result<_>>()?;
super::paint::check_keep_model(p, C)?;
// A color its swatch already has keeps its model. The others take the document's.
let old: Vec<Option<Color>> =
s.doc()?.doc.swatch_groups.iter().find(|g| g.name == group).map(|g| g.swatches.iter().map(|w| w.paint.color()).collect()).unwrap_or_default();
+2
View File
@@ -619,6 +619,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
},
};
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
super::paint::check_keep_model(p, C)?;
change.store_colors(s, p);
if change.is_empty() {
return Err(bad(C, "nothing to change"));
@@ -839,6 +840,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
return Err(bad(C, "area type needs a path that encloses an area"));
}
super::paint::check_keep_model(p, C)?;
let style = super::create::new_type_style(s, p);
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
+1
View File
@@ -691,6 +691,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
None => None,
};
super::paint::check_keep_model(p, C)?;
let fill = match p.get("fill") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(vectorcraft_color::Paint::None),
+127
View File
@@ -500,6 +500,37 @@ fn align_without_a_direction_is_refused() {
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved");
}
/// Align, Distribute and Distribute Spacing refuse a `patterns` given as text or as another value
/// that is not true or false, naming the key: nothing moves and no undo step is recorded. True,
/// false and null still work.
#[test]
fn align_and_distribute_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0);
let c = rect(&mut s, 70.0, 60.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let state = |s: &Session| {
let d = &s.doc().unwrap().doc;
(history(s), [b, c].map(|id| d.node(id).unwrap().geometric_bounds().unwrap().x0))
};
let before = state(&s);
let calls = [
("object.align", json!({"horizontal": "left", "patterns": "true"})),
("object.distribute", json!({"horizontal": "left", "patterns": "false"})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 5, "patterns": 1})),
];
for (cmd, p) in calls {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`patterns` must be true or false"), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}");
}
s.execute("object.align", &json!({"horizontal": "left", "patterns": null})).unwrap();
s.execute("object.distribute", &json!({"vertical": "top", "patterns": false})).unwrap();
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "patterns": true})).unwrap();
assert_eq!(state(&s).1, [0.0, 0.0]);
}
/// Align, Distribute and Distribute Spacing return an error that lists the choices of a param
/// given a value outside them: nothing moves and no undo step is recorded. A null value counts as
/// not given, and Distribute Spacing without an `axis` spaces the objects horizontally.
@@ -1058,6 +1089,49 @@ fn a_string_for_an_optional_transform_number_is_an_error() {
assert!((bounds(&s).width() - 60.0).abs() < 1e-9);
}
/// A transform option given as text or as another value that is not true or false (`"false"`, 1)
/// is an error naming the key, with Scale Strokes & Effects on. The object, its stroke and its undo
/// history stay as they were, and no copy is made. False and null still work: `object.scale` with
/// `strokes: false` scales the object and leaves its stroke width as it is.
#[test]
fn a_boolean_given_as_text_for_a_transform_is_an_error() {
let mut s = session();
s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": true})).unwrap();
let a = rect(&mut s, 10.0, 10.0, 50.0, 40.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5})).unwrap();
let bounds = |s: &Session| s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
let state = |s: &Session| {
let d = &s.doc().unwrap().doc;
(history(s), bounds(s), d.node(a).unwrap().appearance.stroke_width(), d.layers[0].children().unwrap().len())
};
let before = state(&s);
let calls = [
("object.transform", json!({"matrix": [2, 0, 0, 2, 0, 0], "strokes": "false"}), "strokes"),
("object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "anchors": "true"}), "anchors"),
("object.move", json!({"dx": 5, "copy": "true"}), "copy"),
("object.move", json!({"dx": 5, "objects": 0}), "objects"),
("object.nudge", json!({"dx": 1, "big": "true"}), "big"),
("object.rotate", json!({"angle": 45, "absolute": "true"}), "absolute"),
("object.scale", json!({"sx": 200, "strokes": "false"}), "strokes"),
("object.scale", json!({"sx": 200, "corners": [true]}), "corners"),
("object.reflect", json!({"patterns": "true"}), "patterns"),
("object.shear", json!({"angle": 10, "typeAreas": 1}), "typeAreas"),
("object.transformAgain", json!({"copy": "true"}), "copy"),
("object.setBounds", json!({"width": 100, "proportional": "true"}), "proportional"),
("object.setBounds", json!({"width": 100, "copy": "false"}), "copy"),
];
for (cmd, p, key) in calls {
let r = s.execute(cmd, &p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}");
}
s.execute("object.scale", &json!({"sx": 200, "strokes": false, "corners": null, "copy": false})).unwrap();
let (b, w) = (bounds(&s), s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width());
assert_eq!((b.width(), w), (100.0, before.2));
}
/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H
/// (`object.setBounds`) are the box around those anchors, and only they move or scale.
#[test]
@@ -1288,6 +1362,33 @@ fn object_properties_already_set_record_no_undo_step() {
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
}
/// Object Properties refuses `visible`, `locked`, `isolate` or `knockoutShape` given as text or as
/// another value that is not true or false, naming the key, and leaves the object and the undo
/// history as they were. True and false still set the value, and null leaves it as it is.
#[test]
fn object_properties_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let props = |s: &Session| {
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
(n.visible, n.locked, n.isolate, n.knockout_shape)
};
let before = (history(&s), props(&s));
for (key, p) in [
("visible", json!({"id": a.0, "visible": "false"})),
("locked", json!({"id": a.0, "locked": "true"})),
("isolate", json!({"id": a.0, "opacity": 50, "isolate": 1})),
("knockoutShape", json!({"id": a.0, "knockoutShape": [true]})),
] {
let r = s.execute("object.setProps", &p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}");
assert_eq!((history(&s), props(&s)), before, "{p}");
}
s.execute("object.setProps", &json!({"id": a.0, "visible": false, "locked": null, "isolate": true, "knockoutShape": null})).unwrap();
assert_eq!(props(&s), (false, false, true, false));
}
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
@@ -2596,6 +2697,32 @@ fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
assert_eq!(r["tiles"], json!(2));
}
/// Gray or Delete Raster given as text or as another value that is not true or false is an error naming the key. No
/// mosaic is made and the image stays. True and null still work.
#[test]
fn object_mosaic_refuses_a_boolean_given_as_text() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let images = |s: &Session| {
s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))).count()
};
let before = (undo(&s), images(&s));
for (p, msg) in [
(json!({"gray": "true"}), "`gray` must be true or false"),
(json!({"columns": 2, "gray": 1}), "`gray` must be true or false"),
(json!({"deleteRaster": "false"}), "`deleteRaster` must be true or false"),
(json!({"deleteRaster": [true]}), "`deleteRaster` must be true or false"),
] {
let r = s.execute("object.createObjectMosaic", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!((undo(&s), images(&s)), before, "{p}");
}
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "gray": true, "deleteRaster": null})).unwrap();
assert_eq!((r["tiles"].clone(), images(&s)), (json!(2), 1));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
+47
View File
@@ -137,6 +137,32 @@ fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err());
}
/// Moving a spine point refuses an `independent`, and reshaping a spine segment a `perpendicular`,
/// given as text or as another value that is not true or false, and the blend stays as it was; null
/// is read as the key left out.
#[test]
fn spine_edits_refuse_a_boolean_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 75, "y": 5})).unwrap();
let before = node(&s, g);
for (cmd, key, p) in [
("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": "true"})),
("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "x": 55, "y": 60, "independent": 1})),
("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dx": 10, "dy": 40, "perpendicular": "true"})),
("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": [false]})),
] {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be true or false")), "{cmd} {p}: {r:?}");
assert_eq!(node(&s, g), before, "{cmd} {p}");
}
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": null})).unwrap();
let a = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
assert_eq!((a.h_out, a.kind), (Point::new(75.0, 40.0), vectorcraft_geom::AnchorKind::Smooth), "the point stays smooth");
s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": null})).unwrap();
}
#[test]
fn only_points_without_a_key_can_be_deleted() {
let mut s = session();
@@ -170,6 +196,27 @@ fn spine_commands_refuse_an_index_that_is_not_a_whole_number() {
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p, Point::new(55.0, 60.0));
}
/// Reshape Spine Segment refuses a `t`, `dx` or `dy` given as text or as another value that is not
/// a number, instead of bending the spine by 0 or at the segment's middle, and the blend stays as
/// it was; null is read as the key left out.
#[test]
fn spine_reshape_segment_refuses_a_number_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let before = node(&s, g);
for key in ["t", "dx", "dy"] {
for v in [json!("10"), json!([10]), json!(true)] {
let mut p = json!({"id": g.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 40});
p[key] = v.clone();
let r = s.execute("object.blend.spine.reshapeSegment", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a number, not {v}")), "{p}: {r:?}");
assert_eq!(node(&s, g), before, "{p}");
}
}
s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "t": null, "dx": null, "dy": 40})).unwrap();
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the bent spine");
}
#[test]
fn info_lists_the_spine() {
let mut s = session();
+23
View File
@@ -1046,6 +1046,29 @@ fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_setti
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
/// In a CMYK document the Symbol Stainer refuses a `keepModel` given as text or as another value that
/// is not true or false, and the instance and the undo history stay as they were. Null is read as
/// `keepModel` left out.
#[test]
fn symbol_stainer_refuses_a_keep_model_given_as_text() {
use vectorcraft_color::{Color, Paint, cms};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
let (inst, _, b) = make_symbol(&mut s);
let at = json!([[b.center().x, b.center().y]]);
let state = |s: &Session| (node(s, inst), s.doc().unwrap().history.undo.len());
let before = state(&s);
for keep in [json!("true"), json!(1), json!("false"), json!([true])] {
let p = json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": keep});
let r = s.execute("symbol.adjust", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{p}: {r:?}");
assert_eq!(state(&s), before, "{p}");
}
s.execute("symbol.adjust", &json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": null})).unwrap();
let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent);
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
#[test]
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
let mut s = session();
+277
View File
@@ -70,6 +70,18 @@ fn close(a: Point, b: Point, tol: f64) -> bool {
a.distance(b) <= tol
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// Values for an on/off param that are not true, false or null.
fn not_booleans() -> [serde_json::Value; 4] {
[json!("true"), json!(1), json!("false"), json!([true])]
}
// ---------- width ----------
#[test]
@@ -110,6 +122,22 @@ fn width_profile_set_and_clear() {
assert_eq!(profile(&s, id), None);
}
/// Width Profile refuses a point with a place or width given as text, null or another value that is
/// not a number, such as `[0.5, "1", 2, 2]`, and leaves the profile and the undo history as they were.
#[test]
fn width_profile_set_refuses_a_width_given_as_text() {
let mut s = session();
let id = line(&mut s);
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap();
let before = (profile(&s, id), undo_len(&s));
for points in [json!([[0.5, "1", 2, 2]]), json!([[0.5, 1, 2, "3"]]), json!([[0.5, null, 2, 2]]), json!([[true, 0.5, 2, 2]])] {
let r = s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": points}));
let want = "points must be [t, left, right] with non-negative widths";
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{points}: {r:?}");
assert_eq!((profile(&s, id), undo_len(&s)), before, "{points}");
}
}
#[test]
fn width_tool_gesture_is_one_undo_step() {
let mut s = session();
@@ -178,6 +206,26 @@ fn width_commands_that_change_nothing_record_no_undo_step() {
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Width Point refuses an `adjustAdjoining` given as text or as another value that is not true or
/// false, for a new point and for a point given by its index: the profile and the undo history stay
/// as they were. Null is read as the key left out, and the points either side keep their widths.
#[test]
fn width_point_set_refuses_adjust_adjoining_given_as_text() {
let mut s = session();
let id = line(&mut s);
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})).unwrap();
let before = (profile(&s, id), undo_len(&s));
for v in not_booleans() {
let added = json!({"id": id.0, "t": 0.25, "left": 4, "right": 4, "adjustAdjoining": v});
refuses_non_boolean(&mut s, "stroke.widthPoint.set", &added, "adjustAdjoining");
let moved = json!({"id": id.0, "index": 1, "t": 0.5, "left": 4, "right": 4, "adjustAdjoining": v});
refuses_non_boolean(&mut s, "stroke.widthPoint.set", &moved, "adjustAdjoining");
assert_eq!((profile(&s, id), undo_len(&s)), before, "{v}");
}
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.25, "left": 4, "right": 4, "adjustAdjoining": null})).unwrap();
assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 1.0, 1.0), (0.25, 0.8, 0.8), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)]);
}
// ---------- liquify ----------
fn warp_params() -> serde_json::Value {
@@ -253,6 +301,54 @@ fn liquify_tool_replays_from_the_journal() {
assert_eq!(path(&t, a), after);
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
fn refuses_non_number(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be a number, not {}", p[key]);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// Liquify refuses a brush size, angle, intensity, detail, simplify, rate, complexity or wrinkle
/// amount given as text or as another value that is not a number, with the tool that uses the key and
/// with one that does not: the path and the undo history stay as they were. Null is read as the key
/// left out.
#[test]
fn liquify_refuses_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (path(&s, a), undo_len(&s));
let with = |tool: &str, key: &str, v: serde_json::Value| {
let mut p = warp_params();
p["tool"] = json!(tool);
p[key] = v;
(key.to_string(), p)
};
let calls = [
with("warp", "diameter", json!("40")),
with("warp", "width", json!("80")),
with("pucker", "height", json!([80])),
with("twirl", "angle", json!("45")),
with("bloat", "intensity", json!("80%")),
with("warp", "detail", json!(true)),
with("warp", "simplify", json!("50")),
with("twirl", "rate", json!("90")),
with("crystallize", "complexity", json!("3")),
with("wrinkle", "horizontal", json!("50")),
with("wrinkle", "vertical", json!({"value": 50})),
with("warp", "complexity", json!("3")),
];
for (key, p) in calls {
refuses_non_number(&mut s, "object.liquify", &p, &key);
assert_eq!((path(&s, a), undo_len(&s)), before, "{p}");
}
let mut p = warp_params();
p["diameter"] = json!(null);
p["intensity"] = json!(null);
assert_eq!(s.execute("object.liquify", &p).unwrap()["ids"], json!([a.0]));
assert_eq!(undo_len(&s), before.1 + 1);
}
/// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a
/// command or dragged with the tool, and the command replies with no ids.
#[test]
@@ -270,6 +366,32 @@ fn liquify_that_reshapes_no_path_records_no_undo_step() {
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0));
}
/// Liquify refuses a `usePressure`, `simplifyOn`, `affectAnchors`, `affectIn` or `affectOut` given as
/// text or as another value that is not true or false, whichever tool it is given with: the path and
/// the undo history stay as they were. Null is read as the key left out.
#[test]
fn liquify_refuses_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (path(&s, a), undo_len(&s));
let crystallize = json!({"tool": "crystallize", "points": [[300, 150]], "diameter": 100, "intensity": 100});
let keys = [("usePressure", warp_params()), ("simplifyOn", warp_params()), ("affectAnchors", crystallize.clone())];
let keys = keys.into_iter().chain([("affectIn", crystallize.clone()), ("affectOut", crystallize), ("affectOut", warp_params())]);
for (key, mut p) in keys {
for v in not_booleans() {
p[key] = v;
refuses_non_boolean(&mut s, "object.liquify", &p, key);
}
}
assert_eq!((path(&s, a), undo_len(&s)), before, "nothing changed");
let mut p = warp_params();
p["usePressure"] = json!(null);
p["simplifyOn"] = json!(null);
assert_eq!(s.execute("object.liquify", &p).unwrap()["ids"], json!([a.0]));
assert_eq!(undo_len(&s), before.1 + 1);
}
fn session_with_rect() -> Session {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
@@ -299,6 +421,32 @@ fn puppet_warp_satisfies_pins_and_keeps_area() {
assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[1, 1]], "moved": []})).is_err());
}
/// Puppet Warp and Puppet Warp Pins refuse an `expand` given as text or as another value that is not
/// a number, and Puppet Warp refuses an angle that is not a number or null, such as "90": the art and
/// the undo history stay as they were. A null `expand` is read as the key left out.
#[test]
fn puppet_warp_refuses_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let before = (path(&s, a), undo_len(&s));
let with = |key: &str, v: serde_json::Value| {
let mut p = json!({"id": a.0, "pins": [[120, 150], [380, 150]], "moved": [[120, 150], [370, 90]]});
p[key] = v;
p
};
refuses_non_number(&mut s, "object.puppetWarp", &with("expand", json!("5")), "expand");
refuses_non_number(&mut s, "object.puppetWarp", &with("expand", json!([5])), "expand");
refuses_non_number(&mut s, "object.puppetWarp.pins", &json!({"id": a.0, "expand": "5"}), "expand");
for angles in [json!(["90", null]), json!([null, true])] {
let r = s.execute("object.puppetWarp", &with("angles", angles.clone()));
let want = "angles must be a list as long as pins (degrees or null)";
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{angles}: {r:?}");
}
assert_eq!((path(&s, a), undo_len(&s)), before);
s.execute("object.puppetWarp", &with("expand", json!(null))).unwrap();
assert_eq!(undo_len(&s), before.1 + 1);
}
#[test]
fn puppet_tool_drag_warps_selection() {
let mut s = session();
@@ -358,6 +506,23 @@ fn puppet_warp_that_moves_nothing_records_no_undo_step() {
}
}
/// Puppet Warp refuses a `rest` given as text or as another value that is not true or false: the art
/// and the undo history stay as they were. Null is read as the key left out.
#[test]
fn puppet_warp_refuses_rest_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let before = (path(&s, a), undo_len(&s));
let warp = |rest| json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]], "rest": rest});
for v in not_booleans() {
refuses_non_boolean(&mut s, "object.puppetWarp", &warp(v), "rest");
}
assert_eq!((path(&s, a), undo_len(&s)), before, "nothing changed");
s.execute("object.puppetWarp", &warp(json!(null))).unwrap();
assert_eq!(undo_len(&s), before.1 + 1);
assert!(path(&s, a).bounds().unwrap().y0 < 80.0);
}
// ---------- perspective ----------
fn grid(s: &Session) -> PerspectiveGrid {
@@ -484,6 +649,24 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() {
assert!(s.doc().unwrap().doc.node(id).is_none());
}
/// Draw in Perspective refuses a `snap` given as text or as another value that is not true or false
/// before it draws: no shape is made and the undo history stays as it was. Null is read as the key
/// left out.
#[test]
fn perspective_draw_refuses_snap_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
let before = (history(&s), s.doc().unwrap().selection.objects.clone());
let draw = |snap| json!({"command": "shape.rectangle", "params": {"x": 300, "y": 320, "width": 50, "height": 80}, "snap": snap});
for v in not_booleans() {
refuses_non_boolean(&mut s, "perspective.draw", &draw(v), "snap");
}
assert_eq!((history(&s), s.doc().unwrap().selection.objects.clone()), before, "nothing drawn");
let r = s.execute("perspective.draw", &draw(json!(null))).unwrap();
assert_eq!((r["plane"].clone(), history(&s).0), (json!("left"), before.0.0 + 1));
}
#[test]
fn perspective_selection_moves_within_the_plane() {
let mut s = session();
@@ -516,6 +699,43 @@ fn perspective_move_attaches_loose_objects_to_the_given_plane() {
assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err());
}
/// Transform in Perspective refuses a `depth`, Nudge in Perspective refuses a `dx` or `dy`, and Move Plane
/// refuses an `offset` or `by` given as text or as another value that is not a number, such as "10". Draw in
/// Perspective refuses an `at` that is not two numbers. The object, the grid and the undo history stay as they
/// were. A null `depth` or `at` is read as the key left out.
#[test]
fn perspective_commands_refuse_a_number_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (path(&s, a), grid(&s).definition_json(), undo_len(&s));
let calls = [
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": "10"}), "depth"),
("perspective.nudge", json!({"dx": "1", "dy": 0}), "dx"),
("perspective.nudge", json!({"dx": 0, "dy": [1]}), "dy"),
("perspective.plane.move", json!({"plane": "right", "offset": "10"}), "offset"),
("perspective.plane.move", json!({"plane": "right", "offset": "10", "by": 5}), "offset"),
("perspective.plane.move", json!({"plane": "right", "by": true}), "by"),
];
for (c, p, key) in calls {
refuses_non_number(&mut s, c, &p, key);
assert_eq!((path(&s, a), grid(&s).definition_json(), undo_len(&s)), before, "{c} {p}");
}
let draw = |at: serde_json::Value| json!({"command": "shape.rectangle", "params": {"x": 450, "y": 380, "width": 20, "height": 20}, "plane": "right", "at": at});
for at in [json!(["450", 380]), json!("450, 380"), json!([450]), json!([450, 380, 0])] {
let r = s.execute("perspective.draw", &draw(at.clone()));
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "at {at}: {r:?}");
assert_eq!((path(&s, a), grid(&s).definition_json(), undo_len(&s)), before, "at {at}");
}
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 10, 0], "depth": null})).unwrap();
assert_eq!(undo_len(&s), before.2 + 1);
s.execute("perspective.draw", &draw(json!(null))).unwrap();
assert_eq!(undo_len(&s), before.2 + 2);
}
/// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not),
/// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move
/// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for
@@ -552,6 +772,28 @@ fn perspective_grid_commands_that_change_nothing_record_no_undo_step() {
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Define Perspective Grid refuses a `reproject`, and Show Perspective Grid a `visible`, given as text
/// or as another value that is not true or false: the grid, the object on it and the undo history
/// stay as they were. A null `reproject` leaves the object where it is, and a null `visible` toggles
/// the grid, as the keys left out do.
#[test]
fn perspective_grid_commands_refuse_a_boolean_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
let state = |s: &Session| (grid(s).definition_json(), grid(s).visible, path(s, a), history(s));
let before = state(&s);
for v in not_booleans() {
refuses_non_boolean(&mut s, "perspective.grid.set", &json!({"horizon": 250, "reproject": v}), "reproject");
refuses_non_boolean(&mut s, "perspective.grid.show", &json!({"visible": v}), "visible");
assert_eq!(state(&s), before, "{v}");
}
s.execute("perspective.grid.set", &json!({"horizon": 250, "reproject": null})).unwrap();
assert_eq!((grid(&s).horizon, path(&s, a)), (250.0, before.2));
assert_eq!(s.execute("perspective.grid.show", &json!({"visible": null})).unwrap(), json!({"visible": !before.1}));
}
/// For an object in perspective, Move in Perspective by 0 (along its plane or its normal),
/// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection
/// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that
@@ -615,3 +857,38 @@ fn perspective_moves_that_move_nothing_record_no_undo_step() {
assert_ne!(r, json!({"ids": [a.0]}));
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Move in Perspective refuses a `copy`, `perpendicular` or `snap`, Transform in Perspective a `copy`,
/// and Nudge in Perspective a `big` or `copy`, given as text or as another value that is not true or
/// false: the object stays where it is, nothing is copied, and the undo history stays as it was. Null
/// is read as the key left out.
#[test]
fn perspective_moves_refuse_a_boolean_given_as_text() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let state = |s: &Session| (path(s, a), history(s), s.doc().unwrap().selection.objects.clone());
let before = state(&s);
let (from, to) = (json!([480, 410]), json!([500, 420]));
for v in not_booleans() {
for key in ["copy", "perpendicular", "snap"] {
refuses_non_boolean(&mut s, "perspective.move", &json!({"from": from, "to": to, key: v}), key);
}
refuses_non_boolean(&mut s, "perspective.transform", &json!({"matrix": [1.5, 0, 0, 1.5, 0, 0], "copy": v}), "copy");
for key in ["big", "copy"] {
refuses_non_boolean(&mut s, "perspective.nudge", &json!({"dx": 1, "dy": 0, key: v}), key);
}
assert_eq!(state(&s), before, "{v}");
}
let calls = [
("perspective.move", json!({"from": from, "to": to, "copy": null, "perpendicular": null, "snap": null})),
("perspective.transform", json!({"matrix": [1.5, 0, 0, 1.5, 0, 0], "copy": null})),
("perspective.nudge", json!({"dx": 1, "dy": 0, "big": null, "copy": null})),
];
for (i, (c, p)) in calls.into_iter().enumerate() {
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
assert_eq!(history(&s).0, before.1.0 + i + 1, "{c} {p}");
}
}
+176
View File
@@ -867,6 +867,72 @@ fn draw2_commands_reject_bad_params() {
}
}
/// The Pencil refuses a `fidelity` or a redraw `within` given as text or as another value that is
/// not a number, and draws or redraws nothing; null is read as the key left out.
#[test]
fn pencil_refuses_a_fidelity_or_within_given_as_text() {
let mut s = session();
let id = line(&mut s, 100.0, 100.0, 300.0, 100.0);
let stroke = json!({"points": [[100, 100], [150, 140], [200, 140], [250, 100]]});
refuses_a_number_given_as_text(&mut s, "path.freehand", &stroke, "fidelity");
for v in [json!("12"), json!([12]), json!(false)] {
let p = with(&stroke, json!({"redraw": {"id": id.0, "within": v}}));
refuses_with(&mut s, "path.freehand", &p, &format!("`within` must be a number, not {v}"));
}
let before = path(&s, id);
s.execute("path.freehand", &with(&stroke, json!({"fidelity": null, "redraw": {"id": id.0, "within": null}}))).unwrap();
assert!(path(&s, id) != before, "the stroke redraws the line within 12 pt");
}
/// The drawing tools' commands refuse a point whose x or y is not a number, such as "10" or a point
/// written as text, and a pen pressure given as text, instead of leaving the point out or drawing it
/// at full pressure; the document stays as it was.
#[test]
fn drawing_commands_refuse_a_point_or_pressure_given_as_text() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let good = [json!([90, 150]), json!([150, 150]), json!([210, 150])];
let cmds = ["path.freehand", "path.knife", "path.eraseRegion", "path.eraseSegments", "path.smoothRegion", "path.joinScrub", "path.blob"];
for cmd in cmds.into_iter().chain(["path.curvature"]) {
for bad in [json!(["150", 150]), json!({"x": 150, "y": "150"}), json!("150, 150"), json!([150]), json!(null)] {
let points: Vec<_> = good.iter().cloned().chain([bad]).collect();
refuses_with(&mut s, cmd, &json!({"points": points}), "x and y must both be numbers");
}
}
for cmd in cmds {
for bad in [json!([150, 150, "0.5"]), json!({"x": 150, "y": 150, "pressure": "0.5"})] {
let points: Vec<_> = good.iter().cloned().chain([bad]).collect();
refuses_with(&mut s, cmd, &json!({"points": points}), "`pressure` must be a number, not \"0.5\"");
}
}
let pressed = json!({"points": [[100, 400], [150, 420, null], {"x": 200, "y": 400, "pressure": 0.5}], "keepSelected": false});
s.execute("path.freehand", &pressed).unwrap();
}
/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, width, radius,
/// fidelity or tolerance given as text or as another value that is not a number, and change
/// nothing; null is read as the key left out.
#[test]
fn drawing_commands_refuse_a_size_given_as_text() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let stroke = json!({"points": [[90, 150], [150, 150], [210, 150]]});
for (cmd, key) in [
("path.eraseRegion", "size"),
("path.eraseSegments", "width"),
("path.smoothRegion", "radius"),
("path.smoothRegion", "fidelity"),
("path.joinScrub", "tolerance"),
("path.blob", "size"),
] {
refuses_a_number_given_as_text(&mut s, cmd, &stroke, key);
}
let erased = s.execute("path.eraseRegion", &with(&stroke, json!({"size": null}))).unwrap();
assert_eq!(erased["ids"].as_array().map(Vec::len), Some(2), "the default 10 pt Eraser cuts the square in two: {erased}");
}
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
fn two_subpaths(s: &mut Session) -> NodeId {
@@ -898,6 +964,33 @@ fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json
}
}
/// Runs `cmd` with `p` and `key` set to each of "10", [10] and true: each call fails with
/// "`key` must be a number, not <value>", and the document and the undo history stay as they were.
fn refuses_a_number_given_as_text(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
for v in [json!("10"), json!([10]), json!(true)] {
let mut p = p.clone();
p[key] = v.clone();
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let r = s.execute(cmd, &p);
let want = format!("`{key}` must be a number, not {v}");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
}
/// Runs `cmd` with `p`: the call fails with `want`, and the document and the undo history stay as
/// they were.
fn refuses_with(s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str) {
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let r = s.execute(cmd, p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{cmd} {p}: {r:?}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
/// subpath 0 alone.
@@ -1054,6 +1147,89 @@ fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
}
/// `path.reshapeSegment` refuses a `t`, `dx` or `dy` given as text or as another value that is not
/// a number, instead of bending the segment by 0 or at its middle; `dy: 20` bends it, with a null
/// `t` read as the middle.
#[test]
fn reshape_segment_refuses_a_number_given_as_text() {
let mut s = session();
let id = curve(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
for key in ["t", "dx", "dy"] {
refuses_a_number_given_as_text(&mut s, "path.reshapeSegment", &p, key);
}
let before = path(&s, id);
s.execute("path.reshapeSegment", &with(&p, json!({"t": null}))).unwrap();
assert!(path(&s, id) != before, "the segment bends");
}
/// `path.convertAnchor` refuses an `x` or `y` given as text, and an `x` without a `y` or a `y`
/// without an `x`, instead of converting the anchor without a handle position; both as numbers
/// place the out handle there.
#[test]
fn convert_anchor_refuses_a_handle_position_given_as_text_or_alone() {
let mut s = session();
let id = curve(&mut s);
let p = json!({"id": id.0, "anchor": 1, "to": "smooth", "x": 330, "y": 150});
for key in ["x", "y"] {
refuses_a_number_given_as_text(&mut s, "path.convertAnchor", &p, key);
}
for lone in [json!({"y": null}), json!({"x": null}), json!({"y": null, "to": "corner"})] {
refuses_with(&mut s, "path.convertAnchor", &with(&p, lone), "x and y must be given together");
}
s.execute("path.convertAnchor", &p).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[1].h_out, Point::new(330.0, 150.0));
}
/// The Curvature tool's edits and Add Anchor Point refuse a `t`, `x` or `y` given as text or as
/// another value that is not a number, instead of inserting at the middle of the segment or leaving
/// the new anchor where it was inserted.
#[test]
fn curvature_edit_and_insert_anchor_refuse_a_number_given_as_text() {
let mut s = session();
let id = curve(&mut s);
let insert = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.25});
let moved = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.25, "x": 150, "y": 160});
refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &insert, "t");
for key in ["x", "y"] {
refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &moved, key);
refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &json!({"id": id.0, "op": "move", "anchor": 1, "x": 300, "y": 150}), key);
}
refuses_a_number_given_as_text(&mut s, "path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.25}), "t");
s.execute("path.curvatureEdit", &moved).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 160.0));
s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": null})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 4, "a null t inserts at the middle");
}
/// Move Anchors, the Reshape tool's command and Close Path refuse a number or handle given as text
/// or as another value that is not a number, and change nothing; null is read as the key left out.
#[test]
fn move_anchors_reshape_and_close_refuse_a_number_given_as_text() {
let mut s = session();
let id = curve(&mut s);
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
let nudge = json!({"dx": 10, "dy": 0});
for key in ["dx", "dy"] {
refuses_a_number_given_as_text(&mut s, "path.moveAnchors", &nudge, key);
}
let reshape = json!({"id": id.0, "x": 200, "y": 100, "dx": 0, "dy": 30, "tol": 3, "radius": 50});
for key in ["dx", "dy", "tol", "radius"] {
refuses_a_number_given_as_text(&mut s, "path.reshape", &reshape, key);
}
for h in [json!("200, 150"), json!([200]), json!([200, "150"]), json!({"x": 200, "y": 150})] {
refuses_with(&mut s, "path.close", &json!({"id": id.0, "in": h}), "`in` must be [x, y]");
}
let before = path(&s, id);
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": null})).unwrap();
s.execute("path.reshape", &with(&reshape, json!({"radius": null}))).unwrap();
s.execute("path.close", &json!({"id": id.0, "in": null})).unwrap();
// The Reshape tool's grab adds an anchor between the two, so the moved anchor is the last.
let after = path(&s, id);
let moved = after.subpaths[0].anchors.last().map(|a| a.p.x);
assert!(after.subpaths[0].closed && moved == Some(before.subpaths[0].anchors[1].p.x + 10.0), "{after:?}");
}
#[test]
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
+100
View File
@@ -257,6 +257,50 @@ fn cmyk_session() -> Session {
s
}
/// `cmd` with `p` fails with "`keepModel` must be true or false".
fn refuses_keep_model(s: &mut Session, cmd: &str, p: &Value) {
let r = s.execute(cmd, p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}");
}
/// How many steps there are to undo.
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// In a CMYK document, `paint.setFill`, `paint.setStroke`, `appearance.setItem`, `livePaint.fill`,
/// `livePaint.strokeEdge` and a `shapeBuilder.merge` fill refuse a `keepModel` given as text or as
/// another value that is not true or false. The objects, the defaults for new art and the undo
/// history stay as they were. Null is read as `keepModel` left out.
#[test]
fn the_paint_commands_refuse_a_keep_model_given_as_text() {
let mut s = cmyk_session();
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.paint.clone(), undo_len(s));
let before = state(&s);
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]);
let calls = [
("paint.setFill", json!({"color": "#ff0000", "keepModel": "true"})),
("paint.setStroke", json!({"color": "#ff0000", "keepModel": 1})),
("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": "false"})),
("appearance.setItem", json!({"index": 0, "color": "#ff0000", "keepModel": "true"})),
("livePaint.fill", json!({"ids": [a.0, b.0], "point": [20, 30], "color": "#ff0000", "keepModel": [true]})),
("shapeBuilder.merge", json!({"ids": [a.0, b.0], "points": [[20, 30], [80, 30]], "fill": {"color": "#ff0000", "keepModel": "true"}})),
];
for (cmd, p) in &calls {
refuses_keep_model(&mut s, cmd, p);
assert_eq!(state(&s), before, "{cmd} {p}");
}
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000", "keepModel": null})).unwrap();
assert_eq!(node(&s, a).appearance.fill_paint().color(), Some(managed_cmyk(Color::rgb8(255, 0, 0))));
let r = s.execute("livePaint.fill", &json!({"ids": [a.0, b.0], "point": [20, 30], "color": "#ff0000"})).unwrap();
let g = NodeId(r["group"].as_u64().unwrap());
let before = state(&s);
refuses_keep_model(&mut s, "livePaint.strokeEdge", &json!({"group": g.0, "point": [10, 30], "color": "#ff0000", "keepModel": "true"}));
assert_eq!(state(&s), before, "livePaint.strokeEdge");
}
/// RGB colors given to the type commands in a CMYK document separate through the color settings,
/// as `paint.setFill` stores them: `text.create {color}`, `text.setStyle {fill}` and
/// `text.setRangeStyle {fill, stroke}`. CMYK colors are stored as given.
@@ -280,6 +324,30 @@ fn rgb_type_colors_given_to_a_cmyk_document_separate_through_the_color_settings(
assert_eq!(runs(&s)[0].1, Some(Color::rgb8(83, 170, 223)), "keepModel keeps RGB");
}
/// In a CMYK document, `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle`
/// refuse a `keepModel` given as text or as another value that is not true or false: no type is made,
/// and the type, the path and the undo history stay as they were. Null is read as `keepModel` left out.
#[test]
fn the_type_commands_refuse_a_keep_model_given_as_text() {
let mut s = cmyk_session();
let t = id_of(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hello"})).unwrap());
let path = rect(&mut s, 200.0);
let before = (s.doc().unwrap().doc.clone(), undo_len(&s));
let calls = [
("text.create", json!({"x": 10, "y": 90, "text": "Hi", "color": "#ff0000", "keepModel": "true"})),
("text.createInPath", json!({"path": path.0, "mode": "area", "text": "Hi", "color": "#ff0000", "keepModel": 1})),
("text.setStyle", json!({"ids": [t.0], "fill": "#ff0000", "keepModel": "false"})),
("text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 2, "fill": "#ff0000", "keepModel": [true]})),
];
for (cmd, p) in &calls {
refuses_keep_model(&mut s, cmd, p);
assert_eq!((s.doc().unwrap().doc.clone(), undo_len(&s)), before, "{cmd} {p}");
}
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "fill": "#ff0000", "keepModel": null})).unwrap();
let NodeKind::Text(o) = node(&s, t).kind else { panic!("not type") };
assert_eq!(o.runs[0].style.fill.color(), Some(managed_cmyk(Color::rgb8(255, 0, 0))));
}
/// `shapeBuilder.merge {fill: "#rrggbb"}` in a CMYK document fills the merged shape as
/// `fill: {color}` does.
#[test]
@@ -375,6 +443,38 @@ fn rgb_gradient_and_mesh_colors_given_to_a_cmyk_document_separate_through_the_co
assert!(mesh(&s).contains(&orange), "{:?}", mesh(&s));
}
/// In a CMYK document, `paint.editGradient`, `paint.freeform.addPoint` and `setPoint`, and
/// `object.mesh.setPointColor` and `addLine` refuse a `keepModel` given as text or as another value
/// that is not true or false. The gradient, the mesh and the undo history stay as they were. Null is
/// read as `keepModel` left out.
#[test]
fn the_gradient_and_mesh_commands_refuse_a_keep_model_given_as_text() {
let mut s = cmyk_session();
let a = rect(&mut s, 10.0);
let (cyan, magenta) = (json!({"c": 100, "m": 0, "y": 0, "k": 0}), json!({"c": 0, "m": 100, "y": 0, "k": 0}));
let points = json!([{"at": [20, 20], "color": cyan}, {"at": [50, 50], "color": magenta}]);
s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let m = rect(&mut s, 300.0);
s.execute("object.mesh.create", &json!({"ids": [m.0], "rows": 1, "cols": 1})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = (s.doc().unwrap().doc.clone(), undo_len(&s));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}]);
let calls = [
("paint.editGradient", json!({"stops": stops, "keepModel": "true"})),
("paint.freeform.addPoint", json!({"at": [40, 20], "color": "#ff0000", "keepModel": "true"})),
("paint.freeform.setPoint", json!({"index": 1, "color": "#ff0000", "keepModel": 1})),
("object.mesh.setPointColor", json!({"id": m.0, "index": 1, "color": "#ff0000", "keepModel": "false"})),
("object.mesh.addLine", json!({"id": m.0, "x": 320, "y": 30, "color": "#ff0000", "keepModel": [true]})),
];
for (cmd, p) in &calls {
refuses_keep_model(&mut s, cmd, p);
assert_eq!((s.doc().unwrap().doc.clone(), undo_len(&s)), before, "{cmd} {p}");
}
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 1, "color": "#ff0000", "keepModel": null})).unwrap();
let NodeKind::Mesh(g) = node(&s, m).kind else { panic!("not a mesh") };
assert_eq!(g.points[1].color, managed_cmyk(Color::rgb8(255, 0, 0)));
}
#[test]
fn the_options_are_a_preference() {
let mut s = Session::new();
+122
View File
@@ -80,6 +80,13 @@ fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
// ---------- Blend ----------
#[test]
@@ -171,6 +178,38 @@ fn blend_options_refuse_a_number_given_as_text() {
assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed");
}
/// Make and Blend Options refuse a `smooth` given as text or as another value that is not true or
/// false, also beside Specified Steps: Make makes no blend, and the selected blend and the options new
/// blends start with keep their spacing. Null is read as `smooth` left out, and true still gives
/// Smooth Color.
#[test]
fn blend_make_and_options_refuse_a_smooth_given_as_text() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
sel(&mut s, &[a, b]);
let n = undo_len(&s);
let calls = [json!({"smooth": "true"}), json!({"smooth": 1}), json!({"steps": 4, "smooth": "false"}), json!({"smooth": [true]})];
for p in &calls {
refuses_non_boolean(&mut s, "object.blend.make", p, "smooth");
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not blended");
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "smooth": null})).unwrap());
let n = undo_len(&s);
for p in &calls {
refuses_non_boolean(&mut s, "object.blend.options", p, "smooth");
}
assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options");
s.execute("object.blend.options", &json!({"smooth": true})).unwrap();
assert!(matches!(&node(&s, g).kind, NodeKind::Blend { spec, .. } if spec.spacing == BlendSpacing::SmoothColor));
s.execute("select.none", &json!({})).unwrap();
let defaults = s.prefs.blend_options;
for p in &calls {
refuses_non_boolean(&mut s, "object.blend.options", p, "smooth");
}
assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed");
}
#[test]
fn blend_mid_step_interpolates_geometry_and_colour() {
let mut s = session();
@@ -534,6 +573,24 @@ fn envelope_options_and_edit_contents() {
assert_eq!(s.doc().unwrap().selection.objects, vec![e]);
}
/// Edit Contents refuses an `editing` given as text or as another value that is not true or false,
/// and the envelope, the selection and the undo history stay as they were; a null `editing` toggles,
/// as a missing one does.
#[test]
fn envelope_edit_contents_refuses_editing_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
let before = (node(&s, e), undo_len(&s), s.doc().unwrap().selection.objects.clone());
for p in [json!({"editing": "true"}), json!({"editing": 1}), json!({"editing": "false"}), json!({"editing": [true]})] {
refuses_non_boolean(&mut s, "object.envelope.editContents", &p, "editing");
assert_eq!((node(&s, e), undo_len(&s), s.doc().unwrap().selection.objects.clone()), before, "{p}");
}
let r = s.execute("object.envelope.editContents", &json!({"editing": null})).unwrap();
assert_eq!((r["editing"].clone(), s.doc().unwrap().selection.objects.clone()), (json!(true), vec![a]));
}
/// The warp's `bend`, and `h` and `v` by those keys or by their long names, given as text.
fn warp_numbers_as_text() -> [(&'static str, Value); 5] {
[
@@ -601,6 +658,37 @@ fn envelope_reset_with_mesh_refuses_a_number_given_as_text() {
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
}
/// Make with Warp and Reset with Warp refuse a `horizontal` given as text or as another value that
/// is not true or false, also beside an `orientation`, and Reset with Mesh refuses such a
/// `maintainShape`: no envelope is made, and a warp envelope keeps its warp. Null is read as the key
/// left out.
#[test]
fn envelope_make_and_reset_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let n = undo_len(&s);
let warps = [json!({"horizontal": "false"}), json!({"style": "arch", "horizontal": 0}), json!({"orientation": "vertical", "horizontal": "true"})];
for p in &warps {
refuses_non_boolean(&mut s, "object.envelope.makeWithWarp", p, "horizontal");
}
assert_eq!(undo_len(&s), n, "nothing changed");
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50, "horizontal": null})).unwrap());
let (before, n) = (node(&s, e), undo_len(&s));
for p in &warps {
refuses_non_boolean(&mut s, "object.envelope.resetWithWarp", p, "horizontal");
}
for p in [json!({"maintainShape": "false"}), json!({"rows": 2, "cols": 2, "maintainShape": 0})] {
refuses_non_boolean(&mut s, "object.envelope.resetWithMesh", &p, "maintainShape");
}
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
s.execute("object.envelope.resetWithWarp", &json!({"horizontal": false})).unwrap();
assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { horizontal: false, .. })));
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2, "maintainShape": null})).unwrap();
assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 2, cols: 2, .. })));
}
/// Envelope Options refuses a fidelity, bend or distortion given as text, for the selected
/// envelope and for the options new envelopes get, and changes neither.
#[test]
@@ -623,6 +711,40 @@ fn envelope_options_refuse_a_number_given_as_text() {
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
}
/// Envelope Options refuses Anti-Alias, the three Distort options or a warp's `horizontal` given as
/// text or as another value that is not true or false, for a selected warp envelope, a selected mesh
/// envelope and the options new envelopes get, and changes none of them; null keeps the current
/// value, as a missing key does.
#[test]
fn envelope_options_refuse_a_boolean_given_as_text() {
let mut s = session();
let a = text_rect(&mut s);
sel(&mut s, &[a]);
let warp = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
let b = rect(&mut s, 400.0, 100.0, 100.0, 100.0);
sel(&mut s, &[b]);
let mesh = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
let calls = [
("antiAlias", json!({"antiAlias": "false"})),
("distortAppearance", json!({"fidelity": 80, "distortAppearance": 1})),
("distortLinearGradients", json!({"distortLinearGradients": "true"})),
("distortPatternFills", json!({"distortPatternFills": [true]})),
("horizontal", json!({"horizontal": "false"})),
];
for ids in [vec![warp], vec![mesh], vec![]] {
sel(&mut s, &ids);
let before = (node(&s, warp), node(&s, mesh), undo_len(&s));
for (key, p) in &calls {
refuses_non_boolean(&mut s, "object.envelope.options", p, key);
}
assert_eq!((node(&s, warp), node(&s, mesh), undo_len(&s)), before, "nothing changed with {ids:?} selected");
}
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
sel(&mut s, &[warp]);
s.execute("object.envelope.options", &json!({"antiAlias": false, "distortAppearance": null, "horizontal": null})).unwrap();
assert!(matches!(&node(&s, warp).kind, NodeKind::Envelope { options, .. } if !options.anti_alias && options.distort_appearance));
}
#[test]
fn envelope_renders_distorted() {
let mut s = session();
+43
View File
@@ -331,6 +331,49 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
/// Invert Pie and Make Sides Equal given as text or as another value that is not true or false are
/// an error naming the key: the ellipse's pie, the polygon and the undo history stay as they were.
/// True makes the change, and false and null leave the shape as it is.
#[test]
fn invert_pie_and_make_sides_equal_refuse_a_boolean_given_as_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
run(&mut s, json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 90}));
let polygon = id(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap());
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let shapes = |s: &Session| {
let d = &s.doc().unwrap().doc;
[ellipse, polygon].map(|i| match &d.node(i).unwrap().kind {
NodeKind::Path { live: Some(l), .. } => (**l).clone(),
k => panic!("{k:?}"),
})
};
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = (undo(&s), shapes(&s));
for (key, p) in [
("invertPie", json!({"id": ellipse.0, "invertPie": "true"})),
("invertPie", json!({"id": ellipse.0, "pieEnd": 180, "invertPie": 1})),
("makeSidesEqual", json!({"id": polygon.0, "makeSidesEqual": "true"})),
("makeSidesEqual", json!({"id": polygon.0, "sides": 5, "makeSidesEqual": [true]})),
] {
let r = s.execute("object.setLiveShape", &p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}");
assert_eq!((undo(&s), shapes(&s)), before, "{p}");
}
for p in [json!({"id": ellipse.0, "invertPie": false}), json!({"id": polygon.0, "makeSidesEqual": null})] {
run(&mut s, p);
}
assert_eq!((undo(&s), shapes(&s)), before);
run(&mut s, json!({"id": ellipse.0, "invertPie": true}));
run(&mut s, json!({"id": polygon.0, "makeSidesEqual": true}));
let [LiveShape::Ellipse { pie, .. }, l] = shapes(&s) else { panic!("an ellipse and a polygon") };
assert_eq!(pie, (90.0, 360.0));
assert!(l.polygon_sides_equal());
}
/// The Selection tool's pie widgets: dragging the End widget from 0° to straight up sets Pie End
/// Angle to 90 in one undo step; on an inverted pie a drag moves only the dragged angle; a
/// double-click on a widget makes the ellipse whole again in one step.
+98
View File
@@ -90,6 +90,13 @@ fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
// ---------- Object → Lock / Hide ----------
#[test]
@@ -429,6 +436,30 @@ fn transform_each_refuses_a_number_given_as_text() {
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
}
/// Transform Each refuses Reflect X, Reflect Y, Random or Copy given as text or as another value
/// that is not true or false, and the object stays as it was and is not copied; true, false and null
/// still work.
#[test]
fn transform_each_refuses_a_boolean_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let before = (undo_len(&s), bounds(&s, a), count(&s));
for (key, p) in [
("reflectX", json!({"reflectX": "true"})),
("reflectY", json!({"moveH": 10, "reflectY": 1})),
("random", json!({"moveH": 10, "random": "false"})),
("copy", json!({"copy": "true"})),
("copy", json!({"moveH": 10, "copy": [true]})),
] {
refuses_non_boolean(&mut s, "object.transformEach", &p, key);
assert_eq!((undo_len(&s), bounds(&s, a), count(&s)), before, "{p}");
}
s.execute("object.transformEach", &json!({"moveH": 10, "reflectX": true, "random": false, "copy": null})).unwrap();
assert_eq!((bounds(&s, a), count(&s)), (Rect::new(10.0, 0.0, 110.0, 100.0), before.2));
}
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
#[test]
@@ -673,6 +704,24 @@ fn crop_image_refuses_a_rect_given_as_text() {
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
}
/// Crop Image refuses a `trim` given as text or as another value that is not true or false, and the
/// image stays as it was; a null `trim` crops to `rect` as a missing one does.
#[test]
fn crop_image_refuses_a_trim_given_as_text() {
let mut s = session();
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
sel(&mut s, &[a]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let before = (undo_len(&s), node(&s, img));
for p in [json!({"trim": "true"}), json!({"trim": "false", "rect": [10, 10, 50, 40]}), json!({"trim": 1})] {
refuses_non_boolean(&mut s, "object.cropImage", &p, "trim");
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
}
s.execute("object.cropImage", &json!({"trim": null, "rect": [10, 10, 50, 40]})).unwrap();
let b = bounds(&s, img);
assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}");
}
#[test]
fn crop_image_disabled_without_image() {
let mut s = session();
@@ -745,6 +794,32 @@ fn trim_marks_refuse_a_number_given_as_text() {
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
}
/// Create Trim Marks refuses an `allArtboards` that is not true or false and a `style` that is not
/// text, around the selection and around the artboards, and makes no marks; null takes the default.
#[test]
fn trim_marks_refuse_a_boolean_or_style_of_the_wrong_kind() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let style = "`style` must be roman or japanese";
let calls = [
(json!({"allArtboards": "true"}), "`allArtboards` must be true or false"),
(json!({"allArtboards": 0}), "`allArtboards` must be true or false"),
(json!({"style": 5}), style),
(json!({"allArtboards": true, "style": true}), style),
(json!({"style": ["roman"]}), style),
];
for ids in [vec![a], vec![]] {
sel(&mut s, &ids);
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
for (p, msg) in &calls {
let r = s.execute("object.createTrimMarks", p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
}
}
s.execute("object.createTrimMarks", &json!({"allArtboards": null, "style": null})).unwrap();
}
// ---------- Convert to Shape ----------
#[test]
@@ -896,6 +971,29 @@ fn rearrange_refuses_a_spacing_given_as_text() {
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
}
/// Rearrange All Artboards refuses `byColumn` or `moveArtwork` given as text or as another value
/// that is not true or false, and the artboards and the art stay where they were; `moveArtwork:
/// false` leaves the art in place.
#[test]
fn rearrange_refuses_a_boolean_given_as_text() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
let b = rect(&mut s, 1050.0, 25.0, 10.0, 10.0);
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let before = (boards(&s), undo_len(&s), bounds(&s, b));
for (key, p) in [
("byColumn", json!({"byColumn": "true"})),
("byColumn", json!({"layout": "row", "byColumn": 1})),
("moveArtwork", json!({"moveArtwork": "false"})),
("moveArtwork", json!({"columns": 1, "moveArtwork": 0})),
] {
refuses_non_boolean(&mut s, "artboard.rearrange", &p, key);
assert_eq!((boards(&s), undo_len(&s), bounds(&s, b)), before, "{p}");
}
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50, "byColumn": null, "moveArtwork": false})).unwrap();
assert_eq!((boards(&s)[1], bounds(&s, b)), (Rect::new(0.0, 650.0, 400.0, 950.0), before.2));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
+72
View File
@@ -227,6 +227,78 @@ fn focus_false_keeps_the_active_proxy() {
assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
}
/// Toggle Fill/Stroke Focus, Invert, Complement, Apply Last Color, Apply Last Gradient,
/// `paint.setFill` and `paint.setStroke` refuse a `fill`, `stroke`, `focus` or `none` given as text or
/// as another value that is not true or false. The object, the defaults for new art, the proxy in
/// front and the undo history stay as they were, with the object selected and with nothing selected.
/// Null is read as the key left out.
#[test]
fn proxy_commands_refuse_a_boolean_given_as_text() {
let mut s = session();
let id = rect(&mut s);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let calls = [
("paint.toggleActive", json!({"fill": "false"}), "fill"),
("paint.invert", json!({"stroke": "true"}), "stroke"),
("paint.complement", json!({"stroke": 1}), "stroke"),
("paint.lastColor", json!({"stroke": "true"}), "stroke"),
("paint.lastGradient", json!({"stroke": [true]}), "stroke"),
("paint.setStroke", json!({"color": "#0000ff", "focus": "false"}), "focus"),
("paint.setFill", json!({"none": "true"}), "none"),
("paint.setFill", json!({"color": "#0000ff", "none": "false"}), "none"),
];
let state = |s: &Session| (node(s, id), s.paint.clone(), s.fill_active, history(s));
for selected in [true, false] {
if !selected {
s.execute("select.none", &json!({})).unwrap();
}
let before = state(&s);
for (cmd, p, key) in &calls {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}, selected: {selected}");
}
}
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("paint.toggleActive", &json!({"fill": null})).unwrap();
assert_eq!(r["fillActive"], json!(false), "a null `fill` swaps the proxy in front");
s.execute("paint.setFill", &json!({"color": "#0000ff", "none": null, "focus": null})).unwrap();
assert_eq!((node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), s.fill_active), ("#0000ff".to_string(), true));
}
/// `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill`
/// object, `swatch.new` and `swatch.edit`'s `paint` refuse a `none` given as text or as another value
/// that is not true or false. The document, the defaults for new art and the undo history stay as
/// they were. Null is read as `none` left out.
#[test]
fn the_commands_that_take_a_paint_refuse_a_none_given_as_text() {
let mut s = session();
let id = rect(&mut s);
let name = s.execute("swatch.new", &json!({"color": "#00ff00"})).unwrap()["name"].clone();
let refused = |r: &Result<Value>| matches!(r, Err(EngineError::BadParams { msg, .. }) if msg == "`none` must be true or false");
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.paint.clone(), history(s));
let before = state(&s);
let calls = [
("appearance.setItem", json!({"index": 0, "ids": [id.0], "none": "true"})),
("livePaint.fill", json!({"ids": [id.0], "point": [20, 30], "none": 1})),
("shapeBuilder.merge", json!({"ids": [id.0], "points": [[20, 30]], "fill": {"none": "true"}})),
("swatch.new", json!({"none": [true]})),
("swatch.edit", json!({"name": name, "paint": {"none": "false"}})),
];
for (cmd, p) in &calls {
let r = s.execute(cmd, p);
assert!(refused(&r), "{cmd} {p}: {r:?}");
assert_eq!(state(&s), before, "{cmd} {p}");
}
let r = s.execute("livePaint.fill", &json!({"ids": [id.0], "point": [20, 30], "color": "#ff0000", "none": null})).unwrap();
let g = NodeId(r["group"].as_u64().unwrap());
let before = state(&s);
let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [10, 30], "none": "true"}));
assert!(refused(&r), "livePaint.strokeEdge: {r:?}");
assert_eq!(state(&s), before, "livePaint.strokeEdge");
}
#[test]
fn a_mixed_selection_shows_a_question_mark_proxy() {
let mut s = session();
+57
View File
@@ -28,6 +28,18 @@ fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false.
fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be true or false");
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
/// Values for an on/off param that are not true, false or null.
fn not_booleans() -> [Value; 4] {
[json!("true"), json!(1), json!("false"), json!([true])]
}
// ---------- M8.8: Define Grid ----------
#[test]
@@ -209,6 +221,24 @@ fn presets_export_import_and_live_in_the_preferences() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().perspective_presets.is_empty());
}
/// Import Perspective Grid Presets refuses a `replace` given as text or as another value that is not
/// true or false: the saved presets stay as they were. Null is read as the key left out, so the
/// imported presets whose names are taken get a number.
#[test]
fn presets_import_refuses_replace_given_as_text() {
let mut s = session();
run(&mut s, "perspective.presets.save", json!({"name": "Street", "gridline": 1}));
let text = run(&mut s, "perspective.presets.export", json!({}))["data"].as_str().unwrap().to_string();
let before = s.prefs.perspective_presets.clone();
for v in not_booleans() {
refuses_non_boolean(&mut s, "perspective.presets.import", &json!({"data": text, "replace": v}), "replace");
assert_eq!(s.prefs.perspective_presets, before, "{v}");
}
assert_eq!(run(&mut s, "perspective.presets.import", json!({"data": text, "replace": null}))["imported"], json!(["Street 2"]));
assert_eq!(run(&mut s, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street"]));
assert_eq!(s.prefs.perspective_presets.len(), 2);
}
// ---------- M8.10: view options ----------
use vectorcraft_geom::Point;
@@ -246,6 +276,33 @@ fn view_options_toggle_without_undo_steps_and_survive_presets() {
assert_eq!(PerspectiveGrid::from_doc(&back), Some(g));
}
/// Lock Grid, Lock Station Point, Snap to Grid and Show Rulers refuse an `on` given as text or as
/// another value that is not true or false: the grid and the undo history stay as they were. Null is
/// read as the key left out, so it toggles the option.
#[test]
fn view_options_refuse_on_given_as_text() {
let mut s = session();
run(&mut s, "perspective.grid.preset", json!({"kind": 2}));
run(&mut s, "perspective.grid.lock", json!({"on": true}));
let before = (grid(&s), undo_len(&s));
for id in ["perspective.grid.lock", "perspective.grid.lockStation", "perspective.grid.snap", "perspective.grid.rulers"] {
for v in not_booleans() {
refuses_non_boolean(&mut s, id, &json!({"on": v}), "on");
assert_eq!((grid(&s), undo_len(&s)), before, "{id} {v}");
}
}
for (id, on) in [
("perspective.grid.lock", false),
("perspective.grid.lockStation", true),
("perspective.grid.snap", false),
("perspective.grid.rulers", true),
] {
assert_eq!(run(&mut s, id, json!({"on": null})), json!({"on": on}), "{id} toggles");
assert_eq!(run(&mut s, id, json!({"on": on})), json!({"on": on}), "{id} is set");
}
assert_eq!(undo_len(&s), before.1);
}
#[test]
fn lock_grid_keeps_the_widgets_still_but_planes_switch() {
let mut s = session();
+32
View File
@@ -278,6 +278,38 @@ fn a_cmyk_document_stores_rgb_colors_given_to_the_swatch_commands_as_cmyk() {
assert_eq!(color(&s, &r["names"][0]), Some(green));
}
/// In a CMYK document `swatch.new` (with `color`, `colors` or `mode`), `swatch.newGroup`,
/// `swatch.edit` (its `keepModel` and its `paint`'s) and `swatch.editGroup` refuse a `keepModel` given
/// as text or as another value that is not true or false, and the swatches and the undo history stay
/// as they were. Null is read as `keepModel` left out.
#[test]
fn the_swatch_commands_refuse_a_keep_model_given_as_text() {
use vectorcraft_color::Color;
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
let cms = vectorcraft_color::cms::active();
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone();
run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]}));
let before = (doc(&s).clone(), undo_len(&s));
let calls = [
("swatch.new", json!({"color": "#ff0000", "keepModel": "true"})),
("swatch.new", json!({"colors": ["#ff0000"], "keepModel": 1})),
("swatch.new", json!({"color": "#ff0000", "mode": "rgb", "keepModel": "false"})),
("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": "true"})),
("swatch.edit", json!({"name": name, "color": "#ff0000", "keepModel": [true]})),
("swatch.edit", json!({"name": name, "paint": {"color": "#ff0000", "keepModel": "true"}, "mode": "cmyk"})),
("swatch.editGroup", json!({"group": "G", "colors": ["#ff0000"], "keepModel": "true"})),
];
for (cmd, p) in &calls {
let r = s.execute(cmd, p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}");
assert_eq!((doc(&s).clone(), undo_len(&s)), before, "{cmd} {p}");
}
let r = run(&mut s, "swatch.new", json!({"color": "#ff0000", "keepModel": null}));
assert_eq!(doc(&s).swatch(r["name"].as_str().unwrap()).and_then(|w| w.paint.color()), Some(managed(Color::rgb(1.0, 0.0, 0.0))));
}
#[test]
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
use vectorcraft_color::Color;
+58 -1
View File
@@ -942,6 +942,13 @@ missing one does.
`` `columns` must be a number, not "4" ``. Each message names the key that was given, and the command then changes
nothing.
These Object menu commands refuse a string, a number or another value that is not true or false with
`` `copy` must be true or false ``, naming the key, and change nothing: `reflectX`, `reflectY`, `random` and `copy` in
`object.transformEach`; `gray` and `deleteRaster` in `object.createObjectMosaic`; `trim` in `object.cropImage`;
`allArtboards` in `object.createTrimMarks`; and `byColumn` and `moveArtwork` in `artboard.rearrange`. A null is read as
a key left out. `object.createTrimMarks` refuses a `style` that is not a string, such as `5`, with
`` `style` must be roman or japanese ``, the message it gives for a string that names no style.
A whole number that is larger than the build's `usize` can hold (4294967295 on a 32-bit build, such as the
`wasm32-unknown-unknown` web build) counts as larger than any index, place, byte offset or count. An index, such as
a `subpath`, `anchor`, `segment`, document `index`, `artboard`, appearance `item` or `index`, gradient `stop` or
@@ -951,7 +958,6 @@ commands fail and change nothing, and `appearance.removeItem` removes nothing. A
the item last. A text `start` or `end` is past the end of the text. `object.slice.divide` refuses such a `rows` or
`columns`, `recolor.reduce` keeps each color in its own row for such a `colors`, and `layer.setProps` refuses such a
`color`. `object.setBounds` reads a `reference` past 8 as 8, as `object.transformEach` does.
The blend, envelope and gradient mesh commands refuse a string or another value that is not a number with the same
message, as in `` `steps` must be a number, not "8" ``, and change nothing: `steps`, `distance` and `value` in
`object.blend.make` and `object.blend.options`; `bend`, `h`, `v`, `horizontalDistortion` and `verticalDistortion` in
@@ -960,6 +966,24 @@ message, as in `` `steps` must be a number, not "8" ``, and change nothing: `ste
`highlight` in `object.mesh.create`; and `opacity` in `object.mesh.setPointColor`. A null is read as a key left out.
`object.mesh.create` refuses an `at` that is not two numbers with `` `at` must be [x, y] ``.
The transform, Align, Object Properties and Live Shape Properties commands refuse a string, a number or another value
that is not true or false with `` `strokes` must be true or false ``, naming the key, and leave the document and its
undo history as they were: the options `copy`, `strokes`, `corners`, `patterns`, `objects` and `typeAreas` in
`object.transform`, `object.move`, `object.nudge`, `object.rotate`, `object.scale`, `object.reflect`, `object.shear`,
`object.transformAgain` and `object.setBounds`; `anchors` in `object.transform`; `big` in `object.nudge`;
`absolute` in `object.rotate`; `proportional` in `object.setBounds`; `patterns` in `object.align`, `object.distribute`
and `object.distributeSpacing`; `visible`, `locked`, `isolate` and `knockoutShape` in `object.setProps`; and
`invertPie` and `makeSidesEqual` in `object.setLiveShape`. A null is read as a key left out.
The blend and envelope commands refuse a string, a number or another value that is not true or false with
`` `smooth` must be true or false ``, naming the key, and leave the document, its undo history and the options new
blends and envelopes start with as they were: `smooth` in `object.blend.make` and `object.blend.options`;
`independent` in `object.blend.spine.moveAnchor`; `perpendicular` in `object.blend.spine.reshapeSegment`; `horizontal`
in `object.envelope.makeWithWarp`, `resetWithWarp` and `options`, also when an `orientation` is given; `maintainShape`
in `object.envelope.resetWithMesh`; `antiAlias`, `distortAppearance`, `distortLinearGradients` and
`distortPatternFills` in `object.envelope.options`; and `editing` in `object.envelope.editContents`. A null is read as
a key left out.
A step can use an earlier step's result, in `run` as in the `command_batch` tool. A parameter string that is exactly
`$N` or `$N.path` takes that value from the result of step `N` (counting from 0, in the order they ran; `run --in`'s
open isn't a step), and `$last…` from the step before. A path goes into objects by key and arrays by index:
@@ -2123,6 +2147,20 @@ the canvas (coordinates out of range)".
is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``,
naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in
`path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y`
in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in
`brush.freehand`, which runs it); `size` in `path.eraseRegion` and `path.blob`; `width` in `path.eraseSegments`;
`radius` and `fidelity` in `path.smoothRegion`; `tolerance` in `path.joinScrub`; `dx` and `dy` in `path.moveAnchors`;
and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key left out. `path.convertAnchor` refuses
an `x` without a `y`, or a `y` without an `x`, with "x and y must be given together". `path.freehand`,
`path.curvature`, `path.knife`, `path.eraseRegion`, `path.eraseSegments`, `path.smoothRegion`, `path.joinScrub` and
`path.blob` refuse a point in `points` whose x or y is not a number, such as `["10", 10]` or a point written as a
string, with "x and y must both be numbers". All of them except `path.curvature` refuse a pen pressure that is neither
a number nor null with `` `pressure` must be a number, not "0.5" ``. `path.close` refuses an `in` that is not `[x, y]`
with `` `in` must be [x, y] ``.
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
Curvature and anchor tools until one is chosen; the release gives the tool back as it was, so the Pen goes on drawing
@@ -4488,6 +4526,17 @@ stroke at once, so the journal entry replays it exactly.
{"name":"pointer_gesture","arguments":{"tool":"twirl","events":[{"kind":"down","x":300,"y":200,"holdMs":800},{"kind":"up","x":300,"y":200}]}}
```
The Liquify, Puppet Warp and perspective commands refuse a string or another value that is not a number with
`` `diameter` must be a number, not "40" ``, naming the key, and leave the document and its undo history as they
were: `diameter`, `width`, `height`, `angle`, `intensity`, `detail`, `simplify`, `rate`, `complexity`, `horizontal`
and `vertical` in `object.liquify`, with any `tool`; `expand` in `object.puppetWarp` and `object.puppetWarp.pins`;
`depth` in `perspective.transform`; `dx` and `dy` in `perspective.nudge`; and `offset` and `by` in
`perspective.plane.move`. A null is read as a key left out. `object.puppetWarp` refuses an `angles` entry that is not
a number or null with "angles must be a list as long as pins (degrees or null)", `perspective.draw` refuses an `at`
that is not two numbers, such as `["450", 380]`, with "`at` must be [x, y]", and `stroke.widthProfile.set` refuses a
point that is not three numbers, such as `[0.5, "1", 2, 2]`, with "points must be [t, left, right] with non-negative
widths".
## Perspective Selection
Objects in perspective keep their attachment themselves (`perspective` on the object: `plane` and `depth`, the
@@ -4506,6 +4555,14 @@ copies). Object › Transform › Transform Again repeats the last perspective m
{"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}}
```
The perspective, width point, Liquify and Puppet Warp commands refuse a string, a number or another value that is not
true or false with `` `copy` must be true or false ``, naming the key, and leave the document and its undo history as
they were: `copy`, `perpendicular` and `snap` in `perspective.move`; `copy` in `perspective.transform`; `big` and
`copy` in `perspective.nudge`; `snap` in `perspective.draw`; `reproject` in `perspective.grid.set`; `visible` in
`perspective.grid.show`; `adjustAdjoining` in `stroke.widthPoint.set`; `usePressure`, `simplifyOn`, `affectAnchors`,
`affectIn` and `affectOut` in `object.liquify`, with any `tool`; and `rest` in `object.puppetWarp`. A null is read as
a key left out.
Planes move along their normals: `perspective.plane.move {plane, offset | by, objects}` (`leftOffset`,
`rightOffset`, `groundOffset` in the grid). The plane widgets (a diamond on each plane, drawn while the grid shows)
do it with the Perspective Grid and Perspective Selection tools: a plain drag moves the plane alone, Shift-drag moves
+9
View File
@@ -583,6 +583,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies).
`path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0.
`path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size.
`path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`.
- **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path.
With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. With the Anchor Point tool, Shift held at the press on an anchor (or a selected blend's spine point) moves it, handles and all, instead of pulling out handles; Shift kept down keeps the move at 45° steps, and a Shift-click that doesn't move leaves the anchor as it was (`path.moveAnchor`). A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512).
@@ -760,11 +761,17 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
Liquify (its brush Width, Height and Angle, Intensity, Detail, Simplify, Twirl Rate, Complexity, Horizontal and Vertical), Puppet Warp (its Expand and each pin's angle), Transform in Perspective (its depth), Nudge in Perspective (its direction) and Move Plane (its location or distance) refuse a value other than a number or null, such as "40", Draw in Perspective refuses a click point that is not two numbers, and Width Profile refuses a point that is not three numbers; each leaves the document and its undo history as they were.
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s.
Lock Grid, Lock Station Point, Snap to Grid and Show Rulers (View › Perspective Grid) and Import Perspective Grid Presets (its choice to replace saved presets of the same names) refuse a value other than true, false or null, such as "true", and leave the grid and the saved presets as they were.
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
Move in Perspective (its copy, perpendicular and Snap to Grid choices), Transform in Perspective (its copy), Nudge in Perspective (its ×10 and copy), Draw in Perspective (Snap to Grid), Define Perspective Grid (its reproject choice), Show Perspective Grid, Width Point (Adjust Adjoining Width Points), Liquify (its pen pressure, Simplify and Affect choices) and Puppet Warp (its pins' rest shape) refuse a value other than true, false or null, such as "true", and leave the document and its undo history as they were.
Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4.
Move, Rotate, Scale, Reflect, Shear, Transform Again, Nudge and the Transform panel's values (their copy, Scale Strokes & Effects, Scale Corners, Transform Patterns, Transform Objects and area type options), Align, Distribute and Distribute Spacing (Transform Patterns), Object Properties (visible, locked, Isolate Blending and Opacity & Mask Define Knockout Shape) and Live Shape Properties (Invert Pie and Make Sides Equal) refuse a value other than true, false or null, such as "false", and leave the document as it was.
Transform Each (its scales, moves and rotation), Rasterize (its resolution), Create Object Mosaic (its new width and height and its spacing), Create Trim Marks (its offset, length, bleed and weight) and Rearrange All Artboards (its spacing) refuse a value other than a number or null, such as "50", and change nothing. Crop Image refuses a rectangle that is not four numbers, such as "0, 0, 20, 20".
Transform Each (Reflect X, Reflect Y, Random and Copy), Create Object Mosaic (gray tiles and deleting the image), Crop Image (its trim), Create Trim Marks (all artboards) and Rearrange All Artboards (by column and Move Artwork with Artboard) refuse a value other than true, false or null, such as "false", and change nothing. Create Trim Marks refuses a style that is not text, such as 5.
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
The commands that take `keepModel` (fills and strokes, Appearance rows, Live Paint, Shape Builder fills, gradient stops and freeform points, gradient mesh points, type colors, the Symbol Stainer and the swatch commands) refuse a `keepModel` other than true, false or null, such as "true". Toggle Fill/Stroke Focus (`fill`), Invert, Complement, Apply Last Color and Apply Last Gradient (`stroke`), `paint.setFill` and `paint.setStroke` (`focus`, `none`), and `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill`, `swatch.new` and `swatch.edit`'s `paint` (`none`) refuse such a value too. Each leaves the document, its undo history and the defaults for new art as they were.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
A variable font's type gets one slider and field per variation axis under the Character panel's Variable Font button, each named as the font names the axis (Weight, Width), so the type can be set between or past its named instances: the settings stay with the characters (`CharStyle::font_axes`, `text.setFormat {fontAxes}` and `text.setRangeStyle {fontAxes}`, clamped to each axis's range; `text.fontList {family, style}` lists the axes), and layout, outlines, the substituted-glyph highlight and PDF and SVG export (`font-variation-settings`, embedded fonts) use the font instanced at them. Another font or style clears them; legacy `.ai` export writes the style and warns that they are left out.
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
@@ -899,6 +906,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`.
- **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were.
- **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.<id>`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged.
- **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged.