diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 7c3434a52..46ff6574e 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -410,6 +410,7 @@ const DEFAULT_STEPS: u32 = 5; const DEFAULT_DISTANCE: f64 = 10.0; fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { + check_numbers(p, &["value", "steps", "distance"], 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"); @@ -971,7 +972,11 @@ fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { // ---------- Envelope ---------- +/// The number keys of a warp: `bend`, and the two distortions by their short and long names. +const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"]; + fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { + check_numbers(p, WARP_NUMBERS, cmd)?; let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); @@ -1005,6 +1010,7 @@ fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, f /// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`. fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> { + check_numbers(p, &["rows", "cols", "columns"], cmd)?; let rows = f64_or(p, "rows", default.0 as f64); let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" }; let cols = f64_or(p, cols_key, default.1 as f64); @@ -1241,6 +1247,8 @@ fn env_release(s: &mut Session, _: &Value) -> Result { fn env_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.options"; + check_numbers(p, &["fidelity"], C)?; + check_numbers(p, WARP_NUMBERS, 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")); @@ -1386,8 +1394,12 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, None => MeshAppearance::Flat, }; + check_numbers(p, &["highlight"], C)?; let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); - let at = point_param(p, "at"); + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), + }; let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) }; let roots = roots_param(s, p)?; let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { @@ -1522,6 +1534,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result { fn mesh_set_color(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.setPointColor"; + check_numbers(p, &["opacity"], 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); diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index 5a87fdd27..59bfc4e70 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -73,6 +73,13 @@ fn all_anchors(nodes: &[Node]) -> Vec { v } +/// `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: &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:?}"); +} + // ---------- Blend ---------- #[test] @@ -116,6 +123,54 @@ fn blend_steps_that_are_not_a_whole_number_are_refused() { assert_eq!(steps_of(&s, g).len(), 6); } +/// Make refuses Specified Steps or Distance given as text or as another value that is not a number, +/// and makes no blend; `steps: 8` as a number makes 8 steps. +#[test] +fn blend_make_refuses_a_number_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); + for (key, p) in [ + ("steps", json!({"steps": "8"})), + ("distance", json!({"distance": "10"})), + ("value", json!({"spacing": "steps", "value": "3"})), + ("value", json!({"spacing": "distance", "value": [5]})), + ("distance", json!({"spacing": "distance", "distance": true})), + ] { + refuses_non_number(&mut s, "object.blend.make", &p, key); + } + 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": 8})).unwrap()); + assert_eq!(steps_of(&s, g).len(), 10); +} + +/// Blend Options refuses Specified Steps or Distance given as text, for the selected blends and for +/// the options new blends start with, and changes neither. +#[test] +fn blend_options_refuse_a_number_given_as_text() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"steps": 3})); + let n = undo_len(&s); + let calls = [ + ("steps", json!({"steps": "4"})), + ("value", json!({"spacing": "steps", "value": "4"})), + ("distance", json!({"distance": "20"})), + ("distance", json!({"spacing": "distance", "value": null, "distance": "20"})), + ]; + for (key, p) in &calls { + refuses_non_number(&mut s, "object.blend.options", p, key); + } + assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options"); + s.execute("select.none", &json!({})).unwrap(); + let defaults = s.prefs.blend_options; + for (key, p) in &calls { + refuses_non_number(&mut s, "object.blend.options", p, key); + } + 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(); @@ -479,6 +534,95 @@ fn envelope_options_and_edit_contents() { assert_eq!(s.doc().unwrap().selection.objects, vec![e]); } +/// 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] { + [ + ("bend", json!({"bend": "40"})), + ("h", json!({"h": "10"})), + ("v", json!({"v": "-10"})), + ("horizontalDistortion", json!({"horizontalDistortion": "10"})), + ("verticalDistortion", json!({"verticalDistortion": "10%"})), + ] +} + +/// Make with Warp refuses a bend or distortion given as text, and makes no envelope. +#[test] +fn envelope_make_with_warp_refuses_a_number_given_as_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let n = undo_len(&s); + for (key, p) in warp_numbers_as_text() { + refuses_non_number(&mut s, "object.envelope.makeWithWarp", &p, key); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped"); +} + +/// Reset with Warp refuses a bend or distortion given as text, and the envelope keeps its warp. +#[test] +fn envelope_reset_with_warp_refuses_a_number_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, n) = (node(&s, e), undo_len(&s)); + for (key, p) in warp_numbers_as_text() { + refuses_non_number(&mut s, "object.envelope.resetWithWarp", &p, key); + } + assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); +} + +/// Make with Mesh refuses rows or columns given as text, and makes no envelope. +#[test] +fn envelope_make_with_mesh_refuses_a_number_given_as_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let n = undo_len(&s); + for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"rows": 2, "columns": "3"}))] { + refuses_non_number(&mut s, "object.envelope.makeWithMesh", &p, key); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped"); +} + +/// Reset with Mesh refuses rows or columns given as text, and the envelope keeps its mesh. +#[test] +fn envelope_reset_with_mesh_refuses_a_number_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.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let (before, n) = (node(&s, e), undo_len(&s)); + for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"columns": "3", "maintainShape": false}))] { + refuses_non_number(&mut s, "object.envelope.resetWithMesh", &p, key); + } + assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); +} + +/// 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] +fn envelope_options_refuse_a_number_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, n) = (node(&s, e), undo_len(&s)); + let mut calls = vec![("fidelity", json!({"fidelity": "80", "antiAlias": false}))]; + calls.extend(warp_numbers_as_text()); + for (key, p) in &calls { + refuses_non_number(&mut s, "object.envelope.options", p, key); + } + assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); + s.execute("select.none", &json!({})).unwrap(); + for (key, p) in &calls { + refuses_non_number(&mut s, "object.envelope.options", p, key); + } + assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed"); +} + #[test] fn envelope_renders_distorted() { let mut s = session(); @@ -634,6 +778,46 @@ fn mesh_commands_refuse_an_index_or_count_that_is_not_a_whole_number() { assert_eq!(mesh_of(&s, a).points[4].p, Point::new(40.0, 60.0)); } +/// Create Gradient Mesh refuses rows, columns or a highlight given as text, and an `at` that is not +/// two numbers, and makes no mesh; `at: [50, 40]` with a null highlight makes one. +#[test] +fn mesh_create_refuses_a_number_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 n = undo_len(&s); + for (key, p) in [ + ("rows", json!({"rows": "3"})), + ("cols", json!({"cols": "2"})), + ("columns", json!({"columns": "2"})), + ("highlight", json!({"appearance": "center", "highlight": "50"})), + ] { + refuses_non_number(&mut s, "object.mesh.create", &p, key); + } + for at in [json!(["50", "40"]), json!([50]), json!(50), json!({"x": 50, "y": 40})] { + let r = s.execute("object.mesh.create", &json!({ "at": at })); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "{at}: {r:?}"); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "still a path"); + s.execute("object.mesh.create", &json!({"at": [50, 40], "highlight": null})).unwrap(); + assert_eq!((mesh_of(&s, a).rows, mesh_of(&s, a).cols), (2, 2)); +} + +/// Set Mesh Point Color refuses an opacity given as text, and the point keeps its color and opacity. +#[test] +fn mesh_set_point_color_refuses_an_opacity_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + let (before, n) = (mesh_of(&s, a), undo_len(&s)); + for p in [json!({"id": a.0, "index": 3, "color": "#00ff00", "opacity": "0.5"}), json!({"id": a.0, "index": 3, "opacity": "50%"})] { + refuses_non_number(&mut s, "object.mesh.setPointColor", &p, "opacity"); + } + assert_eq!((mesh_of(&s, a), undo_len(&s)), (before, n), "nothing changed"); +} + #[test] fn mesh_create_at_point_single_undo() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 7f5bc558b..c3b21dcc6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -880,6 +880,14 @@ journal recorded by an older version replays as it did, except for an effect `co `` `columns` must be a number, not "4" ``. Each message names the key that was given, and the command then changes nothing. +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 +`object.envelope.makeWithWarp`, `resetWithWarp` and `options`; `rows`, `cols` and `columns` in +`object.envelope.makeWithMesh`, `resetWithMesh` and `object.mesh.create`; `fidelity` in `object.envelope.options`; +`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] ``. + 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: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fc5bb205d..0bc8018b9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -803,6 +803,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. +- **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]`. + - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. - **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged.