Merge pull request #6467 from dotCooCoo/fix/pathops-param-types

Path commands: Offset Path, Simplify, Split Into Grid, Clean Up, Trap and Contour reject a param of the wrong type
This commit is contained in:
BFlat
2026-11-11 05:25:49 +07:00
committed by GitHub
6 changed files with 188 additions and 39 deletions
+5 -5
View File
@@ -12,8 +12,8 @@ use vectorcraft_doc::NodeKind;
use vectorcraft_render::effects;
use super::super::edit::{copy_above, selected_roots};
use super::super::pathops::{len_param, num_param};
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param};
use super::super::pathops::{length_given, number_given};
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, text_param};
const C: &str = "addon.contour.set";
/// The live effect that does the offsetting (the one Effect › Path › Offset Path applies).
@@ -44,12 +44,12 @@ fn joins_for(corners: Option<&str>) -> Result<&'static str> {
}
fn contour(s: &mut Session, p: &Value) -> Result<Value> {
let offset = len_param(p, "offset").ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
let offset = length_given(p, "offset", C)?.ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
if offset.abs() > MAX_OFFSET {
return Err(bad(C, "offset out of range"));
}
let joins = joins_for(str_param(p, "corners"))?;
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
let joins = joins_for(text_param(p, "corners", C)?)?;
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
let effect = effects::new_effect(EFFECT, &json!({"offset": offset, "joins": joins, "miterLimit": miter}))
.ok_or_else(|| EngineError::Other("Contour: the Offset Path effect is not available".into()))?;
let roots = selected_roots(s)?;
@@ -158,6 +158,39 @@ fn contour_refuses_what_it_cannot_offset() {
assert!(err.contains("select paths or shapes"), "{err}");
}
/// A param of the wrong type is an error naming the param, and leaves the document, the selection
/// and the undo history as they were. Null keeps a param's default, and `offset` still takes a
/// string with a unit.
#[test]
fn contour_refuses_a_param_of_the_wrong_type() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.doc.clone(), st.selection.objects.clone(), st.history.undo.len())
};
let before = state(&s);
for (p, want) in [
(json!({"offset": true}), "`offset` must be a number, not true"),
(json!({"offset": "abc"}), "`offset` must be a number, not \"abc\""),
(json!({"offset": [5]}), "`offset` must be a number, not [5]"),
(json!({"offset": 5, "corners": 3}), "`corners` must be a string"),
(json!({"offset": 5, "miterLimit": "high"}), "`miterLimit` must be a number, not \"high\""),
(json!({"offset": 5, "miterLimit": false}), "`miterLimit` must be a number, not false"),
] {
let r = s.execute("addon.contour.set", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}");
assert!(state(&s) == before, "{p} changed the document, the selection or the undo history");
}
// A null `offset` is a missing one.
let err = s.execute("addon.contour.set", &json!({"offset": null})).unwrap_err().to_string();
assert!(err.contains("missing `offset`"), "{err}");
// Null corners and miter limit keep sharp corners at 4; the offset takes a unit.
let r = s.execute("addon.contour.set", &json!({"offset": "1 in", "corners": null, "miterLimit": null})).unwrap();
let fx = effects_of(&s, ids_of(&r, "created")[0]);
assert_eq!((fx[0].1["offset"].clone(), fx[0].1["joins"].clone(), fx[0].1["miterLimit"].clone()), (json!(72.0), json!("miter"), json!(4.0)));
}
/// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then
/// the gap taken out of the border with Minus Front, leaves a ring between the two.
#[test]
+59 -31
View File
@@ -16,8 +16,8 @@ use vectorcraft_pathops::{BoolOp, PathfinderOp};
use super::edit::selected_roots;
use super::expand::{fill_shape, reshaped, seeded};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection,
number_or, str_param, switch_or,
CommandSpec, EngineError, Result, Session, always, bad, check_switches, checked_ids_or_selection, cmd, has_doc, has_selected_path, has_selection,
number_or, switch_or, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -221,6 +221,25 @@ pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
}
}
/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it, or `None` when `p` leaves it out
/// or gives null. Any other value, such as `true` or `"wide"`, is an error: "`{key}` must be a
/// number, not {v}".
pub(crate) fn number_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None`
/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it.
pub(crate) fn length_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
}
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
@@ -687,18 +706,19 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps};
const ID: &str = "object.pathfinder.trap";
let mut q = p.clone();
if let Some(t) = len_param(p, "thickness") {
if let Some(t) = length_given(p, "thickness", ID)? {
if !(po::TRAP_MIN_WIDTH..=po::TRAP_MAX_WIDTH).contains(&t) {
return Err(bad(ID, "thickness must be from 0.01 to 5000 pt"));
}
q["thickness"] = json!(t);
}
if num_param(p, "heightWidth").is_some_and(|v| !(1.0..=1000.0).contains(&v)) {
if number_given(p, "heightWidth", ID)?.is_some_and(|v| !(1.0..=1000.0).contains(&v)) {
return Err(bad(ID, "heightWidth must be from 1 to 1000 %"));
}
if num_param(p, "tintReduction").is_some_and(|v| !(0.0..=100.0).contains(&v)) {
if number_given(p, "tintReduction", ID)?.is_some_and(|v| !(0.0..=100.0).contains(&v)) {
return Err(bad(ID, "tintReduction must be from 0 to 100 %"));
}
check_switches(p, &["trapsWithProcessColor", "reverseTraps"], ID)?;
// The effect-only settings stay at their defaults.
if let Some(o) = q.as_object_mut() {
o.remove("precision");
@@ -743,21 +763,23 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Offset Path ----------
fn join_param(p: &Value, key: &str) -> po::Join {
match str_param(p, key).map(str::to_ascii_lowercase).as_deref() {
Some("round") => po::Join::Round,
Some("bevel") => po::Join::Bevel,
_ => po::Join::Miter,
fn join_param(p: &Value, key: &str, cmd: &str) -> Result<po::Join> {
match text_param(p, key, cmd)?.map(str::to_ascii_lowercase).as_deref() {
None | Some("miter") => Ok(po::Join::Miter),
Some("round") => Ok(po::Join::Round),
Some("bevel") => Ok(po::Join::Bevel),
Some(_) => Err(bad(cmd, format!("`{key}` must be one of miter, round, bevel"))),
}
}
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let delta = len_param(p, "offset").unwrap_or(10.0);
const C: &str = "object.path.offsetPath";
let delta = length_given(p, "offset", C)?.unwrap_or(10.0);
if delta.abs() > 1e5 {
return Err(bad("object.path.offsetPath", "offset out of range"));
return Err(bad(C, "offset out of range"));
}
let join = join_param(p, "joins");
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
let join = join_param(p, "joins", C)?;
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
let roots = selected_roots(s)?;
let ids = s.edit("Offset Path", |d, sel| {
let (mut out, mut above) = (vec![], vec![]);
@@ -1167,23 +1189,21 @@ pub fn auto_simplify_level(s: &Session) -> Result<f64> {
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.path.simplify";
let (auto, given_level, given_tolerance) = (switch_or(p, "auto", false, C)?, number_given(p, "level", C)?, length_given(p, "tolerance", C)?);
let corner_angle = number_given(p, "cornerAngle", C)?.unwrap_or(30.0).clamp(0.0, 180.0);
let (straight_lines, whole) = (switch_or(p, "straightLines", false, C)?, switch_or(p, "wholePaths", false, C)?);
// With auto, the slider's level is Auto-Simplify's for the selection's size; otherwise it is the level given.
let level = if bool_or(p, "auto", false) {
let level = if auto {
Some(auto_simplify_level(s)?)
} else {
num_param(p, "level").map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL })
given_level.map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL })
};
let tolerance = match level {
Some(l) => po::simplify_level_tolerance(l),
None => len_param(p, "tolerance").unwrap_or(1.0),
None => given_tolerance.unwrap_or(1.0),
};
let opts = po::SimplifyOptions {
tolerance: tolerance.clamp(1e-4, 1e4),
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
straight_lines: bool_or(p, "straightLines", false),
smooth: 0.0,
};
let whole = bool_or(p, "wholePaths", false);
let opts = po::SimplifyOptions { tolerance: tolerance.clamp(1e-4, 1e4), corner_angle_deg: corner_angle, straight_lines, smooth: 0.0 };
// A path with segments direct-selected simplifies only their runs, and keeps those runs (and the
// other anchors it had selected) direct-selected.
let roots = selected_roots(s)?;
@@ -1257,10 +1277,17 @@ fn grid_axis(p: &Value, n: f64, [size, gutter, total]: [&str; 3], extent: f64) -
}
fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
let rows = num_param(p, "rows").unwrap_or(2.0).round();
let cols = num_param(p, "columns").unwrap_or(2.0).round();
const C: &str = "object.path.splitIntoGrid";
let rows = number_given(p, "rows", C)?.unwrap_or(2.0).round();
let cols = number_given(p, "columns", C)?.unwrap_or(2.0).round();
if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) {
return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500"));
return Err(bad(C, "rows and columns must be 1..500"));
}
let add_guides = switch_or(p, "addGuides", false, C)?;
// Each distance [`grid_axis`] reads is a number, a string with a unit or null, checked before the
// selection, so a bad one is an error whatever is selected.
for key in ["height", "rowGutter", "totalHeight", "width", "columnGutter", "totalWidth", "gutter"] {
length_given(p, key, C)?;
}
let too_small = || EngineError::Other("Split Into Grid: select paths large enough for the grid".into());
// The selected paths, back to front: one grid over them all replaces them, where the topmost
@@ -1281,7 +1308,7 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
return Err(too_small());
}
let mut edges: Vec<(bool, f64)> = Vec::new();
if bool_or(p, "addGuides", false) {
if add_guides {
// Row-major cells: the first row has the column edges, each row's first cell the row edges.
let bounds = cells.iter().filter_map(PathData::bounds);
edges.extend(bounds.clone().take(cols).flat_map(|c| [(true, c.x0), (true, c.x1)]));
@@ -1316,9 +1343,10 @@ fn is_painted(a: &Appearance) -> bool {
}
fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
let stray = bool_or(p, "strayPoints", true);
let unpainted = bool_or(p, "unpaintedObjects", true);
let empty_text = bool_or(p, "emptyTextPaths", true);
const C: &str = "object.path.cleanUp";
let stray = switch_or(p, "strayPoints", true, C)?;
let unpainted = switch_or(p, "unpaintedObjects", true, C)?;
let empty_text = switch_or(p, "emptyTextPaths", true, C)?;
let found = {
let d = &s.doc()?.doc;
let mut v = vec![];
+63
View File
@@ -844,6 +844,69 @@ fn clean_up_removes_junk() {
assert!(d.node(unpainted).is_none() && d.node(stray).is_none() && d.node(empty).is_none());
}
/// Offset Path, Simplify, Split Into Grid and Clean Up refuse a param of the wrong type with an
/// error naming the param, and leave the document, the selection and the undo history as they were.
/// Null keeps a param's default, and a distance still takes a string with a unit.
#[test]
fn path_commands_refuse_a_param_of_the_wrong_type() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
s.execute("path.create", &json!({"anchors": [{"x": 5, "y": 5}]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.doc.clone(), st.selection.objects.clone(), st.history.undo.len())
};
let before = state(&s);
let number = |k: &str, v: &str| format!("`{k}` must be a number, not {v}");
let switch = |k: &str| format!("`{k}` must be true or false");
for (cmd, p, want) in [
("object.path.offsetPath", json!({"offset": true}), number("offset", "true")),
("object.path.offsetPath", json!({"offset": "wide"}), number("offset", "\"wide\"")),
("object.path.offsetPath", json!({"offset": 5, "joins": 3}), "`joins` must be a string".to_string()),
("object.path.offsetPath", json!({"offset": 5, "joins": "pointy"}), "`joins` must be one of miter, round, bevel".to_string()),
("object.path.offsetPath", json!({"offset": 5, "miterLimit": [4]}), number("miterLimit", "[4]")),
("object.path.simplify", json!({"auto": "true"}), switch("auto")),
("object.path.simplify", json!({"level": false}), number("level", "false")),
("object.path.simplify", json!({"level": 50, "tolerance": {}}), number("tolerance", "{}")),
("object.path.simplify", json!({"cornerAngle": "sharp"}), number("cornerAngle", "\"sharp\"")),
("object.path.simplify", json!({"straightLines": 1}), switch("straightLines")),
("object.path.simplify", json!({"wholePaths": "no"}), switch("wholePaths")),
("object.path.splitIntoGrid", json!({"rows": "many"}), number("rows", "\"many\"")),
("object.path.splitIntoGrid", json!({"columns": true}), number("columns", "true")),
("object.path.splitIntoGrid", json!({"gutter": false}), number("gutter", "false")),
("object.path.splitIntoGrid", json!({"rowGutter": 2, "gutter": "x"}), number("gutter", "\"x\"")),
("object.path.splitIntoGrid", json!({"height": 5, "totalHeight": [1]}), number("totalHeight", "[1]")),
("object.path.splitIntoGrid", json!({"width": "wide"}), number("width", "\"wide\"")),
("object.path.splitIntoGrid", json!({"columnGutter": true}), number("columnGutter", "true")),
("object.path.splitIntoGrid", json!({"totalWidth": {}}), number("totalWidth", "{}")),
("object.path.splitIntoGrid", json!({"rowGutter": "a"}), number("rowGutter", "\"a\"")),
("object.path.splitIntoGrid", json!({"addGuides": "yes"}), switch("addGuides")),
("object.path.cleanUp", json!({"strayPoints": "false"}), switch("strayPoints")),
("object.path.cleanUp", json!({"unpaintedObjects": 0}), switch("unpaintedObjects")),
("object.path.cleanUp", json!({"emptyTextPaths": "no"}), switch("emptyTextPaths")),
] {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
assert!(state(&s) == before, "{cmd} {p} changed the document, the selection or the undo history");
}
// Null takes the default: a 10 pt offset with mitered joins.
let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": null, "joins": null, "miterLimit": null})).unwrap());
assert!(close(bounds(&s, r[0]), Rect::new(90.0, 90.0, 160.0, 150.0), 1e-2), "{:?}", bounds(&s, r[0]));
// A distance with a unit, and a join named in capitals.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": "0.1 in", "joins": "Bevel"})).unwrap());
assert!(close(bounds(&s, r[0]), Rect::new(92.8, 92.8, 157.2, 147.2), 1e-2), "{:?}", bounds(&s, r[0]));
// Null switches keep Clean Up's defaults, which remove the stray point.
let r = s.execute("object.path.cleanUp", &json!({"strayPoints": null, "unpaintedObjects": null, "emptyTextPaths": null})).unwrap();
assert_eq!(r["removed"], 1);
// Split Into Grid with null counts and a gutter with a unit: two by two, 0.5 in apart.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = ids(&s.execute("object.path.splitIntoGrid", &json!({"rows": null, "columns": null, "gutter": "0.5 in", "addGuides": null})).unwrap());
assert_eq!(r.len(), 4);
assert!(close(bounds(&s, r[0]), Rect::new(100.0, 100.0, 107.0, 102.0), 1e-9), "{:?}", bounds(&s, r[0]));
}
#[test]
fn divide_objects_below_cuts() {
let mut s = session();
+23 -3
View File
@@ -138,15 +138,35 @@ fn no_adjoining_edges_is_a_clear_error_that_changes_nothing() {
}
#[test]
fn out_of_range_and_unreadable_numbers_are_refused_or_defaulted() {
fn out_of_range_and_unreadable_numbers_are_refused() {
let (mut s, _, _) = abutting();
let before = s.doc().unwrap().doc.clone();
let undo_len = s.doc().unwrap().history.undo.len();
for p in [json!({"thickness": 1e300}), json!({"thickness": 0}), json!({"heightWidth": 0}), json!({"tintReduction": 101})] {
assert!(s.execute("object.pathfinder.trap", &p).is_err(), "{p}");
}
// A thickness that isn't a number falls back to the default.
let r = s.execute("object.pathfinder.trap", &json!({"thickness": "NaN", "tintReduction": 100})).unwrap();
// A value of the wrong type is refused with an error naming the param, and changes nothing.
for (p, want) in [
(json!({"thickness": "NaN"}), "`thickness` must be a number, not \"NaN\""),
(json!({"thickness": true}), "`thickness` must be a number, not true"),
(json!({"heightWidth": [100]}), "`heightWidth` must be a number, not [100]"),
(json!({"tintReduction": false}), "`tintReduction` must be a number, not false"),
(json!({"trapsWithProcessColor": "yes"}), "`trapsWithProcessColor` must be true or false"),
(json!({"reverseTraps": 1}), "`reverseTraps` must be true or false"),
] {
let r = s.execute("object.pathfinder.trap", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}");
}
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len);
// Null keeps the default thickness, 0.25 pt; a thickness with a unit is read as a distance.
let r = s.execute("object.pathfinder.trap", &json!({"thickness": null, "reverseTraps": null, "tintReduction": 100})).unwrap();
let bb = traps(&s, &r)[0].geometric_bounds().unwrap();
assert!(near(bb.x1, 50.25), "{bb:?}");
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("object.pathfinder.trap", &json!({"thickness": "0.1 in", "tintReduction": 100})).unwrap();
let bb = traps(&s, &r)[0].geometric_bounds().unwrap();
assert!(near(bb.x1, 57.2), "{bb:?}");
}
#[test]
+5
View File
@@ -3675,6 +3675,11 @@ dialog `simplifySlider` with field `level`) at the Auto-Simplify level for the s
opens the Simplify dialog at the slider's tolerance. When OK confirms the dialog with `retain` ticked, the menu item opens
the dialog on those settings next time.
`object.path.simplify`, `object.path.offsetPath`, `object.path.splitIntoGrid`, `object.path.cleanUp`,
`object.pathfinder.trap` and `addon.contour.set` reject a param of the wrong type, such as `"offset": true`,
`"joins": 3`, `"rows": "many"` or `"addGuides": "yes"`, and change nothing. A distance still takes a string with a unit
(`"5 mm"`). `joins` must be `miter`, `round` or `bevel`. Omitting a param or giving null keeps its default.
## Smooth
`object.path.smooth` smooths the selected paths (or the objects in `ids`), compound path members included, as one undo