Skip to main content

visualization/
demo_mesh.rs

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    /// When `Some`, the aggregate surface is coloured by this result field.
36    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/// View-only aids for reviewing an automatically detected contact pair.
49///
50/// Separation is deliberately expressed as a percentage of the model's
51/// bounding-box diagonal and is applied only to render transforms. FEM node
52/// coordinates, contact search geometry, and exported data remain unchanged.
53#[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/// Enables the 3-D master/slave markers for the selected automatic MPC
73/// spider candidate. The UI toggles this with the Contact page, keeping the
74/// visualization crate independent from sidebar implementation details.
75#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub struct RigidSpiderReviewSettings {
77    pub active: bool,
78}
79
80/// View-only selection of an MPC equation already present in the analysis
81/// setup. Positive- and negative-coefficient nodes are rendered with the two
82/// existing MPC highlight colours so imported equations can be audited in the
83/// viewport without changing their exported values.
84#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub struct DefinedMpcPreview {
86    pub selected: Option<usize>,
87
88    pub active: bool,
89}
90
91/// Two nodes captured while a simple equal-displacement MPC is being built
92/// from viewport selections. The positive/reference node uses the existing
93/// magenta MPC marker and the negative/coupled node uses cyan.
94#[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/// View-only selection of a contact pair already defined in the model.
112///
113/// This is separate from [`ContactReviewSettings`], which controls the
114/// exploded review of automatically detected candidates. Defined contacts
115/// never move parts: they only colour the master side blue and the slave
116/// side orange while the Contact page is active.
117#[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/// Geometry captured while a new contact pair is being assembled in the UI.
142/// It intentionally stores raw members instead of creating solver groups
143/// immediately, so recapturing a side does not leave orphan NGRP/SGRP entries.
144#[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/// Which result field to display as a rainbow contour, and optional
190/// deformation scaling.
191#[derive(Debug, Clone, PartialEq)]
192pub struct ContourSettings {
193    /// Mesh index within `FemModel::meshes`.
194    pub mesh_index: usize,
195
196    pub step_index: usize,
197
198    pub field_name: String,
199
200    /// If `true`, node positions are offset by `displacement_field × deformation_scale`.
201    pub show_deformation: bool,
202
203    pub displacement_field: String,
204
205    /// Scale factor applied to the raw displacement vector before offsetting.
206    pub deformation_scale: f32,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum VisualizationMode {
211    ShadedWithEdges,
212
213    Shaded,
214
215    /// Unlit solid colour — no PBR highlights, depth readable via ambient
216    /// occlusion only.  Equivalent to a "clay model" look.
217    Flat,
218
219    /// GPU wireframe via [`bevy::pbr::wireframe::Wireframe`] — shows every
220    /// triangle edge of the boundary surface mesh.
221    Wireframe,
222
223    /// Semi-transparent shaded surface — lets internal elements, contacts,
224    /// and interior boundary faces show through. Useful for checking
225    /// internal structure (ribs, cavities, contact interfaces) without
226    /// switching to a section/clip view.
227    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            // Shaded surface visible in all modes that show a solid mesh.
267            (
268                Self::Shaded,
269                VisualizationMode::Shaded
270                | VisualizationMode::ShadedWithEdges
271                | VisualizationMode::Flat
272                | VisualizationMode::Wireframe
273                | VisualizationMode::Transparent,
274            ) => true,
275            // Boundary-edge cuboids visible alongside shading or alone.
276            // In Wireframe mode we suppress them: the GPU wireframe already
277            // shows every triangle edge so adding boundary-edge cuboids on
278            // top creates a double-edge artefact.
279            (
280                Self::Edge,
281                VisualizationMode::Edges
282                | VisualizationMode::ShadedWithEdges
283                | VisualizationMode::Transparent,
284            ) => true,
285            // Nodes only in Both mode.
286            (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/// Marks the overlay entities that highlight the master/slave sides of the
300/// active contact review.
301///
302/// Unlike [`TopologyHighlight`], each of these covers an arbitrary number of
303/// The same two entities are reused for both an automatically detected
304/// candidate and a contact pair already defined in the model. Their meshes
305/// are rebuilt from scratch whenever the active review changes.
306#[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    /// The last-rendered preview group, so [`update_topology_highlights`]
325    /// only rebuilds geometry when it actually changes (rebuilding walks
326    /// every target's boundary face and rebuilds a full merged mesh, not
327    /// free on a large model).
328    hover: Vec<FemEntityRef>,
329
330    selected: Vec<FemEntityRef>,
331}
332
333/// Marker for any entity spawned to visualize the current [`FemModel`].
334///
335/// All per-element/face/edge/node visuals and the aggregated boundary
336/// surface/edge visuals carry this component so they can be cleanly
337/// despawned and respawned when the model is reloaded.
338#[derive(Component, Debug, Clone, Copy)]
339pub struct FemMeshVisual;
340
341/// Identifies which assembly mesh produced a visual entity. During a direct
342/// manipulation drag, every visual for one part receives the same temporary
343/// transform and the FEM node coordinates are updated only on release.
344#[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/// Unlit material used in [`VisualizationMode::Flat`].
353///
354/// Stored alongside [`NormalMaterial`] on every shaded entity so that
355/// [`apply_visualization_mode`] can switch between them without touching
356/// the asset storage.
357#[derive(Component)]
358pub struct FlatMaterial(pub Handle<StandardMaterial>);
359
360/// Semi-transparent material used in [`VisualizationMode::Transparent`].
361#[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    /// Unlit material used in [`VisualizationMode::Flat`].
379    flat: Handle<StandardMaterial>,
380
381    /// Semi-transparent material used in [`VisualizationMode::Transparent`].
382    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
412/// Spawns all per-mesh visualization entities for `fem_model`.
413///
414/// Every spawned entity carries [`FemMeshVisual`] so that a later reload
415/// can despawn exactly this set before respawning from the new model.
416///
417/// When the model has more than one mesh (i.e. more than one
418/// [`fem_core::Part`] — an assembly built via `add_mesh`/"Add Mesh"), each
419/// mesh's *base* colour (normal / flat / transparent) is hue-rotated by
420/// [`part_hue_shift`] so parts are visually distinguishable at a glance.
421/// Hover (yellow) and selected (green) colours are never tinted — those
422/// convey interaction state, not part identity, and must stay consistent
423/// across the whole assembly.
424pub(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    // Selected colour: bright opaque lime-green — same hue as the
433    // topology-highlight overlay so per-entity and aggregate models look
434    // consistent when something is selected. Selection must write depth;
435    // otherwise rear faces can show through when the camera moves.
436    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        // Base (normal) colours, hue-shifted per part.
464        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        // Flat colours: unlit, slightly lighter than the corresponding
470        // normal colour so the same object is still recognisable in Flat
471        // mode, also hue-shifted per part.
472        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        // Bright, unmistakably-not-a-normal-colour magenta for element
511        // types this platform doesn't recognize (`ElementType::Unsupported`).
512        // Hover/selected stay the usual yellow/green so interaction state
513        // still reads consistently, but the *resting* colour deliberately
514        // clashes with every other part of the model — a parser gap should
515        // be obvious at a glance, not blend in as if it were ordinary solid
516        // geometry.
517        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
623/// Hue rotation, in degrees, applied to the `mesh_index`-th part's base
624/// colour so an assembly's parts are visually distinguishable.
625///
626/// Uses the golden-angle (~137.5°) increment, which spreads any number of
627/// parts around the colour wheel with minimal adjacent-hue collisions —
628/// the same technique used for well-distributed categorical palettes.
629fn 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
635/// Rotates `color`'s hue by `shift_deg` degrees, preserving saturation,
636/// lightness, and alpha. A `shift_deg` of `0.0` returns `color` unchanged
637/// (skips the HSLA round-trip).
638fn 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        // Flat / clay-model material: unlit, same hue but slightly lighter
682        // so the mesh is still recognisably the same object.
683        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        // Transparent / "X-ray" material: low, fixed alpha so internal
695        // elements, contact interfaces, and overlapping parts show through.
696        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    // Hover: warm yellow — "you can click this"
750    //
751    // `depth_bias` (not a geometric vertex offset) is what keeps this
752    // coincident with the true surface from fighting/flickering — see
753    // `build_multi_face_highlight_mesh`'s doc comment for why a vertex
754    // offset causes a jagged silhouette on curved surfaces at grazing
755    // viewing angles (very visible on a coplanar-selected bore or fillet)
756    // and isn't used here.
757    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    // Selected: bright opaque lime-green. Keeping this in the opaque render
767    // pass makes it write depth, so rear selected faces and internal edges do
768    // not leak through a merged multi-face highlight as the camera moves.
769    // Back-face culling stays disabled for shell meshes that must remain
770    // selectable and visible from either side.
771    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
811/// Spawns the (initially hidden) master/slave overlay entities used by
812/// [`update_contact_candidate_highlights`] to preview the currently
813/// selected contact candidate.
814pub(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        // Selected / hovered states always use their vivid materials,
919        // regardless of the active visualization mode — selection must be
920        // visible no matter what.
921        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            // In Flat mode, non-selected entities use the unlit flat material.
927            // Fall back to NormalMaterial when FlatMaterial is not present
928            // (e.g. edge / node entities that haven't been given one yet).
929            material.0 = flat
930                .map(|f| f.0.clone())
931                .unwrap_or_else(|| normal.0.clone());
932        } else if use_transparent {
933            // Same fallback logic as Flat above, but for the X-ray material.
934            // Without this branch, this system (which runs every frame)
935            // would overwrite the Transparent material that
936            // `apply_visualization_mode` set on mode-change frames as soon
937            // as one frame passes without a mode change.
938            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
947/// A material-color rebuild replaces render entities but not FEM selection
948/// identities. Rebind only directly highlighted targets: grown Element picks
949/// must retain their face patch, not turn into whole-element highlights.
950pub(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
975/// Rebuilds the hover and selected highlight overlays whenever either
976/// group of targets changes.
977///
978/// Both overlays cover a *set* of targets, not just one: [`TopologyHighlight::Selected`]
979/// shows the model's entire current selection (every face the person has
980/// clicked/box-selected, not only the most recent one), and
981/// [`TopologyHighlight::Hover`] shows [`fem_core::HoverPreviewTargets`] —
982/// the full Coplanar/Smooth group that would be added if the person clicked
983/// right now, computed by `ui`'s `update_hover_preview_group` (or just the
984/// single hovered entity in Single mode).
985pub(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    // When every hover-preview target is already part of the selection
1008    // (the common case: hovering the thing you just selected, or —
1009    // with surface growth on — hovering back over the same group),
1010    // hide the hover overlay entirely and let only the selected (bright
1011    // green) overlay show. Without this both overlays render at the same
1012    // position and blend into a confusing colour.
1013    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
1084/// Switches materials between PBR and unlit (Flat mode) and toggles the
1085/// `Wireframe` component on boundary-surface entities when the
1086/// [`VisualizationMode`] changes.
1087///
1088/// * **Flat** — replaces the material handle on every `VisualLayer::Shaded`
1089///   entity with its stored [`FlatMaterial`], giving an unlit clay-model look.
1090/// * **Wireframe** — inserts Bevy's `Wireframe` component on those same
1091///   entities so the GPU renders their triangle edges rather than filled
1092///   triangles.
1093/// * **Any other mode** — restores the [`NormalMaterial`] and removes the
1094///   `Wireframe` component.
1095pub(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
1147/// Resolves the selected contact candidate into render-only part offsets.
1148/// This is kept separate from [`apply_contact_review`] so face-centroid
1149/// calculations run only when the candidate, model, or review settings
1150/// change rather than on every frame.
1151pub(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
1187/// Applies contact-review ghosting and exploded offsets to model visuals.
1188///
1189/// When review is inactive this system only runs once on the active→inactive
1190/// transition, restoring the ordinary render state. That is important because
1191/// the assembly editor uses the same render transforms for its drag preview.
1192pub(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
1303/// Despawns and respawns all [`FemMeshVisual`] entities whenever
1304/// [`FemModelVersion`] changes (e.g. after a mesh file is loaded).
1305///
1306/// Selection and hover state reference entities and topology ids that no
1307/// longer exist once the model is replaced, so both are cleared here as
1308/// well to avoid stale highlights or panics on lookup. Any pending contact
1309/// candidates are cleared too, since their [`fem_core::FaceId`]s and mesh
1310/// indices are only meaningful for the model they were computed from.
1311pub(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        // The initial spawn is handled by `spawn_demo_mesh` at Startup.
1338        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    // Clear selection markers before despawning the entities carrying them.
1354    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
1373/// Rebuilds element visuals when section assignments change outside of a
1374/// mesh reload, so shell/beam elements switch to their shape-specific
1375/// rendering once thickness/profile data becomes available.
1376///
1377/// Boundary conditions, loads, materials, and solver settings do not change
1378/// element geometry. Ignoring those changes avoids rebuilding a large
1379/// aggregate surface after every `.cnt` load.
1380pub(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    // Only existence/ambiguity of referenced names affects colors. Adding an
1405    // unused library entry or editing E/nu/rho must not rebuild the mesh.
1406    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
1461/// Rebuilds the master/slave overlays for either the selected automatic
1462/// candidate or the selected contact pair already defined in the model.
1463/// Candidate review takes precedence. Defined NODE-SURF pairs render slave
1464/// nodes as orange markers; SURF-SURF pairs render both sides as surfaces.
1465pub(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
1811/// Renders `targets` as one highlight overlay.
1812///
1813/// Edge and Face/Element groups are merged into one overlay so multi-click
1814/// growth shows the complete group while preserving its topology kind.
1815/// Node selection still highlights only the most recent target.
1816fn 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        // Keep short mesh edges legible without letting their marker become
1893        // wider than the surrounding finite elements.
1894        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
1945/// Builds one merged highlight mesh from `targets`. A `Face` target
1946/// contributes only that boundary face; an
1947/// `Element` target contributes every geometric face of the FEM element,
1948/// making surface selection and whole-element selection visually distinct.
1949/// The mesh is rendered exactly coincident with the true geometry — no
1950/// vertex offset.
1951///
1952/// An earlier version of this nudged each triangle outward along its own
1953/// normal to avoid z-fighting with the base mesh. That works fine for a
1954/// single small, roughly front-facing face, but once a whole coplanar
1955/// group is merged into one mesh (which is the point of this function),
1956/// that group typically wraps around a curved surface far enough to
1957/// include faces seen nearly edge-on — a cylindrical bore's silhouette
1958/// rim, say. There, even a tiny offset along the *local* normal shifts
1959/// the *screen-space* position by several pixels (the more grazing the
1960/// angle, the more a small out-of-plane nudge reads as a large in-plane
1961/// one), so the highlight's outline visibly saw-tooths away from the
1962/// model's actual silhouette. `depth_bias` on the material (see
1963/// `spawn_topology_highlights`) solves the z-fighting this offset existed
1964/// for without moving any vertices, so there's no longer a reason to pay
1965/// that cost.
1966fn 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
2028/// Builds a single mesh covering every boundary face in `face_ids`,
2029/// rendered exactly coincident with the true surface — no vertex offset;
2030/// see [`build_multi_face_highlight_mesh`]'s doc comment for why (the
2031/// contact master/slave surfaces this is used for can be just as curved as
2032/// a coplanar-selected surface, so the same silhouette-drift problem would
2033/// apply).
2034///
2035/// Used to preview a [`fem_core::ContactCandidate`]'s master/slave surface
2036/// as one overlay — the contact-candidate analogue of
2037/// [`build_multi_face_highlight_mesh`] (which covers the topology hover/
2038/// selected overlays instead, and resolves faces from [`FemEntityId`]
2039/// targets rather than a flat [`FaceId`] list already scoped to one mesh).
2040/// Faces that no longer exist in `fem_mesh` (e.g. a stale candidate after a
2041/// reload) are silently skipped.
2042fn 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
2079/// Dispatches to a shape-appropriate spawn function based on
2080/// `element.element_type`: a thin extruded plate for plane/shell elements,
2081/// cylinders along the geometric segments of line/beam elements, and the
2082/// actual corner-face geometry for solids and interface elements. Unknown
2083/// element types retain the bounding-box fallback.
2084///
2085/// `section` is the [`fem_core::Section`] resolved for this specific
2086/// element (via [`fem_core::AnalysisSetup::build_element_section_map`]),
2087/// providing shell thickness / beam cross-section area when available. A
2088/// `None` section falls back to a size derived from the element's own
2089/// bounding box, so shells/beams still render reasonably (just without the
2090/// solver's exact thickness/area) on a mesh with no `.cnt` loaded yet.
2091fn 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
2128/// Renders a 3-D solid or interface element from its actual corner faces.
2129/// A bounding-box cuboid is retained only as a fallback for unknown or
2130/// malformed element types with no usable face topology.
2131fn 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
2203/// Number of *corner* nodes for a shell element type, ignoring mid-side
2204/// nodes on quadratic elements (Tri6's nodes 4-6, Quad8's nodes 5-8) which
2205/// follow this corners-then-midsides ordering convention in every format
2206/// this platform parses (Gmsh, HECMW, Abaqus/CalculiX `.inp`).
2207fn shell_corner_count(element_type: &fem_core::ElementType) -> usize {
2208    element_type.surface_corner_count().unwrap_or(0)
2209}
2210
2211/// Renders a shell element (Tri3/Tri6/Quad4/Quad8) as a thin plate: the
2212/// element's corner polygon extruded by `±thickness/2` along its face
2213/// normal, so the element's actual flat-panel shape is visible instead of
2214/// being hidden inside a cuboid bounding box.
2215///
2216/// Falls back to 2% of the element's own planar size when no [`Section`]
2217/// (or no `Shell` section) is available, so the element is still
2218/// recognizably thin rather than defaulting to solid-cuboid proportions.
2219fn 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            // Clamp against the *model's* scale, not just the element's
2252            // own bounding box: a degenerate or unexpectedly large element
2253            // (corrupt data, a unit mismatch, etc.) would otherwise produce
2254            // an equally-oversized "thin" plate that's anything but thin.
2255            // The element-relative term keeps normal meshes looking right;
2256            // the model-relative ceiling only ever kicks in for outliers.
2257            (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    // Top and bottom faces.
2270    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    // Side walls: one quad (as two triangles) per edge of the polygon.
2276    // Winding order [bottom[i], bottom[j], top[j], top[i]] gives an
2277    // outward-facing normal for a corner polygon wound CCW as seen from
2278    // `normal`'s direction (verified by hand for a unit-square case before
2279    // committing to it — the seemingly-equivalent [top[i], top[j],
2280    // bottom[j], bottom[i]] order actually produces inward-facing normals).
2281    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
2314/// Renders a line/beam/truss/connector element as cylinders along its
2315/// geometric segments. Quadratic line elements therefore include their
2316/// mid-side node, while mixed-DOF elements ignore rotation-only nodes.
2317///
2318/// radius derived from the assigned [`Section`]'s cross-sectional area
2319/// (`r = sqrt(area / π)`, i.e. an equivalent-area circular cross-section —
2320/// detailed beam profile shapes are out of scope, see
2321/// [`fem_core::SectionKind::Beam`]'s doc comment).
2322///
2323/// Falls back to 1.5% of the element's own length when no [`Section`] (or
2324/// no `Beam` section) is available, so the element still reads as "thin
2325/// and long" rather than the cuboid default.
2326fn 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        // Same model-scale safety ceiling as the shell thickness fallback
2360        // above — see its comment for why a purely element-relative value
2361        // is risky for outlier/degenerate elements.
2362        _ => (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
2437/// Builds a thin prism that follows the exact face polygon. The previous
2438/// face visual used an oriented bounding cuboid, so triangular faces showed
2439/// the unused corners of that rectangle outside the element.
2440fn 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
2549/// Builds one merged triangle mesh for a part's exterior surface. Assembly
2550/// tools reuse this geometry for whole-part hover and selected overlays.
2551pub 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
2597/// Like [`build_part_surface_mesh`] but colours each vertex according to
2598/// the supplied `contour` field (rainbow palette) and optionally offsets
2599/// node positions by a displacement field.
2600///
2601/// When deformation is enabled, each vertex is moved by
2602/// `displacement[node_index] × deformation_scale` before triangulation so
2603/// the deformed shape is rendered without a separate mesh.
2604pub(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        // Fan-triangulate.
2682        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
2709/// Builds a merged line mesh for a part's boundary and beam edges.
2710pub 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
2714/// Use the same nodal displacement and scale as the contour surface.
2715pub(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    // Line-like elements do not contribute faces, so they never appear in
2761    // `cached_boundary_edges`. Add them explicitly, including in meshes
2762    // that also contain solids/shells.
2763    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    // Transparent material: same hue as `normal` but always alpha-blended
2858    // with a low, fixed opacity, regardless of whether the base material
2859    // already blends (element/face fills already blend at a much higher
2860    // alpha for normal viewing, so we can't just reuse `normal` as-is).
2861    // `with_alpha` works across every `Color` variant, unlike matching on
2862    // `Color::Srgba` directly.
2863    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        // Selection is always opaque, even when the resting face/element
2871        // material is blended. This keeps selected geometry in the depth-
2872        // writing render pass and prevents rear geometry showing through.
2873        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}