diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index f98880e204..a72075d233 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -713,6 +713,71 @@ fn corner_commands_refuse_bad_params() { } } +#[test] +fn corner_commands_refuse_an_unknown_item_key() { + let mut s = session(); + let id = square(&mut s); + let i = id.0; + let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}"); + let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]); + s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap(); + let remove = json!({"items": [{"id": i, "anchor": [0]}]}); + let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string(); + assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]); + assert_eq!(drawn(&s, id, 0), 10.0); +} + +#[test] +fn corner_items_take_their_documented_keys() { + let mut s = session(); + let id = square(&mut s); + let i = id.0; + let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap(); + assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0)); + // A member without `anchors` takes the request's. + s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap(); + assert_eq!(drawn(&s, id, 2), 6.0); + let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap(); + assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0)); + // A null `ids` beside `items` counts as not given. + s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap(); + assert_eq!(drawn(&s, id, 2), 0.0); +} + +#[test] +fn corner_commands_refuse_items_with_ids() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5}); + let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string(); + assert!(e.contains("give `items` or `ids`, not both"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerSet", &[both]); + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap(); + let both = json!({"items": [{"id": id.0}], "ids": [id.0]}); + let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string(); + assert!(e.contains("give `items` or `ids`, not both"), "{e}"); + refuses(&mut s, "addon.pathtools.cornerRemove", &[both]); +} + +#[test] +fn saved_corner_steps_with_older_item_keys_still_play() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5}); + s.replay("addon.pathtools.cornerSet", &saved).unwrap(); + assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0)); + s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap(); + assert_eq!(drawn(&s, id, 0), 0.0); +} + /// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd /// numbers counting from 1), its inner corners at the odd ones. fn star_path(s: &mut Session) -> NodeId { diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 20e5054aeb..fe8431a6c6 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -10,7 +10,7 @@ //! save or Export for Screens: those are checked against the options of the formats the command //! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the //! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of -//! `object.setLiveShape`. +//! `object.setLiveShape` and `addon.pathtools.cornerSet`. //! //! [`Session::execute`] answers a key the description doesn't name with a parameter error //! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the @@ -256,6 +256,22 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> { } } +/// Parameters a command takes instead of one another: (command, key, other key). Giving both, +/// neither null, is a parameter error ([`check_exclusive_keys`]). +const EXCLUSIVE_KEYS: &[(&str, &str, &str)] = &[("addon.pathtools.cornerSet", "items", "ids"), ("addon.pathtools.cornerRemove", "items", "ids")]; + +/// `params` of command `id` give at most one of each pair in [`EXCLUSIVE_KEYS`], or the parameter +/// error naming the pair. +fn check_exclusive_keys(id: &str, params: &Value) -> Result<()> { + let given = |key: &str| params.get(key).filter(|v| !v.is_null()); + for (_, key, other) in EXCLUSIVE_KEYS.iter().filter(|(c, ..)| *c == id) { + if given(key).is_some() && given(other).is_some() { + return Err(bad(id, format!("give `{key}` or `{other}`, not both"))); + } + } + Ok(()) +} + /// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of /// them, or all of them. pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError { @@ -273,7 +289,11 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError /// Commands whose params hold an array of objects with keys of their own: the command, the key of /// the array and the keys each of its objects takes. -const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])]; +const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[ + ("object.setLiveShape", "items", &["id", "corners"]), + ("addon.pathtools.cornerSet", "items", &["id", "anchors"]), + ("addon.pathtools.cornerRemove", "items", &["id", "anchors"]), +]; /// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or /// the parameter error naming the first other one with its place (`items[0].corner`), and the @@ -848,9 +868,9 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> { check_option_keys(id, "", given, &keys, why_not, &trees) } -/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, -/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an -/// export. A command the engine doesn't have passes: running it reports that. +/// [`check_keys`], [`check_exclusive_keys`] and [`check_member_keys`] for an engine command, the steps of a `command.batch` or an +/// `action.batch` included, [`check_effect_params`] for the effect `effect.apply` adds and +/// [`check_export_options`] for an export. A command the engine doesn't have passes: running it reports that. pub fn check_params(id: &str, params: &Value) -> Result<()> { check_params_in(id, params, false) } @@ -869,6 +889,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> { return Ok(()); } check_keys(id, spec.params, params)?; + check_exclusive_keys(id, params)?; check_member_keys(id, params)?; if in_batch { crate::schemas::check(spec, params, false)?; diff --git a/docs/mcp.md b/docs/mcp.md index 912ac32562..eb7f06da84 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10753,7 +10753,8 @@ Commands: - `addon.pathtools.cornerSet {ids?, anchors?, items?, radius, kind?, every?, minAngle?, maxAngle?}` cuts live corners, the ones Direct Selection's corner widgets and the Corners dialog edit, as one undo step. `ids` (default: the selection) are the paths and `anchors` the anchor indices of each path's outline with no corners cut; without `anchors`, a path's direct-selected - anchors, or all of them. `items: [{id, anchors?}…]` gives each path its own instead. Only anchors where two sides meet + anchors, or all of them. `items: [{id, anchors?}…]` gives each path its own instead; a member key other than `id` + and `anchors`, or `items` given with `ids`, is an error. Only anchors where two sides meet at an angle take a corner: smooth anchors and open ends are skipped, and it is an error if none is left. `radius` is 0 to 1,000,000 pt, drawn no larger than fits; `kind` is `round`, `invertedRound` or `chamfer` (default: each corner's own). `every` (`all`, `odd` or `even`; default `all`) keeps, of those anchors, only the odd- or even-numbered ones, @@ -10763,7 +10764,7 @@ Commands: be more than `maxAngle`, and it is an error, saying so, if no chosen corner is in the range. Object › Plug-ins › Path Precision Tools › Set Corners… (also in the Anchor Inspector's Corners section) sets these with Preview. → `{ids, corners}`. - `addon.pathtools.cornerRemove {ids?, anchors?, items?}` makes those corners sharp again, as one undo step; a plain path - with none left is plain again. → `{ids, corners}`. + with none left is plain again. Its `items` take the same keys as `cornerSet`'s. → `{ids, corners}`. - `addon.pathtools.slideAnchor {id, subpath?, anchor, t}` moves an anchor along its path, the segments beside it refitted so the outline stays (the anchor count and index stay). `t`, -0.98 to 0.98, says where: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before. An anchor at a sharp corner loses it, as diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26681e5b7f..b019a547b7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2064,6 +2064,9 @@ Corners section in the Anchor Inspector sets the radius and kind, counts the sel Set Corners… (in the submenu and the Corners section) sets the radius and kind with Preview, on all the chosen anchors or only the odd- or even-numbered ones (`every`, #5546), and only the corners whose angle is in a range (`minAngle`/`maxAngle`, #5545). +`cornerSet` and `cornerRemove` refuse an `items` member key other than `id` and `anchors`, and `items` given with `ids`; +saved steps still play (`corner_commands_refuse_an_unknown_item_key`, `corner_commands_refuse_items_with_ids`, +`saved_corner_steps_with_older_item_keys_still_play`). Slide Anchor Along Path drags an anchor along its curve, the two segments beside it refitted by the keep-shape kernel (Shift: where the tangent is a multiple of 45°). Commands `addon.pathtools.cornerSet`, `cornerRemove` and `slideAnchor`, each one undo step; `vectorcraft_pathops::addons::pathtools::slide_anchor`. Tests: `addons::pathtools` in