use super::MaterialInfo;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PbrMaterial {
pub base_color_rgb: [f32; 3],
pub alpha: f32,
pub metallic: f32,
pub roughness: f32,
pub double_sided: bool,
}
impl Default for PbrMaterial {
fn default() -> Self {
Self {
base_color_rgb: [0.75, 0.75, 0.75],
alpha: 1.0,
metallic: 0.0,
roughness: 0.6,
double_sided: false,
}
}
}
impl PbrMaterial {
pub fn from_material_info(info: &MaterialInfo) -> Self {
let base = color_from_packed(info.color_packed);
let alpha = alpha_from_transparency(info.transparency);
let (metallic, roughness, double_sided) = classify_from_name(&info.name);
Self {
base_color_rgb: base,
alpha,
metallic,
roughness,
double_sided,
}
}
}
fn color_from_packed(packed: Option<u32>) -> [f32; 3] {
let Some(p) = packed else {
let linear = srgb_to_linear(0.75);
return [linear, linear, linear];
};
let r = ((p & 0x0000_00FF) as f32) / 255.0;
let g = (((p & 0x0000_FF00) >> 8) as f32) / 255.0;
let b = (((p & 0x00FF_0000) >> 16) as f32) / 255.0;
[srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)]
}
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn alpha_from_transparency(t: Option<f64>) -> f32 {
match t {
Some(v) => ((1.0 - v.clamp(0.0, 1.0)) as f32).clamp(0.0, 1.0),
None => 1.0,
}
}
fn classify_from_name(name: &str) -> (f32, f32, bool) {
let n = name.to_ascii_lowercase();
if n.contains("glass") || n.contains("glazing") || n.contains("acrylic") {
return (0.0, 0.05, true);
}
if n.contains("metal")
|| n.contains("steel")
|| n.contains("aluminum")
|| n.contains("aluminium")
|| n.contains("copper")
|| n.contains("brass")
|| n.contains("iron")
|| n.contains("bronze")
{
return (1.0, 0.2, false);
}
if n.contains("wood") || n.contains("timber") || n.contains("oak") || n.contains("pine") {
return (0.0, 0.7, false);
}
if n.contains("concrete")
|| n.contains("masonry")
|| n.contains("stone")
|| n.contains("brick")
|| n.contains("mortar")
{
return (0.0, 0.9, false);
}
if n.contains("paint") || n.contains("ceramic") || n.contains("tile") {
return (0.0, 0.5, false);
}
(0.0, 0.6, false)
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(name: &str, color: Option<u32>, transparency: Option<f64>) -> MaterialInfo {
MaterialInfo {
name: name.into(),
color_packed: color,
transparency,
}
}
#[test]
fn default_material_is_mid_grey_dielectric() {
let m = PbrMaterial::default();
assert_eq!(m.base_color_rgb, [0.75, 0.75, 0.75]);
assert_eq!(m.alpha, 1.0);
assert_eq!(m.metallic, 0.0);
}
#[test]
fn glass_material_is_double_sided_transparent() {
let info = mk("Glass — Tinted", Some(0x00FFFFFF), Some(0.5));
let pbr = PbrMaterial::from_material_info(&info);
assert!(pbr.double_sided);
assert!(pbr.roughness < 0.1);
assert_eq!(pbr.metallic, 0.0);
assert!((pbr.alpha - 0.5).abs() < 1e-6);
}
#[test]
fn steel_material_is_metallic() {
let info = mk("Steel — Structural", Some(0x00808080), None);
let pbr = PbrMaterial::from_material_info(&info);
assert_eq!(pbr.metallic, 1.0);
assert!(!pbr.double_sided);
}
#[test]
fn aluminum_also_detected() {
let pbr = PbrMaterial::from_material_info(&mk("Aluminium Frame", None, None));
assert_eq!(pbr.metallic, 1.0);
}
#[test]
fn wood_material_is_rough_dielectric() {
let pbr = PbrMaterial::from_material_info(&mk("Wood — Oak", None, None));
assert_eq!(pbr.metallic, 0.0);
assert!(pbr.roughness > 0.5);
}
#[test]
fn concrete_is_very_rough() {
let pbr = PbrMaterial::from_material_info(&mk("Concrete — Cast-in-Place", None, None));
assert!(pbr.roughness > 0.8);
}
#[test]
fn unknown_material_gets_default_roughness() {
let pbr = PbrMaterial::from_material_info(&mk("Custom Material 42", None, None));
assert_eq!(pbr.metallic, 0.0);
assert!((pbr.roughness - 0.6).abs() < 1e-6);
assert!(!pbr.double_sided);
}
#[test]
fn color_unpacks_from_packed_bgr() {
let pbr = PbrMaterial::from_material_info(&mk("Paint", Some(0x000080FF), None));
assert!((pbr.base_color_rgb[0] - 1.0).abs() < 0.001);
assert!(pbr.base_color_rgb[1] > 0.2 && pbr.base_color_rgb[1] < 0.25);
assert!(pbr.base_color_rgb[2] < 0.01);
}
#[test]
fn color_missing_is_mid_grey() {
let pbr = PbrMaterial::from_material_info(&mk("Generic", None, None));
for c in pbr.base_color_rgb.iter() {
assert!(*c > 0.45 && *c < 0.6);
}
}
#[test]
fn transparency_1_maps_to_alpha_0() {
let pbr = PbrMaterial::from_material_info(&mk("Ghost", None, Some(1.0)));
assert_eq!(pbr.alpha, 0.0);
}
#[test]
fn transparency_0_maps_to_alpha_1() {
let pbr = PbrMaterial::from_material_info(&mk("Solid", None, Some(0.0)));
assert_eq!(pbr.alpha, 1.0);
}
#[test]
fn transparency_clamps_out_of_range_values() {
let pbr = PbrMaterial::from_material_info(&mk("Weird", None, Some(2.0)));
assert_eq!(pbr.alpha, 0.0);
let pbr = PbrMaterial::from_material_info(&mk("Weird2", None, Some(-1.0)));
assert_eq!(pbr.alpha, 1.0);
}
#[test]
fn srgb_to_linear_matches_known_values() {
assert!((srgb_to_linear(0.0) - 0.0).abs() < 1e-6);
assert!((srgb_to_linear(1.0) - 1.0).abs() < 1e-6);
let v = srgb_to_linear(0.5);
assert!(v > 0.21 && v < 0.22);
}
#[test]
fn classifier_is_case_insensitive() {
let (m, _, _) = classify_from_name("GLASS PANE");
assert_eq!(m, 0.0);
let (m, _, _) = classify_from_name("gLaSs");
assert_eq!(m, 0.0);
}
}