diff --git a/plotters/src/style/color.rs b/plotters/src/style/color.rs index 5721fe74..b5a4a63d 100644 --- a/plotters/src/style/color.rs +++ b/plotters/src/style/color.rs @@ -139,15 +139,29 @@ impl BackendStyle for RGBColor { #[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))] pub struct HSLColor(pub f64, pub f64, pub f64); +impl HSLColor { + #[inline] + pub fn from_degrees(h_deg: f64, s: f64, l: f64) -> Self { + Self(h_deg / 360.0, s, l) + } +} + impl Color for HSLColor { #[inline(always)] #[allow(clippy::many_single_char_names)] fn to_backend_color(&self) -> BackendColor { - let (h, s, l) = ( - self.0.clamp(0.0, 1.0), - self.1.clamp(0.0, 1.0), - self.2.clamp(0.0, 1.0), - ); + // Hue: do not clamp; normalize + // - If >1.0, treat as degrees (divide by 360), then wrap to [0,1) + // - Else, wrap value to [0,1) to accept negatives + let h = if self.0 > 1.0 { + (self.0 / 360.0).rem_euclid(1.0) + } else { + self.0.rem_euclid(1.0) + }; + + // Saturation & lightness remain clamped to valid ranges + let s = self.1.clamp(0.0, 1.0); + let l = self.2.clamp(0.0, 1.0); if s == 0.0 { let value = (l * 255.0).round() as u8; @@ -189,3 +203,29 @@ impl Color for HSLColor { } } } + +#[cfg(test)] +mod hue_robustness_tests { + use super::*; + + #[test] + fn degrees_passed_directly_should_work_for_common_cases() { + let red = HSLColor(0.0, 1.0, 0.5).to_backend_color().rgb; + assert_eq!(red, (255, 0, 0)); + + let green = HSLColor(120.0, 1.0, 0.5).to_backend_color().rgb; + assert_eq!(green, (0, 255, 0)); + + let blue = HSLColor(240.0, 1.0, 0.5).to_backend_color().rgb; + assert_eq!(blue, (0, 0, 255)); + } + + #[test] + fn from_degrees_and_direct_degrees_are_equivalent() { + for ° in &[0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 300.0, 360.0] { + let a = HSLColor(deg, 1.0, 0.5).to_backend_color().rgb; + let b = HSLColor::from_degrees(deg, 1.0, 0.5).to_backend_color().rgb; + assert_eq!(a, b); + } + } +}