#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FitzpatrickType {
Type1,
Type2,
Type3,
Type4,
Type5,
Type6,
}
impl FitzpatrickType {
pub fn melanin_level(&self) -> f32 {
match self {
Self::Type1 => 0.0,
Self::Type2 => 0.2,
Self::Type3 => 0.4,
Self::Type4 => 0.6,
Self::Type5 => 0.8,
Self::Type6 => 1.0,
}
}
pub fn base_rgb(&self) -> [u8; 3] {
match self {
Self::Type1 => [255, 224, 196],
Self::Type2 => [240, 200, 168],
Self::Type3 => [210, 168, 128],
Self::Type4 => [172, 124, 88],
Self::Type5 => [120, 78, 48],
Self::Type6 => [62, 36, 22],
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Type1 => "Type I – Very Fair",
Self::Type2 => "Type II – Fair",
Self::Type3 => "Type III – Medium",
Self::Type4 => "Type IV – Olive",
Self::Type5 => "Type V – Brown",
Self::Type6 => "Type VI – Dark Brown/Black",
}
}
pub fn all() -> [FitzpatrickType; 6] {
[
Self::Type1,
Self::Type2,
Self::Type3,
Self::Type4,
Self::Type5,
Self::Type6,
]
}
}
#[derive(Debug, Clone)]
pub struct SkinColor {
pub melanin: f32,
pub hemoglobin: f32,
pub subsurface: f32,
pub oiliness: f32,
}
const BASE_WHITE: [f32; 3] = [255.0, 220.0, 185.0];
const MELANIN_DARK: [f32; 3] = [50.0, 30.0, 20.0];
const HEMOGLOBIN_TINT: [f32; 3] = [20.0, -5.0, -10.0];
impl SkinColor {
pub fn from_fitzpatrick(t: FitzpatrickType) -> Self {
let melanin = t.melanin_level();
let hemoglobin = (0.35 - melanin * 0.2).clamp(0.0, 1.0);
let subsurface = (0.5 - melanin * 0.15).clamp(0.0, 1.0);
let oiliness = 0.3;
Self {
melanin,
hemoglobin,
subsurface,
oiliness,
}
}
pub fn to_rgb(&self) -> [u8; 3] {
let m = self.melanin.clamp(0.0, 1.0);
let h = self.hemoglobin.clamp(0.0, 1.0);
let channels: [u8; 3] = std::array::from_fn(|i| {
let base = BASE_WHITE[i] + (MELANIN_DARK[i] - BASE_WHITE[i]) * m;
let v = base + HEMOGLOBIN_TINT[i] * h;
v.clamp(0.0, 255.0).round() as u8
});
channels
}
pub fn to_rgba(&self) -> [u8; 4] {
let [r, g, b] = self.to_rgb();
[r, g, b, 255]
}
pub fn lerp(&self, other: &SkinColor, t: f32) -> SkinColor {
let t = t.clamp(0.0, 1.0);
SkinColor {
melanin: self.melanin + (other.melanin - self.melanin) * t,
hemoglobin: self.hemoglobin + (other.hemoglobin - self.hemoglobin) * t,
subsurface: self.subsurface + (other.subsurface - self.subsurface) * t,
oiliness: self.oiliness + (other.oiliness - self.oiliness) * t,
}
}
pub fn apply_tan(&self, amount: f32) -> SkinColor {
SkinColor {
melanin: (self.melanin + amount).clamp(0.0, 1.0),
hemoglobin: self.hemoglobin,
subsurface: self.subsurface,
oiliness: self.oiliness,
}
}
pub fn apply_blush(&self, amount: f32) -> SkinColor {
SkinColor {
melanin: self.melanin,
hemoglobin: (self.hemoglobin + amount).clamp(0.0, 1.0),
subsurface: self.subsurface,
oiliness: self.oiliness,
}
}
pub fn with_variation(&self, seed: u32) -> SkinColor {
let lcg = |s: u32| s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let to_f = |s: u32| (s >> 8) as f32 / 16_777_215.0;
let s0 = lcg(seed);
let s1 = lcg(s0);
let s2 = lcg(s1);
let s3 = lcg(s2);
let vary = |v: f32, r: f32| (v + (r - 0.5) * 0.10).clamp(0.0, 1.0);
SkinColor {
melanin: vary(self.melanin, to_f(s0)),
hemoglobin: vary(self.hemoglobin, to_f(s1)),
subsurface: vary(self.subsurface, to_f(s2)),
oiliness: vary(self.oiliness, to_f(s3)),
}
}
}
#[derive(Debug, Clone)]
pub struct SkinColorMap {
pub base: SkinColor,
pub face: SkinColor,
pub hands: SkinColor,
pub lips: SkinColor,
pub nails: SkinColor,
}
impl SkinColorMap {
pub fn uniform(color: SkinColor) -> Self {
Self {
base: color.clone(),
face: color.clone(),
hands: color.clone(),
lips: color.clone(),
nails: color,
}
}
pub fn from_fitzpatrick(t: FitzpatrickType) -> Self {
let base = SkinColor::from_fitzpatrick(t);
let face = SkinColor {
melanin: (base.melanin - 0.03).clamp(0.0, 1.0),
hemoglobin: (base.hemoglobin + 0.03).clamp(0.0, 1.0),
subsurface: (base.subsurface + 0.05).clamp(0.0, 1.0),
oiliness: base.oiliness,
};
let hands = SkinColor {
melanin: (base.melanin + 0.05).clamp(0.0, 1.0),
hemoglobin: base.hemoglobin,
subsurface: base.subsurface,
oiliness: base.oiliness,
};
let lips = SkinColor {
melanin: (base.melanin * 0.5).clamp(0.0, 1.0),
hemoglobin: (base.hemoglobin + 0.40).clamp(0.0, 1.0),
subsurface: 0.8,
oiliness: 0.4,
};
let nails = SkinColor {
melanin: (base.melanin * 0.3).clamp(0.0, 1.0),
hemoglobin: (base.hemoglobin + 0.15).clamp(0.0, 1.0),
subsurface: 0.6,
oiliness: 0.6,
};
Self {
base,
face,
hands,
lips,
nails,
}
}
pub fn apply_tan(&self, amount: f32) -> Self {
Self {
base: self.base.apply_tan(amount),
face: self.face.apply_tan(amount),
hands: self.hands.apply_tan(amount),
lips: self.lips.apply_tan(amount),
nails: self.nails.apply_tan(amount),
}
}
}
pub fn linear_to_srgb(linear: f32) -> f32 {
let v = linear.clamp(0.0, 1.0);
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
pub fn srgb_to_linear(srgb: f32) -> f32 {
let v = srgb.clamp(0.0, 1.0);
if v <= 0.040_45 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fitzpatrick_melanin_levels_ordered() {
let types = FitzpatrickType::all();
for i in 0..types.len() - 1 {
assert!(
types[i].melanin_level() < types[i + 1].melanin_level(),
"melanin level not strictly ascending at index {}",
i
);
}
}
#[test]
fn fitzpatrick_base_rgb_type1_is_light() {
let [r, _, _] = FitzpatrickType::Type1.base_rgb();
assert!(r > 200, "Type1 R channel should be > 200, got {}", r);
}
#[test]
fn fitzpatrick_base_rgb_type6_is_dark() {
let [r, _, _] = FitzpatrickType::Type6.base_rgb();
assert!(r < 100, "Type6 R channel should be < 100, got {}", r);
}
#[test]
fn fitzpatrick_all_has_six_types() {
assert_eq!(FitzpatrickType::all().len(), 6);
}
#[test]
fn skin_color_from_fitzpatrick_type1() {
let sc = SkinColor::from_fitzpatrick(FitzpatrickType::Type1);
assert!((sc.melanin - 0.0).abs() < 1e-6, "Type1 melanin should be 0");
assert!(sc.hemoglobin > 0.0, "hemoglobin should be positive");
}
#[test]
fn skin_color_to_rgb_type1_is_light() {
let sc = SkinColor::from_fitzpatrick(FitzpatrickType::Type1);
let [r, _, _] = sc.to_rgb();
assert!(r > 200, "Type1 skin R should be > 200, got {}", r);
}
#[test]
fn skin_color_to_rgb_type6_is_dark() {
let sc = SkinColor::from_fitzpatrick(FitzpatrickType::Type6);
let [r, _, _] = sc.to_rgb();
assert!(r < 100, "Type6 skin R should be < 100, got {}", r);
}
#[test]
fn skin_color_lerp_at_zero_equals_self() {
let a = SkinColor::from_fitzpatrick(FitzpatrickType::Type1);
let b = SkinColor::from_fitzpatrick(FitzpatrickType::Type6);
let result = a.lerp(&b, 0.0);
assert!((result.melanin - a.melanin).abs() < 1e-6);
assert!((result.hemoglobin - a.hemoglobin).abs() < 1e-6);
}
#[test]
fn skin_color_lerp_at_one_equals_other() {
let a = SkinColor::from_fitzpatrick(FitzpatrickType::Type1);
let b = SkinColor::from_fitzpatrick(FitzpatrickType::Type6);
let result = a.lerp(&b, 1.0);
assert!((result.melanin - b.melanin).abs() < 1e-6);
assert!((result.hemoglobin - b.hemoglobin).abs() < 1e-6);
}
#[test]
fn skin_color_apply_tan_increases_melanin() {
let sc = SkinColor::from_fitzpatrick(FitzpatrickType::Type2);
let tanned = sc.apply_tan(0.1);
assert!(tanned.melanin > sc.melanin, "tan should increase melanin");
}
#[test]
fn skin_color_apply_blush_increases_hemoglobin() {
let sc = SkinColor::from_fitzpatrick(FitzpatrickType::Type2);
let blushed = sc.apply_blush(0.1);
assert!(
blushed.hemoglobin > sc.hemoglobin,
"blush should increase hemoglobin"
);
}
#[test]
fn skin_color_to_rgba_alpha_is_255() {
let sc = SkinColor::from_fitzpatrick(FitzpatrickType::Type3);
let [_, _, _, a] = sc.to_rgba();
assert_eq!(a, 255, "alpha channel should always be 255");
}
#[test]
fn skin_color_map_uniform_all_same() {
let base = SkinColor::from_fitzpatrick(FitzpatrickType::Type3);
let map = SkinColorMap::uniform(base.clone());
assert!((map.base.melanin - map.face.melanin).abs() < 1e-6);
assert!((map.base.melanin - map.hands.melanin).abs() < 1e-6);
assert!((map.base.melanin - map.lips.melanin).abs() < 1e-6);
assert!((map.base.melanin - map.nails.melanin).abs() < 1e-6);
}
#[test]
fn skin_color_map_from_fitzpatrick() {
let map = SkinColorMap::from_fitzpatrick(FitzpatrickType::Type4);
assert!(
map.lips.hemoglobin > map.base.hemoglobin,
"lips should be more hemoglobin-rich than base"
);
assert!(
map.hands.melanin >= map.base.melanin,
"hands melanin should be >= base"
);
}
#[test]
fn linear_to_srgb_and_back_roundtrip() {
for &v in &[0.0_f32, 0.1, 0.5, 0.9, 1.0] {
let encoded = linear_to_srgb(v);
let decoded = srgb_to_linear(encoded);
assert!(
(decoded - v).abs() < 1e-5,
"roundtrip failed for {}: got {}",
v,
decoded
);
}
}
#[test]
fn srgb_to_linear_0_is_0() {
assert!((srgb_to_linear(0.0) - 0.0).abs() < 1e-6);
}
#[test]
fn srgb_to_linear_1_is_1() {
assert!((srgb_to_linear(1.0) - 1.0).abs() < 1e-6);
}
}