munsellspace/
types.rs

1//! Core types for Munsell color space representation.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use crate::error::{MunsellError, Result};
6use crate::semantic_overlay::{self, MunsellSpec};
7
8/// Represents an RGB color with 8-bit components.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct RgbColor {
11    /// Red component (0-255)
12    pub r: u8,
13    /// Green component (0-255)
14    pub g: u8,
15    /// Blue component (0-255)
16    pub b: u8,
17}
18
19impl RgbColor {
20    /// Create a new RGB color.
21    ///
22    /// # Arguments
23    /// * `r` - Red component (0-255)
24    /// * `g` - Green component (0-255)
25    /// * `b` - Blue component (0-255)
26    ///
27    /// # Examples
28    /// ```
29    /// use munsellspace::RgbColor;
30    /// 
31    /// let red = RgbColor::new(255, 0, 0);
32    /// let green = RgbColor::new(0, 255, 0);
33    /// let blue = RgbColor::new(0, 0, 255);
34    /// ```
35    pub fn new(r: u8, g: u8, b: u8) -> Self {
36        Self { r, g, b }
37    }
38    
39    /// Create an RGB color from an array.
40    ///
41    /// # Arguments
42    /// * `rgb` - Array of [R, G, B] values
43    ///
44    /// # Examples
45    /// ```
46    /// use munsellspace::RgbColor;
47    /// 
48    /// let color = RgbColor::from_array([255, 128, 64]);
49    /// assert_eq!(color.r, 255);
50    /// assert_eq!(color.g, 128);
51    /// assert_eq!(color.b, 64);
52    /// ```
53    pub fn from_array(rgb: [u8; 3]) -> Self {
54        Self {
55            r: rgb[0],
56            g: rgb[1],
57            b: rgb[2],
58        }
59    }
60    
61    /// Convert to an array representation.
62    ///
63    /// # Returns
64    /// Array of [R, G, B] values
65    ///
66    /// # Examples
67    /// ```
68    /// use munsellspace::RgbColor;
69    /// 
70    /// let color = RgbColor::new(255, 128, 64);
71    /// let array = color.to_array();
72    /// assert_eq!(array, [255, 128, 64]);
73    /// ```
74    pub fn to_array(self) -> [u8; 3] {
75        [self.r, self.g, self.b]
76    }
77    
78    /// Check if the color is grayscale (R == G == B).
79    ///
80    /// # Returns
81    /// `true` if all components are equal, `false` otherwise
82    ///
83    /// # Examples
84    /// ```
85    /// use munsellspace::RgbColor;
86    /// 
87    /// let gray = RgbColor::new(128, 128, 128);
88    /// assert!(gray.is_grayscale());
89    /// 
90    /// let red = RgbColor::new(255, 0, 0);
91    /// assert!(!red.is_grayscale());
92    /// ```
93    pub fn is_grayscale(self) -> bool {
94        self.r == self.g && self.g == self.b
95    }
96}
97
98impl fmt::Display for RgbColor {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "RGB({}, {}, {})", self.r, self.g, self.b)
101    }
102}
103
104impl From<[u8; 3]> for RgbColor {
105    fn from(rgb: [u8; 3]) -> Self {
106        Self::from_array(rgb)
107    }
108}
109
110impl From<RgbColor> for [u8; 3] {
111    fn from(color: RgbColor) -> Self {
112        color.to_array()
113    }
114}
115
116/// Represents a color in the Munsell color system.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct MunsellColor {
119    /// Complete Munsell notation string (e.g., "5R 4.0/14.0" or "N 5.6/")
120    pub notation: String,
121    /// Hue component (None for neutral colors)
122    pub hue: Option<String>,
123    /// Value (lightness) component (0.0 to 10.0)
124    pub value: f64,
125    /// Chroma (saturation) component (None for neutral colors)
126    pub chroma: Option<f64>,
127}
128
129impl MunsellColor {
130    /// Create a new chromatic Munsell color.
131    ///
132    /// # Arguments
133    /// * `hue` - Hue component (e.g., "5R", "2.5YR")
134    /// * `value` - Value component (0.0 to 10.0)
135    /// * `chroma` - Chroma component (0.0+)
136    ///
137    /// # Examples
138    /// ```
139    /// use munsellspace::MunsellColor;
140    /// 
141    /// let red = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
142    /// assert_eq!(red.notation, "5R 4.0/14.0");
143    /// assert!(!red.is_neutral());
144    /// ```
145    pub fn new_chromatic(hue: String, value: f64, chroma: f64) -> Self {
146        let notation = format!("{} {:.1}/{:.1}", hue, value, chroma);
147        Self {
148            notation,
149            hue: Some(hue),
150            value,
151            chroma: Some(chroma),
152        }
153    }
154    
155    /// Create a new neutral (achromatic) Munsell color.
156    ///
157    /// # Arguments
158    /// * `value` - Value component (0.0 to 10.0)
159    ///
160    /// # Examples
161    /// ```
162    /// use munsellspace::MunsellColor;
163    /// 
164    /// let gray = MunsellColor::new_neutral(5.6);
165    /// assert_eq!(gray.notation, "N 5.6/");
166    /// assert!(gray.is_neutral());
167    /// ```
168    pub fn new_neutral(value: f64) -> Self {
169        let notation = if value == 0.0 {
170            "N 0.0".to_string()
171        } else {
172            format!("N {:.1}/", value)
173        };
174        Self {
175            notation,
176            hue: None,
177            value,
178            chroma: None,
179        }
180    }
181    
182    /// Parse a Munsell notation string into a MunsellColor.
183    ///
184    /// # Arguments
185    /// * `notation` - Munsell notation string (e.g., "5R 4.0/14.0" or "N 5.6/")
186    ///
187    /// # Returns
188    /// Result containing the parsed MunsellColor or an error
189    ///
190    /// # Examples
191    /// ```
192    /// use munsellspace::MunsellColor;
193    /// 
194    /// let color = MunsellColor::from_notation("5R 4.0/14.0").unwrap();
195    /// assert_eq!(color.hue, Some("5R".to_string()));
196    /// assert_eq!(color.value, 4.0);
197    /// assert_eq!(color.chroma, Some(14.0));
198    /// 
199    /// let gray = MunsellColor::from_notation("N 5.6/").unwrap();
200    /// assert!(gray.is_neutral());
201    /// ```
202    pub fn from_notation(notation: &str) -> Result<Self> {
203        let notation = notation.trim();
204        
205        // Handle neutral colors (e.g., "N 5.6/", "N 5.6", or "N 0.0")
206        if notation.starts_with("N ") {
207            let value_part = notation.strip_prefix("N ").unwrap().trim_end_matches('/');
208            let value = value_part.parse::<f64>().map_err(|_| MunsellError::InvalidNotation {
209                notation: notation.to_string(),
210                reason: "Invalid value component in neutral color".to_string(),
211            })?;
212            
213            if !(0.0..=10.0).contains(&value) {
214                return Err(MunsellError::InvalidNotation {
215                    notation: notation.to_string(),
216                    reason: "Value must be between 0.0 and 10.0".to_string(),
217                });
218            }
219            
220            // Preserve original notation format
221            return Ok(Self {
222                notation: notation.to_string(),
223                hue: None,
224                value,
225                chroma: None,
226            });
227        }
228        
229        // Handle chromatic colors (e.g., "5R 4.0/14.0")
230        let parts: Vec<&str> = notation.split_whitespace().collect();
231        if parts.len() != 2 {
232            return Err(MunsellError::InvalidNotation {
233                notation: notation.to_string(),
234                reason: "Expected format: 'HUE VALUE/CHROMA' or 'N VALUE/'".to_string(),
235            });
236        }
237        
238        let hue = parts[0].to_string();
239        
240        // Validate hue format (should be number + valid hue family)
241        if !is_valid_hue_format(&hue) {
242            return Err(MunsellError::InvalidNotation {
243                notation: notation.to_string(),
244                reason: "Invalid hue format. Expected format like '5R', '2.5YR', etc.".to_string(),
245            });
246        }
247        
248        let value_chroma = parts[1];
249        
250        if !value_chroma.contains('/') {
251            return Err(MunsellError::InvalidNotation {
252                notation: notation.to_string(),
253                reason: "Missing '/' separator between value and chroma".to_string(),
254            });
255        }
256        
257        let value_chroma_parts: Vec<&str> = value_chroma.split('/').collect();
258        if value_chroma_parts.len() != 2 {
259            return Err(MunsellError::InvalidNotation {
260                notation: notation.to_string(),
261                reason: "Invalid value/chroma format".to_string(),
262            });
263        }
264        
265        let value = value_chroma_parts[0].parse::<f64>().map_err(|_| MunsellError::InvalidNotation {
266            notation: notation.to_string(),
267            reason: "Invalid value component".to_string(),
268        })?;
269        
270        let chroma = value_chroma_parts[1].parse::<f64>().map_err(|_| MunsellError::InvalidNotation {
271            notation: notation.to_string(),
272            reason: "Invalid chroma component".to_string(),
273        })?;
274        
275        if !(0.0..=10.0).contains(&value) {
276            return Err(MunsellError::InvalidNotation {
277                notation: notation.to_string(),
278                reason: "Value must be between 0.0 and 10.0".to_string(),
279            });
280        }
281        
282        if chroma < 0.0 {
283            return Err(MunsellError::InvalidNotation {
284                notation: notation.to_string(),
285                reason: "Chroma must be non-negative".to_string(),
286            });
287        }
288        
289        Ok(Self::new_chromatic(hue, value, chroma))
290    }
291    
292    /// Check if this is a neutral (achromatic) color.
293    ///
294    /// # Returns
295    /// `true` if the color is neutral (no hue/chroma), `false` otherwise
296    ///
297    /// # Examples
298    /// ```
299    /// use munsellspace::MunsellColor;
300    /// 
301    /// let gray = MunsellColor::new_neutral(5.6);
302    /// assert!(gray.is_neutral());
303    /// 
304    /// let red = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
305    /// assert!(!red.is_neutral());
306    /// ```
307    pub fn is_neutral(&self) -> bool {
308        self.hue.is_none() || self.chroma.is_none()
309    }
310    
311    /// Check if this is a chromatic color.
312    ///
313    /// # Returns
314    /// `true` if the color has hue and chroma, `false` otherwise
315    ///
316    /// # Examples
317    /// ```
318    /// use munsellspace::MunsellColor;
319    /// 
320    /// let red = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
321    /// assert!(red.is_chromatic());
322    /// 
323    /// let gray = MunsellColor::new_neutral(5.6);
324    /// assert!(!gray.is_chromatic());
325    /// ```
326    pub fn is_chromatic(&self) -> bool {
327        !self.is_neutral()
328    }
329    
330    /// Get the hue family (e.g., "R", "YR", "Y").
331    ///
332    /// # Returns
333    /// Optional hue family string, None for neutral colors
334    ///
335    /// # Examples
336    /// ```
337    /// use munsellspace::MunsellColor;
338    ///
339    /// let red = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
340    /// assert_eq!(red.hue_family(), Some("R".to_string()));
341    ///
342    /// let yellow_red = MunsellColor::new_chromatic("2.5YR".to_string(), 6.0, 8.0);
343    /// assert_eq!(yellow_red.hue_family(), Some("YR".to_string()));
344    /// ```
345    pub fn hue_family(&self) -> Option<String> {
346        self.hue.as_ref().map(|h| {
347            // Extract the alphabetic part (hue family)
348            h.chars().filter(|c| c.is_alphabetic()).collect()
349        })
350    }
351
352    /// Convert to MunsellSpec for semantic overlay operations.
353    ///
354    /// # Returns
355    /// A MunsellSpec suitable for semantic overlay queries
356    ///
357    /// # Examples
358    /// ```
359    /// use munsellspace::MunsellColor;
360    ///
361    /// let color = MunsellColor::new_chromatic("5BG".to_string(), 5.0, 8.0);
362    /// let spec = color.to_munsell_spec();
363    /// assert!(spec.is_some());
364    /// ```
365    pub fn to_munsell_spec(&self) -> Option<MunsellSpec> {
366        if self.is_neutral() {
367            // Neutral colors have no hue, chroma = 0
368            Some(MunsellSpec::neutral(self.value))
369        } else {
370            let hue = self.hue.as_ref()?;
371            let chroma = self.chroma?;
372            let hue_number = semantic_overlay::parse_hue_to_number(hue)?;
373            Some(MunsellSpec::new(hue_number, self.value, chroma))
374        }
375    }
376
377    /// Get the best matching semantic overlay name for this color.
378    ///
379    /// Returns the non-basic color name (e.g., "aqua", "coral", "navy") that
380    /// best matches this Munsell color based on Centore's convex polyhedra
381    /// methodology. Returns None if the color doesn't match any semantic overlay.
382    ///
383    /// # Returns
384    /// Optional color name string (e.g., "teal", "peach", "wine")
385    ///
386    /// # Examples
387    /// ```
388    /// use munsellspace::MunsellColor;
389    ///
390    /// // A color in the teal region
391    /// let teal = MunsellColor::new_chromatic("5BG".to_string(), 5.0, 8.0);
392    /// if let Some(name) = teal.semantic_overlay() {
393    ///     println!("This color is: {}", name);
394    /// }
395    /// ```
396    pub fn semantic_overlay(&self) -> Option<&'static str> {
397        let spec = self.to_munsell_spec()?;
398        semantic_overlay::semantic_overlay(&spec)
399    }
400
401    /// Get all matching semantic overlay names for this color.
402    ///
403    /// A color may fall within multiple overlapping semantic regions.
404    /// This method returns all matching names, ordered by sample count
405    /// (most commonly agreed-upon names first).
406    ///
407    /// # Returns
408    /// Vector of matching color names (may be empty)
409    ///
410    /// # Examples
411    /// ```
412    /// use munsellspace::MunsellColor;
413    ///
414    /// let color = MunsellColor::new_chromatic("2.5P".to_string(), 3.0, 10.0);
415    /// let matches = color.matching_overlays();
416    /// for name in matches {
417    ///     println!("Matches: {}", name);
418    /// }
419    /// ```
420    pub fn matching_overlays(&self) -> Vec<&'static str> {
421        match self.to_munsell_spec() {
422            Some(spec) => semantic_overlay::matching_overlays(&spec),
423            None => Vec::new(),
424        }
425    }
426
427    /// Check if this color matches a specific semantic overlay.
428    ///
429    /// # Arguments
430    /// * `overlay_name` - The overlay name to check (case-insensitive)
431    ///
432    /// # Returns
433    /// `true` if the color falls within the specified overlay region
434    ///
435    /// # Examples
436    /// ```
437    /// use munsellspace::MunsellColor;
438    ///
439    /// let color = MunsellColor::new_chromatic("5BG".to_string(), 5.0, 8.0);
440    /// if color.matches_overlay("teal") {
441    ///     println!("This is a teal color!");
442    /// }
443    /// ```
444    pub fn matches_overlay(&self, overlay_name: &str) -> bool {
445        match self.to_munsell_spec() {
446            Some(spec) => semantic_overlay::matches_overlay(&spec, overlay_name),
447            None => false,
448        }
449    }
450
451    /// Find the closest semantic overlay to this color.
452    ///
453    /// Unlike `semantic_overlay()` which requires the color to be inside
454    /// the overlay region, this method finds the nearest overlay by
455    /// Euclidean distance to the centroid, regardless of containment.
456    ///
457    /// # Returns
458    /// Tuple of (overlay_name, distance) for the closest overlay,
459    /// or None if conversion fails
460    ///
461    /// # Examples
462    /// ```
463    /// use munsellspace::MunsellColor;
464    ///
465    /// let color = MunsellColor::new_chromatic("5R".to_string(), 5.0, 10.0);
466    /// if let Some((name, distance)) = color.closest_overlay() {
467    ///     println!("Closest overlay: {} (distance: {:.2})", name, distance);
468    /// }
469    /// ```
470    pub fn closest_overlay(&self) -> Option<(&'static str, f64)> {
471        let spec = self.to_munsell_spec()?;
472        semantic_overlay::closest_overlay(&spec)
473    }
474}
475
476impl fmt::Display for MunsellColor {
477    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478        write!(f, "{}", self.notation)
479    }
480}
481
482/// Represents an ISCC-NBS color name with all associated metadata.
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484pub struct IsccNbsName {
485    /// ISCC-NBS color number (1-267)
486    pub color_number: u16,
487    /// Full descriptor (e.g., "vivid pink")
488    pub descriptor: String,
489    /// Base color name (e.g., "pink")
490    pub color_name: String,
491    /// Optional modifier (e.g., "vivid", None for "black"/"white")
492    pub modifier: Option<String>,
493    /// Revised color name constructed from modifier rules
494    pub revised_name: String,
495    /// Shade (last word of revised name)
496    pub shade: String,
497}
498
499impl IsccNbsName {
500    /// Create a new ISCC-NBS color name.
501    ///
502    /// # Arguments
503    /// * `color_number` - ISCC-NBS color number (1-267)
504    /// * `descriptor` - Full ISCC-NBS descriptor
505    /// * `color_name` - Base color name
506    /// * `modifier` - Optional modifier string
507    /// * `revised_color` - Revised color name from dataset
508    ///
509    /// # Examples
510    /// ```
511    /// use munsellspace::IsccNbsName;
512    /// 
513    /// let vivid_pink = IsccNbsName::new(
514    ///     1,
515    ///     "vivid pink".to_string(),
516    ///     "pink".to_string(),
517    ///     Some("vivid".to_string()),
518    ///     "pink".to_string()
519    /// );
520    /// assert_eq!(vivid_pink.shade, "pink");
521    /// ```
522    pub fn new(
523        color_number: u16,
524        descriptor: String,
525        color_name: String,
526        modifier: Option<String>,
527        revised_color: String,
528    ) -> Self {
529        // Apply ISCC-NBS naming transformation rules
530        let revised_name = Self::apply_naming_rules(&color_name, &modifier, &revised_color);
531        let shade = Self::extract_shade(&revised_name);
532        
533        Self {
534            color_number,
535            descriptor,
536            color_name,
537            modifier,
538            revised_name,
539            shade,
540        }
541    }
542    
543    /// Apply ISCC-NBS naming transformation rules.
544    fn apply_naming_rules(color_name: &str, modifier: &Option<String>, revised_color: &str) -> String {
545        match modifier.as_deref() {
546            None => {
547                // No modifier for white/black
548                if color_name == "white" || color_name == "black" {
549                    return color_name.to_string();
550                }
551                revised_color.to_string()
552            }
553            Some(mod_str) => {
554                // Handle "-ish" transformation rules
555                if mod_str == "-ish white" {
556                    // "pink" + "-ish white" → "pinkish white"
557                    format!("{}ish white", apply_ish_rules(color_name))
558                } else if mod_str == "-ish gray" {
559                    // "blue" + "-ish gray" → "bluish gray"
560                    format!("{}ish gray", apply_ish_rules(color_name))
561                } else if mod_str.starts_with("dark -ish") {
562                    // "green" + "dark -ish gray" → "dark greenish gray"
563                    let base_mod = mod_str.strip_prefix("dark -ish ").unwrap_or("");
564                    format!("dark {}ish {}", apply_ish_rules(color_name), base_mod)
565                } else {
566                    // Standard modifier + color
567                    format!("{} {}", mod_str, revised_color)
568                }
569            }
570        }
571    }
572    
573    /// Extract the shade (last word) from a revised color name.
574    fn extract_shade(revised_name: &str) -> String {
575        revised_name
576            .split_whitespace()
577            .last()
578            .unwrap_or(revised_name)
579            .to_string()
580    }
581}
582
583/// Apply "-ish" transformation rules with special cases.
584fn apply_ish_rules(color_name: &str) -> String {
585    match color_name {
586        "red" => "reddish".to_string(),  // Double 'd' exception
587        "olive" => "olive".to_string(),  // No change exception
588        other => format!("{}ish", other),
589    }
590}
591
592impl fmt::Display for IsccNbsName {
593    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
594        write!(f, "{}", self.descriptor)
595    }
596}
597
598/// Represents a point in Munsell color space for polygon definition.
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
600pub struct MunsellPoint {
601    /// Starting hue boundary (e.g., "1R")
602    pub hue1: String,
603    /// Ending hue boundary (e.g., "4R")
604    pub hue2: String,
605    /// Chroma coordinate (can be >15 for open-ended regions)
606    pub chroma: f64,
607    /// Value coordinate (0-10)
608    pub value: f64,
609    /// Whether this represents an open-ended chroma region
610    pub is_open_chroma: bool,
611}
612
613impl MunsellPoint {
614    /// Create a new Munsell point for polygon boundary definition.
615    ///
616    /// # Arguments
617    /// * `hue1` - Starting hue boundary (e.g., "1R")
618    /// * `hue2` - Ending hue boundary (e.g., "4R")
619    /// * `chroma` - Chroma coordinate value
620    /// * `value` - Value coordinate (0-10)
621    /// * `is_open_chroma` - Whether this represents an open-ended chroma region
622    ///
623    /// # Examples
624    /// ```
625    /// use munsellspace::MunsellPoint;
626    /// 
627    /// let point = MunsellPoint::new(
628    ///     "5R".to_string(), 
629    ///     "10R".to_string(), 
630    ///     14.0, 
631    ///     6.0, 
632    ///     false
633    /// );
634    /// assert_eq!(point.chroma, 14.0);
635    /// assert_eq!(point.value, 6.0);
636    /// ```
637    pub fn new(hue1: String, hue2: String, chroma: f64, value: f64, is_open_chroma: bool) -> Self {
638        Self {
639            hue1,
640            hue2,
641            chroma,
642            value,
643            is_open_chroma,
644        }
645    }
646    
647    /// Parse chroma value from string, handling ">15" open-ended notation.
648    ///
649    /// # Arguments
650    /// * `chroma_str` - Chroma value as string (e.g., "12.0" or ">15")
651    ///
652    /// # Returns
653    /// Tuple of (chroma_value, is_open_ended)
654    ///
655    /// # Examples
656    /// ```
657    /// use munsellspace::MunsellPoint;
658    /// 
659    /// let (chroma, open) = MunsellPoint::parse_chroma("12.5");
660    /// assert_eq!(chroma, 12.5);
661    /// assert!(!open);
662    /// 
663    /// let (chroma, open) = MunsellPoint::parse_chroma(">15");
664    /// assert_eq!(chroma, 15.0);
665    /// assert!(open);
666    /// ```
667    pub fn parse_chroma(chroma_str: &str) -> (f64, bool) {
668        if chroma_str.starts_with('>') {
669            let value = chroma_str[1..].parse::<f64>().unwrap_or(15.0);
670            (value, true)
671        } else {
672            let value = chroma_str.parse::<f64>().unwrap_or(0.0);
673            (value, false)
674        }
675    }
676}
677
678/// Represents an ISCC-NBS color polygon in Munsell space.
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680pub struct IsccNbsPolygon {
681    /// ISCC-NBS color number (1-267)
682    pub color_number: u16,
683    /// ISCC-NBS descriptor
684    pub descriptor: String,
685    /// Base color name
686    pub color_name: String,
687    /// Optional modifier
688    pub modifier: Option<String>,
689    /// Revised color name
690    pub revised_color: String,
691    /// Polygon boundary points
692    pub points: Vec<MunsellPoint>,
693}
694
695impl IsccNbsPolygon {
696    /// Create a new ISCC-NBS color polygon.
697    ///
698    /// # Arguments
699    /// * `color_number` - ISCC-NBS color number (1-267)
700    /// * `descriptor` - Full ISCC-NBS descriptor (e.g., "vivid pink")
701    /// * `color_name` - Base color name (e.g., "pink")
702    /// * `modifier` - Optional modifier string
703    /// * `revised_color` - Revised color name from dataset
704    /// * `points` - Vector of boundary points defining the polygon
705    ///
706    /// # Examples
707    /// ```
708    /// use munsellspace::{IsccNbsPolygon, MunsellPoint};
709    /// 
710    /// let points = vec![
711    ///     MunsellPoint::new("5R".to_string(), "10R".to_string(), 14.0, 4.0, false),
712    ///     MunsellPoint::new("10R".to_string(), "5YR".to_string(), 16.0, 5.0, false),
713    /// ];
714    /// 
715    /// let polygon = IsccNbsPolygon::new(
716    ///     1,
717    ///     "vivid red".to_string(),
718    ///     "red".to_string(),
719    ///     Some("vivid".to_string()),
720    ///     "red".to_string(),
721    ///     points
722    /// );
723    /// assert_eq!(polygon.color_number, 1);
724    /// ```
725    pub fn new(
726        color_number: u16,
727        descriptor: String,
728        color_name: String,
729        modifier: Option<String>,
730        revised_color: String,
731        points: Vec<MunsellPoint>,
732    ) -> Self {
733        Self {
734            color_number,
735            descriptor,
736            color_name,
737            modifier,
738            revised_color,
739            points,
740        }
741    }
742    
743    /// Check if a Munsell color point is contained within this polygon.
744    ///
745    /// # Arguments
746    /// * `munsell` - The Munsell color to test
747    ///
748    /// # Returns
749    /// `true` if the point is within the polygon boundaries
750    pub fn contains_point(&self, munsell: &MunsellColor) -> bool {
751        // Handle neutral colors
752        if munsell.is_neutral() {
753            return self.contains_neutral_point(munsell.value);
754        }
755        
756        let hue = munsell.hue.as_ref().unwrap();
757        let value = munsell.value;
758        let chroma = munsell.chroma.unwrap_or(0.0);
759        
760        // Convert hue to degrees for comparison
761        let hue_degrees = parse_hue_to_degrees(hue);
762        
763        // Check if point is within any of the polygon's hue-value-chroma regions
764        self.is_point_in_polygon(hue_degrees, value, chroma)
765    }
766    
767    /// Check if a neutral color point is within this polygon.
768    fn contains_neutral_point(&self, value: f64) -> bool {
769        // Neutral colors (N) typically map to gray categories or white/black
770        // For simplicity, check if any polygon point has chroma close to 0
771        self.points.iter().any(|point| {
772            point.chroma <= 1.0 && (point.value - value).abs() <= 1.0
773        })
774    }
775    
776    /// Determine if a point is within the polygon using ray casting algorithm.
777    fn is_point_in_polygon(&self, hue_degrees: f64, value: f64, chroma: f64) -> bool {
778        // For each polygon region, check hue range and value-chroma boundaries
779        let mut hue_ranges: Vec<(f64, f64)> = Vec::new();
780        let mut vc_points: Vec<(f64, f64)> = Vec::new();
781        
782        // Extract hue ranges and value-chroma points
783        for point in &self.points {
784            let hue1_deg = parse_hue_to_degrees(&point.hue1);
785            let hue2_deg = parse_hue_to_degrees(&point.hue2);
786            hue_ranges.push((hue1_deg, hue2_deg));
787            vc_points.push((point.value, point.chroma));
788        }
789        
790        // Check if hue is within any of the hue ranges
791        let hue_in_range = hue_ranges.iter().any(|(h1, h2)| {
792            is_hue_in_circular_range(hue_degrees, *h1, *h2)
793        });
794        
795        if !hue_in_range {
796            return false;
797        }
798        
799        // Use ray casting algorithm for value-chroma polygon
800        ray_casting_point_in_polygon(value, chroma, &vc_points)
801    }
802}
803
804/// Convert Munsell hue notation to degrees (0-360).
805fn parse_hue_to_degrees(hue: &str) -> f64 {
806    let hue_families = [
807        ("R", 0.0), ("YR", 36.0), ("Y", 72.0), ("GY", 108.0), ("G", 144.0),
808        ("BG", 180.0), ("B", 216.0), ("PB", 252.0), ("P", 288.0), ("RP", 324.0)
809    ];
810    
811    // Extract family from end of hue string
812    let family = hue_families
813        .iter()
814        .find(|(fam, _)| hue.ends_with(fam))
815        .map(|(_, deg)| *deg)
816        .unwrap_or(0.0);
817    
818    // Extract number from beginning
819    let number_str = hue.chars()
820        .take_while(|c| c.is_ascii_digit() || *c == '.')
821        .collect::<String>();
822    
823    let number = number_str.parse::<f64>().unwrap_or(5.0);
824    
825    // Each step is 3.6 degrees (36/10), centered at 5
826    family + (number - 5.0) * 3.6
827}
828
829/// Check if a hue angle is within a circular range.
830fn is_hue_in_circular_range(hue: f64, start: f64, end: f64) -> bool {
831    let normalized_hue = hue % 360.0;
832    let normalized_start = start % 360.0;
833    let normalized_end = end % 360.0;
834    
835    if normalized_start <= normalized_end {
836        normalized_hue >= normalized_start && normalized_hue <= normalized_end
837    } else {
838        // Range crosses 0/360 boundary
839        normalized_hue >= normalized_start || normalized_hue <= normalized_end
840    }
841}
842
843/// Ray casting algorithm to determine if a point is inside a polygon.
844fn ray_casting_point_in_polygon(test_x: f64, test_y: f64, vertices: &[(f64, f64)]) -> bool {
845    let mut inside = false;
846    let n = vertices.len();
847    
848    if n < 3 {
849        return false;
850    }
851    
852    let mut j = n - 1;
853    for i in 0..n {
854        let (xi, yi) = vertices[i];
855        let (xj, yj) = vertices[j];
856        
857        if ((yi > test_y) != (yj > test_y)) &&
858           (test_x < (xj - xi) * (test_y - yi) / (yj - yi) + xi) {
859            inside = !inside;
860        }
861        j = i;
862    }
863    
864    inside
865}
866
867/// Validates that a hue string has the correct format (number + valid hue family).
868fn is_valid_hue_format(hue: &str) -> bool {
869    // Valid hue families - order by length (longest first) to avoid matching "B" when we want "PB"
870    let mut valid_families = ["R", "YR", "Y", "GY", "G", "BG", "B", "PB", "P", "RP"];
871    valid_families.sort_by_key(|s| std::cmp::Reverse(s.len()));
872    
873    // Find which family it ends with (checking longest first)
874    let family = valid_families.iter()
875        .find(|&&family| hue.ends_with(family));
876    
877    let family = match family {
878        Some(f) => f,
879        None => return false,
880    };
881    
882    // Extract the numeric part
883    let numeric_part = hue.strip_suffix(family).unwrap_or("");
884    
885    // Check if numeric part is empty or invalid
886    if numeric_part.is_empty() {
887        return false;
888    }
889    
890    // Parse numeric part - should be a valid float in range 0.0-10.0 (inclusive)
891    match numeric_part.parse::<f64>() {
892        Ok(num) => num >= 0.0 && num <= 10.0,
893        Err(_) => false,
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900
901    #[test]
902    fn test_rgb_color() {
903        let color = RgbColor::new(255, 128, 64);
904        assert_eq!(color.r, 255);
905        assert_eq!(color.g, 128);
906        assert_eq!(color.b, 64);
907        assert!(!color.is_grayscale());
908        
909        let gray = RgbColor::new(128, 128, 128);
910        assert!(gray.is_grayscale());
911    }
912
913    #[test]
914    fn test_munsell_color_chromatic() {
915        let color = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
916        assert_eq!(color.notation, "5R 4.0/14.0");
917        assert!(!color.is_neutral());
918        assert!(color.is_chromatic());
919        assert_eq!(color.hue_family(), Some("R".to_string()));
920    }
921
922    #[test]
923    fn test_munsell_color_neutral() {
924        let color = MunsellColor::new_neutral(5.6);
925        assert_eq!(color.notation, "N 5.6/");
926        assert!(color.is_neutral());
927        assert!(!color.is_chromatic());
928        assert_eq!(color.hue_family(), None);
929    }
930
931    #[test]
932    fn test_munsell_parsing() {
933        let color = MunsellColor::from_notation("5R 4.0/14.0").unwrap();
934        assert_eq!(color.hue, Some("5R".to_string()));
935        assert_eq!(color.value, 4.0);
936        assert_eq!(color.chroma, Some(14.0));
937
938        let gray = MunsellColor::from_notation("N 5.6/").unwrap();
939        assert!(gray.is_neutral());
940        assert_eq!(gray.value, 5.6);
941    }
942
943    #[test]
944    fn test_rgb_color_edge_cases() {
945        // Test boundary values
946        let black = RgbColor::new(0, 0, 0);
947        assert!(black.is_grayscale());
948        assert_eq!(black.to_array(), [0, 0, 0]);
949        
950        let white = RgbColor::new(255, 255, 255);
951        assert!(white.is_grayscale());
952        assert_eq!(white.to_array(), [255, 255, 255]);
953        
954        // Test various grayscale values
955        for i in 0..=255 {
956            let gray = RgbColor::new(i, i, i);
957            assert!(gray.is_grayscale());
958        }
959        
960        // Test non-grayscale combinations
961        let red = RgbColor::new(255, 0, 0);
962        assert!(!red.is_grayscale());
963        
964        let green = RgbColor::new(0, 255, 0);
965        assert!(!green.is_grayscale());
966        
967        let blue = RgbColor::new(0, 0, 255);
968        assert!(!blue.is_grayscale());
969    }
970
971    #[test]
972    fn test_munsell_color_edge_cases() {
973        // Test zero chroma
974        let zero_chroma = MunsellColor::new_chromatic("5R".to_string(), 5.0, 0.0);
975        assert_eq!(zero_chroma.notation, "5R 5.0/0.0");
976        assert!(zero_chroma.is_chromatic());
977        
978        // Test high chroma
979        let high_chroma = MunsellColor::new_chromatic("5R".to_string(), 5.0, 20.0);
980        assert_eq!(high_chroma.notation, "5R 5.0/20.0");
981        
982        // Test boundary values
983        let min_value = MunsellColor::new_chromatic("5R".to_string(), 0.0, 10.0);
984        assert_eq!(min_value.value, 0.0);
985        
986        let max_value = MunsellColor::new_chromatic("5R".to_string(), 10.0, 10.0);
987        assert_eq!(max_value.value, 10.0);
988    }
989
990    #[test]
991    fn test_munsell_color_neutral_edge_cases() {
992        // Test boundary neutral values
993        let black_neutral = MunsellColor::new_neutral(0.0);
994        assert_eq!(black_neutral.notation, "N 0.0");
995        assert!(black_neutral.is_neutral());
996        assert!(!black_neutral.is_chromatic());
997        
998        let white_neutral = MunsellColor::new_neutral(10.0);
999        assert_eq!(white_neutral.notation, "N 10.0/");
1000        
1001        // Test fractional values
1002        let mid_neutral = MunsellColor::new_neutral(5.5);
1003        assert_eq!(mid_neutral.notation, "N 5.5/");
1004    }
1005
1006    #[test]
1007    fn test_munsell_parsing_variants() {
1008        // Test different hue families
1009        let hue_families = ["R", "YR", "Y", "GY", "G", "BG", "B", "PB", "P", "RP"];
1010        for family in &hue_families {
1011            let notation = format!("5{} 5.0/10.0", family);
1012            let color = MunsellColor::from_notation(&notation).unwrap();
1013            assert_eq!(color.hue_family(), Some(family.to_string()));
1014            assert_eq!(color.value, 5.0);
1015            assert_eq!(color.chroma, Some(10.0));
1016        }
1017        
1018        // Test different hue numbers
1019        for hue_num in [2.5, 5.0, 7.5, 10.0] {
1020            let notation = format!("{}R 5.0/10.0", hue_num);
1021            let color = MunsellColor::from_notation(&notation).unwrap();
1022            assert!(color.hue.as_ref().unwrap().contains("R"));
1023        }
1024        
1025        // Test decimal values
1026        let precise = MunsellColor::from_notation("5.5R 6.25/12.75").unwrap();
1027        assert_eq!(precise.value, 6.25);
1028        assert_eq!(precise.chroma, Some(12.75));
1029    }
1030
1031    #[test]
1032    fn test_munsell_parsing_invalid_cases() {
1033        // Test invalid notations
1034        assert!(MunsellColor::from_notation("").is_err());
1035        assert!(MunsellColor::from_notation("invalid").is_err());
1036        assert!(MunsellColor::from_notation("5X 5.0/10.0").is_err()); // Invalid hue family
1037        assert!(MunsellColor::from_notation("R 5.0/10.0").is_err()); // Missing hue number
1038        assert!(MunsellColor::from_notation("5R /10.0").is_err()); // Missing value
1039        assert!(MunsellColor::from_notation("5R 5.0/").is_err()); // Missing chroma for chromatic
1040        assert!(MunsellColor::from_notation("5R -1.0/10.0").is_err()); // Negative value
1041        assert!(MunsellColor::from_notation("5R 5.0/-1.0").is_err()); // Negative chroma
1042        assert!(MunsellColor::from_notation("N /").is_err()); // Missing value for neutral
1043        assert!(MunsellColor::from_notation("N 5.0/10.0").is_err()); // Chroma for neutral
1044    }
1045
1046    #[test]
1047    fn test_munsell_color_display() {
1048        let chromatic = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
1049        assert_eq!(format!("{}", chromatic), "5R 4.0/14.0");
1050        
1051        let neutral = MunsellColor::new_neutral(5.6);
1052        assert_eq!(format!("{}", neutral), "N 5.6/");
1053    }
1054
1055    #[test]
1056    fn test_munsell_color_debug() {
1057        let color = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
1058        let debug_str = format!("{:?}", color);
1059        assert!(debug_str.contains("MunsellColor"));
1060        assert!(debug_str.contains("5R"));
1061        assert!(debug_str.contains("4"));
1062        assert!(debug_str.contains("14"));
1063    }
1064
1065    #[test]
1066    fn test_munsell_color_clone() {
1067        let original = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
1068        let cloned = original.clone();
1069        assert_eq!(original.notation, cloned.notation);
1070        assert_eq!(original.hue, cloned.hue);
1071        assert_eq!(original.value, cloned.value);
1072        assert_eq!(original.chroma, cloned.chroma);
1073    }
1074
1075    #[test]
1076    fn test_rgb_color_display() {
1077        let color = RgbColor::new(255, 128, 64);
1078        assert_eq!(format!("{}", color), "RGB(255, 128, 64)");
1079    }
1080
1081    #[test]
1082    fn test_rgb_color_debug() {
1083        let color = RgbColor::new(255, 128, 64);
1084        let debug_str = format!("{:?}", color);
1085        assert!(debug_str.contains("RgbColor"));
1086        assert!(debug_str.contains("255"));
1087        assert!(debug_str.contains("128"));
1088        assert!(debug_str.contains("64"));
1089    }
1090
1091    #[test]
1092    fn test_rgb_color_clone() {
1093        let original = RgbColor::new(255, 128, 64);
1094        let cloned = original.clone();
1095        assert_eq!(original.r, cloned.r);
1096        assert_eq!(original.g, cloned.g);
1097        assert_eq!(original.b, cloned.b);
1098    }
1099
1100    #[test]
1101    fn test_rgb_color_equality() {
1102        let color1 = RgbColor::new(255, 128, 64);
1103        let color2 = RgbColor::new(255, 128, 64);
1104        let color3 = RgbColor::new(255, 128, 65);
1105        
1106        assert_eq!(color1, color2);
1107        assert_ne!(color1, color3);
1108    }
1109
1110    #[test]
1111    fn test_munsell_color_equality() {
1112        let color1 = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
1113        let color2 = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
1114        let color3 = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.1);
1115        
1116        assert_eq!(color1, color2);
1117        assert_ne!(color1, color3);
1118    }
1119
1120    #[test]
1121    fn test_munsell_point_functionality() {
1122        let point = MunsellPoint {
1123            hue1: "5R".to_string(),
1124            hue2: "7R".to_string(),
1125            value: 6.0,
1126            chroma: 12.0,
1127            is_open_chroma: false,
1128        };
1129        
1130        assert_eq!(point.hue1, "5R");
1131        assert_eq!(point.hue2, "7R");
1132        assert_eq!(point.value, 6.0);
1133        assert_eq!(point.chroma, 12.0);
1134        assert!(!point.is_open_chroma);
1135        
1136        // Test cloning
1137        let cloned = point.clone();
1138        assert_eq!(point.hue1, cloned.hue1);
1139        assert_eq!(point.hue2, cloned.hue2);
1140        assert_eq!(point.value, cloned.value);
1141        assert_eq!(point.chroma, cloned.chroma);
1142        assert_eq!(point.is_open_chroma, cloned.is_open_chroma);
1143    }
1144
1145    #[test]
1146    fn test_iscc_nbs_name_functionality() {
1147        let name = IsccNbsName {
1148            color_number: 34,
1149            descriptor: "Strong".to_string(),
1150            color_name: "Red".to_string(),
1151            modifier: None,
1152            revised_name: "Strong Red".to_string(),
1153            shade: "Red".to_string(),
1154        };
1155        
1156        assert_eq!(name.color_number, 34);
1157        assert_eq!(name.color_name, "Red");
1158        assert_eq!(name.revised_name, "Strong Red");
1159        
1160        // Test cloning
1161        let cloned = name.clone();
1162        assert_eq!(name.color_number, cloned.color_number);
1163        assert_eq!(name.color_name, cloned.color_name);
1164        assert_eq!(name.revised_name, cloned.revised_name);
1165    }
1166
1167    #[test]
1168    fn test_iscc_nbs_polygon_functionality() {
1169        let polygon = IsccNbsPolygon {
1170            color_number: 34,
1171            descriptor: "Strong".to_string(),
1172            color_name: "Red".to_string(),
1173            modifier: None,
1174            revised_color: "Strong Red".to_string(),
1175            points: vec![
1176                MunsellPoint {
1177                    hue1: "5R".to_string(),
1178                    hue2: "7R".to_string(),
1179                    value: 5.0,
1180                    chroma: 10.0,
1181                    is_open_chroma: false,
1182                }
1183            ],
1184        };
1185
1186        assert_eq!(polygon.color_number, 34);
1187        assert_eq!(polygon.color_name, "Red");
1188        assert_eq!(polygon.revised_color, "Strong Red");
1189        assert_eq!(polygon.points.len(), 1);
1190
1191        // Test cloning
1192        let cloned = polygon.clone();
1193        assert_eq!(polygon.color_number, cloned.color_number);
1194        assert_eq!(polygon.color_name, cloned.color_name);
1195        assert_eq!(polygon.revised_color, cloned.revised_color);
1196        assert_eq!(polygon.points.len(), cloned.points.len());
1197    }
1198
1199    #[test]
1200    fn test_munsell_color_to_munsell_spec() {
1201        // Test chromatic color conversion
1202        let chromatic = MunsellColor::new_chromatic("5R".to_string(), 4.0, 14.0);
1203        let spec = chromatic.to_munsell_spec();
1204        assert!(spec.is_some());
1205        let spec = spec.unwrap();
1206        assert_eq!(spec.value, 4.0);
1207        assert_eq!(spec.chroma, 14.0);
1208        // 5R: R family_idx=0, family_start=0, hue_number = 0 + 5/2.5 = 2.0
1209        assert!((spec.hue_number - 2.0).abs() < 0.01);
1210
1211        // Test neutral color conversion
1212        let neutral = MunsellColor::new_neutral(5.0);
1213        let spec = neutral.to_munsell_spec();
1214        assert!(spec.is_some());
1215        let spec = spec.unwrap();
1216        assert_eq!(spec.value, 5.0);
1217        assert_eq!(spec.chroma, 0.0);
1218    }
1219
1220    #[test]
1221    fn test_munsell_color_semantic_overlay() {
1222        // Test with a color that might match an overlay
1223        // Using teal centroid: 5BG 5.0/8.0
1224        let teal = MunsellColor::new_chromatic("5BG".to_string(), 5.0, 8.0);
1225
1226        // Should be able to convert
1227        assert!(teal.to_munsell_spec().is_some());
1228
1229        // closest_overlay should always return something
1230        let closest = teal.closest_overlay();
1231        assert!(closest.is_some());
1232        let (name, _distance) = closest.unwrap();
1233        // Should be relatively close to teal
1234        assert!(!name.is_empty());
1235    }
1236
1237    #[test]
1238    fn test_munsell_color_matching_overlays() {
1239        // Test that matching_overlays returns a vector
1240        let color = MunsellColor::new_chromatic("5R".to_string(), 5.0, 10.0);
1241        let matches = color.matching_overlays();
1242        // May or may not have matches, but should not panic
1243        assert!(matches.len() >= 0);
1244
1245        // Neutral should have no matches (neutral is at chroma 0)
1246        let neutral = MunsellColor::new_neutral(5.0);
1247        let matches = neutral.matching_overlays();
1248        // Neutral colors are far from most color name regions
1249        assert!(matches.len() <= 2);
1250    }
1251
1252    #[test]
1253    fn test_munsell_color_matches_overlay() {
1254        // Test matches_overlay with case insensitivity
1255        let color = MunsellColor::new_chromatic("5BG".to_string(), 5.0, 8.0);
1256
1257        // Check that the method works (case insensitive lookup)
1258        // The actual match depends on whether the color is inside the overlay
1259        let _ = color.matches_overlay("teal");
1260        let _ = color.matches_overlay("TEAL");
1261        let _ = color.matches_overlay("Teal");
1262
1263        // Non-existent overlay should return false
1264        assert!(!color.matches_overlay("nonexistent"));
1265    }
1266
1267    #[test]
1268    fn test_munsell_color_closest_overlay() {
1269        // Test closest_overlay for various colors
1270        let colors = [
1271            MunsellColor::new_chromatic("5R".to_string(), 5.0, 10.0),
1272            MunsellColor::new_chromatic("5Y".to_string(), 7.0, 6.0),
1273            MunsellColor::new_chromatic("5B".to_string(), 4.0, 8.0),
1274            MunsellColor::new_chromatic("5P".to_string(), 3.0, 10.0),
1275        ];
1276
1277        for color in &colors {
1278            let result = color.closest_overlay();
1279            assert!(result.is_some(), "closest_overlay should return Some for {}", color);
1280            let (name, distance) = result.unwrap();
1281            assert!(!name.is_empty());
1282            assert!(distance >= 0.0);
1283        }
1284
1285        // Neutral should also work
1286        let neutral = MunsellColor::new_neutral(5.0);
1287        let result = neutral.closest_overlay();
1288        assert!(result.is_some());
1289    }
1290}