use crate::material_colors::{MaterialColorMode, MaterialIdentity, resolve_materials};
use bevy::asset::RenderAssetUsages;
use bevy::math::primitives::{Cuboid, Cylinder};
use bevy::mesh::{Mesh3d, PrimitiveTopology};
use bevy::pbr::MeshMaterial3d;
use bevy::prelude::*;
use fem_core::{
ContactCandidate, ContactCandidateState, ContactPair, ContactSlaveRef, ElementFaceRef, FaceId,
FemEdge, FemElement, FemEntityId, FemEntityRef, FemFace, FemMesh, FemModel, FemNode,
MpcEquation, NodeId, RigidSpiderCandidateState, SurfaceSetRef, rainbow_color,
};
use interaction::HoverResult;
use std::collections::BTreeSet;
use selection::{
EdgeEntity, ElementEntity, FaceEntity, Hovered, NodeEntity, Selectable, Selected,
SelectionState,
};
const NODE_SIZE: f32 = 0.12;
const EDGE_THICKNESS: f32 = 0.04;
const FACE_THICKNESS: f32 = 0.012;
const MIN_VISUAL_SIZE: f32 = 0.01;
const ENTITY_RENDER_LIMIT: usize = 30_000;
const MAX_DEFINED_CONTACT_NODE_MARKERS: usize = 20_000;
#[cfg(test)]
#[path = "material_render_tests.rs"]
mod material_render_tests;
#[derive(Resource, Debug, Clone)]
pub struct VisualizationSettings {
pub mode: VisualizationMode,
pub contour: Option<ContourSettings>,
}
impl Default for VisualizationSettings {
fn default() -> Self {
Self {
mode: VisualizationMode::ShadedWithEdges,
contour: None,
}
}
}
#[derive(Resource, Debug, Clone, Copy, PartialEq)]
pub struct ContactReviewSettings {
pub active: bool,
pub ghost_others: bool,
pub separation_percent: f32,
}
impl Default for ContactReviewSettings {
fn default() -> Self {
Self {
active: false,
ghost_others: true,
separation_percent: 8.0,
}
}
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RigidSpiderReviewSettings {
pub active: bool,
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DefinedMpcPreview {
pub selected: Option<usize>,
pub active: bool,
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct MpcPairDraftPreview {
pub positive: Option<(usize, NodeId)>,
pub negative: Option<(usize, NodeId)>,
pub active: bool,
}
impl MpcPairDraftPreview {
pub fn clear(&mut self) {
self.positive = None;
self.negative = None;
self.active = false;
}
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DefinedContactPreview {
pub selected: Option<usize>,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContactDraftSurface {
pub mesh_index: usize,
pub surfaces: Vec<ElementFaceRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContactDraftSlave {
Nodes {
mesh_index: usize,
nodes: Vec<NodeId>,
},
Surface(ContactDraftSurface),
}
#[derive(Resource, Debug, Clone, PartialEq, Eq, Default)]
pub struct ContactDraftPreview {
pub master: Option<ContactDraftSurface>,
pub slave: Option<ContactDraftSlave>,
pub active: bool,
}
impl ContactDraftPreview {
pub fn clear(&mut self) {
self.master = None;
self.slave = None;
self.active = false;
}
}
#[derive(Resource, Debug, Clone, Copy, PartialEq)]
pub(crate) struct ContactReviewPose {
active: bool,
ghost_others: bool,
mesh_a: usize,
mesh_b: usize,
offset_a: Vec3,
offset_b: Vec3,
}
impl Default for ContactReviewPose {
fn default() -> Self {
Self {
active: false,
ghost_others: true,
mesh_a: 0,
mesh_b: 0,
offset_a: Vec3::ZERO,
offset_b: Vec3::ZERO,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContourSettings {
pub mesh_index: usize,
pub step_index: usize,
pub field_name: String,
pub show_deformation: bool,
pub displacement_field: String,
pub deformation_scale: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualizationMode {
ShadedWithEdges,
Shaded,
Flat,
Wireframe,
Transparent,
Edges,
}
impl VisualizationMode {
pub const ALL: [Self; 6] = [
Self::ShadedWithEdges,
Self::Shaded,
Self::Flat,
Self::Wireframe,
Self::Transparent,
Self::Edges,
];
pub const fn label(self) -> &'static str {
match self {
Self::ShadedWithEdges => "Both",
Self::Shaded => "Shaded",
Self::Flat => "Flat",
Self::Wireframe => "Wire",
Self::Transparent => "X-ray",
Self::Edges => "Edges",
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VisualLayer {
Shaded,
Edge,
Node,
}
impl VisualLayer {
pub(crate) const fn visible_in(self, mode: VisualizationMode) -> bool {
match (self, mode) {
(
Self::Shaded,
VisualizationMode::Shaded
| VisualizationMode::ShadedWithEdges
| VisualizationMode::Flat
| VisualizationMode::Wireframe
| VisualizationMode::Transparent,
) => true,
(
Self::Edge,
VisualizationMode::Edges
| VisualizationMode::ShadedWithEdges
| VisualizationMode::Transparent,
) => true,
(Self::Node, VisualizationMode::ShadedWithEdges) => true,
_ => false,
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TopologyHighlight {
Hover,
Selected,
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ContactCandidateHighlight {
Master,
Slave,
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RigidSpiderHighlight {
Master,
Slave,
}
#[derive(Component, Debug, Clone, Copy, Default)]
pub(crate) struct ContactHighlightAvailability(bool);
#[derive(Default)]
pub(crate) struct TopologyHighlightCache {
hover: Vec<FemEntityRef>,
selected: Vec<FemEntityRef>,
}
#[derive(Component, Debug, Clone, Copy)]
pub struct FemMeshVisual;
#[derive(Component, Debug, Clone, Copy)]
pub struct FemPartVisual {
pub mesh_index: usize,
}
#[derive(Component)]
pub struct NormalMaterial(pub Handle<StandardMaterial>);
#[derive(Component)]
pub struct FlatMaterial(pub Handle<StandardMaterial>);
#[derive(Component)]
pub struct TransparentMaterial(pub Handle<StandardMaterial>);
#[derive(Component)]
pub struct HoverMaterial(pub Handle<StandardMaterial>);
#[derive(Component)]
pub struct SelectedMaterial(pub Handle<StandardMaterial>);
#[derive(Clone)]
struct MaterialSet {
normal: Handle<StandardMaterial>,
hover: Handle<StandardMaterial>,
selected: Handle<StandardMaterial>,
flat: Handle<StandardMaterial>,
transparent: Handle<StandardMaterial>,
}
pub fn spawn_demo_mesh(
mut commands: Commands,
model: Option<Res<FemModel>>,
analysis_setup: Option<Res<fem_core::AnalysisSetup>>,
colors: Res<MaterialColorMode>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let fem_model = model
.as_deref()
.cloned()
.unwrap_or_else(FemModel::demo_hex8);
if model.is_none() {
commands.insert_resource(fem_model.clone());
}
spawn_model_visuals(
&mut commands,
&mut meshes,
&mut materials,
&fem_model,
analysis_setup.as_deref(),
*colors,
);
}
pub(crate) fn spawn_model_visuals(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
fem_model: &FemModel,
analysis_setup: Option<&fem_core::AnalysisSetup>,
color_mode: MaterialColorMode,
) {
let selected_element = Color::srgb(0.10, 1.0, 0.45);
let selected_face = Color::srgb(0.10, 1.0, 0.45);
let selected_edge = Color::srgb(0.10, 1.0, 0.45);
let selected_node = Color::srgb(0.10, 1.0, 0.45);
let hover_element = Color::srgba(1.0, 0.75, 0.15, 0.70);
let hover_face = Color::srgba(1.0, 0.88, 0.15, 0.70);
let hover_edge = Color::srgb(1.0, 0.82, 0.15);
let hover_node = Color::srgb(1.0, 0.82, 0.18);
let multi_part = fem_model.meshes.len() > 1;
let model_scale = model_visual_scale(fem_model);
for (mesh_index, fem_mesh) in fem_model.meshes.iter().enumerate() {
let assignments = (color_mode == MaterialColorMode::Material).then(|| {
resolve_materials(
analysis_setup.unwrap_or(&fem_core::AnalysisSetup::default()),
mesh_index,
fem_mesh,
)
});
let hue_shift = if multi_part {
part_hue_shift(mesh_index)
} else {
0.0
};
let normal_element = tint_hue(Color::srgba(0.25, 0.45, 0.95, 0.22), hue_shift);
let normal_face = tint_hue(Color::srgba(0.20, 0.70, 0.65, 0.18), hue_shift);
let normal_edge = tint_hue(Color::srgb(0.12, 0.14, 0.16), hue_shift);
let normal_node = tint_hue(Color::srgb(0.82, 0.88, 0.95), hue_shift);
let flat_element = tint_hue(Color::srgba(0.45, 0.62, 0.92, 0.45), hue_shift);
let flat_face = tint_hue(Color::srgba(0.46, 0.72, 0.68, 0.45), hue_shift);
let flat_edge = tint_hue(Color::srgb(0.30, 0.34, 0.38), hue_shift);
let flat_node = tint_hue(Color::srgb(0.90, 0.93, 0.97), hue_shift);
let element_materials = material_set(
materials,
normal_element,
hover_element,
selected_element,
flat_element,
true,
);
let face_materials = material_set(
materials,
normal_face,
hover_face,
selected_face,
flat_face,
true,
);
let edge_materials = material_set(
materials,
normal_edge,
hover_edge,
selected_edge,
flat_edge,
false,
);
let node_materials = material_set(
materials,
normal_node,
hover_node,
selected_node,
flat_node,
false,
);
let warning_materials = material_set(
materials,
Color::srgba(0.95, 0.05, 0.85, 0.85),
hover_element,
selected_element,
Color::srgba(0.95, 0.05, 0.85, 0.55),
true,
);
if use_aggregate_rendering(fem_mesh) {
spawn_aggregate_surface_visual(
commands,
meshes,
materials,
mesh_index,
fem_mesh,
hue_shift,
assignments.as_ref(),
);
continue;
}
let section_map = analysis_setup
.map(|setup| setup.build_element_section_map(mesh_index, fem_mesh))
.unwrap_or_default();
let material_palette: std::collections::BTreeMap<_, _> = assignments
.as_ref()
.into_iter()
.flat_map(|map| map.values())
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.map(|identity| {
let color = identity.color();
(
identity,
material_set(
materials,
color,
hover_element,
selected_element,
color,
false,
),
)
})
.collect();
for element in &fem_mesh.elements {
let section = section_map.get(&element.id).copied();
let materials_for_element =
if matches!(element.element_type, fem_core::ElementType::Unsupported(_)) {
&warning_materials
} else {
assignments
.as_ref()
.and_then(|map| map.get(&element.id))
.and_then(|identity| material_palette.get(identity))
.unwrap_or(&element_materials)
};
spawn_element_visual(
commands,
meshes,
mesh_index,
fem_mesh,
element,
materials_for_element,
section,
model_scale,
);
}
for face in fem_mesh.cached_boundary_faces() {
spawn_face_visual(
commands,
meshes,
mesh_index,
fem_mesh,
face,
face.element
.and_then(|id| assignments.as_ref().and_then(|map| map.get(&id)))
.and_then(|identity| material_palette.get(identity))
.unwrap_or(&face_materials),
);
}
for edge in fem_mesh.cached_edges() {
spawn_edge_visual(
commands,
meshes,
mesh_index,
fem_mesh,
edge,
&edge_materials,
);
}
for node in &fem_mesh.nodes {
spawn_node_visual(commands, meshes, mesh_index, node, &node_materials);
}
}
}
fn part_hue_shift(mesh_index: usize) -> f32 {
const GOLDEN_ANGLE_DEG: f32 = 137.50776;
(mesh_index as f32 * GOLDEN_ANGLE_DEG).rem_euclid(360.0)
}
fn tint_hue(color: Color, shift_deg: f32) -> Color {
if shift_deg.abs() < 1.0e-3 {
return color;
}
let hsla: Hsla = color.into();
let new_hue = (hsla.hue + shift_deg).rem_euclid(360.0);
Color::Hsla(Hsla {
hue: new_hue,
..hsla
})
}
fn use_aggregate_rendering(fem_mesh: &FemMesh) -> bool {
fem_mesh.nodes.len()
+ fem_mesh.elements.len()
+ fem_mesh.cached_edges().len()
+ fem_mesh.cached_boundary_faces().len()
> ENTITY_RENDER_LIMIT
}
fn spawn_aggregate_surface_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
mesh_index: usize,
fem_mesh: &FemMesh,
hue_shift: f32,
assignments: Option<&std::collections::BTreeMap<fem_core::ElementId, MaterialIdentity>>,
) {
if let Some(mesh) = build_material_surface_mesh(fem_mesh, assignments) {
let normal_mat = materials.add(StandardMaterial {
base_color: if assignments.is_some() {
Color::WHITE
} else {
tint_hue(Color::srgb(0.35, 0.52, 0.68), hue_shift)
},
perceptual_roughness: 0.82,
cull_mode: None,
..default()
});
let flat_mat = materials.add(StandardMaterial {
base_color: if assignments.is_some() {
Color::WHITE
} else {
tint_hue(Color::srgb(0.48, 0.64, 0.76), hue_shift)
},
unlit: true,
cull_mode: None,
..default()
});
let transparent_mat = materials.add(StandardMaterial {
base_color: if assignments.is_some() {
Color::WHITE.with_alpha(0.18)
} else {
tint_hue(Color::srgba(0.35, 0.52, 0.68, 0.18), hue_shift)
},
alpha_mode: AlphaMode::Blend,
cull_mode: None,
double_sided: true,
..default()
});
let mesh = meshes.add(mesh);
commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(normal_mat.clone()),
Transform::default(),
VisualLayer::Shaded,
Visibility::Visible,
NormalMaterial(normal_mat),
FlatMaterial(flat_mat),
TransparentMaterial(transparent_mat),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new("Aggregated boundary surface"),
));
}
if let Some(mesh) = build_part_edge_mesh(fem_mesh) {
let material = materials.add(StandardMaterial {
base_color: Color::srgb(0.04, 0.05, 0.055),
unlit: true,
..default()
});
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(material),
Transform::default(),
VisualLayer::Edge,
Visibility::Visible,
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new("Aggregated boundary edges"),
));
}
}
pub(crate) fn spawn_topology_highlights(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let hover_material = materials.add(StandardMaterial {
base_color: Color::srgba(1.0, 0.88, 0.15, 0.70),
alpha_mode: AlphaMode::Blend,
cull_mode: None,
unlit: true,
depth_bias: 2.0,
..default()
});
let mut selected_material = selection_material(Color::srgb(0.10, 1.0, 0.45));
selected_material.cull_mode = None;
selected_material.double_sided = true;
selected_material.unlit = true;
selected_material.depth_bias = 3.0;
let selected_material = materials.add(selected_material);
spawn_topology_highlight(
&mut commands,
&mut meshes,
hover_material,
TopologyHighlight::Hover,
"Topology hover highlight",
);
spawn_topology_highlight(
&mut commands,
&mut meshes,
selected_material,
TopologyHighlight::Selected,
"Topology selected highlight",
);
}
fn spawn_topology_highlight(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
material: Handle<StandardMaterial>,
highlight: TopologyHighlight,
name: &'static str,
) {
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
MeshMaterial3d(material),
Transform::default(),
Visibility::Hidden,
highlight,
Name::new(name),
));
}
pub(crate) fn spawn_contact_candidate_highlights(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let master_material = materials.add(StandardMaterial {
base_color: Color::srgba(0.25, 0.55, 1.0, 0.55),
alpha_mode: AlphaMode::Blend,
cull_mode: None,
unlit: true,
depth_bias: 2.0,
..default()
});
let slave_material = materials.add(StandardMaterial {
base_color: Color::srgba(1.0, 0.55, 0.10, 0.55),
alpha_mode: AlphaMode::Blend,
cull_mode: None,
unlit: true,
depth_bias: 2.0,
..default()
});
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
MeshMaterial3d(master_material),
Transform::default(),
Visibility::Hidden,
ContactCandidateHighlight::Master,
ContactHighlightAvailability::default(),
Name::new("Contact candidate master highlight"),
));
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
MeshMaterial3d(slave_material),
Transform::default(),
Visibility::Hidden,
ContactCandidateHighlight::Slave,
ContactHighlightAvailability::default(),
Name::new("Contact candidate slave highlight"),
));
}
pub(crate) fn spawn_rigid_spider_highlights(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let master_material = materials.add(StandardMaterial {
base_color: Color::srgb(1.0, 0.18, 0.78),
unlit: true,
depth_bias: 3.0,
..default()
});
let slave_material = materials.add(StandardMaterial {
base_color: Color::srgb(0.10, 0.90, 0.95),
unlit: true,
depth_bias: 3.0,
..default()
});
for (highlight, material, name) in [
(
RigidSpiderHighlight::Master,
master_material,
"MPC spider center highlight",
),
(
RigidSpiderHighlight::Slave,
slave_material,
"MPC spider slave highlight",
),
] {
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
MeshMaterial3d(material),
Transform::default(),
Visibility::Hidden,
highlight,
ContactHighlightAvailability::default(),
Name::new(name),
));
}
}
pub fn update_hover_materials(
settings: Res<VisualizationSettings>,
mut query: Query<(
&mut MeshMaterial3d<StandardMaterial>,
&NormalMaterial,
Option<&FlatMaterial>,
Option<&TransparentMaterial>,
&HoverMaterial,
&SelectedMaterial,
Option<&Hovered>,
Option<&Selected>,
)>,
) {
let use_flat = matches!(settings.mode, VisualizationMode::Flat);
let use_transparent = matches!(settings.mode, VisualizationMode::Transparent);
for (mut material, normal, flat, transparent, hover, selected, hovered, is_selected) in
query.iter_mut()
{
if is_selected.is_some() {
material.0 = selected.0.clone();
} else if hovered.is_some() {
material.0 = hover.0.clone();
} else if use_flat {
material.0 = flat
.map(|f| f.0.clone())
.unwrap_or_else(|| normal.0.clone());
} else if use_transparent {
material.0 = transparent
.map(|t| t.0.clone())
.unwrap_or_else(|| normal.0.clone());
} else {
material.0 = normal.0.clone();
}
}
}
pub(crate) fn restore_selection_on_new_visuals(
mut commands: Commands,
selection: Option<ResMut<SelectionState>>,
new_visuals: Query<(Entity, &Selectable), (With<FemMeshVisual>, Added<Selectable>)>,
live_visuals: Query<(), With<Selectable>>,
) {
if new_visuals.is_empty() {
return;
}
let Some(mut selection) = selection else {
return;
};
selection.entities.retain(|entity| live_visuals.contains(*entity));
let targets: BTreeSet<_> = selection.targets.iter().copied().collect();
let highlights: BTreeSet<_> = selection.highlight_targets.iter().copied().collect();
for (entity, selectable) in &new_visuals {
if targets.contains(&selectable.target) && highlights.contains(&selectable.target) {
commands.entity(entity).insert(Selected);
if !selection.entities.contains(&entity) {
selection.entities.push(entity);
}
}
}
}
pub(crate) fn update_topology_highlights(
model: Option<Res<FemModel>>,
hover_preview: Res<fem_core::HoverPreviewTargets>,
selection: Res<SelectionState>,
mut meshes: ResMut<Assets<Mesh>>,
mut cache: Local<TopologyHighlightCache>,
mut query: Query<
(
&TopologyHighlight,
&mut Mesh3d,
&mut Transform,
&mut Visibility,
),
Without<VisualLayer>,
>,
) {
let Some(model) = model else {
hide_topology_highlights(&mut query);
return;
};
let hover_is_redundant = !hover_preview.targets.is_empty()
&& hover_preview
.targets
.iter()
.all(|t| selection.targets.contains(t));
let preview_highlights: &[FemEntityRef] = if hover_preview.highlight_targets.is_empty() {
&hover_preview.targets
} else {
&hover_preview.highlight_targets
};
let selected_highlights: &[FemEntityRef] = if selection.highlight_targets.is_empty() {
&selection.targets
} else {
&selection.highlight_targets
};
let hover_targets: &[FemEntityRef] = if hover_is_redundant {
&[]
} else {
preview_highlights
};
if cache.hover.as_slice() == hover_targets && cache.selected == selected_highlights {
return;
}
cache.hover = hover_targets.to_vec();
cache.selected = selected_highlights.to_vec();
for (highlight, mut mesh, mut transform, mut visibility) in &mut query {
let targets: &[FemEntityRef] = match highlight {
TopologyHighlight::Hover => hover_targets,
TopologyHighlight::Selected => selected_highlights,
};
if targets.is_empty() {
*visibility = Visibility::Hidden;
continue;
}
if apply_topology_highlight(
&model,
targets,
&mut meshes,
&mut mesh,
&mut transform,
&mut visibility,
)
.is_none()
{
*visibility = Visibility::Hidden;
}
}
}
pub(crate) fn update_visual_layer_visibility(
settings: Res<VisualizationSettings>,
mut query: Query<(Ref<VisualLayer>, &mut Visibility), Without<TopologyHighlight>>,
) {
for (layer, mut visibility) in &mut query {
if !settings.is_changed() && !layer.is_added() {
continue;
}
*visibility = if layer.visible_in(settings.mode) {
Visibility::Visible
} else {
Visibility::Hidden
};
}
}
pub(crate) fn apply_visualization_mode(
settings: Res<VisualizationSettings>,
mut commands: Commands,
mut query: Query<
(
Entity,
&VisualLayer,
&mut MeshMaterial3d<StandardMaterial>,
Ref<NormalMaterial>,
&FlatMaterial,
&TransparentMaterial,
),
With<FemMeshVisual>,
>,
) {
for (entity, layer, mut mat, normal, flat, transparent) in &mut query {
if !settings.is_changed() && !normal.is_added() {
continue;
}
if *layer != VisualLayer::Shaded {
continue;
}
match settings.mode {
VisualizationMode::Flat => {
mat.0 = flat.0.clone();
commands
.entity(entity)
.remove::<bevy::pbr::wireframe::Wireframe>();
}
VisualizationMode::Wireframe => {
mat.0 = normal.0.clone();
commands
.entity(entity)
.insert(bevy::pbr::wireframe::Wireframe);
}
VisualizationMode::Transparent => {
mat.0 = transparent.0.clone();
commands
.entity(entity)
.remove::<bevy::pbr::wireframe::Wireframe>();
}
_ => {
mat.0 = normal.0.clone();
commands
.entity(entity)
.remove::<bevy::pbr::wireframe::Wireframe>();
}
}
}
}
pub(crate) fn update_contact_review_pose(
model: Option<Res<FemModel>>,
candidates: Res<ContactCandidateState>,
settings: Res<ContactReviewSettings>,
mut pose: ResMut<ContactReviewPose>,
) {
let model_changed = model.as_ref().is_some_and(|model| model.is_changed());
if !model_changed && !candidates.is_changed() && !settings.is_changed() {
return;
}
let next = model
.as_deref()
.zip(candidates.selected_candidate())
.filter(|_| settings.active)
.map(|(model, candidate)| {
let (offset_a, offset_b) =
contact_review_offsets(model, candidate, settings.separation_percent);
ContactReviewPose {
active: true,
ghost_others: settings.ghost_others,
mesh_a: candidate.mesh_a,
mesh_b: candidate.mesh_b,
offset_a,
offset_b,
}
})
.unwrap_or_default();
if *pose != next {
*pose = next;
}
}
pub(crate) fn apply_contact_review(
pose: Res<ContactReviewPose>,
settings: Res<VisualizationSettings>,
mut visuals: Query<(
&FemPartVisual,
&VisualLayer,
&mut Transform,
&mut Visibility,
Option<&mut MeshMaterial3d<StandardMaterial>>,
Option<&TransparentMaterial>,
)>,
) {
if !pose.active && !pose.is_changed() {
return;
}
for (part, layer, mut transform, mut visibility, material, transparent) in &mut visuals {
let relevant =
pose.active && (part.mesh_index == pose.mesh_a || part.mesh_index == pose.mesh_b);
transform.translation = if !pose.active {
Vec3::ZERO
} else if part.mesh_index == pose.mesh_a {
pose.offset_a
} else if part.mesh_index == pose.mesh_b {
pose.offset_b
} else {
Vec3::ZERO
};
transform.rotation = Quat::IDENTITY;
transform.scale = Vec3::ONE;
*visibility = if layer.visible_in(settings.mode) {
Visibility::Visible
} else {
Visibility::Hidden
};
if pose.active && pose.ghost_others && !relevant {
match (*layer, material, transparent) {
(VisualLayer::Shaded, Some(mut material), Some(transparent)) => {
material.0 = transparent.0.clone();
*visibility = Visibility::Visible;
}
_ => {
*visibility = Visibility::Hidden;
}
}
}
}
}
fn contact_review_offsets(
model: &FemModel,
candidate: &ContactCandidate,
separation_percent: f32,
) -> (Vec3, Vec3) {
if candidate.is_self_contact() {
return (Vec3::ZERO, Vec3::ZERO);
}
let Some(mesh_a) = model.meshes.get(candidate.mesh_a) else {
return (Vec3::ZERO, Vec3::ZERO);
};
let Some(mesh_b) = model.meshes.get(candidate.mesh_b) else {
return (Vec3::ZERO, Vec3::ZERO);
};
let contact_a = face_group_centroid(mesh_a, &candidate.faces_a);
let contact_b = face_group_centroid(mesh_b, &candidate.faces_b);
let part_a = mesh_a.bounds().map(|(min, max)| (min + max) * 0.5);
let part_b = mesh_b.bounds().map(|(min, max)| (min + max) * 0.5);
let direction = contact_a
.zip(contact_b)
.and_then(|(a, b)| (b - a).try_normalize())
.or_else(|| {
part_a
.zip(part_b)
.and_then(|(a, b)| (b - a).try_normalize())
})
.unwrap_or(Vec3::X);
let diagonal = model
.bounds()
.map(|(min, max)| (max - min).length())
.unwrap_or(0.0);
let half_separation = diagonal * separation_percent.clamp(0.0, 30.0) * 0.005;
(-direction * half_separation, direction * half_separation)
}
fn face_group_centroid(mesh: &FemMesh, face_ids: &[FaceId]) -> Option<Vec3> {
let ids: BTreeSet<FaceId> = face_ids.iter().copied().collect();
let mut total = Vec3::ZERO;
let mut count = 0usize;
for face in mesh
.cached_boundary_faces()
.iter()
.filter(|face| ids.contains(&face.id))
{
if let Some(geometry) = mesh.face_geometry(face) {
total += geometry.centroid;
count += 1;
}
}
(count > 0).then(|| total / count as f32)
}
pub(crate) fn respawn_visuals_on_reload(
mut commands: Commands,
model: Option<Res<FemModel>>,
version: Res<fem_core::FemModelVersion>,
mut last_version: Local<Option<u64>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
visual_query: Query<Entity, With<FemMeshVisual>>,
hovered_query: Query<Entity, With<Hovered>>,
selected_query: Query<Entity, With<Selected>>,
mut hover: ResMut<HoverResult>,
mut selection: ResMut<SelectionState>,
mut contact_candidates: ResMut<ContactCandidateState>,
analysis_setup: Res<fem_core::AnalysisSetup>,
colors: Res<MaterialColorMode>,
) {
let current = version.value;
if *last_version == Some(current) {
return;
}
let first_run = last_version.is_none();
*last_version = Some(current);
if first_run {
return;
}
let Some(model) = model else {
return;
};
for entity in &hovered_query {
commands.entity(entity).remove::<Hovered>();
}
for entity in &selected_query {
commands.entity(entity).remove::<Selected>();
}
for entity in &visual_query {
commands.entity(entity).despawn();
}
hover.clear();
selection.clear();
contact_candidates.candidates.clear();
contact_candidates.selected = None;
spawn_model_visuals(
&mut commands,
&mut meshes,
&mut materials,
&model,
Some(&analysis_setup),
*colors,
);
}
pub(crate) fn respawn_elements_on_setup_change(
mut commands: Commands,
model: Option<Res<FemModel>>,
setup: Res<fem_core::AnalysisSetup>,
version: Res<fem_core::FemModelVersion>,
mut last_version: Local<Option<u64>>,
mut last_sections: Local<Option<Vec<fem_core::Section>>>,
colors: Res<MaterialColorMode>,
mut last_materials: Local<Vec<(String, usize)>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
visual_query: Query<Entity, With<FemMeshVisual>>,
) {
let version_changed = *last_version != Some(version.value);
*last_version = Some(version.value);
if !setup.is_changed() && !colors.is_changed() {
return;
}
let sections_changed = last_sections
.as_deref()
.is_some_and(|previous| previous != setup.sections.as_slice());
*last_sections = Some(setup.sections.clone());
let referenced: BTreeSet<_> = setup.sections.iter().map(|s| &s.material_name).collect();
let material_names: Vec<_> = referenced
.iter()
.map(|name| {
(
(*name).clone(),
setup.materials.iter().filter(|m| &m.name == *name).count(),
)
})
.collect();
let materials_changed = *last_materials != material_names;
*last_materials = material_names;
if setup.is_added()
|| version_changed
|| !(sections_changed || materials_changed || colors.is_changed())
{
return;
}
let Some(model) = model else {
return;
};
for entity in &visual_query {
commands.entity(entity).despawn();
}
spawn_model_visuals(
&mut commands,
&mut meshes,
&mut materials,
&model,
Some(&setup),
*colors,
);
}
fn hide_topology_highlights(
query: &mut Query<
(
&TopologyHighlight,
&mut Mesh3d,
&mut Transform,
&mut Visibility,
),
Without<VisualLayer>,
>,
) {
for (_, _, _, mut visibility) in query.iter_mut() {
*visibility = Visibility::Hidden;
}
}
pub(crate) fn update_contact_candidate_highlights(
model: Option<Res<FemModel>>,
state: Res<ContactCandidateState>,
pose: Res<ContactReviewPose>,
defined: Res<DefinedContactPreview>,
draft: Res<ContactDraftPreview>,
mut meshes: ResMut<Assets<Mesh>>,
mut query: Query<(
&ContactCandidateHighlight,
&mut Mesh3d,
&mut Transform,
&mut Visibility,
&mut ContactHighlightAvailability,
)>,
) {
let rebuild = state.is_changed()
|| defined.is_changed()
|| draft.is_changed()
|| model.as_ref().is_some_and(|model| model.is_changed());
let candidate = pose.active.then(|| state.selected_candidate()).flatten();
let draft_active = candidate.is_none() && draft.active;
let defined_contact = if candidate.is_none() && !draft_active && defined.active {
model
.as_deref()
.and_then(|model| defined.selected.and_then(|index| model.contacts.get(index)))
} else {
None
};
let source_active = candidate.is_some() || draft_active || defined_contact.is_some();
for (highlight, mut mesh, mut transform, mut visibility, mut availability) in &mut query {
if rebuild {
let built = if let Some(candidate) = candidate {
model.as_deref().and_then(|model| {
let (mesh_index, face_ids) = match highlight {
ContactCandidateHighlight::Master => (candidate.mesh_a, &candidate.faces_a),
ContactCandidateHighlight::Slave => (candidate.mesh_b, &candidate.faces_b),
};
let fem_mesh = model.meshes.get(mesh_index)?;
build_highlight_faces_mesh(fem_mesh, face_ids)
})
} else if draft_active {
model
.as_deref()
.and_then(|model| build_draft_contact_highlight(model, &draft, *highlight))
} else {
model.as_deref().and_then(|model| {
build_defined_contact_highlight(model, defined_contact?, *highlight)
})
};
let Some(built) = built else {
availability.0 = false;
*visibility = Visibility::Hidden;
continue;
};
mesh.0 = meshes.add(built);
availability.0 = true;
}
transform.translation = match (candidate.is_some(), highlight) {
(true, ContactCandidateHighlight::Master) => pose.offset_a,
(true, ContactCandidateHighlight::Slave) => pose.offset_b,
(false, _) => Vec3::ZERO,
};
transform.rotation = Quat::IDENTITY;
transform.scale = Vec3::ONE;
*visibility = if source_active && availability.0 {
Visibility::Visible
} else {
Visibility::Hidden
};
}
}
pub(crate) fn update_rigid_spider_highlights(
model: Option<Res<FemModel>>,
state: Res<RigidSpiderCandidateState>,
settings: Res<RigidSpiderReviewSettings>,
setup: Res<fem_core::AnalysisSetup>,
defined: Res<DefinedMpcPreview>,
pair_draft: Res<MpcPairDraftPreview>,
mut meshes: ResMut<Assets<Mesh>>,
mut query: Query<(
&RigidSpiderHighlight,
&mut Mesh3d,
&mut Visibility,
&mut ContactHighlightAvailability,
)>,
) {
let rebuild = state.is_changed()
|| settings.is_changed()
|| setup.is_changed()
|| defined.is_changed()
|| pair_draft.is_changed()
|| model.as_ref().is_some_and(|model| model.is_changed());
let candidate = settings
.active
.then(|| state.selected_candidate())
.flatten();
let draft_node = |positive: bool| {
pair_draft.active.then_some(if positive {
pair_draft.positive
} else {
pair_draft.negative
})?
};
let equation = defined
.active
.then(|| {
defined
.selected
.and_then(|index| setup.mpc_equations.get(index))
})
.flatten();
for (highlight, mut mesh, mut visibility, mut availability) in &mut query {
if rebuild {
let built = model.as_deref().and_then(|model| {
let radius = model_visual_scale(model)
* match highlight {
RigidSpiderHighlight::Master => 0.012,
RigidSpiderHighlight::Slave => 0.006,
};
if let Some(candidate) = candidate {
match highlight {
RigidSpiderHighlight::Master => build_highlight_nodes_mesh(
model.meshes.get(candidate.master_mesh)?,
&[candidate.master_node],
radius,
),
RigidSpiderHighlight::Slave => build_highlight_nodes_mesh(
model.meshes.get(candidate.slave_mesh)?,
&candidate.slave_nodes,
radius,
),
}
} else if let Some((mesh_index, node)) =
draft_node(matches!(highlight, RigidSpiderHighlight::Master))
{
build_highlight_nodes_mesh(model.meshes.get(mesh_index)?, &[node], radius)
} else {
build_mpc_equation_highlight(
model,
equation?,
matches!(highlight, RigidSpiderHighlight::Master),
radius,
)
}
});
let Some(built) = built else {
availability.0 = false;
*visibility = Visibility::Hidden;
continue;
};
mesh.0 = meshes.add(built);
availability.0 = true;
}
*visibility = if (candidate.is_some()
|| draft_node(matches!(highlight, RigidSpiderHighlight::Master)).is_some()
|| equation.is_some())
&& availability.0
{
Visibility::Visible
} else {
Visibility::Hidden
};
}
}
fn build_mpc_equation_highlight(
model: &FemModel,
equation: &MpcEquation,
positive: bool,
radius: f32,
) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut rendered = BTreeSet::new();
for term in &equation.terms {
let matching_sign = if positive {
term.coefficient > 0.0
} else {
term.coefficient < 0.0
};
if !matching_sign || !rendered.insert((term.mesh_index, term.node)) {
continue;
}
let center = model
.meshes
.get(term.mesh_index)
.and_then(|mesh| mesh.node_position(term.node));
if let Some(center) = center {
append_octahedron(&mut positions, &mut normals, center, radius);
}
}
(!positions.is_empty()).then(|| {
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
})
}
fn build_draft_contact_highlight(
model: &FemModel,
draft: &ContactDraftPreview,
highlight: ContactCandidateHighlight,
) -> Option<Mesh> {
match highlight {
ContactCandidateHighlight::Master => {
build_draft_surface_highlight_mesh(model, draft.master.as_ref()?)
}
ContactCandidateHighlight::Slave => match draft.slave.as_ref()? {
ContactDraftSlave::Nodes { mesh_index, nodes } => build_highlight_nodes_mesh(
model.meshes.get(*mesh_index)?,
nodes,
model_visual_scale(model) * 0.006,
),
ContactDraftSlave::Surface(surface) => {
build_draft_surface_highlight_mesh(model, surface)
}
},
}
}
fn build_draft_surface_highlight_mesh(
model: &FemModel,
surface: &ContactDraftSurface,
) -> Option<Mesh> {
let fem_mesh = model.meshes.get(surface.mesh_index)?;
let element_faces: BTreeSet<_> = surface.surfaces.iter().copied().collect();
let face_ids: Vec<_> = fem_mesh
.cached_boundary_faces()
.iter()
.filter(|face| {
face.element_face_ref()
.is_some_and(|reference| element_faces.contains(&reference))
})
.map(|face| face.id)
.collect();
build_highlight_faces_mesh(fem_mesh, &face_ids)
}
fn build_defined_contact_highlight(
model: &FemModel,
contact: &ContactPair,
highlight: ContactCandidateHighlight,
) -> Option<Mesh> {
match highlight {
ContactCandidateHighlight::Master => {
build_surface_set_highlight_mesh(model, contact.master)
}
ContactCandidateHighlight::Slave => match contact.slave {
ContactSlaveRef::Surface(reference) => {
build_surface_set_highlight_mesh(model, reference)
}
ContactSlaveRef::Nodes(reference) => {
let fem_mesh = model.meshes.get(reference.mesh_index)?;
let node_set = fem_mesh.node_sets.get(reference.node_set_index)?;
build_highlight_nodes_mesh(
fem_mesh,
&node_set.nodes,
model_visual_scale(model) * 0.006,
)
}
},
}
}
fn build_surface_set_highlight_mesh(model: &FemModel, reference: SurfaceSetRef) -> Option<Mesh> {
let fem_mesh = model.meshes.get(reference.mesh_index)?;
let surface_set = fem_mesh.surface_sets.get(reference.surface_set_index)?;
let element_faces: BTreeSet<_> = surface_set.surfaces.iter().copied().collect();
let face_ids: Vec<_> = fem_mesh
.cached_boundary_faces()
.iter()
.filter(|face| {
face.element_face_ref()
.is_some_and(|reference| element_faces.contains(&reference))
})
.map(|face| face.id)
.collect();
build_highlight_faces_mesh(fem_mesh, &face_ids)
}
fn build_highlight_nodes_mesh(
fem_mesh: &FemMesh,
node_ids: &[NodeId],
radius: f32,
) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
let stride = node_ids
.len()
.div_ceil(MAX_DEFINED_CONTACT_NODE_MARKERS)
.max(1);
for node_id in node_ids.iter().step_by(stride) {
let Some(center) = fem_mesh.node_position(*node_id) else {
continue;
};
append_octahedron(&mut positions, &mut normals, center, radius);
}
(!positions.is_empty()).then(|| {
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
})
}
fn append_octahedron(
positions: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>,
center: Vec3,
radius: f32,
) {
let top = center + Vec3::Y * radius;
let bottom = center - Vec3::Y * radius;
let ring = [
center + Vec3::X * radius,
center + Vec3::Z * radius,
center - Vec3::X * radius,
center - Vec3::Z * radius,
];
for index in 0..ring.len() {
let next = (index + 1) % ring.len();
append_face_triangles(positions, normals, &[top, ring[index], ring[next]]);
append_face_triangles(positions, normals, &[bottom, ring[next], ring[index]]);
}
}
fn apply_topology_highlight(
model: &FemModel,
targets: &[FemEntityRef],
meshes: &mut Assets<Mesh>,
mesh: &mut Mesh3d,
transform: &mut Transform,
visibility: &mut Visibility,
) -> Option<()> {
let scale = model_visual_scale(model);
let last = *targets.last()?;
let fem_mesh = model.meshes.get(last.mesh_index)?;
match last.entity {
FemEntityId::Node(id) => {
let position = fem_mesh.node_position(id)?;
mesh.0 = meshes.add(Cuboid::new(scale * 0.012, scale * 0.012, scale * 0.012));
*transform = Transform::from_translation(position);
}
FemEntityId::Edge(_) => {
let edge_targets = targets
.iter()
.copied()
.filter(|target| matches!(target.entity, FemEntityId::Edge(_)));
mesh.0 = meshes.add(build_multi_edge_highlight_mesh(model, edge_targets, scale)?);
*transform = Transform::default();
}
FemEntityId::Face(_) | FemEntityId::Element(_) => {
let face_targets = targets
.iter()
.copied()
.filter(|t| matches!(t.entity, FemEntityId::Face(_) | FemEntityId::Element(_)));
mesh.0 = meshes.add(build_multi_face_highlight_mesh(model, face_targets)?);
*transform = Transform::default();
}
}
*visibility = Visibility::Visible;
Some(())
}
fn build_multi_edge_highlight_mesh(
model: &FemModel,
targets: impl Iterator<Item = FemEntityRef>,
model_scale: f32,
) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
for target in targets {
let FemEntityId::Edge(edge_id) = target.entity else {
continue;
};
let Some(fem_mesh) = model.meshes.get(target.mesh_index) else {
continue;
};
let Some(edge) = fem_mesh
.cached_boundary_edges()
.iter()
.find(|edge| edge.id == edge_id)
else {
continue;
};
let (Some(start), Some(end)) = (
fem_mesh.node_position(edge.nodes[0]),
fem_mesh.node_position(edge.nodes[1]),
) else {
continue;
};
let length = start.distance(end);
if length <= f32::EPSILON {
continue;
}
let thickness = (length * 0.08).min(model_scale * 0.010);
append_edge_prism(&mut positions, &mut normals, start, end, thickness * 0.5);
}
(!positions.is_empty()).then(|| {
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
})
}
fn append_edge_prism(
positions: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>,
start: Vec3,
end: Vec3,
half_width: f32,
) {
let direction = (end - start).normalize();
let helper = if direction.dot(Vec3::Y).abs() < 0.9 {
Vec3::Y
} else {
Vec3::X
};
let side = direction.cross(helper).normalize() * half_width;
let up = direction.cross(side).normalize() * half_width;
let s00 = start - side - up;
let s10 = start + side - up;
let s11 = start + side + up;
let s01 = start - side + up;
let e00 = end - side - up;
let e10 = end + side - up;
let e11 = end + side + up;
let e01 = end - side + up;
for face in [
[s00, s01, s11, s10],
[e00, e10, e11, e01],
[s00, s10, e10, e00],
[s10, s11, e11, e10],
[s11, s01, e01, e11],
[s01, s00, e00, e01],
] {
append_face_triangles(positions, normals, &face);
}
}
fn build_multi_face_highlight_mesh(
model: &FemModel,
targets: impl Iterator<Item = FemEntityRef>,
) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
for target in targets {
append_target_highlight_triangles(model, target, &mut positions, &mut normals);
}
(!positions.is_empty()).then(|| {
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
})
}
fn append_target_highlight_triangles(
model: &FemModel,
target: FemEntityRef,
positions: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>,
) {
let Some(fem_mesh) = model.meshes.get(target.mesh_index) else {
return;
};
match target.entity {
FemEntityId::Face(id) => {
let Some(face) = fem_mesh
.cached_boundary_faces()
.iter()
.find(|face| face.id == id)
else {
return;
};
let Some(points) = fem_mesh.node_positions(&face.nodes) else {
return;
};
append_face_triangles(positions, normals, &points);
}
FemEntityId::Element(id) => {
let Some(element) = fem_mesh.elements.iter().find(|element| element.id == id) else {
return;
};
for face_nodes in element.face_node_ids() {
let Some(points) = fem_mesh.node_positions(&face_nodes) else {
continue;
};
append_face_triangles(positions, normals, &points);
}
}
FemEntityId::Node(_) | FemEntityId::Edge(_) => {}
}
}
fn build_highlight_faces_mesh(fem_mesh: &FemMesh, face_ids: &[FaceId]) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
for face_id in face_ids {
let Some(face) = fem_mesh
.cached_boundary_faces()
.iter()
.find(|face| face.id == *face_id)
else {
continue;
};
let Some(points) = fem_mesh.node_positions(&face.nodes) else {
continue;
};
append_face_triangles(&mut positions, &mut normals, &points);
}
(!positions.is_empty()).then(|| {
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
})
}
pub(crate) fn model_visual_scale(model: &FemModel) -> f32 {
model
.bounds()
.map(|(min, max)| (max - min).length().max(1.0))
.unwrap_or(1.0)
}
fn spawn_element_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
fem_mesh: &FemMesh,
element: &FemElement,
materials: &MaterialSet,
section: Option<&fem_core::Section>,
model_scale: f32,
) {
if element.element_type.is_shell() {
spawn_shell_element_visual(
commands,
meshes,
mesh_index,
fem_mesh,
element,
materials,
section,
model_scale,
);
} else if element.element_type.is_beam() {
spawn_beam_element_visual(
commands,
meshes,
mesh_index,
fem_mesh,
element,
materials,
section,
model_scale,
);
} else {
spawn_solid_element_visual(commands, meshes, mesh_index, fem_mesh, element, materials);
}
}
fn spawn_solid_element_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
fem_mesh: &FemMesh,
element: &FemElement,
materials: &MaterialSet,
) {
let (mesh, transform) = match build_element_surface_mesh(fem_mesh, element) {
Some(mesh) => (meshes.add(mesh), Transform::default()),
None => {
let Some(points) = fem_mesh.node_positions(&element.nodes) else {
return;
};
let Some((min, max)) = bounds(&points) else {
return;
};
let center = (min + max) * 0.5;
let size = visual_size(max - min);
(
meshes.add(Cuboid::new(size.x, size.y, size.z)),
Transform::from_translation(center),
)
}
};
commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(materials.normal.clone()),
transform,
VisualLayer::Shaded,
Visibility::Visible,
Selectable::element(mesh_index, element.id),
ElementEntity::new(element.id),
NormalMaterial(materials.normal.clone()),
FlatMaterial(materials.flat.clone()),
TransparentMaterial(materials.transparent.clone()),
HoverMaterial(materials.hover.clone()),
SelectedMaterial(materials.selected.clone()),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new(format!("Element {}", element.id.0)),
));
}
fn build_element_surface_mesh(fem_mesh: &FemMesh, element: &FemElement) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
for node_ids in element.face_node_ids() {
let Some(points) = fem_mesh.node_positions(&node_ids) else {
continue;
};
append_face_triangles(&mut positions, &mut normals, &points);
}
if positions.is_empty() {
return None;
}
Some(
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals),
)
}
fn shell_corner_count(element_type: &fem_core::ElementType) -> usize {
element_type.surface_corner_count().unwrap_or(0)
}
fn spawn_shell_element_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
fem_mesh: &FemMesh,
element: &FemElement,
materials: &MaterialSet,
section: Option<&fem_core::Section>,
model_scale: f32,
) {
let corner_count = shell_corner_count(&element.element_type);
let Some(all_points) = fem_mesh.node_positions(&element.nodes) else {
return;
};
if all_points.len() < corner_count || corner_count < 3 {
return;
}
let corners = &all_points[..corner_count];
let Some(normal) = face_normal(corners) else {
return;
};
let thickness = match section.map(|s| &s.kind) {
Some(fem_core::SectionKind::Shell { thickness }) => *thickness,
_ => {
let Some((min, max)) = bounds(corners) else {
return;
};
let element_size = (max - min).length();
(element_size * 0.02).min(model_scale * 0.01)
}
}
.max(1.0e-4);
let half = thickness * 0.5;
let top: Vec<Vec3> = corners.iter().map(|&p| p + normal * half).collect();
let bottom: Vec<Vec3> = corners.iter().map(|&p| p - normal * half).collect();
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
append_face_triangles(&mut positions, &mut normals, &top);
let mut bottom_rev = bottom.clone();
bottom_rev.reverse();
append_face_triangles(&mut positions, &mut normals, &bottom_rev);
for i in 0..corner_count {
let j = (i + 1) % corner_count;
let quad = [bottom[i], bottom[j], top[j], top[i]];
append_face_triangles(&mut positions, &mut normals, &quad);
}
let mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(materials.normal.clone()),
Transform::default(),
VisualLayer::Shaded,
Visibility::Visible,
Selectable::element(mesh_index, element.id),
ElementEntity::new(element.id),
NormalMaterial(materials.normal.clone()),
FlatMaterial(materials.flat.clone()),
TransparentMaterial(materials.transparent.clone()),
HoverMaterial(materials.hover.clone()),
SelectedMaterial(materials.selected.clone()),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new(format!("Shell element {}", element.id.0)),
));
}
fn spawn_beam_element_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
fem_mesh: &FemMesh,
element: &FemElement,
materials: &MaterialSet,
section: Option<&fem_core::Section>,
model_scale: f32,
) {
let segments: Vec<(Vec3, Vec3)> = element
.edge_node_ids()
.into_iter()
.filter_map(|nodes| {
Some((
fem_mesh.node_position(nodes[0])?,
fem_mesh.node_position(nodes[1])?,
))
})
.filter(|(start, end)| start.distance_squared(*end) > f32::EPSILON * f32::EPSILON)
.collect();
if segments.is_empty() {
return;
}
let reference_length = segments
.iter()
.map(|(start, end)| start.distance(*end))
.sum::<f32>();
let radius = match section.map(|s| &s.kind) {
Some(fem_core::SectionKind::Beam { area }) => (area / std::f32::consts::PI).sqrt(),
_ => (reference_length * 0.015).min(model_scale * 0.01),
}
.max(1.0e-4);
for (segment_index, (start, end)) in segments.into_iter().enumerate() {
let delta = end - start;
let length = delta.length();
let center = (start + end) * 0.5;
let rotation = Quat::from_rotation_arc(Vec3::Y, delta / length);
commands.spawn((
Mesh3d(meshes.add(Cylinder {
radius,
half_height: length * 0.5,
})),
MeshMaterial3d(materials.normal.clone()),
Transform {
translation: center,
rotation,
..default()
},
VisualLayer::Shaded,
Visibility::Visible,
Selectable::element(mesh_index, element.id),
ElementEntity::new(element.id),
NormalMaterial(materials.normal.clone()),
FlatMaterial(materials.flat.clone()),
TransparentMaterial(materials.transparent.clone()),
HoverMaterial(materials.hover.clone()),
SelectedMaterial(materials.selected.clone()),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new(format!(
"Line element {} segment {}",
element.id.0,
segment_index + 1
)),
));
}
}
fn spawn_face_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
fem_mesh: &FemMesh,
face: &FemFace,
materials: &MaterialSet,
) {
let Some(points) = fem_mesh.node_positions(&face.nodes) else {
return;
};
let Some(mesh) = build_extruded_polygon_mesh(&points, FACE_THICKNESS) else {
return;
};
commands.spawn((
Mesh3d(meshes.add(mesh)),
MeshMaterial3d(materials.normal.clone()),
Transform::default(),
VisualLayer::Shaded,
Visibility::Visible,
Selectable::face(mesh_index, face.id),
FaceEntity::new(face.id),
NormalMaterial(materials.normal.clone()),
FlatMaterial(materials.flat.clone()),
TransparentMaterial(materials.transparent.clone()),
HoverMaterial(materials.hover.clone()),
SelectedMaterial(materials.selected.clone()),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new(format!("Face {}", face.id.0)),
));
}
fn build_extruded_polygon_mesh(points: &[Vec3], thickness: f32) -> Option<Mesh> {
if points.len() < 3 {
return None;
}
let normal = face_normal(points)?;
let half = thickness.max(f32::EPSILON) * 0.5;
let top: Vec<Vec3> = points.iter().map(|point| *point + normal * half).collect();
let bottom: Vec<Vec3> = points.iter().map(|point| *point - normal * half).collect();
let mut positions = Vec::new();
let mut normals = Vec::new();
append_face_triangles(&mut positions, &mut normals, &top);
let mut bottom_reversed = bottom.clone();
bottom_reversed.reverse();
append_face_triangles(&mut positions, &mut normals, &bottom_reversed);
for index in 0..points.len() {
let next = (index + 1) % points.len();
let wall = [bottom[index], bottom[next], top[next], top[index]];
append_face_triangles(&mut positions, &mut normals, &wall);
}
Some(
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals),
)
}
fn spawn_edge_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
fem_mesh: &FemMesh,
edge: &FemEdge,
materials: &MaterialSet,
) {
let Some(start) = fem_mesh.node_position(edge.nodes[0]) else {
return;
};
let Some(end) = fem_mesh.node_position(edge.nodes[1]) else {
return;
};
let delta = end - start;
let length = delta.length();
if length <= f32::EPSILON {
return;
}
let direction = delta / length;
let center = (start + end) * 0.5;
let rotation = Quat::from_rotation_arc(Vec3::X, direction);
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(length, EDGE_THICKNESS, EDGE_THICKNESS))),
MeshMaterial3d(materials.normal.clone()),
Transform {
translation: center,
rotation,
..default()
},
VisualLayer::Edge,
Visibility::Visible,
Selectable::edge(mesh_index, edge.id),
EdgeEntity::new(edge.id),
NormalMaterial(materials.normal.clone()),
FlatMaterial(materials.flat.clone()),
TransparentMaterial(materials.transparent.clone()),
HoverMaterial(materials.hover.clone()),
SelectedMaterial(materials.selected.clone()),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new(format!("Edge {}", edge.id.0)),
));
}
fn spawn_node_visual(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
mesh_index: usize,
node: &FemNode,
materials: &MaterialSet,
) {
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(NODE_SIZE, NODE_SIZE, NODE_SIZE))),
MeshMaterial3d(materials.normal.clone()),
Transform::from_translation(node.position),
VisualLayer::Node,
Visibility::Visible,
Selectable::node(mesh_index, node.id),
NodeEntity::new(node.id),
NormalMaterial(materials.normal.clone()),
FlatMaterial(materials.flat.clone()),
TransparentMaterial(materials.transparent.clone()),
HoverMaterial(materials.hover.clone()),
SelectedMaterial(materials.selected.clone()),
FemPartVisual { mesh_index },
FemMeshVisual,
Name::new(format!("Node {}", node.id.0)),
));
}
pub fn build_part_surface_mesh(fem_mesh: &FemMesh) -> Option<Mesh> {
build_material_surface_mesh(fem_mesh, None)
}
fn build_material_surface_mesh(
fem_mesh: &FemMesh,
assignments: Option<&std::collections::BTreeMap<fem_core::ElementId, MaterialIdentity>>,
) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut colors = Vec::new();
for face in fem_mesh.cached_boundary_faces() {
let Some(points) = fem_mesh.node_positions(&face.nodes) else {
continue;
};
append_face_triangles(&mut positions, &mut normals, &points);
if let Some(assignments) = assignments {
let color = face
.element
.and_then(|id| assignments.get(&id))
.unwrap_or(&MaterialIdentity::Unassigned)
.color()
.to_linear()
.to_f32_array();
colors.resize(positions.len(), color);
}
}
if positions.is_empty() {
return None;
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
if assignments.is_some() {
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
}
Some(mesh)
}
pub(crate) fn build_contour_surface_mesh(
fem_mesh: &FemMesh,
step: &fem_core::StepResult,
settings: &ContourSettings,
range: Option<(f32, f32)>,
) -> Option<Mesh> {
let contour_field = step.field_by_name(&settings.field_name)?;
let range = range.or_else(|| crate::contour_range::bounds(contour_field))?;
let valid = match contour_field {
fem_core::ResultField::NodeScalar { values,.. } => values.len() == fem_mesh.nodes.len(),
fem_core::ResultField::NodeVector { values,.. } => values.len() == fem_mesh.nodes.len(),
fem_core::ResultField::ElementScalar { values,.. } => values.len() == fem_mesh.elements.len(),
};
if !valid { return None; }
let element_indices: std::collections::HashMap<_,_> = fem_mesh.elements.iter().enumerate().map(|(i,e)|(e.id,i)).collect();
let disp_field = if settings.show_deformation {
step.field_by_name(&settings.displacement_field)
} else {
None
};
let node_index_map: std::collections::HashMap<fem_core::NodeId, usize> = fem_mesh
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id, i))
.collect();
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut colors: Vec<[f32; 4]> = Vec::new();
for face in fem_mesh.cached_boundary_faces() {
let element_t = if let fem_core::ResultField::ElementScalar { values,.. } = contour_field {
let Some(index) = face.element.and_then(|id|element_indices.get(&id)) else { continue; };
Some(crate::contour_range::normalize(values[*index], range))
} else { None };
let Some(node_indices_in_mesh): Option<Vec<usize>> = face
.nodes
.iter()
.map(|id| node_index_map.get(id).copied())
.collect()
else {
continue;
};
let points: Vec<Vec3> = node_indices_in_mesh.iter().map(|&index| {
crate::result_probe::deformed_position(fem_mesh.nodes[index].position, index, disp_field, settings.deformation_scale)
}).collect();
if points.len() < 3 {
continue;
}
let Some(normal) = face_normal(&points) else {
continue;
};
let vert_colors: Vec<[f32; 4]> = node_indices_in_mesh
.iter()
.map(|&mesh_idx| {
let t = match contour_field {
fem_core::ResultField::NodeScalar { values, .. } => {
crate::contour_range::normalize(values[mesh_idx], range)
}
fem_core::ResultField::NodeVector { values, .. } => {
crate::contour_range::normalize(values[mesh_idx].length(), range)
}
fem_core::ResultField::ElementScalar {..} => element_t.unwrap(),
};
let c = rainbow_color(t);
[c.red, c.green, c.blue, c.alpha]
})
.collect();
for idx in 1..(points.len() - 1) {
let tri = [points[0], points[idx], points[idx + 1]];
let col = [vert_colors[0], vert_colors[idx], vert_colors[idx + 1]];
for (p, c) in tri.iter().zip(col.iter()) {
positions.push(p.to_array());
normals.push(normal.to_array());
colors.push(*c);
}
}
}
if positions.is_empty() {
return None;
}
Some(
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
.with_inserted_attribute(Mesh::ATTRIBUTE_COLOR, colors),
)
}
pub fn build_part_edge_mesh(fem_mesh: &FemMesh) -> Option<Mesh> {
build_edge_mesh_with_positions(fem_mesh, |id| fem_mesh.node_position(id))
}
pub(crate) fn build_contour_edge_mesh(
mesh: &FemMesh,
step: &fem_core::StepResult,
settings: &ContourSettings,
) -> Option<Mesh> {
let displacements = if settings.show_deformation {
match step.field_by_name(&settings.displacement_field) {
Some(fem_core::ResultField::NodeVector { values, .. }) => Some(values),
_ => None,
}
} else { None };
let positions: std::collections::HashMap<_, _> = mesh.nodes.iter().enumerate().map(|(i, node)| {
let position = match displacements.and_then(|v|v.get(i)) {
Some(displacement) => node.position + *displacement * settings.deformation_scale,
None => node.position,
};
(node.id, position)
}).collect();
build_edge_mesh_with_positions(mesh, |id| positions.get(&id).copied().filter(|p|p.is_finite()))
}
fn build_edge_mesh_with_positions(
fem_mesh: &FemMesh,
position: impl Fn(fem_core::NodeId) -> Option<Vec3>,
) -> Option<Mesh> {
let mut positions = Vec::new();
let mut normals = Vec::new();
let mut seen = BTreeSet::new();
for edge in fem_mesh.cached_boundary_edges() {
seen.insert(ordered_node_pair(edge.nodes));
let Some(start) = position(edge.nodes[0]) else {
continue;
};
let Some(end) = position(edge.nodes[1]) else {
continue;
};
positions.push(start.to_array());
positions.push(end.to_array());
normals.push(Vec3::Y.to_array());
normals.push(Vec3::Y.to_array());
}
for element in &fem_mesh.elements {
if !element.element_type.is_beam() {
continue;
}
for nodes in element.edge_node_ids() {
if !seen.insert(ordered_node_pair(nodes)) {
continue;
}
let Some(start) = position(nodes[0]) else {
continue;
};
let Some(end) = position(nodes[1]) else {
continue;
};
positions.push(start.to_array());
positions.push(end.to_array());
normals.push(Vec3::Y.to_array());
normals.push(Vec3::Y.to_array());
}
}
if positions.is_empty() {
return None;
}
Some(
Mesh::new(
PrimitiveTopology::LineList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals),
)
}
fn ordered_node_pair(nodes: [fem_core::NodeId; 2]) -> (fem_core::NodeId, fem_core::NodeId) {
if nodes[0] <= nodes[1] {
(nodes[0], nodes[1])
} else {
(nodes[1], nodes[0])
}
}
fn append_face_triangles(
positions: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>,
points: &[Vec3],
) {
if points.len() < 3 {
return;
}
let Some(normal) = face_normal(points) else {
return;
};
for index in 1..(points.len() - 1) {
push_triangle(
positions,
normals,
[points[0], points[index], points[index + 1]],
normal,
);
}
}
fn push_triangle(
positions: &mut Vec<[f32; 3]>,
normals: &mut Vec<[f32; 3]>,
triangle: [Vec3; 3],
normal: Vec3,
) {
let normal = normal.to_array();
for point in triangle {
positions.push(point.to_array());
normals.push(normal);
}
}
fn material_set(
materials: &mut Assets<StandardMaterial>,
normal: Color,
hover: Color,
selected: Color,
flat: Color,
blend: bool,
) -> MaterialSet {
let mut flat_material = standard_material(flat, blend);
flat_material.unlit = true;
let mut transparent_material = standard_material(normal.with_alpha(0.18), true);
transparent_material.cull_mode = None;
transparent_material.double_sided = true;
MaterialSet {
normal: materials.add(standard_material(normal, blend)),
hover: materials.add(standard_material(hover, blend)),
selected: materials.add(selection_material(selected)),
flat: materials.add(flat_material),
transparent: materials.add(transparent_material),
}
}
fn selection_material(color: Color) -> StandardMaterial {
standard_material(color.with_alpha(1.0), false)
}
fn standard_material(color: Color, blend: bool) -> StandardMaterial {
let mut material = StandardMaterial {
base_color: color,
perceptual_roughness: 0.78,
..default()
};
if blend {
material.alpha_mode = AlphaMode::Blend;
}
material
}
fn bounds(points: &[Vec3]) -> Option<(Vec3, Vec3)> {
let mut iter = points.iter();
let first = *iter.next()?;
let mut min = first;
let mut max = first;
for point in iter {
min = min.min(*point);
max = max.max(*point);
}
Some((min, max))
}
fn visual_size(size: Vec3) -> Vec3 {
Vec3::new(
size.x.max(MIN_VISUAL_SIZE),
size.y.max(MIN_VISUAL_SIZE),
size.z.max(MIN_VISUAL_SIZE),
)
}
fn face_normal(points: &[Vec3]) -> Option<Vec3> {
let origin = points[0];
for i in 1..points.len() {
let edge_a = points[i] - origin;
for j in (i + 1)..points.len() {
if let Some(normal) = edge_a.cross(points[j] - origin).try_normalize() {
return Some(normal);
}
}
}
None
}
#[cfg(test)]
mod tests {
use bevy::mesh::VertexAttributeValues;
use fem_core::{
ElementId, ElementType, FemElement, FemMesh, FemNode, FemSurfaceSet, MpcTerm, NodeId,
SurfaceSetRef,
};
use super::*;
#[test]
fn selection_material_is_opaque_and_writes_depth() {
let material = selection_material(Color::srgba(0.10, 1.0, 0.45, 0.25));
assert_eq!(material.alpha_mode, AlphaMode::Opaque);
assert_eq!(material.base_color.to_srgba().alpha, 1.0);
}
#[test]
fn contact_review_separates_parts_symmetrically_without_editing_nodes() {
let mut model = FemModel::demo_hex8();
let original_positions: Vec<Vec3> = model.meshes[0]
.nodes
.iter()
.map(|node| node.position)
.collect();
let mut second = FemMesh::demo_hex8();
for node in &mut second.nodes {
node.position += Vec3::X * 3.0;
}
model.add_mesh("Second", second);
let candidate = ContactCandidate {
mesh_a: 0,
mesh_b: 1,
faces_a: Vec::new(),
faces_b: Vec::new(),
pair_count: 1,
average_gap: 0.0,
};
let (offset_a, offset_b) = contact_review_offsets(&model, &candidate, 10.0);
assert!(offset_a.x < 0.0);
assert!(offset_b.x > 0.0);
assert!((offset_a + offset_b).length() < 1.0e-6);
assert_eq!(
model.meshes[0]
.nodes
.iter()
.map(|node| node.position)
.collect::<Vec<_>>(),
original_positions
);
}
#[test]
fn self_contact_review_does_not_explode_one_part() {
let model = FemModel::demo_hex8();
let candidate = ContactCandidate {
mesh_a: 0,
mesh_b: 0,
faces_a: Vec::new(),
faces_b: Vec::new(),
pair_count: 1,
average_gap: 0.0,
};
assert_eq!(
contact_review_offsets(&model, &candidate, 30.0),
(Vec3::ZERO, Vec3::ZERO)
);
}
#[test]
fn element_highlight_contains_the_whole_element_not_one_boundary_face() {
let model = FemModel::demo_hex8();
let face_id = model.meshes[0].cached_boundary_faces()[0].id;
let face =
build_multi_face_highlight_mesh(&model, [FemEntityRef::face(0, face_id)].into_iter())
.unwrap();
let element = build_multi_face_highlight_mesh(
&model,
[FemEntityRef::element(0, ElementId(0))].into_iter(),
)
.unwrap();
assert_eq!(face.count_vertices(), 6);
assert_eq!(element.count_vertices(), 36);
}
#[test]
fn multi_edge_highlight_contains_only_the_requested_edges() {
let model = FemModel::demo_hex8();
let edges = model.meshes[0].cached_boundary_edges();
let rendered = build_multi_edge_highlight_mesh(
&model,
[
FemEntityRef::edge(0, edges[0].id),
FemEntityRef::edge(0, edges[1].id),
]
.into_iter(),
model_visual_scale(&model),
)
.unwrap();
assert_eq!(
rendered.primitive_topology(),
PrimitiveTopology::TriangleList
);
assert_eq!(rendered.count_vertices(), 72);
}
#[test]
fn defined_surface_contact_highlight_uses_only_the_surface_set_faces() {
let mut model = FemModel::demo_hex8();
let surface = model.meshes[0].cached_boundary_faces()[0]
.element_face_ref()
.unwrap();
model.meshes[0].surface_sets.push(FemSurfaceSet {
name: "MASTER".to_string(),
surfaces: vec![surface],
});
let rendered = build_surface_set_highlight_mesh(&model, SurfaceSetRef::new(0, 0)).unwrap();
assert_eq!(rendered.count_vertices(), 6);
}
#[test]
fn node_surface_slave_highlight_draws_one_marker_per_node() {
let model = FemModel::demo_hex8();
let rendered =
build_highlight_nodes_mesh(&model.meshes[0], &[NodeId(0), NodeId(1)], 0.01).unwrap();
assert_eq!(rendered.count_vertices(), 48);
}
#[test]
fn defined_mpc_highlight_splits_coefficient_signs_and_deduplicates_nodes() {
let model = FemModel::demo_hex8();
let equation = MpcEquation::new(
"MPC",
0.0,
vec![
MpcTerm::new(0, NodeId(0), 1, 1.0),
MpcTerm::new(0, NodeId(1), 1, -1.0),
MpcTerm::new(0, NodeId(1), 4, -0.5),
],
);
let positive = build_mpc_equation_highlight(&model, &equation, true, 0.01).unwrap();
let negative = build_mpc_equation_highlight(&model, &equation, false, 0.01).unwrap();
assert_eq!(positive.count_vertices(), 24);
assert_eq!(negative.count_vertices(), 24);
}
#[test]
fn builds_actual_tetrahedron_surface_instead_of_a_bounding_box() {
let mesh = FemMesh::new(
vec![
FemNode::from_xyz(NodeId(1), 0.0, 0.0, 0.0),
FemNode::from_xyz(NodeId(2), 1.0, 0.0, 0.0),
FemNode::from_xyz(NodeId(3), 0.0, 1.0, 0.0),
FemNode::from_xyz(NodeId(4), 0.0, 0.0, 1.0),
],
vec![FemElement::new(
ElementId(1),
ElementType::Tet4,
vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
)],
);
let rendered = build_element_surface_mesh(&mesh, &mesh.elements[0]).unwrap();
assert_eq!(rendered.count_vertices(), 12);
}
#[test]
fn aggregate_edge_mesh_keeps_line_only_models_visible() {
let mesh = FemMesh::new(
vec![
FemNode::from_xyz(NodeId(1), 0.0, 0.0, 0.0),
FemNode::from_xyz(NodeId(2), 2.0, 0.0, 0.0),
FemNode::from_xyz(NodeId(3), 1.0, 1.0, 0.0),
],
vec![FemElement::new(
ElementId(1),
ElementType::Rod3,
vec![NodeId(1), NodeId(2), NodeId(3)],
)],
);
assert!(mesh.cached_boundary_edges().is_empty());
let rendered = build_part_edge_mesh(&mesh).unwrap();
assert_eq!(rendered.count_vertices(), 4);
}
#[test]
fn triangular_face_visual_does_not_include_bounding_rectangle_corners() {
let rendered = build_extruded_polygon_mesh(
&[
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
],
0.01,
)
.unwrap();
let Some(VertexAttributeValues::Float32x3(positions)) =
rendered.attribute(Mesh::ATTRIBUTE_POSITION)
else {
panic!("face mesh is missing Float32x3 positions");
};
assert!(positions.iter().all(|position| {
position[0] >= 0.0 && position[1] >= 0.0 && position[0] + position[1] <= 1.0
}));
}
}