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.