Skip to main content

proof_engine/editor/
material_editor.rs

1
2//! Advanced material editor — PBR parameters, layered materials, material graph, texture slots.
3
4use glam::{Vec2, Vec3, Vec4};
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// Texture slot
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum TextureWrap { Repeat, MirroredRepeat, ClampToEdge, ClampToBorder }
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum TextureFilter { Nearest, Linear, Trilinear, Anisotropic2, Anisotropic4, Anisotropic8, Anisotropic16 }
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum TextureColorSpace { Linear, Srgb }
17
18#[derive(Debug, Clone)]
19pub struct TextureSlot {
20    pub name: String,
21    pub texture_id: Option<u64>,
22    pub uv_channel: u8,
23    pub tiling: Vec2,
24    pub offset: Vec2,
25    pub rotation: f32,
26    pub wrap_u: TextureWrap,
27    pub wrap_v: TextureWrap,
28    pub filter: TextureFilter,
29    pub color_space: TextureColorSpace,
30    pub mip_bias: f32,
31    pub enabled: bool,
32}
33
34impl TextureSlot {
35    pub fn new(name: impl Into<String>) -> Self {
36        Self {
37            name: name.into(),
38            texture_id: None,
39            uv_channel: 0,
40            tiling: Vec2::ONE,
41            offset: Vec2::ZERO,
42            rotation: 0.0,
43            wrap_u: TextureWrap::Repeat,
44            wrap_v: TextureWrap::Repeat,
45            filter: TextureFilter::Anisotropic8,
46            color_space: TextureColorSpace::Srgb,
47            mip_bias: 0.0,
48            enabled: true,
49        }
50    }
51
52    pub fn with_texture(mut self, id: u64) -> Self {
53        self.texture_id = Some(id);
54        self
55    }
56
57    pub fn uv_matrix(&self) -> [[f32; 3]; 2] {
58        let cos = self.rotation.cos();
59        let sin = self.rotation.sin();
60        [
61            [cos * self.tiling.x, -sin * self.tiling.y, self.offset.x],
62            [sin * self.tiling.x,  cos * self.tiling.y, self.offset.y],
63        ]
64    }
65}
66
67// ---------------------------------------------------------------------------
68// PBR material properties
69// ---------------------------------------------------------------------------
70
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum BlendMode {
73    Opaque,
74    AlphaTest,
75    Transparent,
76    Additive,
77    Multiply,
78    Screen,
79    Custom,
80}
81
82impl BlendMode {
83    pub fn label(self) -> &'static str {
84        match self {
85            BlendMode::Opaque => "Opaque",
86            BlendMode::AlphaTest => "Alpha Test",
87            BlendMode::Transparent => "Transparent",
88            BlendMode::Additive => "Additive",
89            BlendMode::Multiply => "Multiply",
90            BlendMode::Screen => "Screen",
91            BlendMode::Custom => "Custom",
92        }
93    }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub enum ShadingModel {
98    Standard,
99    Unlit,
100    SubsurfaceScattering,
101    ThinTranslucent,
102    ClearCoat,
103    Hair,
104    Eye,
105    Cloth,
106    Foliage,
107    Water,
108    Custom,
109}
110
111impl ShadingModel {
112    pub fn label(self) -> &'static str {
113        match self {
114            ShadingModel::Standard => "Standard PBR",
115            ShadingModel::Unlit => "Unlit",
116            ShadingModel::SubsurfaceScattering => "Subsurface Scattering",
117            ShadingModel::ThinTranslucent => "Thin Translucent",
118            ShadingModel::ClearCoat => "Clear Coat",
119            ShadingModel::Hair => "Hair",
120            ShadingModel::Eye => "Eye",
121            ShadingModel::Cloth => "Cloth",
122            ShadingModel::Foliage => "Foliage",
123            ShadingModel::Water => "Water",
124            ShadingModel::Custom => "Custom",
125        }
126    }
127    pub fn uses_metallic(self) -> bool { matches!(self, ShadingModel::Standard | ShadingModel::ClearCoat) }
128    pub fn uses_subsurface(self) -> bool { matches!(self, ShadingModel::SubsurfaceScattering | ShadingModel::Foliage) }
129}
130
131#[derive(Debug, Clone)]
132pub struct PbrProperties {
133    pub base_color: Vec4,
134    pub metallic: f32,
135    pub roughness: f32,
136    pub specular: f32,
137    pub specular_color: Vec3,
138    pub emissive_color: Vec3,
139    pub emissive_intensity: f32,
140    pub normal_scale: f32,
141    pub occlusion_strength: f32,
142    pub alpha_cutoff: f32,
143    pub ior: f32,
144    pub transmission: f32,
145    pub thickness: f32,
146    pub subsurface_color: Vec3,
147    pub subsurface_radius: f32,
148    pub subsurface_power: f32,
149    pub sheen_color: Vec3,
150    pub sheen_roughness: f32,
151    pub clear_coat: f32,
152    pub clear_coat_roughness: f32,
153    pub anisotropy: f32,
154    pub anisotropy_rotation: f32,
155    pub horizon_fade: f32,
156    pub reflectance: f32,
157    pub cloth_sheen_color: Vec3,
158    pub cloth_sheen_roughness: f32,
159    pub cloth_normal_scale: f32,
160    pub dithered_lod_transition: bool,
161    pub receive_decals: bool,
162    pub cast_shadow: bool,
163    pub receive_shadow: bool,
164}
165
166impl Default for PbrProperties {
167    fn default() -> Self {
168        Self {
169            base_color: Vec4::new(0.8, 0.8, 0.8, 1.0),
170            metallic: 0.0,
171            roughness: 0.5,
172            specular: 0.5,
173            specular_color: Vec3::ONE,
174            emissive_color: Vec3::ZERO,
175            emissive_intensity: 1.0,
176            normal_scale: 1.0,
177            occlusion_strength: 1.0,
178            alpha_cutoff: 0.5,
179            ior: 1.5,
180            transmission: 0.0,
181            thickness: 0.0,
182            subsurface_color: Vec3::new(1.0, 0.2, 0.1),
183            subsurface_radius: 1.0,
184            subsurface_power: 12.234,
185            sheen_color: Vec3::ONE,
186            sheen_roughness: 0.3,
187            clear_coat: 0.0,
188            clear_coat_roughness: 0.0,
189            anisotropy: 0.0,
190            anisotropy_rotation: 0.0,
191            horizon_fade: 1.0,
192            reflectance: 0.5,
193            cloth_sheen_color: Vec3::new(0.83, 0.81, 0.78),
194            cloth_sheen_roughness: 0.5,
195            cloth_normal_scale: 1.0,
196            dithered_lod_transition: false,
197            receive_decals: true,
198            cast_shadow: true,
199            receive_shadow: true,
200        }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Material definition
206// ---------------------------------------------------------------------------
207
208#[derive(Debug, Clone)]
209pub struct Material {
210    pub id: u64,
211    pub name: String,
212    pub shading_model: ShadingModel,
213    pub blend_mode: BlendMode,
214    pub properties: PbrProperties,
215    pub texture_slots: HashMap<String, TextureSlot>,
216    pub two_sided: bool,
217    pub depth_write: bool,
218    pub depth_test: bool,
219    pub stencil_mask: u8,
220    pub render_queue: i32,
221    pub shader_override: Option<String>,
222    pub tags: Vec<String>,
223    pub parent_material: Option<u64>,
224    pub instance_params: HashMap<String, Vec4>,
225    pub is_variant: bool,
226}
227
228impl Material {
229    pub fn new(id: u64, name: impl Into<String>) -> Self {
230        let mut slots = HashMap::new();
231        for slot_name in &["BaseColor", "Normal", "Roughness", "Metallic", "AO", "Emissive", "Height", "Opacity", "Subsurface", "Clearcoat"] {
232            slots.insert(slot_name.to_string(), TextureSlot::new(*slot_name));
233        }
234        Self {
235            id,
236            name: name.into(),
237            shading_model: ShadingModel::Standard,
238            blend_mode: BlendMode::Opaque,
239            properties: PbrProperties::default(),
240            texture_slots: slots,
241            two_sided: false,
242            depth_write: true,
243            depth_test: true,
244            stencil_mask: 0,
245            render_queue: 2000,
246            shader_override: None,
247            tags: Vec::new(),
248            parent_material: None,
249            instance_params: HashMap::new(),
250            is_variant: false,
251        }
252    }
253
254    pub fn new_unlit(id: u64, name: impl Into<String>, color: Vec4) -> Self {
255        let mut mat = Self::new(id, name);
256        mat.shading_model = ShadingModel::Unlit;
257        mat.properties.base_color = color;
258        mat
259    }
260
261    pub fn new_emissive(id: u64, name: impl Into<String>, emissive: Vec3, intensity: f32) -> Self {
262        let mut mat = Self::new(id, name);
263        mat.properties.emissive_color = emissive;
264        mat.properties.emissive_intensity = intensity;
265        mat
266    }
267
268    pub fn new_glass(id: u64, name: impl Into<String>) -> Self {
269        let mut mat = Self::new(id, name);
270        mat.shading_model = ShadingModel::ThinTranslucent;
271        mat.blend_mode = BlendMode::Transparent;
272        mat.properties.base_color = Vec4::new(0.9, 0.95, 1.0, 0.3);
273        mat.properties.roughness = 0.0;
274        mat.properties.metallic = 0.0;
275        mat.properties.transmission = 0.9;
276        mat.properties.ior = 1.5;
277        mat.depth_write = false;
278        mat.two_sided = true;
279        mat
280    }
281
282    pub fn set_texture(&mut self, slot: &str, id: u64) {
283        if let Some(s) = self.texture_slots.get_mut(slot) {
284            s.texture_id = Some(id);
285        }
286    }
287
288    pub fn has_texture(&self, slot: &str) -> bool {
289        self.texture_slots.get(slot).and_then(|s| s.texture_id).is_some()
290    }
291
292    pub fn requires_alpha_pass(&self) -> bool {
293        matches!(self.blend_mode, BlendMode::Transparent | BlendMode::Additive | BlendMode::Multiply | BlendMode::Screen)
294    }
295
296    pub fn generate_shader_defines(&self) -> String {
297        let mut defs = String::new();
298        let m = &self.properties;
299        defs.push_str(&format!("#define SHADING_MODEL_{:?}\n", self.shading_model));
300        defs.push_str(&format!("#define BLEND_MODE_{:?}\n", self.blend_mode));
301        if self.has_texture("BaseColor") { defs.push_str("#define HAS_ALBEDO_MAP\n"); }
302        if self.has_texture("Normal") { defs.push_str("#define HAS_NORMAL_MAP\n"); }
303        if self.has_texture("Roughness") { defs.push_str("#define HAS_ROUGHNESS_MAP\n"); }
304        if self.has_texture("Metallic") { defs.push_str("#define HAS_METALLIC_MAP\n"); }
305        if self.has_texture("AO") { defs.push_str("#define HAS_AO_MAP\n"); }
306        if self.has_texture("Emissive") { defs.push_str("#define HAS_EMISSIVE_MAP\n"); }
307        if m.emissive_intensity > 0.0 && m.emissive_color.length() > 0.01 { defs.push_str("#define HAS_EMISSIVE\n"); }
308        if m.clear_coat > 0.0 { defs.push_str("#define HAS_CLEAR_COAT\n"); }
309        if m.transmission > 0.0 { defs.push_str("#define HAS_TRANSMISSION\n"); }
310        if m.anisotropy.abs() > 0.01 { defs.push_str("#define HAS_ANISOTROPY\n"); }
311        if self.two_sided { defs.push_str("#define TWO_SIDED\n"); }
312        defs
313    }
314
315    pub fn permutation_hash(&self) -> u64 {
316        let mut h = 0u64;
317        h ^= self.shading_model as u64;
318        h ^= (self.blend_mode as u64) << 4;
319        for (i, (_, slot)) in self.texture_slots.iter().enumerate() {
320            if slot.texture_id.is_some() { h ^= 1 << (8 + i); }
321        }
322        if self.two_sided { h ^= 1 << 24; }
323        h
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Material layer (for layered materials)
329// ---------------------------------------------------------------------------
330
331#[derive(Debug, Clone, Copy, PartialEq)]
332pub enum LayerBlendOp {
333    Normal,
334    Add,
335    Multiply,
336    Screen,
337    Overlay,
338    Lerp,
339    HeightBlend,
340    AngleBlend,
341    MaskBlend,
342}
343
344impl LayerBlendOp {
345    pub fn label(self) -> &'static str {
346        match self {
347            LayerBlendOp::Normal => "Normal",
348            LayerBlendOp::Add => "Add",
349            LayerBlendOp::Multiply => "Multiply",
350            LayerBlendOp::Screen => "Screen",
351            LayerBlendOp::Overlay => "Overlay",
352            LayerBlendOp::Lerp => "Lerp",
353            LayerBlendOp::HeightBlend => "Height Blend",
354            LayerBlendOp::AngleBlend => "Angle Blend",
355            LayerBlendOp::MaskBlend => "Mask Blend",
356        }
357    }
358}
359
360#[derive(Debug, Clone)]
361pub struct MaterialLayer {
362    pub name: String,
363    pub material_id: u64,
364    pub blend_op: LayerBlendOp,
365    pub opacity: f32,
366    pub mask_texture: Option<u64>,
367    pub mask_channel: u8,   // 0=R,1=G,2=B,3=A
368    pub enabled: bool,
369    pub properties: PbrProperties,
370    pub uv_tiling: Vec2,
371    pub uv_offset: Vec2,
372    pub triplanar: bool,
373    pub triplanar_blend: f32,
374    pub world_space_mapping: bool,
375}
376
377impl MaterialLayer {
378    pub fn new(name: impl Into<String>, mat_id: u64) -> Self {
379        Self {
380            name: name.into(),
381            material_id: mat_id,
382            blend_op: LayerBlendOp::Normal,
383            opacity: 1.0,
384            mask_texture: None,
385            mask_channel: 0,
386            enabled: true,
387            properties: PbrProperties::default(),
388            uv_tiling: Vec2::ONE,
389            uv_offset: Vec2::ZERO,
390            triplanar: false,
391            triplanar_blend: 1.0,
392            world_space_mapping: false,
393        }
394    }
395}
396
397#[derive(Debug, Clone)]
398pub struct LayeredMaterial {
399    pub id: u64,
400    pub name: String,
401    pub layers: Vec<MaterialLayer>,
402    pub blend_mode: BlendMode,
403    pub two_sided: bool,
404}
405
406impl LayeredMaterial {
407    pub fn new(id: u64, name: impl Into<String>) -> Self {
408        Self { id, name: name.into(), layers: Vec::new(), blend_mode: BlendMode::Opaque, two_sided: false }
409    }
410
411    pub fn add_layer(&mut self, layer: MaterialLayer) {
412        self.layers.push(layer);
413    }
414
415    pub fn remove_layer(&mut self, idx: usize) {
416        if idx < self.layers.len() { self.layers.remove(idx); }
417    }
418
419    pub fn move_layer_up(&mut self, idx: usize) {
420        if idx > 0 { self.layers.swap(idx, idx - 1); }
421    }
422
423    pub fn move_layer_down(&mut self, idx: usize) {
424        if idx + 1 < self.layers.len() { self.layers.swap(idx, idx + 1); }
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Decal material
430// ---------------------------------------------------------------------------
431
432#[derive(Debug, Clone, Copy, PartialEq)]
433pub enum DecalProjection { Box, Sphere, Cylinder }
434#[derive(Debug, Clone, Copy, PartialEq)]
435pub enum DecalBlendTarget { All, AlbedoRoughness, NormalOcclusion, Emissive }
436
437#[derive(Debug, Clone)]
438pub struct DecalMaterial {
439    pub id: u64,
440    pub name: String,
441    pub base_material: u64,
442    pub projection: DecalProjection,
443    pub blend_target: DecalBlendTarget,
444    pub sort_order: i32,
445    pub fade_in_distance: f32,
446    pub fade_out_distance: f32,
447    pub depth_bias: f32,
448    pub draw_on_water: bool,
449    pub draw_on_static: bool,
450    pub draw_on_dynamic: bool,
451}
452
453// ---------------------------------------------------------------------------
454// Material library
455// ---------------------------------------------------------------------------
456
457#[derive(Debug, Clone)]
458pub struct MaterialLibrary {
459    pub materials: Vec<Material>,
460    pub layered: Vec<LayeredMaterial>,
461    pub decals: Vec<DecalMaterial>,
462    pub next_id: u64,
463    pub categories: HashMap<String, Vec<u64>>,
464}
465
466impl MaterialLibrary {
467    pub fn new() -> Self {
468        let mut lib = Self {
469            materials: Vec::new(),
470            layered: Vec::new(),
471            decals: Vec::new(),
472            next_id: 1,
473            categories: HashMap::new(),
474        };
475        lib.populate_defaults();
476        lib
477    }
478
479    fn populate_defaults(&mut self) {
480        let defaults = [
481            ("Default", ShadingModel::Standard, BlendMode::Opaque, Vec4::new(0.8,0.8,0.8,1.0), 0.0, 0.5),
482            ("Metal_Chrome", ShadingModel::Standard, BlendMode::Opaque, Vec4::new(0.8,0.8,0.85,1.0), 1.0, 0.05),
483            ("Rubber_Black", ShadingModel::Standard, BlendMode::Opaque, Vec4::new(0.02,0.02,0.02,1.0), 0.0, 0.9),
484            ("Wood_Oak", ShadingModel::Standard, BlendMode::Opaque, Vec4::new(0.5,0.35,0.2,1.0), 0.0, 0.7),
485            ("Concrete", ShadingModel::Standard, BlendMode::Opaque, Vec4::new(0.6,0.6,0.6,1.0), 0.0, 0.95),
486            ("Glass", ShadingModel::ThinTranslucent, BlendMode::Transparent, Vec4::new(0.9,0.95,1.0,0.2), 0.0, 0.02),
487            ("Emissive_Glow", ShadingModel::Standard, BlendMode::Opaque, Vec4::new(0.1,0.1,0.1,1.0), 0.0, 0.8),
488            ("Skin", ShadingModel::SubsurfaceScattering, BlendMode::Opaque, Vec4::new(0.82,0.67,0.58,1.0), 0.0, 0.6),
489        ];
490        for (name, model, blend, color, metallic, roughness) in &defaults {
491            let id = self.next_id;
492            self.next_id += 1;
493            let mut mat = Material::new(id, *name);
494            mat.shading_model = *model;
495            mat.blend_mode = *blend;
496            mat.properties.base_color = *color;
497            mat.properties.metallic = *metallic;
498            mat.properties.roughness = *roughness;
499            let category = match model {
500                ShadingModel::ThinTranslucent => "Transparent",
501                ShadingModel::SubsurfaceScattering => "Organic",
502                _ => "Standard",
503            };
504            self.categories.entry(category.to_string()).or_default().push(id);
505            self.materials.push(mat);
506        }
507    }
508
509    pub fn add_material(&mut self, mat: Material) -> u64 {
510        let id = mat.id;
511        self.materials.push(mat);
512        id
513    }
514
515    pub fn find(&self, id: u64) -> Option<&Material> {
516        self.materials.iter().find(|m| m.id == id)
517    }
518
519    pub fn find_mut(&mut self, id: u64) -> Option<&mut Material> {
520        self.materials.iter_mut().find(|m| m.id == id)
521    }
522
523    pub fn search(&self, query: &str) -> Vec<&Material> {
524        let q = query.to_lowercase();
525        self.materials.iter().filter(|m| {
526            m.name.to_lowercase().contains(&q) ||
527            m.tags.iter().any(|t| t.to_lowercase().contains(&q)) ||
528            m.shading_model.label().to_lowercase().contains(&q)
529        }).collect()
530    }
531
532    pub fn by_category(&self, cat: &str) -> Vec<&Material> {
533        let ids = self.categories.get(cat).cloned().unwrap_or_default();
534        ids.iter().filter_map(|id| self.find(*id)).collect()
535    }
536
537    pub fn material_count(&self) -> usize { self.materials.len() }
538}
539
540// ---------------------------------------------------------------------------
541// Material editor state
542// ---------------------------------------------------------------------------
543
544#[derive(Debug, Clone, Copy, PartialEq)]
545pub enum MaterialEditorTab {
546    Properties,
547    TextureSlots,
548    ShadingModel,
549    Layers,
550    Preview,
551    Code,
552    History,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq)]
556pub enum PreviewShape {
557    Sphere, Cube, Plane, Cylinder, Capsule, Custom,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq)]
561pub enum PreviewLighting {
562    Studio, Outdoor, Night, Custom,
563}
564
565#[derive(Debug, Clone)]
566pub struct MaterialEditorState {
567    pub library: MaterialLibrary,
568    pub selected_material: Option<u64>,
569    pub active_tab: MaterialEditorTab,
570    pub preview_shape: PreviewShape,
571    pub preview_lighting: PreviewLighting,
572    pub preview_rotate: bool,
573    pub preview_rotation: f32,
574    pub preview_zoom: f32,
575    pub show_wireframe: bool,
576    pub show_uv_grid: bool,
577    pub show_tangent_space: bool,
578    pub search_query: String,
579    pub category_filter: Option<String>,
580    pub history: Vec<Material>,
581    pub history_pos: usize,
582    pub compare_material: Option<u64>,
583}
584
585impl MaterialEditorState {
586    pub fn new() -> Self {
587        Self {
588            library: MaterialLibrary::new(),
589            selected_material: Some(1),
590            active_tab: MaterialEditorTab::Properties,
591            preview_shape: PreviewShape::Sphere,
592            preview_lighting: PreviewLighting::Studio,
593            preview_rotate: true,
594            preview_rotation: 0.0,
595            preview_zoom: 1.0,
596            show_wireframe: false,
597            show_uv_grid: false,
598            show_tangent_space: false,
599            search_query: String::new(),
600            category_filter: None,
601            history: Vec::new(),
602            history_pos: 0,
603            compare_material: None,
604        }
605    }
606
607    pub fn selected_material(&self) -> Option<&Material> {
608        self.selected_material.and_then(|id| self.library.find(id))
609    }
610
611    pub fn selected_material_mut(&mut self) -> Option<&mut Material> {
612        self.selected_material.and_then(|id| self.library.find_mut(id))
613    }
614
615    pub fn snapshot(&mut self) {
616        if let Some(mat) = self.selected_material().cloned() {
617            self.history.truncate(self.history_pos);
618            self.history.push(mat);
619            self.history_pos = self.history.len();
620        }
621    }
622
623    pub fn undo(&mut self) {
624        if self.history_pos > 1 {
625            self.history_pos -= 1;
626            let mat = self.history[self.history_pos - 1].clone();
627            let id = mat.id;
628            if let Some(m) = self.library.find_mut(id) {
629                *m = mat;
630            }
631        }
632    }
633
634    pub fn redo(&mut self) {
635        if self.history_pos < self.history.len() {
636            let mat = self.history[self.history_pos].clone();
637            let id = mat.id;
638            self.history_pos += 1;
639            if let Some(m) = self.library.find_mut(id) {
640                *m = mat;
641            }
642        }
643    }
644
645    pub fn update(&mut self, dt: f32) {
646        if self.preview_rotate {
647            self.preview_rotation += dt * 30.0;
648            self.preview_rotation %= 360.0;
649        }
650    }
651
652    pub fn search_results(&self) -> Vec<&Material> {
653        if self.search_query.is_empty() {
654            match &self.category_filter {
655                Some(cat) => self.library.by_category(cat),
656                None => self.library.materials.iter().collect(),
657            }
658        } else {
659            self.library.search(&self.search_query)
660        }
661    }
662
663    pub fn duplicate_selected(&mut self) -> Option<u64> {
664        let mat = self.selected_material().cloned()?;
665        let new_id = self.library.next_id;
666        self.library.next_id += 1;
667        let mut new_mat = mat;
668        new_mat.id = new_id;
669        new_mat.name = format!("{}_copy", new_mat.name);
670        new_mat.is_variant = false;
671        new_mat.parent_material = None;
672        self.library.add_material(new_mat);
673        Some(new_id)
674    }
675
676    pub fn create_variant(&mut self) -> Option<u64> {
677        let mat = self.selected_material().cloned()?;
678        let new_id = self.library.next_id;
679        self.library.next_id += 1;
680        let mut variant = mat;
681        variant.id = new_id;
682        variant.name = format!("{}_variant", variant.name);
683        variant.is_variant = true;
684        variant.parent_material = self.selected_material;
685        self.library.add_material(variant);
686        Some(new_id)
687    }
688}
689
690// ---------------------------------------------------------------------------
691// Tests
692// ---------------------------------------------------------------------------
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn test_material_creation() {
699        let mat = Material::new(1, "TestMat");
700        assert!(mat.texture_slots.contains_key("BaseColor"));
701        assert!(!mat.requires_alpha_pass());
702    }
703
704    #[test]
705    fn test_glass_material() {
706        let glass = Material::new_glass(2, "Glass");
707        assert!(glass.requires_alpha_pass());
708        assert_eq!(glass.shading_model, ShadingModel::ThinTranslucent);
709    }
710
711    #[test]
712    fn test_shader_defines() {
713        let mat = Material::new(3, "Test");
714        let defs = mat.generate_shader_defines();
715        assert!(defs.contains("SHADING_MODEL"));
716    }
717
718    #[test]
719    fn test_library() {
720        let lib = MaterialLibrary::new();
721        assert!(!lib.materials.is_empty());
722        let results = lib.search("metal");
723        assert!(!results.is_empty());
724    }
725
726    #[test]
727    fn test_editor_undo_redo() {
728        let mut ed = MaterialEditorState::new();
729        ed.snapshot();
730        if let Some(mat) = ed.selected_material_mut() {
731            mat.properties.roughness = 0.99;
732        }
733        ed.undo();
734        if let Some(mat) = ed.selected_material() {
735            assert!((mat.properties.roughness - 0.5).abs() < 0.01);
736        }
737    }
738}