Skip to main content

visualization/
material_colors.rs

1//! Material identity is independent of part number and render entity order.
2use bevy::prelude::*;
3use fem_core::{AnalysisSetup, ElementId, FemMesh};
4use std::collections::{BTreeMap, BTreeSet};
5
6#[derive(Resource, Default, Clone, Copy, Debug, PartialEq, Eq)]
7pub enum MaterialColorMode {
8    Part,
9    #[default]
10    Material,
11}
12
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub(crate) enum MaterialIdentity {
15    Unassigned,
16    Invalid,
17    Assigned(String),
18}
19pub const UNASSIGNED_MATERIAL_COLOR: Color = Color::srgb(0.48, 0.51, 0.55);
20pub const INVALID_MATERIAL_COLOR: Color = Color::srgb(0.95, 0.16, 0.65);
21
22pub fn material_identity_color(name: &str) -> Color {
23    // Stable FNV-1a, not RandomState (which changes between runs). A full hue
24    // range avoids a tiny modulo palette; names remain visible in the legend.
25    let hash = name.bytes().fold(2166136261u32, |hash, byte| {
26        (hash ^ byte as u32).wrapping_mul(16777619)
27    });
28    Color::hsl((hash as f64 / u32::MAX as f64 * 360.0) as f32, 0.48, 0.58)
29}
30impl MaterialIdentity {
31    pub(crate) fn color(&self) -> Color {
32        match self {
33            Self::Unassigned => UNASSIGNED_MATERIAL_COLOR,
34            Self::Invalid => INVALID_MATERIAL_COLOR,
35            Self::Assigned(name) => material_identity_color(name),
36        }
37    }
38}
39
40pub(crate) fn resolve_materials(
41    setup: &AnalysisSetup,
42    mesh_index: usize,
43    mesh: &FemMesh,
44) -> BTreeMap<ElementId, MaterialIdentity> {
45    let sections = setup.build_element_section_map(mesh_index, mesh);
46    let mut names = BTreeMap::<&str, usize>::new();
47    for material in &setup.materials {
48        *names.entry(&material.name).or_default() += 1;
49    }
50    let mut colors: BTreeMap<_, _> = mesh
51        .elements
52        .iter()
53        .map(|element| {
54            let identity = match sections.get(&element.id) {
55                None => MaterialIdentity::Unassigned,
56                Some(section) if names.get(section.material_name.as_str()) == Some(&1) => {
57                    MaterialIdentity::Assigned(section.material_name.clone())
58                }
59                _ => MaterialIdentity::Invalid,
60            };
61            (element.id, identity)
62        })
63        .collect();
64    // Specific groups override whole-mesh defaults. Competing specific
65    // groups with different materials must not silently look unambiguous.
66    let mut owners: BTreeMap<ElementId, BTreeSet<&str>> = BTreeMap::new();
67    for section in setup.sections_for_mesh(mesh_index) {
68        if let Some(group) = section
69            .element_set_name
70            .as_ref()
71            .and_then(|name| mesh.element_sets.iter().find(|group| &group.name == name))
72        {
73            for id in &group.elements {
74                owners
75                    .entry(*id)
76                    .or_default()
77                    .insert(&section.material_name);
78            }
79        }
80    }
81    for (id, owners) in owners {
82        if owners.len() > 1 && colors.contains_key(&id) {
83            colors.insert(id, MaterialIdentity::Invalid);
84        }
85    }
86    colors
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use fem_core::{FemElementSet, SectionKind};
93    #[test]
94    fn identity_is_stable_and_not_based_on_part_number() {
95        assert_eq!(
96            material_identity_color("STEEL"),
97            material_identity_color("STEEL")
98        );
99        assert_ne!(
100            material_identity_color("STEEL"),
101            material_identity_color("AL6082")
102        );
103        let mesh = FemMesh::demo_hex8();
104        let mut setup = AnalysisSetup::default();
105        setup.add_material("STEEL", Some(210e9), Some(0.3), Some(7850.0));
106        for index in [0, 1] {
107            setup.add_section(index, "STEEL", None, SectionKind::Solid);
108        }
109        assert_eq!(
110            resolve_materials(&setup, 0, &mesh),
111            resolve_materials(&setup, 1, &mesh)
112        );
113        assert!(
114            resolve_materials(&setup, 2, &mesh)
115                .values()
116                .all(|color| *color == MaterialIdentity::Unassigned)
117        );
118    }
119    #[test]
120    fn group_precedence_missing_materials_and_conflicts_are_explicit() {
121        let mut mesh = FemMesh::demo_hex8();
122        let mut setup = AnalysisSetup::default();
123        mesh.element_sets.push(FemElementSet {
124            name: "PATCH".into(),
125            elements: vec![ElementId(0)],
126        });
127        for name in ["A", "B"] {
128            setup.add_material(name, Some(1.0), Some(0.3), None);
129        }
130        setup.add_section(0, "A", None, SectionKind::Solid);
131        setup.add_section(0, "B", Some("PATCH".into()), SectionKind::Solid);
132        assert_eq!(
133            resolve_materials(&setup, 0, &mesh)[&ElementId(0)],
134            MaterialIdentity::Assigned("B".into())
135        );
136        setup.add_section(0, "A", Some("PATCH".into()), SectionKind::Solid);
137        assert_eq!(
138            resolve_materials(&setup, 0, &mesh)[&ElementId(0)],
139            MaterialIdentity::Invalid
140        );
141        setup.sections.truncate(1);
142        setup.materials.clear();
143        assert_eq!(
144            resolve_materials(&setup, 0, &mesh)[&ElementId(0)],
145            MaterialIdentity::Invalid
146        );
147    }
148}