Skip to main content

viewport_lib/scene/
material.rs

1/// Per-item render settings: visibility, appearance overrides, pick identity, and selection state.
2///
3/// Replaces the former `AppearanceSettings` struct and absorbs the `pick_id` and `selected`
4/// fields that previously appeared at the top level of each item type.
5///
6/// Always use `Default::default()` as the base, then set individual fields:
7///
8/// ```rust
9/// # use viewport_lib::ItemSettings;
10/// let mut s = ItemSettings::default();
11/// s.hidden = true;
12/// ```
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub struct ItemSettings {
17    /// Hide the object entirely. Default `false`.
18    pub hidden: bool,
19    /// Skip all lighting calculations and output raw colour. Default `false`.
20    pub unlit: bool,
21    /// Global opacity multiplier. 1.0 = fully opaque, 0.0 = fully transparent. Default 1.0.
22    pub opacity: f32,
23    /// Render as wireframe regardless of the global wireframe flag. Default `false`. Extended conceptually to non-mesh
24    /// objects to render their sub-objects as meshes, e.g., a VolumeItem has its voxels as wireframe boxes.
25    pub wireframe: bool,
26    /// GPU pick identifier. `PickId::NONE` = not pickable. Default `PickId::NONE`.
27    pub pick_id: crate::renderer::PickId,
28    /// Whether the item is currently selected. Default `false`.
29    pub selected: bool,
30    /// Whether this item casts shadows in the shadow map pass. Default `true`.
31    ///
32    /// Set to `false` for items that should be visible but should not contribute
33    /// to the shadow map (HUD-style overlays, debug helpers, light source
34    /// indicators). Honoured by mesh-family items; non-mesh items currently do
35    /// not participate in the shadow pass and treat this flag as a no-op.
36    pub cast_shadows: bool,
37    /// Whether this item samples the shadow map when shaded. Default `true`.
38    ///
39    /// Set to `false` to render the item as if no shadow caster occludes it.
40    /// Useful for items that should remain fully lit regardless of the scene's
41    /// shadow casters (debug visualisations, glowing markers). Honoured by
42    /// mesh-family items; non-mesh items currently do not sample shadow maps and
43    /// treat this flag as a no-op.
44    pub receive_shadows: bool,
45}
46
47impl Default for ItemSettings {
48    fn default() -> Self {
49        Self {
50            hidden: false,
51            unlit: false,
52            opacity: 1.0,
53            wireframe: false,
54            pick_id: crate::renderer::PickId::NONE,
55            selected: false,
56            cast_shadows: true,
57            receive_shadows: true,
58        }
59    }
60}
61
62/// Lit shading model selected by a [`Material`].
63///
64/// The variants are mutually exclusive: a material picks one path. The `unlit`
65/// per-item bypass on [`ItemSettings`] is checked independently and takes
66/// precedence over the shading model.
67///
68/// New shading models (Toon, SSS, Gooch, ...) are added here as new variants.
69/// The glTF PBR extensions (clearcoat, sheen, anisotropy, iridescence) layer on
70/// top of `Pbr` as separate fields on `Material` rather than as variants here.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub enum ShadingModel {
74    /// Blinn-Phong lit shading using `ambient` / `diffuse` / `specular` / `shininess`.
75    Phong,
76    /// Cook-Torrance GGX physically based shading driven by `metallic` and `roughness`.
77    Pbr,
78    /// Matcap lookup. Replaces the lit path with a captured-lighting texture.
79    /// Blendable matcaps tint with `base_colour`; static matcaps override colour entirely.
80    Matcap(crate::resources::MatcapId),
81    /// Flat (faceted) shading: the per-vertex normal is discarded and a
82    /// geometric normal is recomputed per-fragment from screen-space
83    /// derivatives of world position. Produces visible polygon edges on
84    /// triangulated meshes without changing the lighting block; everything
85    /// else (hemisphere ambient, scene lights, shadows, AO, alpha mode)
86    /// runs as it does for [`Phong`].
87    ///
88    /// Edges appear at the triangulation level, not at logical-face
89    /// boundaries. A quad authored as two triangles shows both; a CAD
90    /// surface tessellated finely shows the tessellation.
91    Flat,
92}
93
94impl Default for ShadingModel {
95    fn default() -> Self {
96        Self::Phong
97    }
98}
99
100/// Procedural UV visualization mode for parameterization inspection.
101///
102/// When set on a [`Material`], the mesh fragment shader ignores the albedo texture and
103/// renders a procedural pattern driven by the mesh UV coordinates instead. Useful for
104/// inspecting UV distortion, seams, and parameterization quality without needing a texture.
105///
106/// Requires the mesh to have UV coordinates. Has no effect if the mesh lacks UVs.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub enum ParamVisMode {
110    /// Alternating black/white squares tiled in UV space.
111    Checker = 1,
112    /// Thin grid lines at UV integer boundaries.
113    Grid = 2,
114    /// Polar checkerboard centred at UV (0.5, 0.5) : reveals rotational consistency.
115    LocalChecker = 3,
116    /// Concentric rings centred at UV (0.5, 0.5) : reveals radial distortion.
117    LocalRadial = 4,
118}
119
120/// UV parameterization visualization settings.
121///
122/// Attach to [`Material::param_vis`] to enable procedural UV pattern rendering.
123/// The `scale` controls tile frequency : higher values produce more, smaller tiles.
124#[derive(Debug, Clone, Copy, PartialEq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126pub struct ParamVis {
127    /// Which procedural pattern to render.
128    pub mode: ParamVisMode,
129    /// Tile frequency multiplier. Default 8.0 : produces 8 checker squares per UV unit.
130    pub scale: f32,
131}
132
133impl Default for ParamVis {
134    fn default() -> Self {
135        Self {
136            mode: ParamVisMode::Checker,
137            scale: 8.0,
138        }
139    }
140}
141
142/// Procedural pattern for back-face rendering.
143///
144/// Used with [`PatternConfig`] to select which procedural pattern is drawn on back faces.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147pub enum BackfacePattern {
148    /// Alternating squares in world XY.
149    Checker = 0,
150    /// Diagonal lines (45 degrees).
151    Hatching = 1,
152    /// Two sets of diagonal lines (45 and 135 degrees).
153    Crosshatch = 2,
154    /// Horizontal stripes.
155    Stripes = 3,
156}
157
158/// Configuration for procedural back-face patterns.
159///
160/// Used with [`BackfacePolicy::Pattern`]. The `scale` controls how many pattern cells
161/// fit across the object's longest bounding-box dimension, so the pattern always looks
162/// proportional regardless of the mesh's physical size.
163///
164/// Prefer using `..Default::default()` when constructing so that future fields
165/// added to this struct do not require changes at every call site:
166///
167/// ```rust
168/// # use viewport_lib::{PatternConfig, BackfacePattern};
169/// let cfg = PatternConfig {
170///     pattern: BackfacePattern::Hatching,
171///     colour: [1.0, 0.5, 0.0],
172///     ..Default::default()
173/// };
174/// ```
175#[derive(Debug, Clone, Copy, PartialEq)]
176#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
177pub struct PatternConfig {
178    /// Which procedural pattern to draw on back faces.
179    pub pattern: BackfacePattern,
180    /// RGB foreground colour for the pattern (linear 0..1).
181    pub colour: [f32; 3],
182    /// Number of pattern cells across the object's longest bounding-box dimension.
183    ///
184    /// Default 20.0. Increase for finer detail, decrease for coarser.
185    pub scale: f32,
186}
187
188impl Default for PatternConfig {
189    fn default() -> Self {
190        Self {
191            pattern: BackfacePattern::Checker,
192            colour: [1.0, 0.5, 0.0],
193            scale: 20.0,
194        }
195    }
196}
197
198/// Controls how back faces of a mesh are rendered.
199///
200/// Use [`BackfacePolicy::Cull`] (the default) to hide back faces, [`BackfacePolicy::Identical`]
201/// to show them with the same shading as front faces, or [`BackfacePolicy::DifferentColour`]
202/// to shade back faces in a distinct colour : useful for spotting mesh orientation errors or
203/// highlighting the interior of open surfaces.
204#[derive(Debug, Clone, Copy, PartialEq)]
205#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
206pub enum BackfacePolicy {
207    /// Back faces are culled (invisible). Default.
208    Cull,
209    /// Back faces are visible and shaded identically to front faces.
210    Identical,
211    /// Back faces are visible and shaded in the given RGB colour (linear 0..1).
212    ///
213    /// Front faces receive normal shading; back faces receive Blinn-Phong shading
214    /// with the supplied colour and the same ambient/diffuse/specular coefficients.
215    /// The normal is flipped so lighting is computed from the back-face perspective.
216    DifferentColour([f32; 3]),
217    /// Back faces are visible and tinted darker by the given factor (0.0..1.0).
218    ///
219    /// The base colour is multiplied by `(1.0 - factor)`, so `Tint(0.3)` means
220    /// back faces are 30% darker. The normal is flipped for correct lighting.
221    Tint(f32),
222    /// Back faces are rendered with a procedural pattern scaled to the object's size.
223    ///
224    /// The pattern density is relative to the object's world-space bounding box, so
225    /// the appearance stays consistent across meshes of different physical sizes.
226    /// The normal is flipped for correct lighting.
227    Pattern(PatternConfig),
228}
229
230impl Default for BackfacePolicy {
231    fn default() -> Self {
232        BackfacePolicy::Cull
233    }
234}
235
236/// Alpha blending mode for a material, matching the glTF `alphaMode` field.
237///
238/// Controls how the fragment alpha value is interpreted by the renderer.
239#[derive(Debug, Clone, Copy, PartialEq)]
240#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
241pub enum AlphaMode {
242    /// Alpha is ignored; the surface is always fully opaque. Default.
243    Opaque,
244    /// Fragments with alpha below the cutoff are discarded; others are fully opaque.
245    /// The f32 value is the cutoff threshold in [0, 1].
246    Mask(f32),
247    /// Standard alpha blending through the OIT pass.
248    Blend,
249}
250
251impl Default for AlphaMode {
252    fn default() -> Self {
253        AlphaMode::Opaque
254    }
255}
256
257/// Per-object material properties for Blinn-Phong and PBR shading.
258///
259/// Materials carry all shading parameters that were previously global in `LightingSettings`.
260/// Each `SceneRenderItem` now has its own `Material`, enabling per-object visual distinction.
261///
262/// This struct is `#[non_exhaustive]`: construct via [`Material::default`],
263/// [`Material::from_colour`], or spread syntax (`..Default::default()`). This allows new
264/// fields to be added later without breaking downstream code.
265#[non_exhaustive]
266#[derive(Debug, Clone, Copy, PartialEq)]
267#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
268pub struct Material {
269    /// Base diffuse colour [r, g, b] in linear 0..1 range. Default [0.7, 0.7, 0.7].
270    pub base_colour: [f32; 3],
271    /// Ambient light coefficient. Default 0.15.
272    pub ambient: f32,
273    /// Diffuse light coefficient. Default 0.75.
274    pub diffuse: f32,
275    /// Specular highlight coefficient. Default 0.4.
276    pub specular: f32,
277    /// Specular shininess exponent. Default 32.0.
278    pub shininess: f32,
279    /// Metallic factor for PBR Cook-Torrance shading. 0=dielectric, 1=metal. Default 0.0.
280    pub metallic: f32,
281    /// Roughness factor for PBR microfacet distribution. 0=mirror, 1=fully rough. Default 0.5.
282    pub roughness: f32,
283    /// Optional albedo texture identifier. None = no texture applied. Default None.
284    pub texture_id: Option<crate::resources::TextureId>,
285    /// Optional normal map texture identifier. None = no normal mapping. Default None.
286    ///
287    /// The normal map must be in tangent-space with XY encoded as RG (0..1 -> -1..+1).
288    /// Requires UVs and tangents on the mesh for correct TBN construction.
289    pub normal_map_id: Option<crate::resources::TextureId>,
290    /// Optional ambient occlusion map texture identifier. None = no AO map. Default None.
291    ///
292    /// The AO map R channel encodes cavity factor (0=fully occluded, 1=fully lit).
293    /// Applied multiplicatively to ambient and diffuse terms.
294    pub ao_map_id: Option<crate::resources::TextureId>,
295    /// Optional combined metallic-roughness texture (ORM layout). Default None.
296    ///
297    /// Matches the glTF `metallicRoughnessTexture`: G channel encodes roughness,
298    /// B channel encodes metallic. Each channel is multiplied by the corresponding
299    /// scalar factor (`roughness`, `metallic`). Only sampled when the material's
300    /// `shading_model` is `ShadingModel::Pbr`.
301    pub metallic_roughness_texture_id: Option<crate::resources::TextureId>,
302    /// Self-illumination colour added after lighting. Default [0.0, 0.0, 0.0].
303    ///
304    /// Matches glTF `emissiveFactor`. Applied to both Blinn-Phong and PBR paths.
305    /// When `emissive_texture_id` is set, this value is multiplied by the texture sample.
306    pub emissive: [f32; 3],
307    /// Optional emissive texture identifier. Default None.
308    ///
309    /// Matches glTF `emissiveTexture`. Sampled and multiplied by `emissive`.
310    pub emissive_texture_id: Option<crate::resources::TextureId>,
311    /// Alpha handling mode. Default [`AlphaMode::Opaque`].
312    ///
313    /// `Mask(cutoff)` discards fragments below the cutoff using the alpha channel.
314    /// `Blend` routes the mesh through the OIT transparent pass.
315    pub alpha_mode: AlphaMode,
316    /// Which lit shading model evaluates this surface. Default [`ShadingModel::Phong`].
317    ///
318    /// `Phong` uses Blinn-Phong; `Pbr` uses Cook-Torrance GGX driven by `metallic`
319    /// and `roughness`; `Matcap(id)` replaces the lit path with a matcap texture
320    /// lookup. The `unlit` per-item bypass on [`ItemSettings`] is checked
321    /// independently and takes precedence over any value here.
322    pub shading_model: ShadingModel,
323    /// UV parameterization visualization. When set, replaces albedo/lighting with a
324    /// procedural pattern in UV space : useful for inspecting parameterization quality.
325    ///
326    /// Requires UV coordinates on the mesh. Default None (standard shading).
327    pub param_vis: Option<ParamVis>,
328    /// Back-face rendering policy. Default [`BackfacePolicy::Cull`] (back faces hidden).
329    ///
330    /// Use [`BackfacePolicy::Identical`] for single-sided geometry like planes and open
331    /// surfaces. Use [`BackfacePolicy::DifferentColour`] to highlight back faces in a
332    /// distinct colour : helpful for diagnosing mesh orientation errors.
333    pub backface_policy: BackfacePolicy,
334    /// Per-material UV offset applied to all texture samples (albedo, normal,
335    /// AO, metallic-roughness, emissive). Default `[0.0, 0.0]`.
336    ///
337    /// Sampled as `uv = mesh_uv * uv_scale + uv_offset` in the fragment shader.
338    /// Lets one mesh be shared between multiple atlas-region materials without
339    /// duplicating vertex buffers : an asset pack with a single tree atlas can
340    /// pick the trunk or leaves region per material instance.
341    pub uv_offset: [f32; 2],
342    /// Per-material UV scale. Default `[1.0, 1.0]`.
343    ///
344    /// See [`uv_offset`](Self::uv_offset) for the full sampling formula.
345    pub uv_scale: [f32; 2],
346    /// Min/max range applied to the AO map's R sample. Identity `[0.0, 1.0]`
347    /// passes the sample through unchanged. Skipped when `ao_map_id` is None.
348    ///
349    /// The remap is `mix(min, max, raw_sample)`. Useful when a packed mask
350    /// map authors AO in a reduced range (e.g. `[0.4, 1.0]`) and the renderer
351    /// needs to normalise the sample before it drives lighting.
352    pub ao_range: [f32; 2],
353    /// Min/max range applied to the metallic sample (B channel of the
354    /// metallic-roughness texture in the glTF/ORM convention). Identity
355    /// `[0.0, 1.0]`. Skipped when `metallic_roughness_texture_id` is None.
356    ///
357    /// Applied to the texture sample before the scalar `metallic` factor:
358    /// `final = clamp(mix(min, max, raw.b) * metallic, 0.0, 1.0)`.
359    pub metallic_range: [f32; 2],
360    /// Min/max range applied to the roughness sample (G channel of the
361    /// metallic-roughness texture). Identity `[0.0, 1.0]`. Skipped when
362    /// `metallic_roughness_texture_id` is None.
363    ///
364    /// Applied to the texture sample before the scalar `roughness` factor:
365    /// `final = max(mix(min, max, raw.g) * roughness, 0.04)`.
366    pub roughness_range: [f32; 2],
367}
368
369impl Default for Material {
370    fn default() -> Self {
371        Self {
372            base_colour: [0.7, 0.7, 0.7],
373            ambient: 0.15,
374            diffuse: 0.75,
375            specular: 0.4,
376            shininess: 32.0,
377            metallic: 0.0,
378            roughness: 0.5,
379            texture_id: None,
380            normal_map_id: None,
381            ao_map_id: None,
382            metallic_roughness_texture_id: None,
383            emissive: [0.0, 0.0, 0.0],
384            emissive_texture_id: None,
385            alpha_mode: AlphaMode::Opaque,
386            shading_model: ShadingModel::Phong,
387            param_vis: None,
388            backface_policy: BackfacePolicy::Cull,
389            uv_offset: [0.0, 0.0],
390            uv_scale: [1.0, 1.0],
391            ao_range: [0.0, 1.0],
392            metallic_range: [0.0, 1.0],
393            roughness_range: [0.0, 1.0],
394        }
395    }
396}
397
398impl Material {
399    /// Returns `true` if the backface policy makes back faces visible.
400    pub fn is_two_sided(&self) -> bool {
401        !matches!(self.backface_policy, BackfacePolicy::Cull)
402    }
403
404    /// Returns `true` if back faces need the per-object draw path.
405    ///
406    /// `Cull` and `Identical` render correctly through the instanced path: `Cull`
407    /// uses back-face culling and `Identical` disables culling but shades back
408    /// faces with the geometric normal, neither of which needs per-item state the
409    /// instanced shader does not carry. The styled policies (`DifferentColour`,
410    /// `Tint`, `Pattern`) flip the normal and read a per-item back-face colour, so
411    /// they stay on the per-object path.
412    pub fn backface_needs_per_object(&self) -> bool {
413        matches!(
414            self.backface_policy,
415            BackfacePolicy::DifferentColour(_)
416                | BackfacePolicy::Tint(_)
417                | BackfacePolicy::Pattern(_)
418        )
419    }
420
421    /// Returns `true` if this material uses alpha blending and should go through the OIT pass.
422    pub fn is_blend(&self) -> bool {
423        matches!(self.alpha_mode, AlphaMode::Blend)
424    }
425
426    /// Construct from a plain colour, all other parameters at their defaults.
427    pub fn from_colour(colour: [f32; 3]) -> Self {
428        Self {
429            base_colour: colour,
430            ..Default::default()
431        }
432    }
433
434    /// Construct a Cook-Torrance PBR material.
435    ///
436    /// - `metallic`: 0.0 = dielectric, 1.0 = full metal
437    /// - `roughness`: 0.0 = mirror, 1.0 = fully rough
438    ///
439    /// All other parameters take their defaults. Enable post-processing
440    /// (`PostProcessSettings::enabled = true`) for correct HDR tone mapping.
441    pub fn pbr(base_colour: [f32; 3], metallic: f32, roughness: f32) -> Self {
442        Self {
443            base_colour,
444            shading_model: ShadingModel::Pbr,
445            metallic,
446            roughness,
447            ..Default::default()
448        }
449    }
450
451    /// Returns `true` when the material's shading model is Cook-Torrance PBR.
452    pub fn is_pbr(&self) -> bool {
453        matches!(self.shading_model, ShadingModel::Pbr)
454    }
455
456    /// Returns `true` when the material's shading model is flat / faceted.
457    pub fn is_flat(&self) -> bool {
458        matches!(self.shading_model, ShadingModel::Flat)
459    }
460
461    /// Construct a flat (faceted) material with the given base colour. The
462    /// per-vertex normal is replaced at the fragment stage by a geometric
463    /// normal recovered from screen-space derivatives, so polygon edges
464    /// remain visible on otherwise smooth meshes. Everything else takes
465    /// material defaults.
466    pub fn flat(base_colour: [f32; 3]) -> Self {
467        Self {
468            base_colour,
469            shading_model: ShadingModel::Flat,
470            ..Default::default()
471        }
472    }
473
474    /// Returns the matcap id when the material uses a matcap shading model.
475    pub fn matcap_id(&self) -> Option<crate::resources::MatcapId> {
476        match self.shading_model {
477            ShadingModel::Matcap(id) => Some(id),
478            _ => None,
479        }
480    }
481
482    /// Alias for [`Material::from_colour`].
483    ///
484    /// Returns a material with the given base colour and all other fields at defaults.
485    /// "solid" is a more familiar name in some graphics contexts; both names are kept
486    /// so existing `from_colour` call sites do not need updating.
487    pub fn solid(colour: [f32; 3]) -> Self {
488        Self::from_colour(colour)
489    }
490
491    /// Returns a material with only `texture_id` set; all other fields take defaults.
492    ///
493    /// Useful when the mesh already carries a UV layout and the only requirement is
494    /// to apply a texture. Base colour, lighting model, and all other fields stay at
495    /// their default values.
496    pub fn textured(texture_id: crate::resources::TextureId) -> Self {
497        Self {
498            texture_id: Some(texture_id),
499            ..Default::default()
500        }
501    }
502
503    /// PBR constructor that also sets a baked ambient-occlusion texture.
504    ///
505    /// - `base_colour`: RGB albedo in linear 0..1
506    /// - `metallic`: 0.0 = dielectric, 1.0 = full metal
507    /// - `roughness`: 0.0 = mirror, 1.0 = fully rough
508    /// - `ao_map`: baked AO texture id, or `None` to disable AO
509    ///
510    /// Sets `shading_model = ShadingModel::Pbr`. All other fields take defaults.
511    /// Use [`Material::pbr`] when no AO map is needed.
512    pub fn pbr_with_ao(
513        base_colour: [f32; 3],
514        metallic: f32,
515        roughness: f32,
516        ao_map: Option<crate::resources::TextureId>,
517    ) -> Self {
518        Self {
519            base_colour,
520            shading_model: ShadingModel::Pbr,
521            metallic,
522            roughness,
523            ao_map_id: ao_map,
524            ..Default::default()
525        }
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn item_settings_defaults() {
535        let a = ItemSettings::default();
536        assert!(!a.hidden);
537        assert!(!a.unlit);
538        assert!((a.opacity - 1.0).abs() < 1e-6);
539        assert!(!a.wireframe);
540        assert!(!a.selected);
541    }
542
543    #[test]
544    fn item_settings_spread_syntax() {
545        let a = ItemSettings {
546            unlit: true,
547            ..Default::default()
548        };
549        assert!(a.unlit);
550        assert!(!a.hidden);
551        assert!((a.opacity - 1.0).abs() < 1e-6);
552    }
553
554    #[test]
555    fn flat_constructor_and_helpers() {
556        let m = Material::flat([0.4, 0.5, 0.6]);
557        assert!(m.is_flat());
558        assert!(!m.is_pbr());
559        assert!(m.matcap_id().is_none());
560        assert!(matches!(m.shading_model, ShadingModel::Flat));
561        assert_eq!(m.base_colour, [0.4, 0.5, 0.6]);
562
563        let p = Material::default();
564        assert!(!p.is_flat());
565    }
566
567    #[test]
568    fn default_values() {
569        let m = Material::default();
570        assert!((m.base_colour[0] - 0.7).abs() < 1e-6);
571        assert!((m.ambient - 0.15).abs() < 1e-6);
572        assert!((m.diffuse - 0.75).abs() < 1e-6);
573        assert!(!m.is_pbr());
574        assert!(m.texture_id.is_none());
575        assert!(m.normal_map_id.is_none());
576        assert!(m.ao_map_id.is_none());
577        assert!(m.matcap_id().is_none());
578        assert!(m.param_vis.is_none());
579    }
580
581    #[test]
582    fn from_colour_sets_base_colour() {
583        let m = Material::from_colour([1.0, 0.0, 0.5]);
584        assert!((m.base_colour[0] - 1.0).abs() < 1e-6);
585        assert!((m.base_colour[1]).abs() < 1e-6);
586        assert!((m.base_colour[2] - 0.5).abs() < 1e-6);
587        // Other fields should be defaults
588        assert!((m.ambient - 0.15).abs() < 1e-6);
589    }
590
591    #[test]
592    fn pbr_constructor() {
593        let m = Material::pbr([0.8, 0.2, 0.1], 0.9, 0.3);
594        assert!(m.is_pbr());
595        assert!((m.metallic - 0.9).abs() < 1e-6);
596        assert!((m.roughness - 0.3).abs() < 1e-6);
597        assert!((m.base_colour[0] - 0.8).abs() < 1e-6);
598    }
599
600    #[test]
601    fn is_two_sided_cull() {
602        let m = Material::default();
603        assert!(!m.is_two_sided());
604    }
605
606    #[test]
607    fn is_two_sided_identical() {
608        let m = Material {
609            backface_policy: BackfacePolicy::Identical,
610            ..Default::default()
611        };
612        assert!(m.is_two_sided());
613    }
614
615    #[test]
616    fn is_two_sided_different_colour() {
617        let m = Material {
618            backface_policy: BackfacePolicy::DifferentColour([1.0, 0.0, 0.0]),
619            ..Default::default()
620        };
621        assert!(m.is_two_sided());
622    }
623
624    #[test]
625    fn is_two_sided_tint() {
626        let m = Material {
627            backface_policy: BackfacePolicy::Tint(0.3),
628            ..Default::default()
629        };
630        assert!(m.is_two_sided());
631    }
632
633    #[test]
634    fn is_two_sided_pattern() {
635        let m = Material {
636            backface_policy: BackfacePolicy::Pattern(PatternConfig {
637                pattern: BackfacePattern::Hatching,
638                colour: [0.5, 0.5, 0.5],
639                ..Default::default()
640            }),
641            ..Default::default()
642        };
643        assert!(m.is_two_sided());
644    }
645
646    #[test]
647    fn param_vis_default() {
648        let pv = ParamVis::default();
649        assert_eq!(pv.mode, ParamVisMode::Checker);
650        assert!((pv.scale - 8.0).abs() < 1e-6);
651    }
652
653    #[test]
654    fn solid_delegates_to_from_colour() {
655        let colour = [1.0_f32, 0.0, 0.5];
656        let a = Material::solid(colour);
657        let b = Material::from_colour(colour);
658        assert!((a.base_colour[0] - b.base_colour[0]).abs() < 1e-6);
659        assert!((a.base_colour[1] - b.base_colour[1]).abs() < 1e-6);
660        assert!((a.base_colour[2] - b.base_colour[2]).abs() < 1e-6);
661        assert_eq!(a.is_pbr(), b.is_pbr());
662        assert_eq!(a.texture_id, b.texture_id);
663        assert_eq!(a.ao_map_id, b.ao_map_id);
664        assert!((a.ambient - b.ambient).abs() < 1e-6);
665    }
666
667    #[test]
668    fn textured_sets_texture_id() {
669        let m = Material::textured(crate::resources::TextureId(42));
670        assert_eq!(m.texture_id, Some(crate::resources::TextureId(42)));
671        let def = Material::default();
672        assert!((m.base_colour[0] - def.base_colour[0]).abs() < 1e-6);
673        assert!(!m.is_pbr());
674    }
675
676    #[test]
677    fn pbr_with_ao_sets_fields() {
678        let m = Material::pbr_with_ao(
679            [0.8, 0.2, 0.1],
680            0.9,
681            0.3,
682            Some(crate::resources::TextureId(7)),
683        );
684        assert!(m.is_pbr());
685        assert!((m.metallic - 0.9).abs() < 1e-6);
686        assert!((m.roughness - 0.3).abs() < 1e-6);
687        assert_eq!(m.ao_map_id, Some(crate::resources::TextureId(7)));
688        assert!((m.base_colour[0] - 0.8).abs() < 1e-6);
689    }
690
691    #[test]
692    fn pbr_with_ao_accepts_none() {
693        let m = Material::pbr_with_ao([0.5; 3], 0.0, 1.0, None);
694        assert_eq!(m.ao_map_id, None);
695    }
696
697    #[test]
698    fn channel_ranges_default_to_identity() {
699        let m = Material::default();
700        assert_eq!(m.ao_range, [0.0, 1.0]);
701        assert_eq!(m.metallic_range, [0.0, 1.0]);
702        assert_eq!(m.roughness_range, [0.0, 1.0]);
703    }
704
705    #[test]
706    fn channel_ranges_round_trip_with_spread() {
707        let m = Material {
708            ao_range: [0.4, 1.0],
709            metallic_range: [0.1, 0.9],
710            roughness_range: [0.2, 0.8],
711            ..Default::default()
712        };
713        assert_eq!(m.ao_range, [0.4, 1.0]);
714        assert_eq!(m.metallic_range, [0.1, 0.9]);
715        assert_eq!(m.roughness_range, [0.2, 0.8]);
716        // Other defaults unaffected.
717        assert_eq!(m.uv_offset, [0.0, 0.0]);
718        assert_eq!(m.uv_scale, [1.0, 1.0]);
719    }
720}