diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 64bb116ca..378201a87 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -162,7 +162,9 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { } Value::Object(out) } - (Value::Number(b), Value::Number(p)) => { + // Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would + // not deserialize as an integer, and the whole preset would silently not apply. + (Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => { let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0)); serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null) } @@ -267,6 +269,25 @@ mod tests { assert_eq!(lots.light.exposure, 5.0); } + /// A preset with the Grain group (its seed is an integer) used to do nothing at any amount + /// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped. + #[test] + fn amount_keeps_integer_fields_whole() { + let mut a = DevelopSettings::default(); + a.light.exposure = 1.0; + a.grain.amount = 40.0; + a.grain.seed = 12345; + let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy()); + assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed"); + let base = DevelopSettings::default(); + for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] { + let out = p.apply(&base, amount); + assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure); + assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount); + assert_eq!(out.grain.seed, seed, "{amount}: seed"); + } + } + #[test] fn deep_merge_objects() { let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});