From 1a9509b776b825b6e841d2190523187489652f60 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 22:53:20 -0700 Subject: [PATCH] Blends, meshes and envelopes: an index or count that is not a whole number is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blend spine commands and the gradient mesh point commands read their anchor, segment or index as a number and dropped its fraction, so object.blend.spine.moveAnchor with anchor 1.5 moved point 1 and reported success. object.envelope.setMeshPoint did the same and also read a negative index as 0. Specified Steps in object.blend.make and object.blend.options, and the rows and columns of object.mesh.create, object.envelope.makeWithMesh and resetWithMesh, dropped their fraction in the same way. These commands now fail with "`` must be a whole number" for a fraction, and object.envelope.setMeshPoint fails with "index must be ≥ 0" for a negative index, as the other point commands do. Whole numbers written as 4.0, as the Blend Options and Make with Mesh dialogs send them, still work. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/live.rs | 24 +++++++---- crates/engine/src/tests_blendspine.rs | 22 ++++++++++ crates/engine/src/tests_envelope_edit.rs | 25 ++++++++++++ crates/engine/src/tests_live.rs | 52 ++++++++++++++++++++++++ docs/target-app-parity.md | 2 + 5 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 316af4faa..82e2adbff 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -402,16 +402,20 @@ const DEFAULT_DISTANCE: f64 = 10.0; fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { 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"); let value = num("value"); let sp = match mode { Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, - Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32), + Some("steps") => match value { + Some(v) => steps("value", v)?, + None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?, + }, Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)), Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))), None => { if let Some(n) = num("steps") { - BlendSpacing::Steps(n as u32) + steps("steps", n)? } else if let Some(d) = num("distance") { BlendSpacing::Distance(d) } else if bool_or(p, "smooth", false) { @@ -959,14 +963,15 @@ fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, f Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) } -/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`. +/// `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)> { let rows = f64_or(p, "rows", default.0 as f64); - let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 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); if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { return Err(bad(cmd, "rows and cols must be 1..50")); } - Ok((rows as u32, cols as u32)) + Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32)) } fn env_make_warp(s: &mut Session, p: &Value) -> Result { @@ -1303,7 +1308,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result { fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.setMeshPoint"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; - let index = f64_req(p, "index", C)? as usize; + let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; s.edit("Move Envelope Point", |d, _| { @@ -1466,7 +1471,12 @@ fn index_param(p: &Value, key: &str, cmd: &str) -> Result { if i < 0.0 { return Err(bad(cmd, "index must be ≥ 0")); } - Ok(i as usize) + Ok(whole(i, key, cmd)? as usize) +} + +/// `x`, the value of `key`, when it is a whole number, and an error otherwise. +fn whole(x: f64, key: &str, cmd: &str) -> Result { + if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) } } fn mesh_set_color(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs index 3c1128a27..9d6f4d44f 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests_blendspine.rs @@ -99,6 +99,28 @@ fn only_points_without_a_key_can_be_deleted() { assert_eq!((sp.spine.unwrap().subpaths[0].anchors.len(), sp.key_anchors), (2, vec![0, 1])); } +/// An `anchor` or `segment` that is not a whole number is an error that leaves the blend as it +/// was; a whole number written as 1.0 still names its point. +#[test] +fn spine_commands_refuse_an_index_that_is_not_a_whole_number() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + let before = node(&s, g); + for (cmd, key, p) in [ + ("object.blend.spine.moveAnchor", "anchor", json!({"id": g.0, "anchor": 1.5, "x": 55, "y": 60})), + ("object.blend.spine.moveAnchor", "anchor", json!({"id": g.0, "anchor": 0.5, "handle": "out", "x": 5, "y": 300})), + ("object.blend.spine.reshapeSegment", "segment", json!({"id": g.0, "segment": 0.5, "dy": 40})), + ("object.blend.spine.removeAnchor", "anchor", json!({"id": g.0, "anchor": 1.5})), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{cmd} {p}: {r:?}"); + assert_eq!(node(&s, g), before, "{cmd} {p}"); + } + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1.0, "x": 55, "y": 60})).unwrap(); + assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p, Point::new(55.0, 60.0)); +} + #[test] fn info_lists_the_spine() { let mut s = session(); diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests_envelope_edit.rs index 26e20f43b..5a1186dc1 100644 --- a/crates/engine/src/tests_envelope_edit.rs +++ b/crates/engine/src/tests_envelope_edit.rs @@ -107,6 +107,31 @@ fn mesh_commands_edit_mesh_envelopes() { assert_eq!(mesh(&s, e).0, 2); } +/// `object.envelope.setMeshPoint` refuses an `index` that is negative or not a whole number, and +/// Make and Reset with Mesh refuse `rows`, `cols` or `columns` that are not whole numbers; the +/// envelope stays as it was, and 4.0 names point 4. +#[test] +fn mesh_envelopes_refuse_an_index_or_count_that_is_not_a_whole_number() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2})); + let before = node(&s, e); + for (index, why) in [(json!(4.5), "`index` must be a whole number"), (json!(-1), "index must be ≥ 0"), (json!(-0.5), "index must be ≥ 0")] { + let r = s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": index, "x": 0, "y": 0})); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == why), "{index}: {r:?}"); + } + for (key, p) in [("rows", json!({"rows": 2.5})), ("cols", json!({"cols": 1.5})), ("columns", json!({"columns": 3.25}))] { + let r = s.execute("object.envelope.resetWithMesh", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{p}: {r:?}"); + } + assert_eq!(node(&s, e), before, "nothing changed"); + let b = id_of(&s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100})).unwrap()); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + assert!(s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2.5})).is_err()); + assert!(matches!(node(&s, b).kind, NodeKind::Path { .. }), "not enveloped"); + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4.0, "x": 210, "y": 130})).unwrap(); + assert_eq!(mesh(&s, e).2[4], Point::new(210.0, 130.0)); +} + #[test] fn mesh_handles_turn_with_the_envelope_and_round_trip() { let mut s = session(); diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index c151ed3e6..5a87fdd27 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -94,6 +94,28 @@ fn blend_specified_steps_count() { assert_eq!(steps_of(&s, g).len(), 5); } +/// Specified Steps that are not a whole number are an error, in Make and in Blend Options, and +/// nothing changes; 4.0 is a whole number of steps. +#[test] +fn blend_steps_that_are_not_a_whole_number_are_refused() { + let mut s = session(); + let (_, _, g) = blend(&mut s, json!({"steps": 3})); + let (a, b) = (rect(&mut s, 0.0, 50.0, 10.0, 10.0), rect(&mut s, 100.0, 50.0, 10.0, 10.0)); + let n = undo_len(&s); + for p in [json!({"ids": [a.0, b.0], "steps": 2.5}), json!({"ids": [a.0, b.0], "spacing": "steps", "value": 2.5})] { + let key = if p.get("value").is_some() { "value" } else { "steps" }; + let r = s.execute("object.blend.make", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{p}: {r:?}"); + } + sel(&mut s, &[g]); + for p in [json!({"steps": 2.5}), json!({"spacing": "steps", "value": 1.5})] { + assert!(s.execute("object.blend.options", &p).is_err(), "{p}"); + } + assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "nothing changed"); + s.execute("object.blend.options", &json!({"steps": 4.0})).unwrap(); + assert_eq!(steps_of(&s, g).len(), 6); +} + #[test] fn blend_mid_step_interpolates_geometry_and_colour() { let mut s = session(); @@ -582,6 +604,36 @@ fn mesh_add_line_and_delete_point() { assert!(s.execute("object.mesh.deletePoint", &json!({"id": a.0, "index": 0})).is_err(), "corner lines can't go"); } +/// A mesh point `index`, and `rows`, `cols` or `columns` for Create Gradient Mesh, that is not a +/// whole number is an error that changes nothing; 4.0 names point 4. +#[test] +fn mesh_commands_refuse_an_index_or_count_that_is_not_a_whole_number() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let (before, n) = (mesh_of(&s, a), undo_len(&s)); + sel(&mut s, &[b]); + for (key, p) in [("rows", json!({"rows": 2.5})), ("cols", json!({"cols": 1.5})), ("columns", json!({"columns": 3.25}))] { + let r = s.execute("object.mesh.create", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{p}: {r:?}"); + } + assert!(matches!(node(&s, b).kind, NodeKind::Path { .. }), "still a path"); + for (cmd, p) in [ + ("object.mesh.setPointColor", json!({"id": a.0, "index": 4.5, "color": "#00ff00"})), + ("object.mesh.movePoint", json!({"id": a.0, "index": 4.5, "x": 10, "y": 10})), + ("object.mesh.movePoint", json!({"id": a.0, "index": 3.5, "x": 10, "y": 10, "handle": 0})), + ("object.mesh.deletePoint", json!({"id": a.0, "index": 4.5})), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`index` must be a whole number"), "{cmd} {p}: {r:?}"); + } + assert_eq!((mesh_of(&s, a), undo_len(&s)), (before, n), "nothing changed"); + s.execute("object.mesh.movePoint", &json!({"id": a.0, "index": 4.0, "x": 40, "y": 60})).unwrap(); + assert_eq!(mesh_of(&s, a).points[4].p, Point::new(40.0, 60.0)); +} + #[test] fn mesh_create_at_point_single_undo() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fb8721c15..f244b7d0e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -739,6 +739,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live objects after document changes:** a blend, envelope or repeat, with or without an effect of its own, is evaluated again when one of these changes: the tiling of a pattern whose tiles an envelope bends (Distort Pattern Fills), a symbol shown inside a symbol, a pattern or an opacity mask, the fonts of its type, a placed document's file once the file is read, and the color settings. A gradient mesh, with or without an effect of its own, is colored again when the color settings change. +- **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. + - **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.