Merge pull request #2638 from dotCooCoo/fix/spine-whole-number-index

Blends, meshes and envelopes: an index or count that is not a whole number is refused
This commit is contained in:
BFlat
2026-10-17 17:12:05 +07:00
committed by GitHub
5 changed files with 118 additions and 7 deletions
+17 -7
View File
@@ -402,16 +402,20 @@ const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
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<Arc<Node>>, 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<Value> {
@@ -1303,7 +1308,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
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<usize> {
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<f64> {
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<Value> {
+22
View File
@@ -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();
+25
View File
@@ -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();
+52
View File
@@ -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();
+2
View File
@@ -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.