From e4f15eecdfbb8bc2add8a37c00a6c199c963c80d Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:14 +0200 Subject: [PATCH] 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) --- crates/engine/src/vector_cmds.rs | 18 +++++++++++++++--- crates/engine/src/vector_cmds/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index b732ff26..e32a7e41 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1415,6 +1415,16 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { 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 { 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 { "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 ), diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 00cb58d8..11a4b511 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -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);