Vector masks: validate feather like pixel masks (#4201) (#4202)

layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became
inf (hard edge, null in info, .pcraft save refused) and any huge value was
accepted. It now takes the same finite 0..=1000 px range and error as
layer.layerMask.edit, checked before the edit so a bad value leaves the mask
and history untouched. The params doc says so.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann 2026-10-13 03:17:14 +02:00 committed by GitHub
parent 63a0e43e74
commit e4f15eecdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 37 additions and 3 deletions

View File

@ -1415,6 +1415,16 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.vectorMask.edit";
let id = layer_id(s, p)?;
let path = p.get("path").map(parse_path).transpose().map_err(|e| bad(CMD, e))?;
// Same range as a pixel mask's feather (`layer.layerMask.edit`), checked before any edit (#4201).
let feather = p
.get("feather")
.map(|v| {
v.as_f64()
.filter(|n| n.is_finite() && (0.0..=1000.0).contains(n))
.map(|n| n as f32)
.ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000"))
})
.transpose()?;
s.edit("Edit Vector Mask", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?;
@ -1430,8 +1440,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(d) = f64p(p, "density") {
vm.density = (d / 100.0).clamp(0.0, 1.0) as f32;
}
if let Some(f) = f64p(p, "feather") {
vm.feather = f.max(0.0) as f32;
if let Some(f) = feather {
vm.feather = f;
}
if let Some(b) = p.get("invert").and_then(Value::as_bool)
&& b
@ -1821,7 +1831,9 @@ pub fn specs() -> Vec<CommandSpec> {
"layer.vectorMask.edit",
"Edit Vector Mask",
[],
leak(format!(r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":px?,"invert":true?}}. {PATH_FORM}"##)),
leak(format!(
r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"##
)),
has_layer,
vector_mask_edit
),

View File

@ -701,6 +701,28 @@ fn vector_mask_commands_and_compositing() {
assert!(s.execute("layer.vectorMask.revealAll", json!({})).is_err());
}
/// #4201: the vector mask's feather takes the pixel mask's range (finite 0..=1000 px); anything
/// else is refused before the edit, so no infinite feather can block a .pcraft save.
#[test]
fn vector_mask_edit_rejects_out_of_range_feather() {
let mut s = session(32, 32, 8);
s.execute("layer.new.layer", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("layer.vectorMask.revealAll", json!({})).unwrap();
let feather = |s: &Session| doc(s).layer(id).unwrap().vector_mask.as_ref().unwrap().feather;
let steps = s.active().unwrap().history.past_len();
for bad in [json!(1e39), json!(-1), json!(1001), json!("x"), json!(null)] {
assert!(s.execute("layer.vectorMask.edit", json!({"feather": bad})).is_err(), "feather {bad}");
assert_eq!(feather(&s), 0.0, "feather {bad}");
assert_eq!(s.active().unwrap().history.past_len(), steps, "feather {bad}");
}
let r = s.execute("layer.vectorMask.edit", json!({"feather": 12.5})).unwrap();
assert_eq!(r["feather"], 12.5);
assert_eq!(feather(&s), 12.5);
s.execute("layer.vectorMask.edit", json!({"feather": 1000})).unwrap();
assert_eq!(feather(&s), 1000.0);
}
#[test]
fn translate_vectors_moves_shapes_and_masks() {
let mut s = session(100, 100, 8);