use std::f64::consts::PI;
pub const HUE_FAMILIES: [&str; 10] = ["R", "YR", "Y", "GY", "G", "BG", "B", "PB", "P", "RP"];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MunsellCartesian {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl MunsellCartesian {
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
pub fn distance(&self, other: &MunsellCartesian) -> f64 {
let dx = self.x - other.x;
let dy = self.y - other.y;
let dz = self.z - other.z;
(dx * dx + dy * dy + dz * dz).sqrt()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MunsellSpec {
pub hue_number: f64,
pub value: f64,
pub chroma: f64,
}
impl MunsellSpec {
pub fn new(hue_number: f64, value: f64, chroma: f64) -> Self {
Self {
hue_number,
value,
chroma,
}
}
pub fn neutral(value: f64) -> Self {
Self {
hue_number: 0.0,
value,
chroma: 0.0,
}
}
pub fn to_cartesian(&self) -> MunsellCartesian {
let theta = self.hue_number * 9.0 * PI / 180.0;
MunsellCartesian {
x: self.chroma * theta.cos(),
y: self.chroma * theta.sin(),
z: self.value,
}
}
pub fn from_cartesian(cart: &MunsellCartesian) -> Self {
let chroma = (cart.x * cart.x + cart.y * cart.y).sqrt();
if chroma < 1e-10 {
return Self::neutral(cart.z);
}
let mut theta = cart.y.atan2(cart.x);
if theta < 0.0 {
theta += 2.0 * PI;
}
let hue_number = theta * 180.0 / PI / 9.0;
Self {
hue_number: hue_number % 40.0,
value: cart.z,
chroma,
}
}
pub fn to_notation(&self) -> String {
if self.chroma < 0.5 {
return format!("N {:.1}/", self.value);
}
let (hue_str, _) = hue_number_to_string(self.hue_number);
format!("{} {:.1}/{:.1}", hue_str, self.value, self.chroma)
}
pub fn distance_from(&self, other: &MunsellSpec) -> f64 {
self.to_cartesian().distance(&other.to_cartesian())
}
}
pub fn parse_hue_to_number(hue: &str) -> Option<f64> {
let families_by_length: [&str; 10] = ["YR", "GY", "BG", "PB", "RP", "R", "Y", "G", "B", "P"];
let (family, family_idx) = families_by_length
.iter()
.find_map(|&fam| {
if hue.ends_with(fam) {
let idx = HUE_FAMILIES.iter().position(|&f| f == fam)?;
Some((fam, idx))
} else {
None
}
})?;
let num_str = hue.strip_suffix(family)?;
let num: f64 = num_str.parse().ok()?;
if !(0.0..=10.0).contains(&num) {
return None;
}
let family_start = family_idx as f64 * 4.0;
let hue_number = (family_start + num / 2.5) % 40.0;
Some(hue_number)
}
pub fn hue_number_to_string(hue_number: f64) -> (String, &'static str) {
let normalized = ((hue_number % 40.0) + 40.0) % 40.0;
let within_family = (normalized % 4.0) * 2.5;
let (final_family_idx, final_num) = if within_family < 0.001 && normalized > 0.001 {
let prev_family_idx = ((normalized / 4.0).floor() as usize + 9) % 10;
(prev_family_idx, 10.0_f64)
} else if within_family < 0.001 {
(9, 10.0_f64) } else {
let family_idx = (normalized / 4.0).floor() as usize % 10;
(family_idx, within_family)
};
let final_family = HUE_FAMILIES[final_family_idx % 10];
let hue_str = if (final_num - final_num.round()).abs() < 0.001 {
format!("{}{}", final_num.round() as i32, final_family)
} else {
format!("{:.1}{}", final_num, final_family)
};
(hue_str, final_family)
}
pub fn parse_munsell_notation(notation: &str) -> Option<MunsellSpec> {
let notation = notation.trim();
if notation.starts_with("N ") {
let value_str = notation.strip_prefix("N ")?.trim_end_matches('/');
let value: f64 = value_str.parse().ok()?;
return Some(MunsellSpec::neutral(value));
}
let parts: Vec<&str> = notation.split_whitespace().collect();
if parts.len() != 2 {
return None;
}
let hue_number = parse_hue_to_number(parts[0])?;
let vc_parts: Vec<&str> = parts[1].split('/').collect();
if vc_parts.len() != 2 {
return None;
}
let value: f64 = vc_parts[0].parse().ok()?;
let chroma: f64 = vc_parts[1].parse().ok()?;
Some(MunsellSpec::new(hue_number, value, chroma))
}
#[derive(Debug, Clone)]
pub struct SemanticOverlay {
pub name: &'static str,
pub polyhedron: ConvexPolyhedron,
pub centroid: MunsellSpec,
pub sample_count: u32,
}
impl SemanticOverlay {
pub fn new(
name: &'static str,
vertices: &[(f64, f64, f64)],
faces: &[(usize, usize, usize)],
centroid: MunsellSpec,
sample_count: u32,
) -> Self {
Self {
name,
polyhedron: ConvexPolyhedron::from_arrays(vertices, faces),
centroid,
sample_count,
}
}
pub fn contains(&self, color: &MunsellSpec) -> bool {
let point = color.to_cartesian();
self.polyhedron.contains_point(&point)
}
pub fn contains_with_tolerance(&self, color: &MunsellSpec, tolerance: f64) -> bool {
let point = color.to_cartesian();
self.polyhedron.contains_point_with_tolerance(&point, tolerance)
}
pub fn distance_to_centroid(&self, color: &MunsellSpec) -> f64 {
color.distance_from(&self.centroid)
}
pub fn centroid_notation(&self) -> String {
self.centroid.to_notation()
}
}
#[derive(Debug, Clone)]
pub struct SemanticOverlayRegistry {
overlays: Vec<SemanticOverlay>,
}
impl SemanticOverlayRegistry {
pub fn new(overlays: Vec<SemanticOverlay>) -> Self {
Self { overlays }
}
pub fn all(&self) -> &[SemanticOverlay] {
&self.overlays
}
pub fn get(&self, name: &str) -> Option<&SemanticOverlay> {
let name_lower = name.to_lowercase();
self.overlays.iter().find(|o| o.name.to_lowercase() == name_lower)
}
pub fn matches(&self, color: &MunsellSpec, overlay_name: &str) -> bool {
self.get(overlay_name)
.map(|o| o.contains(color))
.unwrap_or(false)
}
pub fn matching_overlays(&self, color: &MunsellSpec) -> Vec<&SemanticOverlay> {
self.overlays
.iter()
.filter(|o| o.contains(color))
.collect()
}
pub fn best_match(&self, color: &MunsellSpec) -> Option<&SemanticOverlay> {
let matches = self.matching_overlays(color);
if matches.is_empty() {
return None;
}
matches
.into_iter()
.min_by(|a, b| {
let dist_a = a.distance_to_centroid(color);
let dist_b = b.distance_to_centroid(color);
dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal)
})
}
pub fn matching_overlays_ranked(&self, color: &MunsellSpec) -> Vec<(&SemanticOverlay, f64)> {
let mut matches: Vec<(&SemanticOverlay, f64)> = self
.overlays
.iter()
.filter(|o| o.contains(color))
.map(|o| (o, o.distance_to_centroid(color)))
.collect();
matches.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
matches
}
pub fn closest_overlay(&self, color: &MunsellSpec) -> Option<(&SemanticOverlay, f64)> {
self.overlays
.iter()
.map(|o| (o, o.distance_to_centroid(color)))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn names(&self) -> Vec<&'static str> {
self.overlays.iter().map(|o| o.name).collect()
}
pub fn len(&self) -> usize {
self.overlays.len()
}
pub fn is_empty(&self) -> bool {
self.overlays.is_empty()
}
}
pub const OVERLAY_NAMES: [&str; 30] = [
"aqua", "beige", "coral", "fuchsia", "gold",
"lavender", "lilac", "magenta", "mauve", "navy",
"peach", "rose", "rust", "sand", "tan",
"taupe", "teal", "turquoise", "violet", "wine",
"blue", "brown", "gray", "green", "orange",
"pink", "purple", "red", "white", "yellow",
];
pub mod centroids {
use super::MunsellSpec;
fn spec(notation: &str) -> MunsellSpec {
super::parse_munsell_notation(notation)
.unwrap_or_else(|| panic!("Invalid centroid notation: {}", notation))
}
pub fn aqua() -> MunsellSpec { spec("7.4BG 6.2/3.4") }
pub fn beige() -> MunsellSpec { spec("6.7YR 6.1/3.4") }
pub fn coral() -> MunsellSpec { spec("6.5R 5.8/8.3") }
pub fn fuchsia() -> MunsellSpec { spec("4.8RP 4.1/10.3") }
pub fn gold() -> MunsellSpec { spec("9.8YR 6.4/7.4") }
pub fn lavender() -> MunsellSpec { spec("5.6P 5.4/4.8") }
pub fn lilac() -> MunsellSpec { spec("7.8P 5.6/4.8") }
pub fn magenta() -> MunsellSpec { spec("3.8RP 3.4/9.4") }
pub fn mauve() -> MunsellSpec { spec("1.2RP 5.1/3.9") }
pub fn navy() -> MunsellSpec { spec("7.3PB 2.1/3.6") }
pub fn peach() -> MunsellSpec { spec("2.9YR 7.0/5.9") }
pub fn rose() -> MunsellSpec { spec("0.5R 5.0/7.7") }
pub fn rust() -> MunsellSpec { spec("9.4R 3.9/7.4") }
pub fn sand() -> MunsellSpec { spec("7.6YR 6.3/3.2") }
pub fn tan() -> MunsellSpec { spec("6.3YR 5.2/4.1") }
pub fn taupe() -> MunsellSpec { spec("3.2YR 4.7/1.4") }
pub fn teal() -> MunsellSpec { spec("1.6B 3.3/4.5") }
pub fn turquoise() -> MunsellSpec { spec("1.6B 5.5/5.9") }
pub fn violet() -> MunsellSpec { spec("7.0P 3.8/6.2") }
pub fn wine() -> MunsellSpec { spec("2.7R 3.0/4.9") }
pub fn blue() -> MunsellSpec { spec("1.8PB 4.8/5.0") }
pub fn brown() -> MunsellSpec { spec("2.2YR 3.5/3.4") }
pub fn gray() -> MunsellSpec { spec("3.2Y 5.0/1.9") }
pub fn green() -> MunsellSpec { spec("2.3G 5.0/4.0") }
pub fn orange() -> MunsellSpec { spec("2.5YR 6.1/10.3") }
pub fn pink() -> MunsellSpec { spec("0.7R 6.1/7.2") }
pub fn purple() -> MunsellSpec { spec("4.3P 3.0/6.5") }
pub fn red() -> MunsellSpec { spec("5.1R 3.9/9.6") }
pub fn white() -> MunsellSpec { spec("2.2Y 8.3/1.6") }
pub fn yellow() -> MunsellSpec { spec("3.9Y 7.8/8.0") }
pub fn get(name: &str) -> Option<MunsellSpec> {
match name.to_lowercase().as_str() {
"aqua" => Some(aqua()),
"beige" => Some(beige()),
"coral" => Some(coral()),
"fuchsia" => Some(fuchsia()),
"gold" => Some(gold()),
"lavender" => Some(lavender()),
"lilac" => Some(lilac()),
"magenta" => Some(magenta()),
"mauve" => Some(mauve()),
"navy" => Some(navy()),
"peach" => Some(peach()),
"rose" => Some(rose()),
"rust" => Some(rust()),
"sand" => Some(sand()),
"tan" => Some(tan()),
"taupe" => Some(taupe()),
"teal" => Some(teal()),
"turquoise" => Some(turquoise()),
"violet" => Some(violet()),
"wine" => Some(wine()),
"blue" => Some(blue()),
"brown" => Some(brown()),
"gray" | "grey" => Some(gray()),
"green" => Some(green()),
"orange" => Some(orange()),
"pink" => Some(pink()),
"purple" => Some(purple()),
"red" => Some(red()),
"white" => Some(white()),
"yellow" => Some(yellow()),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TriFace {
pub v0: usize,
pub v1: usize,
pub v2: usize,
}
impl TriFace {
pub fn new(v0: usize, v1: usize, v2: usize) -> Self {
Self { v0, v1, v2 }
}
}
#[derive(Debug, Clone)]
pub struct ConvexPolyhedron {
pub vertices: Vec<MunsellCartesian>,
pub faces: Vec<TriFace>,
}
impl ConvexPolyhedron {
pub fn new(vertices: Vec<MunsellCartesian>, faces: Vec<TriFace>) -> Self {
Self { vertices, faces }
}
pub fn from_arrays(vertices: &[(f64, f64, f64)], faces: &[(usize, usize, usize)]) -> Self {
let verts: Vec<MunsellCartesian> = vertices
.iter()
.map(|(x, y, z)| MunsellCartesian::new(*x, *y, *z))
.collect();
let face_list: Vec<TriFace> = faces
.iter()
.map(|(v0, v1, v2)| TriFace::new(*v0, *v1, *v2))
.collect();
Self::new(verts, face_list)
}
pub fn centroid(&self) -> MunsellCartesian {
if self.vertices.is_empty() {
return MunsellCartesian::new(0.0, 0.0, 0.0);
}
let n = self.vertices.len() as f64;
let sum_x: f64 = self.vertices.iter().map(|v| v.x).sum();
let sum_y: f64 = self.vertices.iter().map(|v| v.y).sum();
let sum_z: f64 = self.vertices.iter().map(|v| v.z).sum();
MunsellCartesian::new(sum_x / n, sum_y / n, sum_z / n)
}
pub fn contains_point(&self, point: &MunsellCartesian) -> bool {
if self.faces.is_empty() || self.vertices.len() < 4 {
return false;
}
let centroid = self.centroid();
for face in &self.faces {
let v0 = &self.vertices[face.v0];
let v1 = &self.vertices[face.v1];
let v2 = &self.vertices[face.v2];
let edge1 = (v1.x - v0.x, v1.y - v0.y, v1.z - v0.z);
let edge2 = (v2.x - v0.x, v2.y - v0.y, v2.z - v0.z);
let normal = cross_product(edge1, edge2);
let d = -(normal.0 * v0.x + normal.1 * v0.y + normal.2 * v0.z);
let point_side = normal.0 * point.x + normal.1 * point.y + normal.2 * point.z + d;
let centroid_side = normal.0 * centroid.x + normal.1 * centroid.y + normal.2 * centroid.z + d;
const EPSILON: f64 = 1e-10;
if centroid_side > EPSILON {
if point_side < -EPSILON {
return false;
}
} else if centroid_side < -EPSILON {
if point_side > EPSILON {
return false;
}
}
}
true
}
pub fn contains_point_with_tolerance(&self, point: &MunsellCartesian, tolerance: f64) -> bool {
if self.contains_point(point) {
return true;
}
for vertex in &self.vertices {
if point.distance(vertex) <= tolerance {
return true;
}
}
false
}
}
fn cross_product(a: (f64, f64, f64), b: (f64, f64, f64)) -> (f64, f64, f64) {
(
a.1 * b.2 - a.2 * b.1,
a.2 * b.0 - a.0 * b.2,
a.0 * b.1 - a.1 * b.0,
)
}
pub fn point_in_polyhedron(
point: &MunsellCartesian,
vertices: &[(f64, f64, f64)],
faces: &[(usize, usize, usize)],
) -> bool {
let poly = ConvexPolyhedron::from_arrays(vertices, faces);
poly.contains_point(point)
}
pub fn munsell_in_polyhedron(
color: &MunsellSpec,
vertices: &[(f64, f64, f64)],
faces: &[(usize, usize, usize)],
) -> bool {
let point = color.to_cartesian();
point_in_polyhedron(&point, vertices, faces)
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier for unified color naming. Access semantic names via ColorDescriptor::semantic_name. This function will be removed in v2.0.0."
)]
pub fn semantic_overlay(color: &MunsellSpec) -> Option<&'static str> {
let registry = crate::semantic_overlay_data::get_registry();
registry.best_match(color).map(|o| o.name)
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier for unified color naming. Access semantic matches via ColorDescriptor::semantic_name and semantic_alternates. This function will be removed in v2.0.0."
)]
pub fn matching_overlays(color: &MunsellSpec) -> Vec<&'static str> {
let registry = crate::semantic_overlay_data::get_registry();
registry.matching_overlays(color)
.into_iter()
.map(|o| o.name)
.collect()
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier for unified color naming. Access ranked matches via ColorDescriptor::nearest_semantic_descriptor(). This function will be removed in v2.0.0."
)]
pub fn matching_overlays_ranked(color: &MunsellSpec) -> Vec<(&'static str, f64)> {
let registry = crate::semantic_overlay_data::get_registry();
registry.matching_overlays_ranked(color)
.into_iter()
.map(|(o, d)| (o.name, d))
.collect()
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier for unified color naming. Check semantic matches via ColorDescriptor. This function will be removed in v2.0.0."
)]
pub fn matches_overlay(color: &MunsellSpec, overlay_name: &str) -> bool {
let registry = crate::semantic_overlay_data::get_registry();
registry.matches(color, overlay_name)
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier for unified color naming. Access nearest overlay via ColorDescriptor::nearest_semantic. This function will be removed in v2.0.0."
)]
pub fn closest_overlay(color: &MunsellSpec) -> Option<(&'static str, f64)> {
let registry = crate::semantic_overlay_data::get_registry();
registry.closest_overlay(color).map(|(o, d)| (o.name, d))
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier::classify_munsell() for unified color naming. This function will be removed in v2.0.0."
)]
#[allow(deprecated)] pub fn semantic_overlay_from_notation(notation: &str) -> Option<&'static str> {
let spec = parse_munsell_notation(notation)?;
semantic_overlay(&spec)
}
#[cfg(test)]
#[allow(deprecated)] mod tests {
use super::*;
fn unit_cube() -> ConvexPolyhedron {
let vertices = vec![
(-0.5, -0.5, -0.5),
(0.5, -0.5, -0.5),
(0.5, 0.5, -0.5),
(-0.5, 0.5, -0.5),
(-0.5, -0.5, 0.5),
(0.5, -0.5, 0.5),
(0.5, 0.5, 0.5),
(-0.5, 0.5, 0.5),
];
let faces = vec![
(0, 2, 1),
(0, 3, 2),
(4, 5, 6),
(4, 6, 7),
(0, 1, 5),
(0, 5, 4),
(2, 3, 7),
(2, 7, 6),
(0, 4, 7),
(0, 7, 3),
(1, 2, 6),
(1, 6, 5),
];
ConvexPolyhedron::from_arrays(&vertices, &faces)
}
fn tetrahedron() -> ConvexPolyhedron {
let vertices = vec![
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(0.5, 0.866, 0.0),
(0.5, 0.289, 0.816),
];
let faces = vec![
(0, 2, 1), (0, 1, 3), (1, 2, 3), (2, 0, 3), ];
ConvexPolyhedron::from_arrays(&vertices, &faces)
}
#[test]
fn test_cube_contains_center() {
let cube = unit_cube();
let center = MunsellCartesian::new(0.0, 0.0, 0.0);
assert!(cube.contains_point(¢er));
}
#[test]
fn test_cube_contains_interior_points() {
let cube = unit_cube();
let points = vec![
MunsellCartesian::new(0.1, 0.1, 0.1),
MunsellCartesian::new(-0.2, 0.3, -0.1),
MunsellCartesian::new(0.4, -0.4, 0.4),
];
for point in &points {
assert!(cube.contains_point(point), "Point {:?} should be inside cube", point);
}
}
#[test]
fn test_cube_excludes_exterior_points() {
let cube = unit_cube();
let points = vec![
MunsellCartesian::new(1.0, 0.0, 0.0),
MunsellCartesian::new(0.0, 1.0, 0.0),
MunsellCartesian::new(0.0, 0.0, 1.0),
MunsellCartesian::new(-1.0, -1.0, -1.0),
];
for point in &points {
assert!(!cube.contains_point(point), "Point {:?} should be outside cube", point);
}
}
#[test]
fn test_tetrahedron_contains_centroid() {
let tet = tetrahedron();
let centroid = tet.centroid();
assert!(tet.contains_point(¢roid));
}
#[test]
fn test_tetrahedron_excludes_exterior() {
let tet = tetrahedron();
let outside = MunsellCartesian::new(2.0, 2.0, 2.0);
assert!(!tet.contains_point(&outside));
}
#[test]
fn test_centroid_calculation() {
let cube = unit_cube();
let centroid = cube.centroid();
assert!(centroid.x.abs() < 0.001);
assert!(centroid.y.abs() < 0.001);
assert!(centroid.z.abs() < 0.001);
}
#[test]
fn test_point_in_polyhedron_function() {
let vertices = vec![
(-1.0, -1.0, -1.0),
(1.0, -1.0, -1.0),
(1.0, 1.0, -1.0),
(-1.0, 1.0, -1.0),
(-1.0, -1.0, 1.0),
(1.0, -1.0, 1.0),
(1.0, 1.0, 1.0),
(-1.0, 1.0, 1.0),
];
let faces = vec![
(0, 2, 1), (0, 3, 2),
(4, 5, 6), (4, 6, 7),
(0, 1, 5), (0, 5, 4),
(2, 3, 7), (2, 7, 6),
(0, 4, 7), (0, 7, 3),
(1, 2, 6), (1, 6, 5),
];
let inside = MunsellCartesian::new(0.0, 0.0, 0.0);
let outside = MunsellCartesian::new(5.0, 5.0, 5.0);
assert!(point_in_polyhedron(&inside, &vertices, &faces));
assert!(!point_in_polyhedron(&outside, &vertices, &faces));
}
#[test]
fn test_munsell_in_polyhedron() {
let vertices = vec![
(5.0, 0.0, 4.0),
(10.0, 0.0, 4.0),
(10.0, 5.0, 4.0),
(5.0, 5.0, 4.0),
(5.0, 0.0, 6.0),
(10.0, 0.0, 6.0),
(10.0, 5.0, 6.0),
(5.0, 5.0, 6.0),
];
let faces = vec![
(0, 2, 1), (0, 3, 2),
(4, 5, 6), (4, 6, 7),
(0, 1, 5), (0, 5, 4),
(2, 3, 7), (2, 7, 6),
(0, 4, 7), (0, 7, 3),
(1, 2, 6), (1, 6, 5),
];
let inside_color = MunsellSpec::new(2.0, 5.0, 7.0); assert!(munsell_in_polyhedron(&inside_color, &vertices, &faces));
let outside_color = MunsellSpec::new(2.0, 5.0, 1.0); assert!(!munsell_in_polyhedron(&outside_color, &vertices, &faces));
let wrong_value = MunsellSpec::new(2.0, 8.0, 7.0); assert!(!munsell_in_polyhedron(&wrong_value, &vertices, &faces));
}
#[test]
fn test_centroid_functions() {
for name in &super::OVERLAY_NAMES {
let centroid = super::centroids::get(name);
assert!(centroid.is_some(), "Centroid for '{}' should exist", name);
}
let aqua = super::centroids::aqua();
assert!((aqua.value - 6.2).abs() < 0.01);
let navy = super::centroids::navy();
assert!((navy.value - 2.1).abs() < 0.01);
}
#[test]
fn test_centroid_get_case_insensitive() {
assert!(super::centroids::get("AQUA").is_some());
assert!(super::centroids::get("Aqua").is_some());
assert!(super::centroids::get("aqua").is_some());
assert!(super::centroids::get("invalid").is_none());
}
#[test]
fn test_overlay_names_count() {
assert_eq!(super::OVERLAY_NAMES.len(), 30);
}
#[test]
fn test_semantic_overlay_creation() {
let vertices = vec![
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(0.5, 0.866, 0.0),
(0.5, 0.289, 0.816),
];
let faces = vec![
(0, 2, 1),
(0, 1, 3),
(1, 2, 3),
(2, 0, 3),
];
let centroid = MunsellSpec::new(2.0, 5.0, 8.0);
let overlay = SemanticOverlay::new("test", &vertices, &faces, centroid, 100);
assert_eq!(overlay.name, "test");
assert_eq!(overlay.sample_count, 100);
}
#[test]
fn test_semantic_overlay_at_centroid() {
assert_eq!(semantic_overlay(&super::centroids::aqua()), Some("aqua"));
assert_eq!(semantic_overlay(&super::centroids::navy()), Some("navy"));
assert_eq!(semantic_overlay(&super::centroids::beige()), Some("beige"));
}
#[test]
fn test_matching_overlays_at_centroid() {
let aqua = super::centroids::aqua();
let matches = matching_overlays(&aqua);
assert!(matches.contains(&"aqua"));
}
#[test]
fn test_matches_overlay_function() {
let navy = super::centroids::navy();
assert!(matches_overlay(&navy, "navy"));
assert!(matches_overlay(&navy, "NAVY")); assert!(matches_overlay(&navy, "Navy"));
}
#[test]
fn test_closest_overlay_function() {
let gray = MunsellSpec::neutral(5.0);
let result = closest_overlay(&gray);
assert!(result.is_some());
let (name, distance) = result.unwrap();
assert!(!name.is_empty());
assert!(distance > 0.0);
}
#[test]
fn test_semantic_overlay_from_notation_function() {
assert_eq!(semantic_overlay_from_notation("7.4BG 6.2/3.4"), Some("aqua"));
assert_eq!(semantic_overlay_from_notation("invalid"), None);
}
#[test]
fn test_registry_basic_operations() {
let vertices = vec![
(-1.0, -1.0, -1.0),
(1.0, -1.0, -1.0),
(1.0, 1.0, -1.0),
(-1.0, 1.0, -1.0),
(-1.0, -1.0, 1.0),
(1.0, -1.0, 1.0),
(1.0, 1.0, 1.0),
(-1.0, 1.0, 1.0),
];
let faces = vec![
(0, 2, 1), (0, 3, 2),
(4, 5, 6), (4, 6, 7),
(0, 1, 5), (0, 5, 4),
(2, 3, 7), (2, 7, 6),
(0, 4, 7), (0, 7, 3),
(1, 2, 6), (1, 6, 5),
];
let overlay1 = SemanticOverlay::new(
"test1",
&vertices,
&faces,
MunsellSpec::new(0.0, 0.0, 0.0),
50,
);
let registry = SemanticOverlayRegistry::new(vec![overlay1]);
assert_eq!(registry.len(), 1);
assert!(!registry.is_empty());
assert!(registry.get("test1").is_some());
assert!(registry.get("TEST1").is_some()); assert!(registry.get("nonexistent").is_none());
}
#[test]
fn test_hue_to_number_basic() {
assert!((parse_hue_to_number("5R").unwrap() - 2.0).abs() < 0.001);
assert!((parse_hue_to_number("5YR").unwrap() - 6.0).abs() < 0.001);
assert!((parse_hue_to_number("5Y").unwrap() - 10.0).abs() < 0.001);
assert!((parse_hue_to_number("5GY").unwrap() - 14.0).abs() < 0.001);
assert!((parse_hue_to_number("5G").unwrap() - 18.0).abs() < 0.001);
assert!((parse_hue_to_number("5BG").unwrap() - 22.0).abs() < 0.001);
assert!((parse_hue_to_number("5B").unwrap() - 26.0).abs() < 0.001);
assert!((parse_hue_to_number("5PB").unwrap() - 30.0).abs() < 0.001);
assert!((parse_hue_to_number("5P").unwrap() - 34.0).abs() < 0.001);
assert!((parse_hue_to_number("5RP").unwrap() - 38.0).abs() < 0.001);
}
#[test]
fn test_hue_to_number_boundaries() {
assert!((parse_hue_to_number("10R").unwrap() - 4.0).abs() < 0.001);
assert!((parse_hue_to_number("10YR").unwrap() - 8.0).abs() < 0.001);
assert!((parse_hue_to_number("10RP").unwrap() - 0.0).abs() < 0.001); }
#[test]
fn test_hue_to_number_fractional() {
assert!((parse_hue_to_number("2.5R").unwrap() - 1.0).abs() < 0.001);
assert!((parse_hue_to_number("7.5R").unwrap() - 3.0).abs() < 0.001);
assert!((parse_hue_to_number("2.5YR").unwrap() - 5.0).abs() < 0.001);
}
#[test]
fn test_hue_to_number_invalid() {
assert!(parse_hue_to_number("").is_none());
assert!(parse_hue_to_number("R").is_none());
assert!(parse_hue_to_number("11R").is_none());
assert!(parse_hue_to_number("-1R").is_none());
assert!(parse_hue_to_number("5X").is_none());
}
#[test]
fn test_hue_number_to_string() {
let (hue, _) = hue_number_to_string(2.0);
assert_eq!(hue, "5R");
let (hue, _) = hue_number_to_string(6.0);
assert_eq!(hue, "5YR");
let (hue, _) = hue_number_to_string(1.0);
assert!(hue.contains("R")); }
#[test]
fn test_roundtrip_hue_conversion() {
let test_hues = ["5R", "2.5YR", "7.5BG", "10PB", "5RP"];
for hue in &test_hues {
let num = parse_hue_to_number(hue).unwrap();
let (back, _) = hue_number_to_string(num);
let num2 = parse_hue_to_number(&back).unwrap();
assert!((num - num2).abs() < 0.001, "Roundtrip failed for {}: {} -> {} -> {}", hue, num, back, num2);
}
}
#[test]
fn test_cartesian_conversion() {
let spec = MunsellSpec::new(0.0, 5.0, 10.0); let cart = spec.to_cartesian();
assert!((cart.x - 10.0).abs() < 0.001);
assert!(cart.y.abs() < 0.001);
assert!((cart.z - 5.0).abs() < 0.001);
let spec90 = MunsellSpec::new(10.0, 5.0, 10.0);
let cart90 = spec90.to_cartesian();
assert!(cart90.x.abs() < 0.001); assert!((cart90.y - 10.0).abs() < 0.001); }
#[test]
fn test_cartesian_roundtrip() {
let original = MunsellSpec::new(15.0, 6.0, 8.0);
let cart = original.to_cartesian();
let recovered = MunsellSpec::from_cartesian(&cart);
assert!((original.hue_number - recovered.hue_number).abs() < 0.001);
assert!((original.value - recovered.value).abs() < 0.001);
assert!((original.chroma - recovered.chroma).abs() < 0.001);
}
#[test]
fn test_neutral_cartesian() {
let neutral = MunsellSpec::neutral(5.0);
let cart = neutral.to_cartesian();
assert!(cart.x.abs() < 0.001);
assert!(cart.y.abs() < 0.001);
assert!((cart.z - 5.0).abs() < 0.001);
let recovered = MunsellSpec::from_cartesian(&cart);
assert!(recovered.chroma < 0.001);
}
#[test]
fn test_parse_munsell_notation() {
let spec = parse_munsell_notation("5R 4.0/12.0").unwrap();
assert!((spec.hue_number - 2.0).abs() < 0.001);
assert!((spec.value - 4.0).abs() < 0.001);
assert!((spec.chroma - 12.0).abs() < 0.001);
let neutral = parse_munsell_notation("N 5.0/").unwrap();
assert!(neutral.chroma < 0.001);
assert!((neutral.value - 5.0).abs() < 0.001);
}
#[test]
fn test_to_notation() {
let spec = MunsellSpec::new(2.0, 4.0, 12.0);
let notation = spec.to_notation();
assert!(notation.contains("R"));
assert!(notation.contains("4.0"));
assert!(notation.contains("12.0"));
let neutral = MunsellSpec::neutral(5.0);
let n_notation = neutral.to_notation();
assert!(n_notation.starts_with("N"));
}
#[test]
fn test_distance() {
let p1 = MunsellCartesian::new(0.0, 0.0, 0.0);
let p2 = MunsellCartesian::new(3.0, 4.0, 0.0);
assert!((p1.distance(&p2) - 5.0).abs() < 0.001);
let p3 = MunsellCartesian::new(0.0, 0.0, 5.0);
assert!((p1.distance(&p3) - 5.0).abs() < 0.001);
}
#[test]
fn test_centore_centroids() {
let aqua = parse_munsell_notation("7.4BG 6.2/3.4").unwrap();
assert!((aqua.value - 6.2).abs() < 0.001);
assert!((aqua.chroma - 3.4).abs() < 0.001);
let beige = parse_munsell_notation("6.7YR 6.1/3.4").unwrap();
assert!((beige.value - 6.1).abs() < 0.001);
let navy = parse_munsell_notation("7.3PB 2.1/3.6").unwrap();
assert!((navy.value - 2.1).abs() < 0.001);
}
#[test]
fn test_matching_overlays_ranked_single_match() {
let navy = super::centroids::navy();
let ranked = matching_overlays_ranked(&navy);
assert!(!ranked.is_empty(), "Navy centroid should match at least navy");
assert_eq!(ranked[0].0, "navy", "Best match should be navy");
assert!(ranked[0].1 < 0.001, "Distance to own centroid should be ~0");
}
#[test]
fn test_matching_overlays_ranked_is_sorted() {
let beige = super::centroids::beige();
let ranked = matching_overlays_ranked(&beige);
if ranked.len() > 1 {
for i in 1..ranked.len() {
assert!(
ranked[i].1 >= ranked[i-1].1,
"Ranked overlays should be sorted by distance: {} (dist {}) should not come after {} (dist {})",
ranked[i].0, ranked[i].1,
ranked[i-1].0, ranked[i-1].1
);
}
}
}
#[test]
fn test_matching_overlays_ranked_empty_for_black() {
let pure_black = MunsellSpec::neutral(0.0);
let ranked = matching_overlays_ranked(&pure_black);
assert!(ranked.is_empty(), "Pure black should not match any overlays");
}
#[test]
fn test_matching_overlays_ranked_public_api() {
let aqua = super::centroids::aqua();
let ranked = super::matching_overlays_ranked(&aqua);
assert!(!ranked.is_empty());
let (name, distance) = ranked[0];
assert_eq!(name, "aqua");
assert!(distance < 0.001);
}
#[test]
fn test_matching_overlays_ranked_consistency_with_best_match() {
for name in &super::OVERLAY_NAMES {
let centroid = super::centroids::get(name).unwrap();
let ranked = matching_overlays_ranked(¢roid);
let best = semantic_overlay(¢roid);
if !ranked.is_empty() && best.is_some() {
assert_eq!(
ranked[0].0, best.unwrap(),
"First ranked should equal best_match for {}",
name
);
}
}
}
}