From 2f0979f61f3bcdef408df66f2d19446f9dedceda Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:38:05 +0200 Subject: [PATCH] Global Light: reject angles beyond f32 range instead of storing NaN (#4181) (#4182) layer.layerStyle.globalLight cast its params straight to f32, so an angle of 1e39 became inf and rem_euclid turned it into NaN, which was stored, recorded as a history step and fed to every effect using global light. Angle and altitude are now read as f64 and rejected with BadParams when non-finite or beyond f32 range, before the edit. In-range wrapping and the altitude clamp are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/layer_menu_cmds.rs | 10 ++++++++-- crates/engine/src/layer_menu_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index a895210c19..366f9c8d39 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -612,8 +612,14 @@ fn blend_range(v: &Value) -> Option { fn global_light(s: &mut Session, p: &Value) -> Result { let cur = s.active().ok_or(EngineError::NoDocument)?.doc.global_light; - let angle = num(p, "angle", cur.angle); - let altitude = num(p, "altitude", cur.altitude).clamp(0.0, 90.0); + // Read as f64 and reject what f32 can't hold: 1e39 would become inf, and rem_euclid NaN. + let f32_param = |key: &str, default: f32| match p.get(key).and_then(Value::as_f64) { + None => Ok(default), + Some(v) if v.is_finite() && v.abs() <= f64::from(f32::MAX) => Ok(v as f32), + Some(_) => Err(bad("layer.layerStyle.globalLight", format!("{key} must be a finite number"))), + }; + let angle = f32_param("angle", cur.angle)?; + let altitude = f32_param("altitude", cur.altitude)?.clamp(0.0, 90.0); let angle = (angle + 180.0).rem_euclid(360.0) - 180.0; s.edit("Global Light", |doc, _| { doc.global_light.angle = angle; diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 51ad90500f..708bee5fce 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,24 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn global_light_rejects_numbers_beyond_f32() { + let mut s = session(8, "rgb"); + // In-range values still wrap into -180..=180 and clamp to 0..=90. + s.execute("layer.layerStyle.globalLight", json!({"angle": 450, "altitude": 120})).unwrap(); + assert_eq!((doc(&s).global_light.angle, doc(&s).global_light.altitude), (90.0, 90.0)); + s.execute("layer.layerStyle.globalLight", json!({"angle": 540, "altitude": 30})).unwrap(); + assert_eq!((doc(&s).global_light.angle.abs(), doc(&s).global_light.altitude), (180.0, 30.0)); + let before = doc(&s).global_light; + let steps = s.active().unwrap().history.past_len(); + // 1e39 overflowed f32 to inf, which rem_euclid turned into a stored NaN (#4181). + for p in [json!({"angle": 1e39}), json!({"angle": -1e39}), json!({"altitude": 1e39}), json!({"altitude": -1e39})] { + assert!(s.execute("layer.layerStyle.globalLight", p.clone()).is_err(), "{p}"); + assert_eq!(doc(&s).global_light, before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } +} + #[test] fn blending_options_blend_if() { for depth in DEPTHS {