Skip to main content

polyscope_render/
materials.rs

1//! Material system for surface rendering.
2//!
3//! Materials define how surfaces are shaded. Blendable materials (clay, wax, candy, flat)
4//! use 4-channel matcap textures (R/G/B/K) for color-tinted lighting. Static materials
5//! (mud, ceramic, jade, normal) use a single matcap texture for all channels.
6
7use std::collections::HashMap;
8
9/// A material definition for rendering.
10///
11/// Materials control the appearance of surfaces. Blendable materials have separate
12/// R/G/B/K matcap textures that are weighted by the surface color. Static materials
13/// use a single matcap texture.
14#[derive(Debug, Clone)]
15pub struct Material {
16    /// Material name.
17    pub name: String,
18    /// Whether this is a flat (unlit) material.
19    pub is_flat: bool,
20    /// Whether this material has separate R/G/B/K matcap channels (blendable).
21    pub is_blendable: bool,
22    /// Ambient light factor (0.0 - 1.0). Used as fallback if matcap not loaded.
23    pub ambient: f32,
24    /// Diffuse reflection factor (0.0 - 1.0). Used as fallback if matcap not loaded.
25    pub diffuse: f32,
26    /// Specular reflection intensity (0.0 - 1.0). Used as fallback if matcap not loaded.
27    pub specular: f32,
28    /// Specular shininess/exponent (higher = sharper highlights). Used as fallback.
29    pub shininess: f32,
30}
31
32impl Material {
33    /// Creates a new material with default properties (not blendable).
34    pub fn new(name: impl Into<String>) -> Self {
35        Self {
36            name: name.into(),
37            is_flat: false,
38            is_blendable: false,
39            ambient: 0.2,
40            diffuse: 0.7,
41            specular: 0.3,
42            shininess: 32.0,
43        }
44    }
45
46    /// Creates a new blendable material with custom properties.
47    pub fn blendable(
48        name: impl Into<String>,
49        ambient: f32,
50        diffuse: f32,
51        specular: f32,
52        shininess: f32,
53    ) -> Self {
54        Self {
55            name: name.into(),
56            is_flat: false,
57            is_blendable: true,
58            ambient,
59            diffuse,
60            specular,
61            shininess,
62        }
63    }
64
65    /// Creates a new static (non-blendable) material with custom properties.
66    pub fn static_mat(
67        name: impl Into<String>,
68        ambient: f32,
69        diffuse: f32,
70        specular: f32,
71        shininess: f32,
72    ) -> Self {
73        Self {
74            name: name.into(),
75            is_flat: false,
76            is_blendable: false,
77            ambient,
78            diffuse,
79            specular,
80            shininess,
81        }
82    }
83
84    /// Creates a flat (unlit) material. Flat is blendable but shader skips matcap.
85    pub fn flat(name: impl Into<String>) -> Self {
86        Self {
87            name: name.into(),
88            is_flat: true,
89            is_blendable: true,
90            ambient: 1.0,
91            diffuse: 0.0,
92            specular: 0.0,
93            shininess: 1.0,
94        }
95    }
96
97    /// Creates the "clay" material - matte, minimal specularity. Blendable.
98    #[must_use]
99    pub fn clay() -> Self {
100        Self::blendable("clay", 0.25, 0.75, 0.1, 8.0)
101    }
102
103    /// Creates the "wax" material - slightly glossy, soft highlights. Blendable.
104    #[must_use]
105    pub fn wax() -> Self {
106        Self::blendable("wax", 0.2, 0.7, 0.4, 16.0)
107    }
108
109    /// Creates the "candy" material - shiny, bright highlights. Blendable.
110    #[must_use]
111    pub fn candy() -> Self {
112        Self::blendable("candy", 0.15, 0.6, 0.7, 64.0)
113    }
114
115    /// Creates the "ceramic" material - smooth, moderate gloss. Static.
116    #[must_use]
117    pub fn ceramic() -> Self {
118        Self::static_mat("ceramic", 0.2, 0.65, 0.5, 32.0)
119    }
120
121    /// Creates the "jade" material - translucent appearance (simulated). Static.
122    #[must_use]
123    pub fn jade() -> Self {
124        Self::static_mat("jade", 0.3, 0.6, 0.3, 24.0)
125    }
126
127    /// Creates the "mud" material - very matte, no specularity. Static.
128    #[must_use]
129    pub fn mud() -> Self {
130        Self::static_mat("mud", 0.3, 0.7, 0.0, 1.0)
131    }
132
133    /// Creates the "normal" material - balanced properties. Static.
134    #[must_use]
135    pub fn normal() -> Self {
136        Self::static_mat("normal", 0.2, 0.7, 0.3, 32.0)
137    }
138}
139
140impl Default for Material {
141    fn default() -> Self {
142        Self::clay()
143    }
144}
145
146/// GPU-compatible material uniforms.
147#[repr(C)]
148#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
149pub struct MaterialUniforms {
150    /// Ambient factor.
151    pub ambient: f32,
152    /// Diffuse factor.
153    pub diffuse: f32,
154    /// Specular intensity.
155    pub specular: f32,
156    /// Shininess exponent.
157    pub shininess: f32,
158}
159
160impl From<&Material> for MaterialUniforms {
161    fn from(mat: &Material) -> Self {
162        Self {
163            ambient: mat.ambient,
164            diffuse: mat.diffuse,
165            specular: mat.specular,
166            shininess: mat.shininess,
167        }
168    }
169}
170
171impl Default for MaterialUniforms {
172    fn default() -> Self {
173        Self {
174            ambient: 0.2,
175            diffuse: 0.7,
176            specular: 0.3,
177            shininess: 32.0,
178        }
179    }
180}
181
182/// Pre-built GPU resources for a matcap material.
183///
184/// Each material has 4 texture views (R, G, B, K channels) and a shared sampler.
185/// For blendable materials, each channel is a different texture.
186/// For static materials, all 4 views point to the same single texture.
187pub struct MatcapTextureSet {
188    /// Texture view for the R channel.
189    pub tex_r: wgpu::TextureView,
190    /// Texture view for the G channel.
191    pub tex_g: wgpu::TextureView,
192    /// Texture view for the B channel.
193    pub tex_b: wgpu::TextureView,
194    /// Texture view for the K (remainder) channel.
195    pub tex_k: wgpu::TextureView,
196    /// Linear filtering sampler.
197    pub sampler: wgpu::Sampler,
198    /// Pre-built bind group for this material.
199    pub bind_group: wgpu::BindGroup,
200}
201
202/// Registry for managing materials.
203#[derive(Default)]
204pub struct MaterialRegistry {
205    materials: HashMap<String, Material>,
206    default_material: String,
207}
208
209impl MaterialRegistry {
210    /// Creates a new material registry with default materials.
211    #[must_use]
212    pub fn new() -> Self {
213        let mut registry = Self {
214            materials: HashMap::new(),
215            default_material: "clay".to_string(),
216        };
217        registry.register_defaults();
218        registry
219    }
220
221    fn register_defaults(&mut self) {
222        // Register default materials (matching C++ Polyscope style)
223        self.register(Material::clay());
224        self.register(Material::wax());
225        self.register(Material::candy());
226        self.register(Material::ceramic());
227        self.register(Material::jade());
228        self.register(Material::mud());
229        self.register(Material::normal());
230        self.register(Material::flat("flat"));
231    }
232
233    /// Registers a material.
234    pub fn register(&mut self, material: Material) {
235        self.materials.insert(material.name.clone(), material);
236    }
237
238    /// Gets a material by name.
239    #[must_use]
240    pub fn get(&self, name: &str) -> Option<&Material> {
241        self.materials.get(name)
242    }
243
244    /// Returns true if a material with the given name is registered.
245    #[must_use]
246    pub fn has(&self, name: &str) -> bool {
247        self.materials.contains_key(name)
248    }
249
250    /// Gets the default material.
251    #[must_use]
252    pub fn default_material(&self) -> &Material {
253        self.materials
254            .get(&self.default_material)
255            .unwrap_or_else(|| {
256                self.materials
257                    .values()
258                    .next()
259                    .expect("no materials registered")
260            })
261    }
262
263    /// Sets the default material name.
264    pub fn set_default(&mut self, name: &str) {
265        if self.materials.contains_key(name) {
266            self.default_material = name.to_string();
267        }
268    }
269
270    /// Returns all material names, with built-in materials first in a stable order,
271    /// followed by custom materials sorted alphabetically.
272    #[must_use]
273    pub fn names(&self) -> Vec<&str> {
274        const BUILTIN_ORDER: &[&str] = &[
275            "clay", "wax", "candy", "flat", "mud", "ceramic", "jade", "normal",
276        ];
277        let mut names: Vec<&str> = Vec::new();
278        // Built-ins first, in canonical order
279        for &builtin in BUILTIN_ORDER {
280            if self.materials.contains_key(builtin) {
281                names.push(builtin);
282            }
283        }
284        // Custom materials after built-ins, sorted alphabetically
285        let mut custom: Vec<&str> = self
286            .materials
287            .keys()
288            .map(String::as_str)
289            .filter(|n| !BUILTIN_ORDER.contains(n))
290            .collect();
291        custom.sort_unstable();
292        names.extend(custom);
293        names
294    }
295
296    /// Returns the number of registered materials.
297    #[must_use]
298    pub fn len(&self) -> usize {
299        self.materials.len()
300    }
301
302    /// Returns true if no materials are registered.
303    #[must_use]
304    pub fn is_empty(&self) -> bool {
305        self.materials.is_empty()
306    }
307}
308
309// Embedded matcap texture data (extracted from C++ Polyscope bindata).
310// Blendable materials have 4 separate HDR files (R/G/B/K channels).
311// Static materials have 1 JPEG file (reused for all 4 channels).
312mod matcap_data {
313    // Blendable: Clay
314    pub const CLAY_R: &[u8] = include_bytes!("../data/matcaps/clay_r.hdr");
315    pub const CLAY_G: &[u8] = include_bytes!("../data/matcaps/clay_g.hdr");
316    pub const CLAY_B: &[u8] = include_bytes!("../data/matcaps/clay_b.hdr");
317    pub const CLAY_K: &[u8] = include_bytes!("../data/matcaps/clay_k.hdr");
318
319    // Blendable: Wax
320    pub const WAX_R: &[u8] = include_bytes!("../data/matcaps/wax_r.hdr");
321    pub const WAX_G: &[u8] = include_bytes!("../data/matcaps/wax_g.hdr");
322    pub const WAX_B: &[u8] = include_bytes!("../data/matcaps/wax_b.hdr");
323    pub const WAX_K: &[u8] = include_bytes!("../data/matcaps/wax_k.hdr");
324
325    // Blendable: Candy
326    pub const CANDY_R: &[u8] = include_bytes!("../data/matcaps/candy_r.hdr");
327    pub const CANDY_G: &[u8] = include_bytes!("../data/matcaps/candy_g.hdr");
328    pub const CANDY_B: &[u8] = include_bytes!("../data/matcaps/candy_b.hdr");
329    pub const CANDY_K: &[u8] = include_bytes!("../data/matcaps/candy_k.hdr");
330
331    // Blendable: Flat
332    pub const FLAT_R: &[u8] = include_bytes!("../data/matcaps/flat_r.hdr");
333    pub const FLAT_G: &[u8] = include_bytes!("../data/matcaps/flat_g.hdr");
334    pub const FLAT_B: &[u8] = include_bytes!("../data/matcaps/flat_b.hdr");
335    pub const FLAT_K: &[u8] = include_bytes!("../data/matcaps/flat_k.hdr");
336
337    // Static: Mud, Ceramic, Jade, Normal (JPEG)
338    pub const MUD: &[u8] = include_bytes!("../data/matcaps/mud.jpg");
339    pub const CERAMIC: &[u8] = include_bytes!("../data/matcaps/ceramic.jpg");
340    pub const JADE: &[u8] = include_bytes!("../data/matcaps/jade.jpg");
341    pub const NORMAL: &[u8] = include_bytes!("../data/matcaps/normal.jpg");
342}
343
344/// Decode an embedded image (HDR or JPEG) into float RGBA pixel data.
345///
346/// Returns `(width, height, rgba_f32_pixels)` where pixels are laid out as
347/// `[r, g, b, a, r, g, b, a, ...]` in linear float space.
348fn decode_matcap_image(data: &[u8]) -> (u32, u32, Vec<f32>) {
349    use image::GenericImageView;
350
351    let img = image::load_from_memory(data).expect("Failed to decode matcap image");
352    let (width, height) = img.dimensions();
353
354    // Convert to Rgba32F
355    let rgb32f = img.to_rgb32f();
356    let pixels = rgb32f.as_raw();
357
358    // Pad RGB -> RGBA with alpha=1.0
359    let mut rgba = Vec::with_capacity((width * height * 4) as usize);
360    for chunk in pixels.chunks(3) {
361        rgba.push(chunk[0]);
362        rgba.push(chunk[1]);
363        rgba.push(chunk[2]);
364        rgba.push(1.0);
365    }
366
367    (width, height, rgba)
368}
369
370/// Decode an image file from disk into float RGBA pixel data.
371///
372/// Returns `(width, height, rgba_f32_pixels)` where pixels are laid out as
373/// `[r, g, b, a, r, g, b, a, ...]` in linear float space.
374///
375/// Supports any format the `image` crate can open: HDR, JPEG, PNG, EXR, etc.
376pub fn decode_matcap_image_from_file(
377    path: &std::path::Path,
378) -> std::result::Result<(u32, u32, Vec<f32>), String> {
379    use image::GenericImageView;
380
381    let img =
382        image::open(path).map_err(|e| format!("failed to open '{}': {}", path.display(), e))?;
383    let (width, height) = img.dimensions();
384
385    if width == 0 || height == 0 {
386        return Err(format!("image '{}' has zero dimensions", path.display()));
387    }
388
389    let rgb32f = img.to_rgb32f();
390    let pixels = rgb32f.as_raw();
391
392    // Pad RGB -> RGBA with alpha=1.0
393    let mut rgba = Vec::with_capacity((width * height * 4) as usize);
394    for chunk in pixels.chunks(3) {
395        rgba.push(chunk[0]);
396        rgba.push(chunk[1]);
397        rgba.push(chunk[2]);
398        rgba.push(1.0);
399    }
400
401    Ok((width, height, rgba))
402}
403
404/// Upload a decoded matcap image as a GPU texture.
405#[must_use]
406pub fn upload_matcap_texture(
407    device: &wgpu::Device,
408    queue: &wgpu::Queue,
409    label: &str,
410    width: u32,
411    height: u32,
412    rgba_data: &[f32],
413) -> wgpu::Texture {
414    let texture = device.create_texture(&wgpu::TextureDescriptor {
415        label: Some(label),
416        size: wgpu::Extent3d {
417            width,
418            height,
419            depth_or_array_layers: 1,
420        },
421        mip_level_count: 1,
422        sample_count: 1,
423        dimension: wgpu::TextureDimension::D2,
424        format: wgpu::TextureFormat::Rgba16Float,
425        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
426        view_formats: &[],
427    });
428
429    // Convert f32 -> f16 for upload
430    let half_data: Vec<u16> = rgba_data
431        .iter()
432        .map(|&v| half::f16::from_f32(v).to_bits())
433        .collect();
434
435    queue.write_texture(
436        wgpu::TexelCopyTextureInfo {
437            texture: &texture,
438            mip_level: 0,
439            origin: wgpu::Origin3d::ZERO,
440            aspect: wgpu::TextureAspect::All,
441        },
442        bytemuck::cast_slice(&half_data),
443        wgpu::TexelCopyBufferLayout {
444            offset: 0,
445            bytes_per_row: Some(width * 4 * 2), // 4 channels * 2 bytes per f16
446            rows_per_image: Some(height),
447        },
448        wgpu::Extent3d {
449            width,
450            height,
451            depth_or_array_layers: 1,
452        },
453    );
454
455    texture
456}
457
458/// Create a linear filtering sampler for matcap textures.
459#[must_use]
460pub fn create_matcap_sampler(device: &wgpu::Device) -> wgpu::Sampler {
461    device.create_sampler(&wgpu::SamplerDescriptor {
462        label: Some("Matcap Sampler"),
463        address_mode_u: wgpu::AddressMode::ClampToEdge,
464        address_mode_v: wgpu::AddressMode::ClampToEdge,
465        address_mode_w: wgpu::AddressMode::ClampToEdge,
466        mag_filter: wgpu::FilterMode::Linear,
467        min_filter: wgpu::FilterMode::Linear,
468        mipmap_filter: wgpu::FilterMode::Linear,
469        ..Default::default()
470    })
471}
472
473/// Initialize all matcap textures and bind groups.
474///
475/// Returns a `HashMap` mapping material name -> `MatcapTextureSet`.
476#[must_use]
477pub fn init_matcap_textures(
478    device: &wgpu::Device,
479    queue: &wgpu::Queue,
480    bind_group_layout: &wgpu::BindGroupLayout,
481) -> HashMap<String, MatcapTextureSet> {
482    // Blendable material entry: (name, R channel, G channel, B channel, K channel)
483    type BlendableMatEntry<'a> = (&'a str, &'a [u8], &'a [u8], &'a [u8], &'a [u8]);
484
485    let sampler = create_matcap_sampler(device);
486    let mut textures = HashMap::new();
487
488    // Helper: decode + upload a single texture
489    let upload = |label: &str, data: &[u8]| -> wgpu::Texture {
490        let (w, h, rgba) = decode_matcap_image(data);
491        upload_matcap_texture(device, queue, label, w, h, &rgba)
492    };
493
494    // Blendable materials: 4 separate textures (R, G, B, K channels)
495    let blendable_mats: &[BlendableMatEntry<'_>] = &[
496        (
497            "clay",
498            matcap_data::CLAY_R,
499            matcap_data::CLAY_G,
500            matcap_data::CLAY_B,
501            matcap_data::CLAY_K,
502        ),
503        (
504            "wax",
505            matcap_data::WAX_R,
506            matcap_data::WAX_G,
507            matcap_data::WAX_B,
508            matcap_data::WAX_K,
509        ),
510        (
511            "candy",
512            matcap_data::CANDY_R,
513            matcap_data::CANDY_G,
514            matcap_data::CANDY_B,
515            matcap_data::CANDY_K,
516        ),
517        (
518            "flat",
519            matcap_data::FLAT_R,
520            matcap_data::FLAT_G,
521            matcap_data::FLAT_B,
522            matcap_data::FLAT_K,
523        ),
524    ];
525
526    for &(name, r_data, g_data, b_data, k_data) in blendable_mats {
527        let tex_r = upload(&format!("matcap_{name}_r"), r_data);
528        let tex_g = upload(&format!("matcap_{name}_g"), g_data);
529        let tex_b = upload(&format!("matcap_{name}_b"), b_data);
530        let tex_k = upload(&format!("matcap_{name}_k"), k_data);
531
532        let view_r = tex_r.create_view(&wgpu::TextureViewDescriptor::default());
533        let view_g = tex_g.create_view(&wgpu::TextureViewDescriptor::default());
534        let view_b = tex_b.create_view(&wgpu::TextureViewDescriptor::default());
535        let view_k = tex_k.create_view(&wgpu::TextureViewDescriptor::default());
536
537        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
538            label: Some(&format!("matcap_{name}_bind_group")),
539            layout: bind_group_layout,
540            entries: &[
541                wgpu::BindGroupEntry {
542                    binding: 0,
543                    resource: wgpu::BindingResource::TextureView(&view_r),
544                },
545                wgpu::BindGroupEntry {
546                    binding: 1,
547                    resource: wgpu::BindingResource::TextureView(&view_g),
548                },
549                wgpu::BindGroupEntry {
550                    binding: 2,
551                    resource: wgpu::BindingResource::TextureView(&view_b),
552                },
553                wgpu::BindGroupEntry {
554                    binding: 3,
555                    resource: wgpu::BindingResource::TextureView(&view_k),
556                },
557                wgpu::BindGroupEntry {
558                    binding: 4,
559                    resource: wgpu::BindingResource::Sampler(&sampler),
560                },
561            ],
562        });
563
564        textures.insert(
565            name.to_string(),
566            MatcapTextureSet {
567                tex_r: view_r,
568                tex_g: view_g,
569                tex_b: view_b,
570                tex_k: view_k,
571                sampler: create_matcap_sampler(device), // each set gets its own
572                bind_group,
573            },
574        );
575    }
576
577    // Static materials: 1 texture reused for all 4 channels
578    let static_mats: &[(&str, &[u8])] = &[
579        ("mud", matcap_data::MUD),
580        ("ceramic", matcap_data::CERAMIC),
581        ("jade", matcap_data::JADE),
582        ("normal", matcap_data::NORMAL),
583    ];
584
585    for &(name, data) in static_mats {
586        let tex = upload(&format!("matcap_{name}"), data);
587        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
588
589        // For static materials, create 4 views from the same texture
590        let view_r = tex.create_view(&wgpu::TextureViewDescriptor::default());
591        let view_g = tex.create_view(&wgpu::TextureViewDescriptor::default());
592        let view_b = tex.create_view(&wgpu::TextureViewDescriptor::default());
593
594        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
595            label: Some(&format!("matcap_{name}_bind_group")),
596            layout: bind_group_layout,
597            entries: &[
598                wgpu::BindGroupEntry {
599                    binding: 0,
600                    resource: wgpu::BindingResource::TextureView(&view),
601                },
602                wgpu::BindGroupEntry {
603                    binding: 1,
604                    resource: wgpu::BindingResource::TextureView(&view_r),
605                },
606                wgpu::BindGroupEntry {
607                    binding: 2,
608                    resource: wgpu::BindingResource::TextureView(&view_g),
609                },
610                wgpu::BindGroupEntry {
611                    binding: 3,
612                    resource: wgpu::BindingResource::TextureView(&view_b),
613                },
614                wgpu::BindGroupEntry {
615                    binding: 4,
616                    resource: wgpu::BindingResource::Sampler(&sampler),
617                },
618            ],
619        });
620
621        textures.insert(
622            name.to_string(),
623            MatcapTextureSet {
624                tex_r: tex.create_view(&wgpu::TextureViewDescriptor::default()),
625                tex_g: tex.create_view(&wgpu::TextureViewDescriptor::default()),
626                tex_b: tex.create_view(&wgpu::TextureViewDescriptor::default()),
627                tex_k: tex.create_view(&wgpu::TextureViewDescriptor::default()),
628                sampler: create_matcap_sampler(device),
629                bind_group,
630            },
631        );
632    }
633
634    textures
635}
636
637/// Create the matcap bind group layout (5 entries: 4 textures + 1 sampler).
638#[must_use]
639pub fn create_matcap_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
640    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
641        label: Some("Matcap Bind Group Layout"),
642        entries: &[
643            // Binding 0: mat_r texture
644            wgpu::BindGroupLayoutEntry {
645                binding: 0,
646                visibility: wgpu::ShaderStages::FRAGMENT,
647                ty: wgpu::BindingType::Texture {
648                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
649                    view_dimension: wgpu::TextureViewDimension::D2,
650                    multisampled: false,
651                },
652                count: None,
653            },
654            // Binding 1: mat_g texture
655            wgpu::BindGroupLayoutEntry {
656                binding: 1,
657                visibility: wgpu::ShaderStages::FRAGMENT,
658                ty: wgpu::BindingType::Texture {
659                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
660                    view_dimension: wgpu::TextureViewDimension::D2,
661                    multisampled: false,
662                },
663                count: None,
664            },
665            // Binding 2: mat_b texture
666            wgpu::BindGroupLayoutEntry {
667                binding: 2,
668                visibility: wgpu::ShaderStages::FRAGMENT,
669                ty: wgpu::BindingType::Texture {
670                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
671                    view_dimension: wgpu::TextureViewDimension::D2,
672                    multisampled: false,
673                },
674                count: None,
675            },
676            // Binding 3: mat_k texture
677            wgpu::BindGroupLayoutEntry {
678                binding: 3,
679                visibility: wgpu::ShaderStages::FRAGMENT,
680                ty: wgpu::BindingType::Texture {
681                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
682                    view_dimension: wgpu::TextureViewDimension::D2,
683                    multisampled: false,
684                },
685                count: None,
686            },
687            // Binding 4: sampler
688            wgpu::BindGroupLayoutEntry {
689                binding: 4,
690                visibility: wgpu::ShaderStages::FRAGMENT,
691                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
692                count: None,
693            },
694        ],
695    })
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn test_flat_material() {
704        let mat = Material::flat("test_flat");
705        assert!(mat.is_flat);
706        assert!(mat.is_blendable);
707        assert_eq!(mat.diffuse, 0.0);
708        assert_eq!(mat.specular, 0.0);
709    }
710
711    #[test]
712    fn test_material_registry() {
713        let registry = MaterialRegistry::new();
714        assert!(registry.get("clay").is_some());
715        assert!(registry.get("wax").is_some());
716        assert!(registry.get("candy").is_some());
717        assert!(registry.get("flat").is_some());
718        assert!(registry.get("nonexistent").is_none());
719    }
720
721    #[test]
722    fn test_material_uniforms() {
723        let mat = Material::candy();
724        let uniforms = MaterialUniforms::from(&mat);
725        assert_eq!(uniforms.ambient, mat.ambient);
726        assert_eq!(uniforms.specular, mat.specular);
727    }
728
729    #[test]
730    fn test_blendable_materials() {
731        assert!(Material::clay().is_blendable);
732        assert!(Material::wax().is_blendable);
733        assert!(Material::candy().is_blendable);
734        assert!(Material::flat("flat").is_blendable);
735        assert!(!Material::mud().is_blendable);
736        assert!(!Material::ceramic().is_blendable);
737        assert!(!Material::jade().is_blendable);
738        assert!(!Material::normal().is_blendable);
739    }
740
741    #[test]
742    fn test_material_registry_has() {
743        let registry = MaterialRegistry::new();
744        assert!(registry.has("clay"));
745        assert!(registry.has("wax"));
746        assert!(registry.has("normal"));
747        assert!(!registry.has("nonexistent"));
748        assert!(!registry.has("my_custom"));
749    }
750
751    #[test]
752    fn test_material_registry_names_order() {
753        let registry = MaterialRegistry::new();
754        let names = registry.names();
755        // Built-ins should appear in canonical order
756        assert_eq!(
757            names,
758            vec![
759                "clay", "wax", "candy", "flat", "mud", "ceramic", "jade", "normal"
760            ]
761        );
762    }
763
764    #[test]
765    fn test_material_registry_custom() {
766        let mut registry = MaterialRegistry::new();
767        let mut custom = Material::clay();
768        custom.name = "zebra_mat".to_string();
769        registry.register(custom);
770
771        let mut custom2 = Material::clay();
772        custom2.name = "alpha_mat".to_string();
773        registry.register(custom2);
774
775        assert!(registry.has("zebra_mat"));
776        assert!(registry.has("alpha_mat"));
777
778        let names = registry.names();
779        // Built-ins first in canonical order, then custom sorted alphabetically
780        let expected = vec![
781            "clay",
782            "wax",
783            "candy",
784            "flat",
785            "mud",
786            "ceramic",
787            "jade",
788            "normal",
789            "alpha_mat",
790            "zebra_mat",
791        ];
792        assert_eq!(names, expected);
793    }
794}