From 00de304ab05a9f430592b677c43c1121a3f5eaa9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:12:05 -0800 Subject: [PATCH] Live Shapes: saved object.setLiveShape steps with a negative radius or out-of-range sides replay again Since #7294, object.setLiveShape refuses a negative `radius` and a `sides` that is not a whole number from 3 to 1000. Session::replay applied the same checks, so a journal entry or action step recorded before that change with such a value stopped with a parameter error. On replay, a negative radius now counts as 0, which is how it drew before. A whole side count below 3 or above 1000 is brought into that range, and a fractional or negative one is ignored. Direct calls still refuse these values. --- crates/engine/src/cmd/object/shape.rs | 17 +++++++++++++++-- crates/engine/src/tests/livecorners.rs | 24 ++++++++++++++++++++++++ docs/mcp.md | 4 +++- docs/target-app-parity.md | 2 +- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index e3909c5935..b30959de99 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -149,9 +149,22 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); - let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?; + // Saved steps ([`Session::replay`]) can hold values a direct call refuses, recorded before these + // checks: a negative radius is 0, a whole side count of 0 or more is brought into 3 to 1000, and + // a fractional or negative one is ignored. A whole number written as 5.0 counts as 5, as in a + // direct call. + let replaying = s.replaying(); + let radius = if replaying { + number("radius", |_| true, "a number")?.map(|r| r.max(0.0)) + } else { + number("radius", |r| r >= 0.0, "a radius of 0 or more")? + }; // A polygon's side count: a whole number from 3 to 1000. - let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64); + let sides = if replaying { + number("sides", |_| true, "a number")?.filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(1000.0) as u64) + } else { + number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64) + }; // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); diff --git a/crates/engine/src/tests/livecorners.rs b/crates/engine/src/tests/livecorners.rs index 190cd0d354..b9e3793913 100644 --- a/crates/engine/src/tests/livecorners.rs +++ b/crates/engine/src/tests/livecorners.rs @@ -126,6 +126,30 @@ fn bad_sides_radii_and_keys_are_errors() { assert_eq!(corners(&s, id).0, [0.0; 4]); } +/// Saved steps play as they did before `sides` and `radius` were checked: a negative radius +/// removes the rounding, a whole side count outside 3 to 1000 is brought into that range, and a +/// fractional or negative side count is ignored. +#[test] +fn saved_steps_with_out_of_range_sides_or_radius_still_play() { + let (mut s, id) = session(); + let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap()); + let replay = |s: &mut Session, p: Value| s.replay("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + run(&mut s, json!({"id": id.0, "radius": 6})); + replay(&mut s, json!({"id": id.0, "radius": -4})); + assert_eq!(corners(&s, id).0, [0.0; 4]); + let sides = |s: &Session| match node_path(s, poly).1 { + Some(LiveShape::Polygon { sides, .. }) => sides, + other => panic!("{other:?}"), + }; + for (n, want) in [(json!(2), 3), (json!(1001), 1000), (json!(2.5), 1000), (json!(-1), 1000), (json!(5.0), 5)] { + replay(&mut s, json!({"ids": [poly.0], "sides": n})); + assert_eq!(sides(&s), want, "{n}"); + } + // A direct call still refuses them. + assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "radius": -4})).is_err()); + assert!(s.execute("object.setLiveShape", &json!({"ids": [poly.0], "sides": 2})).is_err()); +} + #[test] fn direct_selected_corners_round_alone_and_stay_selected() { let (mut s, id) = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..87c4260377 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5546,7 +5546,9 @@ one undo step. `object.setLiveShape` returns a parameter error, and leaves the document and its undo history as they were, for a key it does not take, at the top level (`` unknown parameter `path` ``) or in one of `items` (`` unknown parameter `items[0].corner`; did you mean `items[0].corners`? ``), for a `sides` that is not a whole number -from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding. +from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding. Saved +steps (a journal or an action) replay these values without an error: a negative `radius` counts as 0, a whole `sides` +below 3 or above 1000 counts as 3 or 1000, and a fractional or negative `sides` is ignored. ```json {"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..ea3a8977ad 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1587,7 +1587,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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, Make Sides Equal and relative rounding) refuse a value other than true, false or null, such as "false", and leave the document as it was. Object Properties (its opacity), Live Shape Properties (corner radius and polygon sides), the Transform panel's reference point and Align, Distribute and Distribute Spacing to an artboard (the artboard's number) refuse a value other than a number or null, such as "50", and leave the document as it was. - Live Shape Properties refuses a key it does not take, at the top level or in one of its items, a polygon side count that is not a whole number from 3 to 1000 and a negative corner radius, and leaves the document as it was; the Transform panel's Sides field takes 3 to 1000. + Live Shape Properties refuses a key it does not take, at the top level or in one of its items, a polygon side count that is not a whole number from 3 to 1000 and a negative corner radius, and leaves the document as it was; the Transform panel's Sides field takes 3 to 1000. Saved steps with such a side count or radius still replay, as they did before the check. 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, Copy, Scale Strokes & Effects, Scale Corners, Transform Objects and Transform Patterns), Create Object Mosaic (gray tiles and deleting the image), Crop Image (its trim), Create Trim Marks (all artboards) and Rearrange All Artboards (by column, Move Artwork with Artboard and Transform Patterns) 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. New Artboard refuses a position or size other than a number or null, such as "10", and New Artboard and Artboard Options refuse a name other than text or null, such as 5; each leaves the artboards and the undo history as they were.