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