mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
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:
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user