1use crate::material_colors::{MaterialColorMode, MaterialIdentity, resolve_materials};
2use bevy::asset::RenderAssetUsages;
3use bevy::math::primitives::{Cuboid, Cylinder};
4use bevy::mesh::{Mesh3d, PrimitiveTopology};
5use bevy::pbr::MeshMaterial3d;
6use bevy::prelude::*;
7use fem_core::{
8 ContactCandidate, ContactCandidateState, ContactPair, ContactSlaveRef, ElementFaceRef, FaceId,
9 FemEdge, FemElement, FemEntityId, FemEntityRef, FemFace, FemMesh, FemModel, FemNode,
10 MpcEquation, NodeId, RigidSpiderCandidateState, SurfaceSetRef, rainbow_color,
11};
12use interaction::HoverResult;
13use std::collections::BTreeSet;
14
15use selection::{
16 EdgeEntity, ElementEntity, FaceEntity, Hovered, NodeEntity, Selectable, Selected,
17 SelectionState,
18};
19
20const NODE_SIZE: f32 = 0.12;
21const EDGE_THICKNESS: f32 = 0.04;
22const FACE_THICKNESS: f32 = 0.012;
23const MIN_VISUAL_SIZE: f32 = 0.01;
24const ENTITY_RENDER_LIMIT: usize = 30_000;
25const MAX_DEFINED_CONTACT_NODE_MARKERS: usize = 20_000;
26
27#[cfg(test)]
28#[path = "material_render_tests.rs"]
29mod material_render_tests;
30
31#[derive(Resource, Debug, Clone)]
32pub struct VisualizationSettings {
33 pub mode: VisualizationMode,
34
35 pub contour: Option<ContourSettings>,
37}
38
39impl Default for VisualizationSettings {
40 fn default() -> Self {
41 Self {
42 mode: VisualizationMode::ShadedWithEdges,
43 contour: None,
44 }
45 }
46}
47
48#[derive(Resource, Debug, Clone, Copy, PartialEq)]
54pub struct ContactReviewSettings {
55 pub active: bool,
56
57 pub ghost_others: bool,
58
59 pub separation_percent: f32,
60}
61
62impl Default for ContactReviewSettings {
63 fn default() -> Self {
64 Self {
65 active: false,
66 ghost_others: true,
67 separation_percent: 8.0,
68 }
69 }
70}
71
72#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub struct RigidSpiderReviewSettings {
77 pub active: bool,
78}
79
80#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub struct DefinedMpcPreview {
86 pub selected: Option<usize>,
87
88 pub active: bool,
89}
90
91#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct MpcPairDraftPreview {
96 pub positive: Option<(usize, NodeId)>,
97
98 pub negative: Option<(usize, NodeId)>,
99
100 pub active: bool,
101}
102
103impl MpcPairDraftPreview {
104 pub fn clear(&mut self) {
105 self.positive = None;
106 self.negative = None;
107 self.active = false;
108 }
109}
110
111#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub struct DefinedContactPreview {
119 pub selected: Option<usize>,
120
121 pub active: bool,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct ContactDraftSurface {
126 pub mesh_index: usize,
127
128 pub surfaces: Vec<ElementFaceRef>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ContactDraftSlave {
133 Nodes {
134 mesh_index: usize,
135 nodes: Vec<NodeId>,
136 },
137
138 Surface(ContactDraftSurface),
139}
140
141#[derive(Resource, Debug, Clone, PartialEq, Eq, Default)]
145pub struct ContactDraftPreview {
146 pub master: Option<ContactDraftSurface>,
147
148 pub slave: Option<ContactDraftSlave>,
149
150 pub active: bool,
151}
152
153impl ContactDraftPreview {
154 pub fn clear(&mut self) {
155 self.master = None;
156 self.slave = None;
157 self.active = false;
158 }
159}
160
161#[derive(Resource, Debug, Clone, Copy, PartialEq)]
162pub(crate) struct ContactReviewPose {
163 active: bool,
164
165 ghost_others: bool,
166
167 mesh_a: usize,
168
169 mesh_b: usize,
170
171 offset_a: Vec3,
172
173 offset_b: Vec3,
174}
175
176impl Default for ContactReviewPose {
177 fn default() -> Self {
178 Self {
179 active: false,
180 ghost_others: true,
181 mesh_a: 0,
182 mesh_b: 0,
183 offset_a: Vec3::ZERO,
184 offset_b: Vec3::ZERO,
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq)]
192pub struct ContourSettings {
193 pub mesh_index: usize,
195
196 pub step_index: usize,
197
198 pub field_name: String,
199
200 pub show_deformation: bool,
202
203 pub displacement_field: String,
204
205 pub deformation_scale: f32,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum VisualizationMode {
211 ShadedWithEdges,
212
213 Shaded,
214
215 Flat,
218
219 Wireframe,
222
223 Transparent,
228
229 Edges,
230}
231
232impl VisualizationMode {
233 pub const ALL: [Self; 6] = [
234 Self::ShadedWithEdges,
235 Self::Shaded,
236 Self::Flat,
237 Self::Wireframe,
238 Self::Transparent,
239 Self::Edges,
240 ];
241
242 pub const fn label(self) -> &'static str {
243 match self {
244 Self::ShadedWithEdges => "Both",
245 Self::Shaded => "Shaded",
246 Self::Flat => "Flat",
247 Self::Wireframe => "Wire",
248 Self::Transparent => "X-ray",
249 Self::Edges => "Edges",
250 }
251 }
252}
253
254#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
255pub(crate) enum VisualLayer {
256 Shaded,
257
258 Edge,
259
260 Node,
261}
262
263impl VisualLayer {
264 pub(crate) const fn visible_in(self, mode: VisualizationMode) -> bool {
265 match (self, mode) {
266 (
268 Self::Shaded,
269 VisualizationMode::Shaded
270 | VisualizationMode::ShadedWithEdges
271 | VisualizationMode::Flat
272 | VisualizationMode::Wireframe
273 | VisualizationMode::Transparent,
274 ) => true,
275 (
280 Self::Edge,
281 VisualizationMode::Edges
282 | VisualizationMode::ShadedWithEdges
283 | VisualizationMode::Transparent,
284 ) => true,
285 (Self::Node, VisualizationMode::ShadedWithEdges) => true,
287 _ => false,
288 }
289 }
290}
291
292#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
293pub(crate) enum TopologyHighlight {
294 Hover,
295
296 Selected,
297}
298
299#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
307pub(crate) enum ContactCandidateHighlight {
308 Master,
309
310 Slave,
311}
312
313#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
314pub(crate) enum RigidSpiderHighlight {
315 Master,
316 Slave,
317}
318
319#[derive(Component, Debug, Clone, Copy, Default)]
320pub(crate) struct ContactHighlightAvailability(bool);
321
322#[derive(Default)]
323pub(crate) struct TopologyHighlightCache {
324 hover: Vec<FemEntityRef>,
329
330 selected: Vec<FemEntityRef>,
331}
332
333#[derive(Component, Debug, Clone, Copy)]
339pub struct FemMeshVisual;
340
341#[derive(Component, Debug, Clone, Copy)]
345pub struct FemPartVisual {
346 pub mesh_index: usize,
347}
348
349#[derive(Component)]
350pub struct NormalMaterial(pub Handle<StandardMaterial>);
351
352#[derive(Component)]
358pub struct FlatMaterial(pub Handle<StandardMaterial>);
359
360#[derive(Component)]
362pub struct TransparentMaterial(pub Handle<StandardMaterial>);
363
364#[derive(Component)]
365pub struct HoverMaterial(pub Handle<StandardMaterial>);
366
367#[derive(Component)]
368pub struct SelectedMaterial(pub Handle<StandardMaterial>);
369
370#[derive(Clone)]
371struct MaterialSet {
372 normal: Handle<StandardMaterial>,
373
374 hover: Handle<StandardMaterial>,
375
376 selected: Handle<StandardMaterial>,
377
378 flat: Handle<StandardMaterial>,
380
381 transparent: Handle<StandardMaterial>,
383}
384
385pub fn spawn_demo_mesh(
386 mut commands: Commands,
387 model: Option<Res<FemModel>>,
388 analysis_setup: Option<Res<fem_core::AnalysisSetup>>,
389 colors: Res<MaterialColorMode>,
390 mut meshes: ResMut<Assets<Mesh>>,
391 mut materials: ResMut<Assets<StandardMaterial>>,
392) {
393 let fem_model = model
394 .as_deref()
395 .cloned()
396 .unwrap_or_else(FemModel::demo_hex8);
397
398 if model.is_none() {
399 commands.insert_resource(fem_model.clone());
400 }
401
402 spawn_model_visuals(
403 &mut commands,
404 &mut meshes,
405 &mut materials,
406 &fem_model,
407 analysis_setup.as_deref(),
408 *colors,
409 );
410}
411
412pub(crate) fn spawn_model_visuals(
425 commands: &mut Commands,
426 meshes: &mut Assets<Mesh>,
427 materials: &mut Assets<StandardMaterial>,
428 fem_model: &FemModel,
429 analysis_setup: Option<&fem_core::AnalysisSetup>,
430 color_mode: MaterialColorMode,
431) {
432 let selected_element = Color::srgb(0.10, 1.0, 0.45);
437 let selected_face = Color::srgb(0.10, 1.0, 0.45);
438 let selected_edge = Color::srgb(0.10, 1.0, 0.45);
439 let selected_node = Color::srgb(0.10, 1.0, 0.45);
440
441 let hover_element = Color::srgba(1.0, 0.75, 0.15, 0.70);
442 let hover_face = Color::srgba(1.0, 0.88, 0.15, 0.70);
443 let hover_edge = Color::srgb(1.0, 0.82, 0.15);
444 let hover_node = Color::srgb(1.0, 0.82, 0.18);
445
446 let multi_part = fem_model.meshes.len() > 1;
447 let model_scale = model_visual_scale(fem_model);
448
449 for (mesh_index, fem_mesh) in fem_model.meshes.iter().enumerate() {
450 let assignments = (color_mode == MaterialColorMode::Material).then(|| {
451 resolve_materials(
452 analysis_setup.unwrap_or(&fem_core::AnalysisSetup::default()),
453 mesh_index,
454 fem_mesh,
455 )
456 });
457 let hue_shift = if multi_part {
458 part_hue_shift(mesh_index)
459 } else {
460 0.0
461 };
462
463 let normal_element = tint_hue(Color::srgba(0.25, 0.45, 0.95, 0.22), hue_shift);
465 let normal_face = tint_hue(Color::srgba(0.20, 0.70, 0.65, 0.18), hue_shift);
466 let normal_edge = tint_hue(Color::srgb(0.12, 0.14, 0.16), hue_shift);
467 let normal_node = tint_hue(Color::srgb(0.82, 0.88, 0.95), hue_shift);
468
469 let flat_element = tint_hue(Color::srgba(0.45, 0.62, 0.92, 0.45), hue_shift);
473 let flat_face = tint_hue(Color::srgba(0.46, 0.72, 0.68, 0.45), hue_shift);
474 let flat_edge = tint_hue(Color::srgb(0.30, 0.34, 0.38), hue_shift);
475 let flat_node = tint_hue(Color::srgb(0.90, 0.93, 0.97), hue_shift);
476
477 let element_materials = material_set(
478 materials,
479 normal_element,
480 hover_element,
481 selected_element,
482 flat_element,
483 true,
484 );
485 let face_materials = material_set(
486 materials,
487 normal_face,
488 hover_face,
489 selected_face,
490 flat_face,
491 true,
492 );
493 let edge_materials = material_set(
494 materials,
495 normal_edge,
496 hover_edge,
497 selected_edge,
498 flat_edge,
499 false,
500 );
501 let node_materials = material_set(
502 materials,
503 normal_node,
504 hover_node,
505 selected_node,
506 flat_node,
507 false,
508 );
509
510 let warning_materials = material_set(
518 materials,
519 Color::srgba(0.95, 0.05, 0.85, 0.85),
520 hover_element,
521 selected_element,
522 Color::srgba(0.95, 0.05, 0.85, 0.55),
523 true,
524 );
525
526 if use_aggregate_rendering(fem_mesh) {
527 spawn_aggregate_surface_visual(
528 commands,
529 meshes,
530 materials,
531 mesh_index,
532 fem_mesh,
533 hue_shift,
534 assignments.as_ref(),
535 );
536
537 continue;
538 }
539
540 let section_map = analysis_setup
541 .map(|setup| setup.build_element_section_map(mesh_index, fem_mesh))
542 .unwrap_or_default();
543
544 let material_palette: std::collections::BTreeMap<_, _> = assignments
545 .as_ref()
546 .into_iter()
547 .flat_map(|map| map.values())
548 .cloned()
549 .collect::<BTreeSet<_>>()
550 .into_iter()
551 .map(|identity| {
552 let color = identity.color();
553 (
554 identity,
555 material_set(
556 materials,
557 color,
558 hover_element,
559 selected_element,
560 color,
561 false,
562 ),
563 )
564 })
565 .collect();
566
567 for element in &fem_mesh.elements {
568 let section = section_map.get(&element.id).copied();
569 let materials_for_element =
570 if matches!(element.element_type, fem_core::ElementType::Unsupported(_)) {
571 &warning_materials
572 } else {
573 assignments
574 .as_ref()
575 .and_then(|map| map.get(&element.id))
576 .and_then(|identity| material_palette.get(identity))
577 .unwrap_or(&element_materials)
578 };
579
580 spawn_element_visual(
581 commands,
582 meshes,
583 mesh_index,
584 fem_mesh,
585 element,
586 materials_for_element,
587 section,
588 model_scale,
589 );
590 }
591
592 for face in fem_mesh.cached_boundary_faces() {
593 spawn_face_visual(
594 commands,
595 meshes,
596 mesh_index,
597 fem_mesh,
598 face,
599 face.element
600 .and_then(|id| assignments.as_ref().and_then(|map| map.get(&id)))
601 .and_then(|identity| material_palette.get(identity))
602 .unwrap_or(&face_materials),
603 );
604 }
605
606 for edge in fem_mesh.cached_edges() {
607 spawn_edge_visual(
608 commands,
609 meshes,
610 mesh_index,
611 fem_mesh,
612 edge,
613 &edge_materials,
614 );
615 }
616
617 for node in &fem_mesh.nodes {
618 spawn_node_visual(commands, meshes, mesh_index, node, &node_materials);
619 }
620 }
621}
622
623fn part_hue_shift(mesh_index: usize) -> f32 {
630 const GOLDEN_ANGLE_DEG: f32 = 137.50776;
631
632 (mesh_index as f32 * GOLDEN_ANGLE_DEG).rem_euclid(360.0)
633}
634
635fn tint_hue(color: Color, shift_deg: f32) -> Color {
639 if shift_deg.abs() < 1.0e-3 {
640 return color;
641 }
642
643 let hsla: Hsla = color.into();
644 let new_hue = (hsla.hue + shift_deg).rem_euclid(360.0);
645
646 Color::Hsla(Hsla {
647 hue: new_hue,
648 ..hsla
649 })
650}
651
652fn use_aggregate_rendering(fem_mesh: &FemMesh) -> bool {
653 fem_mesh.nodes.len()
654 + fem_mesh.elements.len()
655 + fem_mesh.cached_edges().len()
656 + fem_mesh.cached_boundary_faces().len()
657 > ENTITY_RENDER_LIMIT
658}
659
660fn spawn_aggregate_surface_visual(
661 commands: &mut Commands,
662 meshes: &mut Assets<Mesh>,
663 materials: &mut Assets<StandardMaterial>,
664 mesh_index: usize,
665 fem_mesh: &FemMesh,
666 hue_shift: f32,
667 assignments: Option<&std::collections::BTreeMap<fem_core::ElementId, MaterialIdentity>>,
668) {
669 if let Some(mesh) = build_material_surface_mesh(fem_mesh, assignments) {
670 let normal_mat = materials.add(StandardMaterial {
671 base_color: if assignments.is_some() {
672 Color::WHITE
673 } else {
674 tint_hue(Color::srgb(0.35, 0.52, 0.68), hue_shift)
675 },
676 perceptual_roughness: 0.82,
677 cull_mode: None,
678 ..default()
679 });
680
681 let flat_mat = materials.add(StandardMaterial {
684 base_color: if assignments.is_some() {
685 Color::WHITE
686 } else {
687 tint_hue(Color::srgb(0.48, 0.64, 0.76), hue_shift)
688 },
689 unlit: true,
690 cull_mode: None,
691 ..default()
692 });
693
694 let transparent_mat = materials.add(StandardMaterial {
697 base_color: if assignments.is_some() {
698 Color::WHITE.with_alpha(0.18)
699 } else {
700 tint_hue(Color::srgba(0.35, 0.52, 0.68, 0.18), hue_shift)
701 },
702 alpha_mode: AlphaMode::Blend,
703 cull_mode: None,
704 double_sided: true,
705 ..default()
706 });
707
708 let mesh = meshes.add(mesh);
709 commands.spawn((
710 Mesh3d(mesh),
711 MeshMaterial3d(normal_mat.clone()),
712 Transform::default(),
713 VisualLayer::Shaded,
714 Visibility::Visible,
715 NormalMaterial(normal_mat),
716 FlatMaterial(flat_mat),
717 TransparentMaterial(transparent_mat),
718 FemPartVisual { mesh_index },
719 FemMeshVisual,
720 Name::new("Aggregated boundary surface"),
721 ));
722 }
723
724 if let Some(mesh) = build_part_edge_mesh(fem_mesh) {
725 let material = materials.add(StandardMaterial {
726 base_color: Color::srgb(0.04, 0.05, 0.055),
727 unlit: true,
728 ..default()
729 });
730
731 commands.spawn((
732 Mesh3d(meshes.add(mesh)),
733 MeshMaterial3d(material),
734 Transform::default(),
735 VisualLayer::Edge,
736 Visibility::Visible,
737 FemPartVisual { mesh_index },
738 FemMeshVisual,
739 Name::new("Aggregated boundary edges"),
740 ));
741 }
742}
743
744pub(crate) fn spawn_topology_highlights(
745 mut commands: Commands,
746 mut meshes: ResMut<Assets<Mesh>>,
747 mut materials: ResMut<Assets<StandardMaterial>>,
748) {
749 let hover_material = materials.add(StandardMaterial {
758 base_color: Color::srgba(1.0, 0.88, 0.15, 0.70),
759 alpha_mode: AlphaMode::Blend,
760 cull_mode: None,
761 unlit: true,
762 depth_bias: 2.0,
763 ..default()
764 });
765
766 let mut selected_material = selection_material(Color::srgb(0.10, 1.0, 0.45));
772 selected_material.cull_mode = None;
773 selected_material.double_sided = true;
774 selected_material.unlit = true;
775 selected_material.depth_bias = 3.0;
776 let selected_material = materials.add(selected_material);
777
778 spawn_topology_highlight(
779 &mut commands,
780 &mut meshes,
781 hover_material,
782 TopologyHighlight::Hover,
783 "Topology hover highlight",
784 );
785 spawn_topology_highlight(
786 &mut commands,
787 &mut meshes,
788 selected_material,
789 TopologyHighlight::Selected,
790 "Topology selected highlight",
791 );
792}
793
794fn spawn_topology_highlight(
795 commands: &mut Commands,
796 meshes: &mut Assets<Mesh>,
797 material: Handle<StandardMaterial>,
798 highlight: TopologyHighlight,
799 name: &'static str,
800) {
801 commands.spawn((
802 Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
803 MeshMaterial3d(material),
804 Transform::default(),
805 Visibility::Hidden,
806 highlight,
807 Name::new(name),
808 ));
809}
810
811pub(crate) fn spawn_contact_candidate_highlights(
815 mut commands: Commands,
816 mut meshes: ResMut<Assets<Mesh>>,
817 mut materials: ResMut<Assets<StandardMaterial>>,
818) {
819 let master_material = materials.add(StandardMaterial {
820 base_color: Color::srgba(0.25, 0.55, 1.0, 0.55),
821 alpha_mode: AlphaMode::Blend,
822 cull_mode: None,
823 unlit: true,
824 depth_bias: 2.0,
825 ..default()
826 });
827 let slave_material = materials.add(StandardMaterial {
828 base_color: Color::srgba(1.0, 0.55, 0.10, 0.55),
829 alpha_mode: AlphaMode::Blend,
830 cull_mode: None,
831 unlit: true,
832 depth_bias: 2.0,
833 ..default()
834 });
835
836 commands.spawn((
837 Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
838 MeshMaterial3d(master_material),
839 Transform::default(),
840 Visibility::Hidden,
841 ContactCandidateHighlight::Master,
842 ContactHighlightAvailability::default(),
843 Name::new("Contact candidate master highlight"),
844 ));
845
846 commands.spawn((
847 Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
848 MeshMaterial3d(slave_material),
849 Transform::default(),
850 Visibility::Hidden,
851 ContactCandidateHighlight::Slave,
852 ContactHighlightAvailability::default(),
853 Name::new("Contact candidate slave highlight"),
854 ));
855}
856
857pub(crate) fn spawn_rigid_spider_highlights(
858 mut commands: Commands,
859 mut meshes: ResMut<Assets<Mesh>>,
860 mut materials: ResMut<Assets<StandardMaterial>>,
861) {
862 let master_material = materials.add(StandardMaterial {
863 base_color: Color::srgb(1.0, 0.18, 0.78),
864 unlit: true,
865 depth_bias: 3.0,
866 ..default()
867 });
868 let slave_material = materials.add(StandardMaterial {
869 base_color: Color::srgb(0.10, 0.90, 0.95),
870 unlit: true,
871 depth_bias: 3.0,
872 ..default()
873 });
874
875 for (highlight, material, name) in [
876 (
877 RigidSpiderHighlight::Master,
878 master_material,
879 "MPC spider center highlight",
880 ),
881 (
882 RigidSpiderHighlight::Slave,
883 slave_material,
884 "MPC spider slave highlight",
885 ),
886 ] {
887 commands.spawn((
888 Mesh3d(meshes.add(Cuboid::new(0.01, 0.01, 0.01))),
889 MeshMaterial3d(material),
890 Transform::default(),
891 Visibility::Hidden,
892 highlight,
893 ContactHighlightAvailability::default(),
894 Name::new(name),
895 ));
896 }
897}
898
899pub fn update_hover_materials(
900 settings: Res<VisualizationSettings>,
901 mut query: Query<(
902 &mut MeshMaterial3d<StandardMaterial>,
903 &NormalMaterial,
904 Option<&FlatMaterial>,
905 Option<&TransparentMaterial>,
906 &HoverMaterial,
907 &SelectedMaterial,
908 Option<&Hovered>,
909 Option<&Selected>,
910 )>,
911) {
912 let use_flat = matches!(settings.mode, VisualizationMode::Flat);
913 let use_transparent = matches!(settings.mode, VisualizationMode::Transparent);
914
915 for (mut material, normal, flat, transparent, hover, selected, hovered, is_selected) in
916 query.iter_mut()
917 {
918 if is_selected.is_some() {
922 material.0 = selected.0.clone();
923 } else if hovered.is_some() {
924 material.0 = hover.0.clone();
925 } else if use_flat {
926 material.0 = flat
930 .map(|f| f.0.clone())
931 .unwrap_or_else(|| normal.0.clone());
932 } else if use_transparent {
933 material.0 = transparent
939 .map(|t| t.0.clone())
940 .unwrap_or_else(|| normal.0.clone());
941 } else {
942 material.0 = normal.0.clone();
943 }
944 }
945}
946
947pub(crate) fn restore_selection_on_new_visuals(
951 mut commands: Commands,
952 selection: Option<ResMut<SelectionState>>,
953 new_visuals: Query<(Entity, &Selectable), (With<FemMeshVisual>, Added<Selectable>)>,
954 live_visuals: Query<(), With<Selectable>>,
955) {
956 if new_visuals.is_empty() {
957 return;
958 }
959 let Some(mut selection) = selection else {
960 return;
961 };
962 selection.entities.retain(|entity| live_visuals.contains(*entity));
963 let targets: BTreeSet<_> = selection.targets.iter().copied().collect();
964 let highlights: BTreeSet<_> = selection.highlight_targets.iter().copied().collect();
965 for (entity, selectable) in &new_visuals {
966 if targets.contains(&selectable.target) && highlights.contains(&selectable.target) {
967 commands.entity(entity).insert(Selected);
968 if !selection.entities.contains(&entity) {
969 selection.entities.push(entity);
970 }
971 }
972 }
973}
974
975pub(crate) fn update_topology_highlights(
986 model: Option<Res<FemModel>>,
987 hover_preview: Res<fem_core::HoverPreviewTargets>,
988 selection: Res<SelectionState>,
989 mut meshes: ResMut<Assets<Mesh>>,
990 mut cache: Local<TopologyHighlightCache>,
991 mut query: Query<
992 (
993 &TopologyHighlight,
994 &mut Mesh3d,
995 &mut Transform,
996 &mut Visibility,
997 ),
998 Without<VisualLayer>,
999 >,
1000) {
1001 let Some(model) = model else {
1002 hide_topology_highlights(&mut query);
1003
1004 return;
1005 };
1006
1007 let hover_is_redundant = !hover_preview.targets.is_empty()
1014 && hover_preview
1015 .targets
1016 .iter()
1017 .all(|t| selection.targets.contains(t));
1018
1019 let preview_highlights: &[FemEntityRef] = if hover_preview.highlight_targets.is_empty() {
1020 &hover_preview.targets
1021 } else {
1022 &hover_preview.highlight_targets
1023 };
1024 let selected_highlights: &[FemEntityRef] = if selection.highlight_targets.is_empty() {
1025 &selection.targets
1026 } else {
1027 &selection.highlight_targets
1028 };
1029 let hover_targets: &[FemEntityRef] = if hover_is_redundant {
1030 &[]
1031 } else {
1032 preview_highlights
1033 };
1034
1035 if cache.hover.as_slice() == hover_targets && cache.selected == selected_highlights {
1036 return;
1037 }
1038
1039 cache.hover = hover_targets.to_vec();
1040 cache.selected = selected_highlights.to_vec();
1041
1042 for (highlight, mut mesh, mut transform, mut visibility) in &mut query {
1043 let targets: &[FemEntityRef] = match highlight {
1044 TopologyHighlight::Hover => hover_targets,
1045 TopologyHighlight::Selected => selected_highlights,
1046 };
1047
1048 if targets.is_empty() {
1049 *visibility = Visibility::Hidden;
1050 continue;
1051 }
1052
1053 if apply_topology_highlight(
1054 &model,
1055 targets,
1056 &mut meshes,
1057 &mut mesh,
1058 &mut transform,
1059 &mut visibility,
1060 )
1061 .is_none()
1062 {
1063 *visibility = Visibility::Hidden;
1064 }
1065 }
1066}
1067
1068pub(crate) fn update_visual_layer_visibility(
1069 settings: Res<VisualizationSettings>,
1070 mut query: Query<(Ref<VisualLayer>, &mut Visibility), Without<TopologyHighlight>>,
1071) {
1072 for (layer, mut visibility) in &mut query {
1073 if !settings.is_changed() && !layer.is_added() {
1074 continue;
1075 }
1076 *visibility = if layer.visible_in(settings.mode) {
1077 Visibility::Visible
1078 } else {
1079 Visibility::Hidden
1080 };
1081 }
1082}
1083
1084pub(crate) fn apply_visualization_mode(
1096 settings: Res<VisualizationSettings>,
1097 mut commands: Commands,
1098 mut query: Query<
1099 (
1100 Entity,
1101 &VisualLayer,
1102 &mut MeshMaterial3d<StandardMaterial>,
1103 Ref<NormalMaterial>,
1104 &FlatMaterial,
1105 &TransparentMaterial,
1106 ),
1107 With<FemMeshVisual>,
1108 >,
1109) {
1110 for (entity, layer, mut mat, normal, flat, transparent) in &mut query {
1111 if !settings.is_changed() && !normal.is_added() {
1112 continue;
1113 }
1114 if *layer != VisualLayer::Shaded {
1115 continue;
1116 }
1117
1118 match settings.mode {
1119 VisualizationMode::Flat => {
1120 mat.0 = flat.0.clone();
1121 commands
1122 .entity(entity)
1123 .remove::<bevy::pbr::wireframe::Wireframe>();
1124 }
1125 VisualizationMode::Wireframe => {
1126 mat.0 = normal.0.clone();
1127 commands
1128 .entity(entity)
1129 .insert(bevy::pbr::wireframe::Wireframe);
1130 }
1131 VisualizationMode::Transparent => {
1132 mat.0 = transparent.0.clone();
1133 commands
1134 .entity(entity)
1135 .remove::<bevy::pbr::wireframe::Wireframe>();
1136 }
1137 _ => {
1138 mat.0 = normal.0.clone();
1139 commands
1140 .entity(entity)
1141 .remove::<bevy::pbr::wireframe::Wireframe>();
1142 }
1143 }
1144 }
1145}
1146
1147pub(crate) fn update_contact_review_pose(
1152 model: Option<Res<FemModel>>,
1153 candidates: Res<ContactCandidateState>,
1154 settings: Res<ContactReviewSettings>,
1155 mut pose: ResMut<ContactReviewPose>,
1156) {
1157 let model_changed = model.as_ref().is_some_and(|model| model.is_changed());
1158
1159 if !model_changed && !candidates.is_changed() && !settings.is_changed() {
1160 return;
1161 }
1162
1163 let next = model
1164 .as_deref()
1165 .zip(candidates.selected_candidate())
1166 .filter(|_| settings.active)
1167 .map(|(model, candidate)| {
1168 let (offset_a, offset_b) =
1169 contact_review_offsets(model, candidate, settings.separation_percent);
1170
1171 ContactReviewPose {
1172 active: true,
1173 ghost_others: settings.ghost_others,
1174 mesh_a: candidate.mesh_a,
1175 mesh_b: candidate.mesh_b,
1176 offset_a,
1177 offset_b,
1178 }
1179 })
1180 .unwrap_or_default();
1181
1182 if *pose != next {
1183 *pose = next;
1184 }
1185}
1186
1187pub(crate) fn apply_contact_review(
1193 pose: Res<ContactReviewPose>,
1194 settings: Res<VisualizationSettings>,
1195 mut visuals: Query<(
1196 &FemPartVisual,
1197 &VisualLayer,
1198 &mut Transform,
1199 &mut Visibility,
1200 Option<&mut MeshMaterial3d<StandardMaterial>>,
1201 Option<&TransparentMaterial>,
1202 )>,
1203) {
1204 if !pose.active && !pose.is_changed() {
1205 return;
1206 }
1207
1208 for (part, layer, mut transform, mut visibility, material, transparent) in &mut visuals {
1209 let relevant =
1210 pose.active && (part.mesh_index == pose.mesh_a || part.mesh_index == pose.mesh_b);
1211
1212 transform.translation = if !pose.active {
1213 Vec3::ZERO
1214 } else if part.mesh_index == pose.mesh_a {
1215 pose.offset_a
1216 } else if part.mesh_index == pose.mesh_b {
1217 pose.offset_b
1218 } else {
1219 Vec3::ZERO
1220 };
1221 transform.rotation = Quat::IDENTITY;
1222 transform.scale = Vec3::ONE;
1223
1224 *visibility = if layer.visible_in(settings.mode) {
1225 Visibility::Visible
1226 } else {
1227 Visibility::Hidden
1228 };
1229
1230 if pose.active && pose.ghost_others && !relevant {
1231 match (*layer, material, transparent) {
1232 (VisualLayer::Shaded, Some(mut material), Some(transparent)) => {
1233 material.0 = transparent.0.clone();
1234 *visibility = Visibility::Visible;
1235 }
1236 _ => {
1237 *visibility = Visibility::Hidden;
1238 }
1239 }
1240 }
1241 }
1242}
1243
1244fn contact_review_offsets(
1245 model: &FemModel,
1246 candidate: &ContactCandidate,
1247 separation_percent: f32,
1248) -> (Vec3, Vec3) {
1249 if candidate.is_self_contact() {
1250 return (Vec3::ZERO, Vec3::ZERO);
1251 }
1252
1253 let Some(mesh_a) = model.meshes.get(candidate.mesh_a) else {
1254 return (Vec3::ZERO, Vec3::ZERO);
1255 };
1256 let Some(mesh_b) = model.meshes.get(candidate.mesh_b) else {
1257 return (Vec3::ZERO, Vec3::ZERO);
1258 };
1259
1260 let contact_a = face_group_centroid(mesh_a, &candidate.faces_a);
1261 let contact_b = face_group_centroid(mesh_b, &candidate.faces_b);
1262 let part_a = mesh_a.bounds().map(|(min, max)| (min + max) * 0.5);
1263 let part_b = mesh_b.bounds().map(|(min, max)| (min + max) * 0.5);
1264
1265 let direction = contact_a
1266 .zip(contact_b)
1267 .and_then(|(a, b)| (b - a).try_normalize())
1268 .or_else(|| {
1269 part_a
1270 .zip(part_b)
1271 .and_then(|(a, b)| (b - a).try_normalize())
1272 })
1273 .unwrap_or(Vec3::X);
1274
1275 let diagonal = model
1276 .bounds()
1277 .map(|(min, max)| (max - min).length())
1278 .unwrap_or(0.0);
1279 let half_separation = diagonal * separation_percent.clamp(0.0, 30.0) * 0.005;
1280
1281 (-direction * half_separation, direction * half_separation)
1282}
1283
1284fn face_group_centroid(mesh: &FemMesh, face_ids: &[FaceId]) -> Option<Vec3> {
1285 let ids: BTreeSet<FaceId> = face_ids.iter().copied().collect();
1286 let mut total = Vec3::ZERO;
1287 let mut count = 0usize;
1288
1289 for face in mesh
1290 .cached_boundary_faces()
1291 .iter()
1292 .filter(|face| ids.contains(&face.id))
1293 {
1294 if let Some(geometry) = mesh.face_geometry(face) {
1295 total += geometry.centroid;
1296 count += 1;
1297 }
1298 }
1299
1300 (count > 0).then(|| total / count as f32)
1301}
1302
1303pub(crate) fn respawn_visuals_on_reload(
1312 mut commands: Commands,
1313 model: Option<Res<FemModel>>,
1314 version: Res<fem_core::FemModelVersion>,
1315 mut last_version: Local<Option<u64>>,
1316 mut meshes: ResMut<Assets<Mesh>>,
1317 mut materials: ResMut<Assets<StandardMaterial>>,
1318 visual_query: Query<Entity, With<FemMeshVisual>>,
1319 hovered_query: Query<Entity, With<Hovered>>,
1320 selected_query: Query<Entity, With<Selected>>,
1321 mut hover: ResMut<HoverResult>,
1322 mut selection: ResMut<SelectionState>,
1323 mut contact_candidates: ResMut<ContactCandidateState>,
1324 analysis_setup: Res<fem_core::AnalysisSetup>,
1325 colors: Res<MaterialColorMode>,
1326) {
1327 let current = version.value;
1328
1329 if *last_version == Some(current) {
1330 return;
1331 }
1332
1333 let first_run = last_version.is_none();
1334 *last_version = Some(current);
1335
1336 if first_run {
1337 return;
1339 }
1340
1341 let Some(model) = model else {
1342 return;
1343 };
1344
1345 for entity in &hovered_query {
1346 commands.entity(entity).remove::<Hovered>();
1347 }
1348
1349 for entity in &selected_query {
1350 commands.entity(entity).remove::<Selected>();
1351 }
1352
1353 for entity in &visual_query {
1355 commands.entity(entity).despawn();
1356 }
1357
1358 hover.clear();
1359 selection.clear();
1360 contact_candidates.candidates.clear();
1361 contact_candidates.selected = None;
1362
1363 spawn_model_visuals(
1364 &mut commands,
1365 &mut meshes,
1366 &mut materials,
1367 &model,
1368 Some(&analysis_setup),
1369 *colors,
1370 );
1371}
1372
1373pub(crate) fn respawn_elements_on_setup_change(
1381 mut commands: Commands,
1382 model: Option<Res<FemModel>>,
1383 setup: Res<fem_core::AnalysisSetup>,
1384 version: Res<fem_core::FemModelVersion>,
1385 mut last_version: Local<Option<u64>>,
1386 mut last_sections: Local<Option<Vec<fem_core::Section>>>,
1387 colors: Res<MaterialColorMode>,
1388 mut last_materials: Local<Vec<(String, usize)>>,
1389 mut meshes: ResMut<Assets<Mesh>>,
1390 mut materials: ResMut<Assets<StandardMaterial>>,
1391 visual_query: Query<Entity, With<FemMeshVisual>>,
1392) {
1393 let version_changed = *last_version != Some(version.value);
1394 *last_version = Some(version.value);
1395
1396 if !setup.is_changed() && !colors.is_changed() {
1397 return;
1398 }
1399
1400 let sections_changed = last_sections
1401 .as_deref()
1402 .is_some_and(|previous| previous != setup.sections.as_slice());
1403 *last_sections = Some(setup.sections.clone());
1404 let referenced: BTreeSet<_> = setup.sections.iter().map(|s| &s.material_name).collect();
1407 let material_names: Vec<_> = referenced
1408 .iter()
1409 .map(|name| {
1410 (
1411 (*name).clone(),
1412 setup.materials.iter().filter(|m| &m.name == *name).count(),
1413 )
1414 })
1415 .collect();
1416 let materials_changed = *last_materials != material_names;
1417 *last_materials = material_names;
1418
1419 if setup.is_added()
1420 || version_changed
1421 || !(sections_changed || materials_changed || colors.is_changed())
1422 {
1423 return;
1424 }
1425
1426 let Some(model) = model else {
1427 return;
1428 };
1429
1430 for entity in &visual_query {
1431 commands.entity(entity).despawn();
1432 }
1433
1434 spawn_model_visuals(
1435 &mut commands,
1436 &mut meshes,
1437 &mut materials,
1438 &model,
1439 Some(&setup),
1440 *colors,
1441 );
1442}
1443
1444
1445fn hide_topology_highlights(
1446 query: &mut Query<
1447 (
1448 &TopologyHighlight,
1449 &mut Mesh3d,
1450 &mut Transform,
1451 &mut Visibility,
1452 ),
1453 Without<VisualLayer>,
1454 >,
1455) {
1456 for (_, _, _, mut visibility) in query.iter_mut() {
1457 *visibility = Visibility::Hidden;
1458 }
1459}
1460
1461pub(crate) fn update_contact_candidate_highlights(
1466 model: Option<Res<FemModel>>,
1467 state: Res<ContactCandidateState>,
1468 pose: Res<ContactReviewPose>,
1469 defined: Res<DefinedContactPreview>,
1470 draft: Res<ContactDraftPreview>,
1471 mut meshes: ResMut<Assets<Mesh>>,
1472 mut query: Query<(
1473 &ContactCandidateHighlight,
1474 &mut Mesh3d,
1475 &mut Transform,
1476 &mut Visibility,
1477 &mut ContactHighlightAvailability,
1478 )>,
1479) {
1480 let rebuild = state.is_changed()
1481 || defined.is_changed()
1482 || draft.is_changed()
1483 || model.as_ref().is_some_and(|model| model.is_changed());
1484 let candidate = pose.active.then(|| state.selected_candidate()).flatten();
1485 let draft_active = candidate.is_none() && draft.active;
1486 let defined_contact = if candidate.is_none() && !draft_active && defined.active {
1487 model
1488 .as_deref()
1489 .and_then(|model| defined.selected.and_then(|index| model.contacts.get(index)))
1490 } else {
1491 None
1492 };
1493 let source_active = candidate.is_some() || draft_active || defined_contact.is_some();
1494
1495 for (highlight, mut mesh, mut transform, mut visibility, mut availability) in &mut query {
1496 if rebuild {
1497 let built = if let Some(candidate) = candidate {
1498 model.as_deref().and_then(|model| {
1499 let (mesh_index, face_ids) = match highlight {
1500 ContactCandidateHighlight::Master => (candidate.mesh_a, &candidate.faces_a),
1501 ContactCandidateHighlight::Slave => (candidate.mesh_b, &candidate.faces_b),
1502 };
1503 let fem_mesh = model.meshes.get(mesh_index)?;
1504 build_highlight_faces_mesh(fem_mesh, face_ids)
1505 })
1506 } else if draft_active {
1507 model
1508 .as_deref()
1509 .and_then(|model| build_draft_contact_highlight(model, &draft, *highlight))
1510 } else {
1511 model.as_deref().and_then(|model| {
1512 build_defined_contact_highlight(model, defined_contact?, *highlight)
1513 })
1514 };
1515
1516 let Some(built) = built else {
1517 availability.0 = false;
1518 *visibility = Visibility::Hidden;
1519 continue;
1520 };
1521
1522 mesh.0 = meshes.add(built);
1523 availability.0 = true;
1524 }
1525
1526 transform.translation = match (candidate.is_some(), highlight) {
1527 (true, ContactCandidateHighlight::Master) => pose.offset_a,
1528 (true, ContactCandidateHighlight::Slave) => pose.offset_b,
1529 (false, _) => Vec3::ZERO,
1530 };
1531 transform.rotation = Quat::IDENTITY;
1532 transform.scale = Vec3::ONE;
1533 *visibility = if source_active && availability.0 {
1534 Visibility::Visible
1535 } else {
1536 Visibility::Hidden
1537 };
1538 }
1539}
1540
1541pub(crate) fn update_rigid_spider_highlights(
1542 model: Option<Res<FemModel>>,
1543 state: Res<RigidSpiderCandidateState>,
1544 settings: Res<RigidSpiderReviewSettings>,
1545 setup: Res<fem_core::AnalysisSetup>,
1546 defined: Res<DefinedMpcPreview>,
1547 pair_draft: Res<MpcPairDraftPreview>,
1548 mut meshes: ResMut<Assets<Mesh>>,
1549 mut query: Query<(
1550 &RigidSpiderHighlight,
1551 &mut Mesh3d,
1552 &mut Visibility,
1553 &mut ContactHighlightAvailability,
1554 )>,
1555) {
1556 let rebuild = state.is_changed()
1557 || settings.is_changed()
1558 || setup.is_changed()
1559 || defined.is_changed()
1560 || pair_draft.is_changed()
1561 || model.as_ref().is_some_and(|model| model.is_changed());
1562 let candidate = settings
1563 .active
1564 .then(|| state.selected_candidate())
1565 .flatten();
1566 let draft_node = |positive: bool| {
1567 pair_draft.active.then_some(if positive {
1568 pair_draft.positive
1569 } else {
1570 pair_draft.negative
1571 })?
1572 };
1573 let equation = defined
1574 .active
1575 .then(|| {
1576 defined
1577 .selected
1578 .and_then(|index| setup.mpc_equations.get(index))
1579 })
1580 .flatten();
1581
1582 for (highlight, mut mesh, mut visibility, mut availability) in &mut query {
1583 if rebuild {
1584 let built = model.as_deref().and_then(|model| {
1585 let radius = model_visual_scale(model)
1586 * match highlight {
1587 RigidSpiderHighlight::Master => 0.012,
1588 RigidSpiderHighlight::Slave => 0.006,
1589 };
1590 if let Some(candidate) = candidate {
1591 match highlight {
1592 RigidSpiderHighlight::Master => build_highlight_nodes_mesh(
1593 model.meshes.get(candidate.master_mesh)?,
1594 &[candidate.master_node],
1595 radius,
1596 ),
1597 RigidSpiderHighlight::Slave => build_highlight_nodes_mesh(
1598 model.meshes.get(candidate.slave_mesh)?,
1599 &candidate.slave_nodes,
1600 radius,
1601 ),
1602 }
1603 } else if let Some((mesh_index, node)) =
1604 draft_node(matches!(highlight, RigidSpiderHighlight::Master))
1605 {
1606 build_highlight_nodes_mesh(model.meshes.get(mesh_index)?, &[node], radius)
1607 } else {
1608 build_mpc_equation_highlight(
1609 model,
1610 equation?,
1611 matches!(highlight, RigidSpiderHighlight::Master),
1612 radius,
1613 )
1614 }
1615 });
1616
1617 let Some(built) = built else {
1618 availability.0 = false;
1619 *visibility = Visibility::Hidden;
1620 continue;
1621 };
1622 mesh.0 = meshes.add(built);
1623 availability.0 = true;
1624 }
1625
1626 *visibility = if (candidate.is_some()
1627 || draft_node(matches!(highlight, RigidSpiderHighlight::Master)).is_some()
1628 || equation.is_some())
1629 && availability.0
1630 {
1631 Visibility::Visible
1632 } else {
1633 Visibility::Hidden
1634 };
1635 }
1636}
1637
1638fn build_mpc_equation_highlight(
1639 model: &FemModel,
1640 equation: &MpcEquation,
1641 positive: bool,
1642 radius: f32,
1643) -> Option<Mesh> {
1644 let mut positions = Vec::new();
1645 let mut normals = Vec::new();
1646 let mut rendered = BTreeSet::new();
1647
1648 for term in &equation.terms {
1649 let matching_sign = if positive {
1650 term.coefficient > 0.0
1651 } else {
1652 term.coefficient < 0.0
1653 };
1654 if !matching_sign || !rendered.insert((term.mesh_index, term.node)) {
1655 continue;
1656 }
1657 let center = model
1658 .meshes
1659 .get(term.mesh_index)
1660 .and_then(|mesh| mesh.node_position(term.node));
1661 if let Some(center) = center {
1662 append_octahedron(&mut positions, &mut normals, center, radius);
1663 }
1664 }
1665
1666 (!positions.is_empty()).then(|| {
1667 Mesh::new(
1668 PrimitiveTopology::TriangleList,
1669 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
1670 )
1671 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
1672 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
1673 })
1674}
1675
1676fn build_draft_contact_highlight(
1677 model: &FemModel,
1678 draft: &ContactDraftPreview,
1679 highlight: ContactCandidateHighlight,
1680) -> Option<Mesh> {
1681 match highlight {
1682 ContactCandidateHighlight::Master => {
1683 build_draft_surface_highlight_mesh(model, draft.master.as_ref()?)
1684 }
1685 ContactCandidateHighlight::Slave => match draft.slave.as_ref()? {
1686 ContactDraftSlave::Nodes { mesh_index, nodes } => build_highlight_nodes_mesh(
1687 model.meshes.get(*mesh_index)?,
1688 nodes,
1689 model_visual_scale(model) * 0.006,
1690 ),
1691 ContactDraftSlave::Surface(surface) => {
1692 build_draft_surface_highlight_mesh(model, surface)
1693 }
1694 },
1695 }
1696}
1697
1698fn build_draft_surface_highlight_mesh(
1699 model: &FemModel,
1700 surface: &ContactDraftSurface,
1701) -> Option<Mesh> {
1702 let fem_mesh = model.meshes.get(surface.mesh_index)?;
1703 let element_faces: BTreeSet<_> = surface.surfaces.iter().copied().collect();
1704 let face_ids: Vec<_> = fem_mesh
1705 .cached_boundary_faces()
1706 .iter()
1707 .filter(|face| {
1708 face.element_face_ref()
1709 .is_some_and(|reference| element_faces.contains(&reference))
1710 })
1711 .map(|face| face.id)
1712 .collect();
1713
1714 build_highlight_faces_mesh(fem_mesh, &face_ids)
1715}
1716
1717fn build_defined_contact_highlight(
1718 model: &FemModel,
1719 contact: &ContactPair,
1720 highlight: ContactCandidateHighlight,
1721) -> Option<Mesh> {
1722 match highlight {
1723 ContactCandidateHighlight::Master => {
1724 build_surface_set_highlight_mesh(model, contact.master)
1725 }
1726 ContactCandidateHighlight::Slave => match contact.slave {
1727 ContactSlaveRef::Surface(reference) => {
1728 build_surface_set_highlight_mesh(model, reference)
1729 }
1730 ContactSlaveRef::Nodes(reference) => {
1731 let fem_mesh = model.meshes.get(reference.mesh_index)?;
1732 let node_set = fem_mesh.node_sets.get(reference.node_set_index)?;
1733 build_highlight_nodes_mesh(
1734 fem_mesh,
1735 &node_set.nodes,
1736 model_visual_scale(model) * 0.006,
1737 )
1738 }
1739 },
1740 }
1741}
1742
1743fn build_surface_set_highlight_mesh(model: &FemModel, reference: SurfaceSetRef) -> Option<Mesh> {
1744 let fem_mesh = model.meshes.get(reference.mesh_index)?;
1745 let surface_set = fem_mesh.surface_sets.get(reference.surface_set_index)?;
1746 let element_faces: BTreeSet<_> = surface_set.surfaces.iter().copied().collect();
1747 let face_ids: Vec<_> = fem_mesh
1748 .cached_boundary_faces()
1749 .iter()
1750 .filter(|face| {
1751 face.element_face_ref()
1752 .is_some_and(|reference| element_faces.contains(&reference))
1753 })
1754 .map(|face| face.id)
1755 .collect();
1756
1757 build_highlight_faces_mesh(fem_mesh, &face_ids)
1758}
1759
1760fn build_highlight_nodes_mesh(
1761 fem_mesh: &FemMesh,
1762 node_ids: &[NodeId],
1763 radius: f32,
1764) -> Option<Mesh> {
1765 let mut positions = Vec::new();
1766 let mut normals = Vec::new();
1767 let stride = node_ids
1768 .len()
1769 .div_ceil(MAX_DEFINED_CONTACT_NODE_MARKERS)
1770 .max(1);
1771
1772 for node_id in node_ids.iter().step_by(stride) {
1773 let Some(center) = fem_mesh.node_position(*node_id) else {
1774 continue;
1775 };
1776 append_octahedron(&mut positions, &mut normals, center, radius);
1777 }
1778
1779 (!positions.is_empty()).then(|| {
1780 Mesh::new(
1781 PrimitiveTopology::TriangleList,
1782 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
1783 )
1784 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
1785 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
1786 })
1787}
1788
1789fn append_octahedron(
1790 positions: &mut Vec<[f32; 3]>,
1791 normals: &mut Vec<[f32; 3]>,
1792 center: Vec3,
1793 radius: f32,
1794) {
1795 let top = center + Vec3::Y * radius;
1796 let bottom = center - Vec3::Y * radius;
1797 let ring = [
1798 center + Vec3::X * radius,
1799 center + Vec3::Z * radius,
1800 center - Vec3::X * radius,
1801 center - Vec3::Z * radius,
1802 ];
1803
1804 for index in 0..ring.len() {
1805 let next = (index + 1) % ring.len();
1806 append_face_triangles(positions, normals, &[top, ring[index], ring[next]]);
1807 append_face_triangles(positions, normals, &[bottom, ring[next], ring[index]]);
1808 }
1809}
1810
1811fn apply_topology_highlight(
1817 model: &FemModel,
1818 targets: &[FemEntityRef],
1819 meshes: &mut Assets<Mesh>,
1820 mesh: &mut Mesh3d,
1821 transform: &mut Transform,
1822 visibility: &mut Visibility,
1823) -> Option<()> {
1824 let scale = model_visual_scale(model);
1825
1826 let last = *targets.last()?;
1827 let fem_mesh = model.meshes.get(last.mesh_index)?;
1828
1829 match last.entity {
1830 FemEntityId::Node(id) => {
1831 let position = fem_mesh.node_position(id)?;
1832 mesh.0 = meshes.add(Cuboid::new(scale * 0.012, scale * 0.012, scale * 0.012));
1833 *transform = Transform::from_translation(position);
1834 }
1835 FemEntityId::Edge(_) => {
1836 let edge_targets = targets
1837 .iter()
1838 .copied()
1839 .filter(|target| matches!(target.entity, FemEntityId::Edge(_)));
1840 mesh.0 = meshes.add(build_multi_edge_highlight_mesh(model, edge_targets, scale)?);
1841 *transform = Transform::default();
1842 }
1843 FemEntityId::Face(_) | FemEntityId::Element(_) => {
1844 let face_targets = targets
1845 .iter()
1846 .copied()
1847 .filter(|t| matches!(t.entity, FemEntityId::Face(_) | FemEntityId::Element(_)));
1848
1849 mesh.0 = meshes.add(build_multi_face_highlight_mesh(model, face_targets)?);
1850 *transform = Transform::default();
1851 }
1852 }
1853
1854 *visibility = Visibility::Visible;
1855
1856 Some(())
1857}
1858
1859fn build_multi_edge_highlight_mesh(
1860 model: &FemModel,
1861 targets: impl Iterator<Item = FemEntityRef>,
1862 model_scale: f32,
1863) -> Option<Mesh> {
1864 let mut positions = Vec::new();
1865 let mut normals = Vec::new();
1866
1867 for target in targets {
1868 let FemEntityId::Edge(edge_id) = target.entity else {
1869 continue;
1870 };
1871 let Some(fem_mesh) = model.meshes.get(target.mesh_index) else {
1872 continue;
1873 };
1874 let Some(edge) = fem_mesh
1875 .cached_boundary_edges()
1876 .iter()
1877 .find(|edge| edge.id == edge_id)
1878 else {
1879 continue;
1880 };
1881 let (Some(start), Some(end)) = (
1882 fem_mesh.node_position(edge.nodes[0]),
1883 fem_mesh.node_position(edge.nodes[1]),
1884 ) else {
1885 continue;
1886 };
1887 let length = start.distance(end);
1888 if length <= f32::EPSILON {
1889 continue;
1890 }
1891
1892 let thickness = (length * 0.08).min(model_scale * 0.010);
1895 append_edge_prism(&mut positions, &mut normals, start, end, thickness * 0.5);
1896 }
1897
1898 (!positions.is_empty()).then(|| {
1899 Mesh::new(
1900 PrimitiveTopology::TriangleList,
1901 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
1902 )
1903 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
1904 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
1905 })
1906}
1907
1908fn append_edge_prism(
1909 positions: &mut Vec<[f32; 3]>,
1910 normals: &mut Vec<[f32; 3]>,
1911 start: Vec3,
1912 end: Vec3,
1913 half_width: f32,
1914) {
1915 let direction = (end - start).normalize();
1916 let helper = if direction.dot(Vec3::Y).abs() < 0.9 {
1917 Vec3::Y
1918 } else {
1919 Vec3::X
1920 };
1921 let side = direction.cross(helper).normalize() * half_width;
1922 let up = direction.cross(side).normalize() * half_width;
1923
1924 let s00 = start - side - up;
1925 let s10 = start + side - up;
1926 let s11 = start + side + up;
1927 let s01 = start - side + up;
1928 let e00 = end - side - up;
1929 let e10 = end + side - up;
1930 let e11 = end + side + up;
1931 let e01 = end - side + up;
1932
1933 for face in [
1934 [s00, s01, s11, s10],
1935 [e00, e10, e11, e01],
1936 [s00, s10, e10, e00],
1937 [s10, s11, e11, e10],
1938 [s11, s01, e01, e11],
1939 [s01, s00, e00, e01],
1940 ] {
1941 append_face_triangles(positions, normals, &face);
1942 }
1943}
1944
1945fn build_multi_face_highlight_mesh(
1967 model: &FemModel,
1968 targets: impl Iterator<Item = FemEntityRef>,
1969) -> Option<Mesh> {
1970 let mut positions = Vec::new();
1971 let mut normals = Vec::new();
1972
1973 for target in targets {
1974 append_target_highlight_triangles(model, target, &mut positions, &mut normals);
1975 }
1976
1977 (!positions.is_empty()).then(|| {
1978 Mesh::new(
1979 PrimitiveTopology::TriangleList,
1980 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
1981 )
1982 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
1983 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
1984 })
1985}
1986
1987fn append_target_highlight_triangles(
1988 model: &FemModel,
1989 target: FemEntityRef,
1990 positions: &mut Vec<[f32; 3]>,
1991 normals: &mut Vec<[f32; 3]>,
1992) {
1993 let Some(fem_mesh) = model.meshes.get(target.mesh_index) else {
1994 return;
1995 };
1996
1997 match target.entity {
1998 FemEntityId::Face(id) => {
1999 let Some(face) = fem_mesh
2000 .cached_boundary_faces()
2001 .iter()
2002 .find(|face| face.id == id)
2003 else {
2004 return;
2005 };
2006 let Some(points) = fem_mesh.node_positions(&face.nodes) else {
2007 return;
2008 };
2009
2010 append_face_triangles(positions, normals, &points);
2011 }
2012 FemEntityId::Element(id) => {
2013 let Some(element) = fem_mesh.elements.iter().find(|element| element.id == id) else {
2014 return;
2015 };
2016
2017 for face_nodes in element.face_node_ids() {
2018 let Some(points) = fem_mesh.node_positions(&face_nodes) else {
2019 continue;
2020 };
2021 append_face_triangles(positions, normals, &points);
2022 }
2023 }
2024 FemEntityId::Node(_) | FemEntityId::Edge(_) => {}
2025 }
2026}
2027
2028fn build_highlight_faces_mesh(fem_mesh: &FemMesh, face_ids: &[FaceId]) -> Option<Mesh> {
2043 let mut positions = Vec::new();
2044 let mut normals = Vec::new();
2045
2046 for face_id in face_ids {
2047 let Some(face) = fem_mesh
2048 .cached_boundary_faces()
2049 .iter()
2050 .find(|face| face.id == *face_id)
2051 else {
2052 continue;
2053 };
2054
2055 let Some(points) = fem_mesh.node_positions(&face.nodes) else {
2056 continue;
2057 };
2058
2059 append_face_triangles(&mut positions, &mut normals, &points);
2060 }
2061
2062 (!positions.is_empty()).then(|| {
2063 Mesh::new(
2064 PrimitiveTopology::TriangleList,
2065 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2066 )
2067 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2068 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
2069 })
2070}
2071
2072pub(crate) fn model_visual_scale(model: &FemModel) -> f32 {
2073 model
2074 .bounds()
2075 .map(|(min, max)| (max - min).length().max(1.0))
2076 .unwrap_or(1.0)
2077}
2078
2079fn spawn_element_visual(
2092 commands: &mut Commands,
2093 meshes: &mut Assets<Mesh>,
2094 mesh_index: usize,
2095 fem_mesh: &FemMesh,
2096 element: &FemElement,
2097 materials: &MaterialSet,
2098 section: Option<&fem_core::Section>,
2099 model_scale: f32,
2100) {
2101 if element.element_type.is_shell() {
2102 spawn_shell_element_visual(
2103 commands,
2104 meshes,
2105 mesh_index,
2106 fem_mesh,
2107 element,
2108 materials,
2109 section,
2110 model_scale,
2111 );
2112 } else if element.element_type.is_beam() {
2113 spawn_beam_element_visual(
2114 commands,
2115 meshes,
2116 mesh_index,
2117 fem_mesh,
2118 element,
2119 materials,
2120 section,
2121 model_scale,
2122 );
2123 } else {
2124 spawn_solid_element_visual(commands, meshes, mesh_index, fem_mesh, element, materials);
2125 }
2126}
2127
2128fn spawn_solid_element_visual(
2132 commands: &mut Commands,
2133 meshes: &mut Assets<Mesh>,
2134 mesh_index: usize,
2135 fem_mesh: &FemMesh,
2136 element: &FemElement,
2137 materials: &MaterialSet,
2138) {
2139 let (mesh, transform) = match build_element_surface_mesh(fem_mesh, element) {
2140 Some(mesh) => (meshes.add(mesh), Transform::default()),
2141 None => {
2142 let Some(points) = fem_mesh.node_positions(&element.nodes) else {
2143 return;
2144 };
2145 let Some((min, max)) = bounds(&points) else {
2146 return;
2147 };
2148
2149 let center = (min + max) * 0.5;
2150 let size = visual_size(max - min);
2151 (
2152 meshes.add(Cuboid::new(size.x, size.y, size.z)),
2153 Transform::from_translation(center),
2154 )
2155 }
2156 };
2157
2158 commands.spawn((
2159 Mesh3d(mesh),
2160 MeshMaterial3d(materials.normal.clone()),
2161 transform,
2162 VisualLayer::Shaded,
2163 Visibility::Visible,
2164 Selectable::element(mesh_index, element.id),
2165 ElementEntity::new(element.id),
2166 NormalMaterial(materials.normal.clone()),
2167 FlatMaterial(materials.flat.clone()),
2168 TransparentMaterial(materials.transparent.clone()),
2169 HoverMaterial(materials.hover.clone()),
2170 SelectedMaterial(materials.selected.clone()),
2171 FemPartVisual { mesh_index },
2172 FemMeshVisual,
2173 Name::new(format!("Element {}", element.id.0)),
2174 ));
2175}
2176
2177fn build_element_surface_mesh(fem_mesh: &FemMesh, element: &FemElement) -> Option<Mesh> {
2178 let mut positions = Vec::new();
2179 let mut normals = Vec::new();
2180
2181 for node_ids in element.face_node_ids() {
2182 let Some(points) = fem_mesh.node_positions(&node_ids) else {
2183 continue;
2184 };
2185
2186 append_face_triangles(&mut positions, &mut normals, &points);
2187 }
2188
2189 if positions.is_empty() {
2190 return None;
2191 }
2192
2193 Some(
2194 Mesh::new(
2195 PrimitiveTopology::TriangleList,
2196 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2197 )
2198 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2199 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals),
2200 )
2201}
2202
2203fn shell_corner_count(element_type: &fem_core::ElementType) -> usize {
2208 element_type.surface_corner_count().unwrap_or(0)
2209}
2210
2211fn spawn_shell_element_visual(
2220 commands: &mut Commands,
2221 meshes: &mut Assets<Mesh>,
2222 mesh_index: usize,
2223 fem_mesh: &FemMesh,
2224 element: &FemElement,
2225 materials: &MaterialSet,
2226 section: Option<&fem_core::Section>,
2227 model_scale: f32,
2228) {
2229 let corner_count = shell_corner_count(&element.element_type);
2230 let Some(all_points) = fem_mesh.node_positions(&element.nodes) else {
2231 return;
2232 };
2233
2234 if all_points.len() < corner_count || corner_count < 3 {
2235 return;
2236 }
2237
2238 let corners = &all_points[..corner_count];
2239 let Some(normal) = face_normal(corners) else {
2240 return;
2241 };
2242
2243 let thickness = match section.map(|s| &s.kind) {
2244 Some(fem_core::SectionKind::Shell { thickness }) => *thickness,
2245 _ => {
2246 let Some((min, max)) = bounds(corners) else {
2247 return;
2248 };
2249 let element_size = (max - min).length();
2250
2251 (element_size * 0.02).min(model_scale * 0.01)
2258 }
2259 }
2260 .max(1.0e-4);
2261
2262 let half = thickness * 0.5;
2263 let top: Vec<Vec3> = corners.iter().map(|&p| p + normal * half).collect();
2264 let bottom: Vec<Vec3> = corners.iter().map(|&p| p - normal * half).collect();
2265
2266 let mut positions: Vec<[f32; 3]> = Vec::new();
2267 let mut normals: Vec<[f32; 3]> = Vec::new();
2268
2269 append_face_triangles(&mut positions, &mut normals, &top);
2271 let mut bottom_rev = bottom.clone();
2272 bottom_rev.reverse();
2273 append_face_triangles(&mut positions, &mut normals, &bottom_rev);
2274
2275 for i in 0..corner_count {
2282 let j = (i + 1) % corner_count;
2283 let quad = [bottom[i], bottom[j], top[j], top[i]];
2284
2285 append_face_triangles(&mut positions, &mut normals, &quad);
2286 }
2287
2288 let mesh = Mesh::new(
2289 PrimitiveTopology::TriangleList,
2290 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2291 )
2292 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2293 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
2294
2295 commands.spawn((
2296 Mesh3d(meshes.add(mesh)),
2297 MeshMaterial3d(materials.normal.clone()),
2298 Transform::default(),
2299 VisualLayer::Shaded,
2300 Visibility::Visible,
2301 Selectable::element(mesh_index, element.id),
2302 ElementEntity::new(element.id),
2303 NormalMaterial(materials.normal.clone()),
2304 FlatMaterial(materials.flat.clone()),
2305 TransparentMaterial(materials.transparent.clone()),
2306 HoverMaterial(materials.hover.clone()),
2307 SelectedMaterial(materials.selected.clone()),
2308 FemPartVisual { mesh_index },
2309 FemMeshVisual,
2310 Name::new(format!("Shell element {}", element.id.0)),
2311 ));
2312}
2313
2314fn spawn_beam_element_visual(
2327 commands: &mut Commands,
2328 meshes: &mut Assets<Mesh>,
2329 mesh_index: usize,
2330 fem_mesh: &FemMesh,
2331 element: &FemElement,
2332 materials: &MaterialSet,
2333 section: Option<&fem_core::Section>,
2334 model_scale: f32,
2335) {
2336 let segments: Vec<(Vec3, Vec3)> = element
2337 .edge_node_ids()
2338 .into_iter()
2339 .filter_map(|nodes| {
2340 Some((
2341 fem_mesh.node_position(nodes[0])?,
2342 fem_mesh.node_position(nodes[1])?,
2343 ))
2344 })
2345 .filter(|(start, end)| start.distance_squared(*end) > f32::EPSILON * f32::EPSILON)
2346 .collect();
2347
2348 if segments.is_empty() {
2349 return;
2350 }
2351
2352 let reference_length = segments
2353 .iter()
2354 .map(|(start, end)| start.distance(*end))
2355 .sum::<f32>();
2356
2357 let radius = match section.map(|s| &s.kind) {
2358 Some(fem_core::SectionKind::Beam { area }) => (area / std::f32::consts::PI).sqrt(),
2359 _ => (reference_length * 0.015).min(model_scale * 0.01),
2363 }
2364 .max(1.0e-4);
2365
2366 for (segment_index, (start, end)) in segments.into_iter().enumerate() {
2367 let delta = end - start;
2368 let length = delta.length();
2369 let center = (start + end) * 0.5;
2370 let rotation = Quat::from_rotation_arc(Vec3::Y, delta / length);
2371
2372 commands.spawn((
2373 Mesh3d(meshes.add(Cylinder {
2374 radius,
2375 half_height: length * 0.5,
2376 })),
2377 MeshMaterial3d(materials.normal.clone()),
2378 Transform {
2379 translation: center,
2380 rotation,
2381 ..default()
2382 },
2383 VisualLayer::Shaded,
2384 Visibility::Visible,
2385 Selectable::element(mesh_index, element.id),
2386 ElementEntity::new(element.id),
2387 NormalMaterial(materials.normal.clone()),
2388 FlatMaterial(materials.flat.clone()),
2389 TransparentMaterial(materials.transparent.clone()),
2390 HoverMaterial(materials.hover.clone()),
2391 SelectedMaterial(materials.selected.clone()),
2392 FemPartVisual { mesh_index },
2393 FemMeshVisual,
2394 Name::new(format!(
2395 "Line element {} segment {}",
2396 element.id.0,
2397 segment_index + 1
2398 )),
2399 ));
2400 }
2401}
2402
2403fn spawn_face_visual(
2404 commands: &mut Commands,
2405 meshes: &mut Assets<Mesh>,
2406 mesh_index: usize,
2407 fem_mesh: &FemMesh,
2408 face: &FemFace,
2409 materials: &MaterialSet,
2410) {
2411 let Some(points) = fem_mesh.node_positions(&face.nodes) else {
2412 return;
2413 };
2414 let Some(mesh) = build_extruded_polygon_mesh(&points, FACE_THICKNESS) else {
2415 return;
2416 };
2417
2418 commands.spawn((
2419 Mesh3d(meshes.add(mesh)),
2420 MeshMaterial3d(materials.normal.clone()),
2421 Transform::default(),
2422 VisualLayer::Shaded,
2423 Visibility::Visible,
2424 Selectable::face(mesh_index, face.id),
2425 FaceEntity::new(face.id),
2426 NormalMaterial(materials.normal.clone()),
2427 FlatMaterial(materials.flat.clone()),
2428 TransparentMaterial(materials.transparent.clone()),
2429 HoverMaterial(materials.hover.clone()),
2430 SelectedMaterial(materials.selected.clone()),
2431 FemPartVisual { mesh_index },
2432 FemMeshVisual,
2433 Name::new(format!("Face {}", face.id.0)),
2434 ));
2435}
2436
2437fn build_extruded_polygon_mesh(points: &[Vec3], thickness: f32) -> Option<Mesh> {
2441 if points.len() < 3 {
2442 return None;
2443 }
2444
2445 let normal = face_normal(points)?;
2446 let half = thickness.max(f32::EPSILON) * 0.5;
2447 let top: Vec<Vec3> = points.iter().map(|point| *point + normal * half).collect();
2448 let bottom: Vec<Vec3> = points.iter().map(|point| *point - normal * half).collect();
2449 let mut positions = Vec::new();
2450 let mut normals = Vec::new();
2451
2452 append_face_triangles(&mut positions, &mut normals, &top);
2453
2454 let mut bottom_reversed = bottom.clone();
2455 bottom_reversed.reverse();
2456 append_face_triangles(&mut positions, &mut normals, &bottom_reversed);
2457
2458 for index in 0..points.len() {
2459 let next = (index + 1) % points.len();
2460 let wall = [bottom[index], bottom[next], top[next], top[index]];
2461 append_face_triangles(&mut positions, &mut normals, &wall);
2462 }
2463
2464 Some(
2465 Mesh::new(
2466 PrimitiveTopology::TriangleList,
2467 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2468 )
2469 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2470 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals),
2471 )
2472}
2473
2474fn spawn_edge_visual(
2475 commands: &mut Commands,
2476 meshes: &mut Assets<Mesh>,
2477 mesh_index: usize,
2478 fem_mesh: &FemMesh,
2479 edge: &FemEdge,
2480 materials: &MaterialSet,
2481) {
2482 let Some(start) = fem_mesh.node_position(edge.nodes[0]) else {
2483 return;
2484 };
2485 let Some(end) = fem_mesh.node_position(edge.nodes[1]) else {
2486 return;
2487 };
2488
2489 let delta = end - start;
2490 let length = delta.length();
2491
2492 if length <= f32::EPSILON {
2493 return;
2494 }
2495
2496 let direction = delta / length;
2497 let center = (start + end) * 0.5;
2498 let rotation = Quat::from_rotation_arc(Vec3::X, direction);
2499
2500 commands.spawn((
2501 Mesh3d(meshes.add(Cuboid::new(length, EDGE_THICKNESS, EDGE_THICKNESS))),
2502 MeshMaterial3d(materials.normal.clone()),
2503 Transform {
2504 translation: center,
2505 rotation,
2506 ..default()
2507 },
2508 VisualLayer::Edge,
2509 Visibility::Visible,
2510 Selectable::edge(mesh_index, edge.id),
2511 EdgeEntity::new(edge.id),
2512 NormalMaterial(materials.normal.clone()),
2513 FlatMaterial(materials.flat.clone()),
2514 TransparentMaterial(materials.transparent.clone()),
2515 HoverMaterial(materials.hover.clone()),
2516 SelectedMaterial(materials.selected.clone()),
2517 FemPartVisual { mesh_index },
2518 FemMeshVisual,
2519 Name::new(format!("Edge {}", edge.id.0)),
2520 ));
2521}
2522
2523fn spawn_node_visual(
2524 commands: &mut Commands,
2525 meshes: &mut Assets<Mesh>,
2526 mesh_index: usize,
2527 node: &FemNode,
2528 materials: &MaterialSet,
2529) {
2530 commands.spawn((
2531 Mesh3d(meshes.add(Cuboid::new(NODE_SIZE, NODE_SIZE, NODE_SIZE))),
2532 MeshMaterial3d(materials.normal.clone()),
2533 Transform::from_translation(node.position),
2534 VisualLayer::Node,
2535 Visibility::Visible,
2536 Selectable::node(mesh_index, node.id),
2537 NodeEntity::new(node.id),
2538 NormalMaterial(materials.normal.clone()),
2539 FlatMaterial(materials.flat.clone()),
2540 TransparentMaterial(materials.transparent.clone()),
2541 HoverMaterial(materials.hover.clone()),
2542 SelectedMaterial(materials.selected.clone()),
2543 FemPartVisual { mesh_index },
2544 FemMeshVisual,
2545 Name::new(format!("Node {}", node.id.0)),
2546 ));
2547}
2548
2549pub fn build_part_surface_mesh(fem_mesh: &FemMesh) -> Option<Mesh> {
2552 build_material_surface_mesh(fem_mesh, None)
2553}
2554
2555fn build_material_surface_mesh(
2556 fem_mesh: &FemMesh,
2557 assignments: Option<&std::collections::BTreeMap<fem_core::ElementId, MaterialIdentity>>,
2558) -> Option<Mesh> {
2559 let mut positions = Vec::new();
2560 let mut normals = Vec::new();
2561 let mut colors = Vec::new();
2562
2563 for face in fem_mesh.cached_boundary_faces() {
2564 let Some(points) = fem_mesh.node_positions(&face.nodes) else {
2565 continue;
2566 };
2567
2568 append_face_triangles(&mut positions, &mut normals, &points);
2569 if let Some(assignments) = assignments {
2570 let color = face
2571 .element
2572 .and_then(|id| assignments.get(&id))
2573 .unwrap_or(&MaterialIdentity::Unassigned)
2574 .color()
2575 .to_linear()
2576 .to_f32_array();
2577 colors.resize(positions.len(), color);
2578 }
2579 }
2580
2581 if positions.is_empty() {
2582 return None;
2583 }
2584
2585 let mut mesh = Mesh::new(
2586 PrimitiveTopology::TriangleList,
2587 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2588 )
2589 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2590 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
2591 if assignments.is_some() {
2592 mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
2593 }
2594 Some(mesh)
2595}
2596
2597pub(crate) fn build_contour_surface_mesh(
2605 fem_mesh: &FemMesh,
2606 step: &fem_core::StepResult,
2607 settings: &ContourSettings,
2608 range: Option<(f32, f32)>,
2609) -> Option<Mesh> {
2610 let contour_field = step.field_by_name(&settings.field_name)?;
2611 let range = range.or_else(|| crate::contour_range::bounds(contour_field))?;
2612 let valid = match contour_field {
2613 fem_core::ResultField::NodeScalar { values,.. } => values.len() == fem_mesh.nodes.len(),
2614 fem_core::ResultField::NodeVector { values,.. } => values.len() == fem_mesh.nodes.len(),
2615 fem_core::ResultField::ElementScalar { values,.. } => values.len() == fem_mesh.elements.len(),
2616 };
2617 if !valid { return None; }
2618 let element_indices: std::collections::HashMap<_,_> = fem_mesh.elements.iter().enumerate().map(|(i,e)|(e.id,i)).collect();
2619
2620 let disp_field = if settings.show_deformation {
2621 step.field_by_name(&settings.displacement_field)
2622 } else {
2623 None
2624 };
2625
2626 let node_index_map: std::collections::HashMap<fem_core::NodeId, usize> = fem_mesh
2627 .nodes
2628 .iter()
2629 .enumerate()
2630 .map(|(i, n)| (n.id, i))
2631 .collect();
2632
2633 let mut positions: Vec<[f32; 3]> = Vec::new();
2634 let mut normals: Vec<[f32; 3]> = Vec::new();
2635 let mut colors: Vec<[f32; 4]> = Vec::new();
2636
2637 for face in fem_mesh.cached_boundary_faces() {
2638 let element_t = if let fem_core::ResultField::ElementScalar { values,.. } = contour_field {
2639 let Some(index) = face.element.and_then(|id|element_indices.get(&id)) else { continue; };
2640 Some(crate::contour_range::normalize(values[*index], range))
2641 } else { None };
2642 let Some(node_indices_in_mesh): Option<Vec<usize>> = face
2643 .nodes
2644 .iter()
2645 .map(|id| node_index_map.get(id).copied())
2646 .collect()
2647 else {
2648 continue;
2649 };
2650
2651 let points: Vec<Vec3> = node_indices_in_mesh.iter().map(|&index| {
2652 crate::result_probe::deformed_position(fem_mesh.nodes[index].position, index, disp_field, settings.deformation_scale)
2653 }).collect();
2654
2655 if points.len() < 3 {
2656 continue;
2657 }
2658
2659 let Some(normal) = face_normal(&points) else {
2660 continue;
2661 };
2662
2663 let vert_colors: Vec<[f32; 4]> = node_indices_in_mesh
2664 .iter()
2665 .map(|&mesh_idx| {
2666 let t = match contour_field {
2667 fem_core::ResultField::NodeScalar { values, .. } => {
2668 crate::contour_range::normalize(values[mesh_idx], range)
2669 }
2670 fem_core::ResultField::NodeVector { values, .. } => {
2671 crate::contour_range::normalize(values[mesh_idx].length(), range)
2672 }
2673 fem_core::ResultField::ElementScalar {..} => element_t.unwrap(),
2674 };
2675
2676 let c = rainbow_color(t);
2677 [c.red, c.green, c.blue, c.alpha]
2678 })
2679 .collect();
2680
2681 for idx in 1..(points.len() - 1) {
2683 let tri = [points[0], points[idx], points[idx + 1]];
2684 let col = [vert_colors[0], vert_colors[idx], vert_colors[idx + 1]];
2685
2686 for (p, c) in tri.iter().zip(col.iter()) {
2687 positions.push(p.to_array());
2688 normals.push(normal.to_array());
2689 colors.push(*c);
2690 }
2691 }
2692 }
2693
2694 if positions.is_empty() {
2695 return None;
2696 }
2697
2698 Some(
2699 Mesh::new(
2700 PrimitiveTopology::TriangleList,
2701 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2702 )
2703 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2704 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
2705 .with_inserted_attribute(Mesh::ATTRIBUTE_COLOR, colors),
2706 )
2707}
2708
2709pub fn build_part_edge_mesh(fem_mesh: &FemMesh) -> Option<Mesh> {
2711 build_edge_mesh_with_positions(fem_mesh, |id| fem_mesh.node_position(id))
2712}
2713
2714pub(crate) fn build_contour_edge_mesh(
2716 mesh: &FemMesh,
2717 step: &fem_core::StepResult,
2718 settings: &ContourSettings,
2719) -> Option<Mesh> {
2720 let displacements = if settings.show_deformation {
2721 match step.field_by_name(&settings.displacement_field) {
2722 Some(fem_core::ResultField::NodeVector { values, .. }) => Some(values),
2723 _ => None,
2724 }
2725 } else { None };
2726 let positions: std::collections::HashMap<_, _> = mesh.nodes.iter().enumerate().map(|(i, node)| {
2727 let position = match displacements.and_then(|v|v.get(i)) {
2728 Some(displacement) => node.position + *displacement * settings.deformation_scale,
2729 None => node.position,
2730 };
2731 (node.id, position)
2732 }).collect();
2733 build_edge_mesh_with_positions(mesh, |id| positions.get(&id).copied().filter(|p|p.is_finite()))
2734}
2735
2736fn build_edge_mesh_with_positions(
2737 fem_mesh: &FemMesh,
2738 position: impl Fn(fem_core::NodeId) -> Option<Vec3>,
2739) -> Option<Mesh> {
2740 let mut positions = Vec::new();
2741 let mut normals = Vec::new();
2742 let mut seen = BTreeSet::new();
2743
2744 for edge in fem_mesh.cached_boundary_edges() {
2745 seen.insert(ordered_node_pair(edge.nodes));
2746
2747 let Some(start) = position(edge.nodes[0]) else {
2748 continue;
2749 };
2750 let Some(end) = position(edge.nodes[1]) else {
2751 continue;
2752 };
2753
2754 positions.push(start.to_array());
2755 positions.push(end.to_array());
2756 normals.push(Vec3::Y.to_array());
2757 normals.push(Vec3::Y.to_array());
2758 }
2759
2760 for element in &fem_mesh.elements {
2764 if !element.element_type.is_beam() {
2765 continue;
2766 }
2767
2768 for nodes in element.edge_node_ids() {
2769 if !seen.insert(ordered_node_pair(nodes)) {
2770 continue;
2771 }
2772
2773 let Some(start) = position(nodes[0]) else {
2774 continue;
2775 };
2776 let Some(end) = position(nodes[1]) else {
2777 continue;
2778 };
2779
2780 positions.push(start.to_array());
2781 positions.push(end.to_array());
2782 normals.push(Vec3::Y.to_array());
2783 normals.push(Vec3::Y.to_array());
2784 }
2785 }
2786
2787 if positions.is_empty() {
2788 return None;
2789 }
2790
2791 Some(
2792 Mesh::new(
2793 PrimitiveTopology::LineList,
2794 RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
2795 )
2796 .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
2797 .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals),
2798 )
2799}
2800
2801fn ordered_node_pair(nodes: [fem_core::NodeId; 2]) -> (fem_core::NodeId, fem_core::NodeId) {
2802 if nodes[0] <= nodes[1] {
2803 (nodes[0], nodes[1])
2804 } else {
2805 (nodes[1], nodes[0])
2806 }
2807}
2808
2809fn append_face_triangles(
2810 positions: &mut Vec<[f32; 3]>,
2811 normals: &mut Vec<[f32; 3]>,
2812 points: &[Vec3],
2813) {
2814 if points.len() < 3 {
2815 return;
2816 }
2817
2818 let Some(normal) = face_normal(points) else {
2819 return;
2820 };
2821
2822 for index in 1..(points.len() - 1) {
2823 push_triangle(
2824 positions,
2825 normals,
2826 [points[0], points[index], points[index + 1]],
2827 normal,
2828 );
2829 }
2830}
2831
2832fn push_triangle(
2833 positions: &mut Vec<[f32; 3]>,
2834 normals: &mut Vec<[f32; 3]>,
2835 triangle: [Vec3; 3],
2836 normal: Vec3,
2837) {
2838 let normal = normal.to_array();
2839
2840 for point in triangle {
2841 positions.push(point.to_array());
2842 normals.push(normal);
2843 }
2844}
2845
2846fn material_set(
2847 materials: &mut Assets<StandardMaterial>,
2848 normal: Color,
2849 hover: Color,
2850 selected: Color,
2851 flat: Color,
2852 blend: bool,
2853) -> MaterialSet {
2854 let mut flat_material = standard_material(flat, blend);
2855 flat_material.unlit = true;
2856
2857 let mut transparent_material = standard_material(normal.with_alpha(0.18), true);
2864 transparent_material.cull_mode = None;
2865 transparent_material.double_sided = true;
2866
2867 MaterialSet {
2868 normal: materials.add(standard_material(normal, blend)),
2869 hover: materials.add(standard_material(hover, blend)),
2870 selected: materials.add(selection_material(selected)),
2874 flat: materials.add(flat_material),
2875 transparent: materials.add(transparent_material),
2876 }
2877}
2878
2879fn selection_material(color: Color) -> StandardMaterial {
2880 standard_material(color.with_alpha(1.0), false)
2881}
2882
2883fn standard_material(color: Color, blend: bool) -> StandardMaterial {
2884 let mut material = StandardMaterial {
2885 base_color: color,
2886 perceptual_roughness: 0.78,
2887 ..default()
2888 };
2889
2890 if blend {
2891 material.alpha_mode = AlphaMode::Blend;
2892 }
2893
2894 material
2895}
2896
2897fn bounds(points: &[Vec3]) -> Option<(Vec3, Vec3)> {
2898 let mut iter = points.iter();
2899 let first = *iter.next()?;
2900 let mut min = first;
2901 let mut max = first;
2902
2903 for point in iter {
2904 min = min.min(*point);
2905 max = max.max(*point);
2906 }
2907
2908 Some((min, max))
2909}
2910
2911fn visual_size(size: Vec3) -> Vec3 {
2912 Vec3::new(
2913 size.x.max(MIN_VISUAL_SIZE),
2914 size.y.max(MIN_VISUAL_SIZE),
2915 size.z.max(MIN_VISUAL_SIZE),
2916 )
2917}
2918
2919fn face_normal(points: &[Vec3]) -> Option<Vec3> {
2920 let origin = points[0];
2921
2922 for i in 1..points.len() {
2923 let edge_a = points[i] - origin;
2924
2925 for j in (i + 1)..points.len() {
2926 if let Some(normal) = edge_a.cross(points[j] - origin).try_normalize() {
2927 return Some(normal);
2928 }
2929 }
2930 }
2931
2932 None
2933}
2934#[cfg(test)]
2935mod tests {
2936 use bevy::mesh::VertexAttributeValues;
2937 use fem_core::{
2938 ElementId, ElementType, FemElement, FemMesh, FemNode, FemSurfaceSet, MpcTerm, NodeId,
2939 SurfaceSetRef,
2940 };
2941
2942 use super::*;
2943
2944 #[test]
2945 fn selection_material_is_opaque_and_writes_depth() {
2946 let material = selection_material(Color::srgba(0.10, 1.0, 0.45, 0.25));
2947
2948 assert_eq!(material.alpha_mode, AlphaMode::Opaque);
2949 assert_eq!(material.base_color.to_srgba().alpha, 1.0);
2950 }
2951
2952 #[test]
2953 fn contact_review_separates_parts_symmetrically_without_editing_nodes() {
2954 let mut model = FemModel::demo_hex8();
2955 let original_positions: Vec<Vec3> = model.meshes[0]
2956 .nodes
2957 .iter()
2958 .map(|node| node.position)
2959 .collect();
2960 let mut second = FemMesh::demo_hex8();
2961 for node in &mut second.nodes {
2962 node.position += Vec3::X * 3.0;
2963 }
2964 model.add_mesh("Second", second);
2965
2966 let candidate = ContactCandidate {
2967 mesh_a: 0,
2968 mesh_b: 1,
2969 faces_a: Vec::new(),
2970 faces_b: Vec::new(),
2971 pair_count: 1,
2972 average_gap: 0.0,
2973 };
2974 let (offset_a, offset_b) = contact_review_offsets(&model, &candidate, 10.0);
2975
2976 assert!(offset_a.x < 0.0);
2977 assert!(offset_b.x > 0.0);
2978 assert!((offset_a + offset_b).length() < 1.0e-6);
2979 assert_eq!(
2980 model.meshes[0]
2981 .nodes
2982 .iter()
2983 .map(|node| node.position)
2984 .collect::<Vec<_>>(),
2985 original_positions
2986 );
2987 }
2988
2989 #[test]
2990 fn self_contact_review_does_not_explode_one_part() {
2991 let model = FemModel::demo_hex8();
2992 let candidate = ContactCandidate {
2993 mesh_a: 0,
2994 mesh_b: 0,
2995 faces_a: Vec::new(),
2996 faces_b: Vec::new(),
2997 pair_count: 1,
2998 average_gap: 0.0,
2999 };
3000
3001 assert_eq!(
3002 contact_review_offsets(&model, &candidate, 30.0),
3003 (Vec3::ZERO, Vec3::ZERO)
3004 );
3005 }
3006
3007 #[test]
3008 fn element_highlight_contains_the_whole_element_not_one_boundary_face() {
3009 let model = FemModel::demo_hex8();
3010 let face_id = model.meshes[0].cached_boundary_faces()[0].id;
3011
3012 let face =
3013 build_multi_face_highlight_mesh(&model, [FemEntityRef::face(0, face_id)].into_iter())
3014 .unwrap();
3015 let element = build_multi_face_highlight_mesh(
3016 &model,
3017 [FemEntityRef::element(0, ElementId(0))].into_iter(),
3018 )
3019 .unwrap();
3020
3021 assert_eq!(face.count_vertices(), 6);
3022 assert_eq!(element.count_vertices(), 36);
3023 }
3024
3025 #[test]
3026 fn multi_edge_highlight_contains_only_the_requested_edges() {
3027 let model = FemModel::demo_hex8();
3028 let edges = model.meshes[0].cached_boundary_edges();
3029 let rendered = build_multi_edge_highlight_mesh(
3030 &model,
3031 [
3032 FemEntityRef::edge(0, edges[0].id),
3033 FemEntityRef::edge(0, edges[1].id),
3034 ]
3035 .into_iter(),
3036 model_visual_scale(&model),
3037 )
3038 .unwrap();
3039
3040 assert_eq!(
3041 rendered.primitive_topology(),
3042 PrimitiveTopology::TriangleList
3043 );
3044 assert_eq!(rendered.count_vertices(), 72);
3045 }
3046
3047 #[test]
3048 fn defined_surface_contact_highlight_uses_only_the_surface_set_faces() {
3049 let mut model = FemModel::demo_hex8();
3050 let surface = model.meshes[0].cached_boundary_faces()[0]
3051 .element_face_ref()
3052 .unwrap();
3053 model.meshes[0].surface_sets.push(FemSurfaceSet {
3054 name: "MASTER".to_string(),
3055 surfaces: vec![surface],
3056 });
3057
3058 let rendered = build_surface_set_highlight_mesh(&model, SurfaceSetRef::new(0, 0)).unwrap();
3059
3060 assert_eq!(rendered.count_vertices(), 6);
3061 }
3062
3063 #[test]
3064 fn node_surface_slave_highlight_draws_one_marker_per_node() {
3065 let model = FemModel::demo_hex8();
3066 let rendered =
3067 build_highlight_nodes_mesh(&model.meshes[0], &[NodeId(0), NodeId(1)], 0.01).unwrap();
3068
3069 assert_eq!(rendered.count_vertices(), 48);
3070 }
3071
3072 #[test]
3073 fn defined_mpc_highlight_splits_coefficient_signs_and_deduplicates_nodes() {
3074 let model = FemModel::demo_hex8();
3075 let equation = MpcEquation::new(
3076 "MPC",
3077 0.0,
3078 vec![
3079 MpcTerm::new(0, NodeId(0), 1, 1.0),
3080 MpcTerm::new(0, NodeId(1), 1, -1.0),
3081 MpcTerm::new(0, NodeId(1), 4, -0.5),
3082 ],
3083 );
3084
3085 let positive = build_mpc_equation_highlight(&model, &equation, true, 0.01).unwrap();
3086 let negative = build_mpc_equation_highlight(&model, &equation, false, 0.01).unwrap();
3087
3088 assert_eq!(positive.count_vertices(), 24);
3089 assert_eq!(negative.count_vertices(), 24);
3090 }
3091
3092 #[test]
3093 fn builds_actual_tetrahedron_surface_instead_of_a_bounding_box() {
3094 let mesh = FemMesh::new(
3095 vec![
3096 FemNode::from_xyz(NodeId(1), 0.0, 0.0, 0.0),
3097 FemNode::from_xyz(NodeId(2), 1.0, 0.0, 0.0),
3098 FemNode::from_xyz(NodeId(3), 0.0, 1.0, 0.0),
3099 FemNode::from_xyz(NodeId(4), 0.0, 0.0, 1.0),
3100 ],
3101 vec![FemElement::new(
3102 ElementId(1),
3103 ElementType::Tet4,
3104 vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
3105 )],
3106 );
3107
3108 let rendered = build_element_surface_mesh(&mesh, &mesh.elements[0]).unwrap();
3109
3110 assert_eq!(rendered.count_vertices(), 12);
3111 }
3112
3113 #[test]
3114 fn aggregate_edge_mesh_keeps_line_only_models_visible() {
3115 let mesh = FemMesh::new(
3116 vec![
3117 FemNode::from_xyz(NodeId(1), 0.0, 0.0, 0.0),
3118 FemNode::from_xyz(NodeId(2), 2.0, 0.0, 0.0),
3119 FemNode::from_xyz(NodeId(3), 1.0, 1.0, 0.0),
3120 ],
3121 vec![FemElement::new(
3122 ElementId(1),
3123 ElementType::Rod3,
3124 vec![NodeId(1), NodeId(2), NodeId(3)],
3125 )],
3126 );
3127
3128 assert!(mesh.cached_boundary_edges().is_empty());
3129
3130 let rendered = build_part_edge_mesh(&mesh).unwrap();
3131
3132 assert_eq!(rendered.count_vertices(), 4);
3133 }
3134
3135 #[test]
3136 fn triangular_face_visual_does_not_include_bounding_rectangle_corners() {
3137 let rendered = build_extruded_polygon_mesh(
3138 &[
3139 Vec3::new(0.0, 0.0, 0.0),
3140 Vec3::new(1.0, 0.0, 0.0),
3141 Vec3::new(0.0, 1.0, 0.0),
3142 ],
3143 0.01,
3144 )
3145 .unwrap();
3146 let Some(VertexAttributeValues::Float32x3(positions)) =
3147 rendered.attribute(Mesh::ATTRIBUTE_POSITION)
3148 else {
3149 panic!("face mesh is missing Float32x3 positions");
3150 };
3151
3152 assert!(positions.iter().all(|position| {
3153 position[0] >= 0.0 && position[1] >= 0.0 && position[0] + position[1] <= 1.0
3154 }));
3155 }
3156}