Skip to main content

visualization/
boundary_viz.rs

1//! Visualization of boundary conditions and loads: constraint symbols
2//! (cones along each restrained translational axis) and load arrows
3//! (cylinder shaft + cone head, scaled by relative magnitude).
4//!
5//! This is the visual half of CLAUDE.md's "human ↔ solver" bridge applied
6//! to analysis setup — a `.cnt` file's `!BOUNDARY`/`!CLOAD` blocks are just
7//! numbers until you can see where they land on the model.
8
9use bevy::asset::RenderAssetUsages;
10use bevy::math::primitives::{Cone, Cylinder};
11use bevy::mesh::{Mesh3d, PrimitiveTopology};
12use bevy::pbr::MeshMaterial3d;
13use bevy::prelude::*;
14use std::collections::HashSet;
15
16use fem_core::{AnalysisSetup, FemModel};
17
18use crate::demo_mesh::model_visual_scale;
19
20/// Marker for every entity spawned by [`spawn_boundary_visuals`], so a
21/// later reload can despawn exactly this set before respawning.
22#[derive(Component)]
23pub struct BoundaryVisual;
24
25/// Type of provisional load currently being authored in the viewport.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BoundaryLoadPreviewKind {
28    Nodal,
29
30    Pressure,
31
32    Gravity,
33}
34
35/// One view-only arrow pointing toward its application point.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct BoundaryLoadPreviewArrow {
38    pub origin: Vec3,
39
40    pub direction: Vec3,
41}
42
43/// One provisional moment shown as a right-hand-rule arc about `axis`.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct BoundaryLoadPreviewMoment {
46    pub origin: Vec3,
47
48    pub axis: Vec3,
49}
50
51/// View-only load feedback assembled by the UI from the current selection.
52/// It never becomes solver input until the user presses an Apply button.
53#[derive(Resource, Debug, Clone, PartialEq, Default)]
54pub struct BoundaryLoadPreview {
55    pub kind: Option<BoundaryLoadPreviewKind>,
56
57    pub arrows: Vec<BoundaryLoadPreviewArrow>,
58
59    pub moments: Vec<BoundaryLoadPreviewMoment>,
60}
61
62#[derive(Component)]
63pub struct BoundaryLoadPreviewVisual;
64
65/// Toggle for whether boundary condition / load symbols are drawn at all.
66#[derive(Resource, Debug, Clone, Copy)]
67pub struct BoundaryVisualSettings {
68    pub show_constraints: bool,
69    pub show_loads: bool,
70}
71
72impl Default for BoundaryVisualSettings {
73    fn default() -> Self {
74        Self {
75            show_constraints: true,
76            show_loads: true,
77        }
78    }
79}
80
81const CONSTRAINT_COLOR: Color = Color::srgb(0.95, 0.30, 0.20);
82const ROTATION_CONSTRAINT_COLOR: Color = Color::srgb(0.96, 0.30, 0.66);
83const LOAD_COLOR: Color = Color::srgb(0.95, 0.65, 0.10);
84const PRESSURE_COLOR: Color = Color::srgb(0.35, 0.75, 0.95);
85const GRAVITY_COLOR: Color = Color::srgb(0.70, 0.55, 0.95);
86const MAX_PREVIEW_ARROWS: usize = 2_000;
87
88/// Rebuilds one combined provisional-arrow mesh when the current selection
89/// or load authoring settings change. Combining arrows keeps previewing a
90/// large node group or pressure surface from creating thousands of entities.
91pub fn spawn_boundary_load_preview(
92    mut commands: Commands,
93    model: Option<Res<FemModel>>,
94    preview: Res<BoundaryLoadPreview>,
95    mut meshes: ResMut<Assets<Mesh>>,
96    mut materials: ResMut<Assets<StandardMaterial>>,
97    existing: Query<Entity, With<BoundaryLoadPreviewVisual>>,
98) {
99    let model_changed = model.as_ref().is_some_and(|model| model.is_changed());
100    if !preview.is_changed() && !model_changed {
101        return;
102    }
103
104    for entity in &existing {
105        commands.entity(entity).despawn();
106    }
107
108    let Some(kind) = preview.kind else {
109        return;
110    };
111    let Some(model) = model.as_deref() else {
112        return;
113    };
114    let size = boundary_symbol_size(model);
115    let Some(mesh) = build_load_preview_mesh(&preview.arrows, &preview.moments, size) else {
116        return;
117    };
118    let color = match kind {
119        BoundaryLoadPreviewKind::Nodal => LOAD_COLOR,
120        BoundaryLoadPreviewKind::Pressure => PRESSURE_COLOR,
121        BoundaryLoadPreviewKind::Gravity => GRAVITY_COLOR,
122    };
123    let material = materials.add(StandardMaterial {
124        base_color: color.with_alpha(0.88),
125        alpha_mode: AlphaMode::Blend,
126        unlit: true,
127        cull_mode: None,
128        depth_bias: 4.0,
129        ..default()
130    });
131
132    commands.spawn((
133        Mesh3d(meshes.add(mesh)),
134        MeshMaterial3d(material),
135        Transform::default(),
136        BoundaryLoadPreviewVisual,
137        Name::new("Boundary load preview"),
138    ));
139}
140
141fn build_load_preview_mesh(
142    arrows: &[BoundaryLoadPreviewArrow],
143    moments: &[BoundaryLoadPreviewMoment],
144    size: f32,
145) -> Option<Mesh> {
146    if arrows.is_empty() && moments.is_empty() {
147        return None;
148    }
149
150    let stride = arrows.len().div_ceil(MAX_PREVIEW_ARROWS).max(1);
151    let sampled = arrows.iter().step_by(stride).take(MAX_PREVIEW_ARROWS);
152    let mut positions = Vec::new();
153    let mut normals = Vec::new();
154
155    for arrow in sampled {
156        append_load_preview_arrow(
157            &mut positions,
158            &mut normals,
159            arrow.origin,
160            arrow.direction,
161            size,
162            6,
163        );
164    }
165
166    let moment_stride = moments.len().div_ceil(MAX_PREVIEW_ARROWS).max(1);
167    for moment in moments
168        .iter()
169        .step_by(moment_stride)
170        .take(MAX_PREVIEW_ARROWS)
171    {
172        append_moment_arc(
173            &mut positions,
174            &mut normals,
175            moment.origin,
176            moment.axis,
177            size * 1.15,
178            size * 0.13,
179            16,
180            4,
181        );
182    }
183
184    (!positions.is_empty()).then(|| {
185        Mesh::new(
186            PrimitiveTopology::TriangleList,
187            RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
188        )
189        .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
190        .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
191    })
192}
193
194fn append_load_preview_arrow(
195    positions: &mut Vec<[f32; 3]>,
196    normals: &mut Vec<[f32; 3]>,
197    target: Vec3,
198    direction: Vec3,
199    size: f32,
200    sides: usize,
201) {
202    let Some(direction) = direction.try_normalize() else {
203        return;
204    };
205    let helper = if direction.dot(Vec3::Y).abs() < 0.9 {
206        Vec3::Y
207    } else {
208        Vec3::X
209    };
210    let tangent = direction.cross(helper).normalize();
211    let bitangent = direction.cross(tangent).normalize();
212    let length = size * 2.4;
213    let head_length = length * 0.35;
214    let shaft_start = target - direction * length;
215    let head_base = target - direction * head_length;
216    let shaft_radius = size * 0.12;
217    let head_radius = size * 0.32;
218
219    for side in 0..sides {
220        let angle0 = std::f32::consts::TAU * side as f32 / sides as f32;
221        let angle1 = std::f32::consts::TAU * (side + 1) as f32 / sides as f32;
222        let radial0 = tangent * angle0.cos() + bitangent * angle0.sin();
223        let radial1 = tangent * angle1.cos() + bitangent * angle1.sin();
224        let shaft_a0 = shaft_start + radial0 * shaft_radius;
225        let shaft_a1 = shaft_start + radial1 * shaft_radius;
226        let shaft_b0 = head_base + radial0 * shaft_radius;
227        let shaft_b1 = head_base + radial1 * shaft_radius;
228        let head0 = head_base + radial0 * head_radius;
229        let head1 = head_base + radial1 * head_radius;
230
231        append_triangle(positions, normals, shaft_a0, shaft_b0, shaft_b1);
232        append_triangle(positions, normals, shaft_a0, shaft_b1, shaft_a1);
233        append_triangle(positions, normals, target, head0, head1);
234    }
235}
236
237/// Appends a curved right-hand-rule arrow around `axis`. A low-poly tube is
238/// used rather than Bevy entities per segment so thousands of selected nodes
239/// still produce one preview mesh.
240fn append_moment_arc(
241    positions: &mut Vec<[f32; 3]>,
242    normals: &mut Vec<[f32; 3]>,
243    center: Vec3,
244    axis: Vec3,
245    radius: f32,
246    tube_radius: f32,
247    arc_segments: usize,
248    tube_sides: usize,
249) {
250    let Some(axis) = axis.try_normalize() else {
251        return;
252    };
253    let Some((u, v)) = perpendicular_basis(axis) else {
254        return;
255    };
256    let arc_segments = arc_segments.max(3);
257    let tube_sides = tube_sides.max(3);
258    let start = -std::f32::consts::PI * 0.75;
259    let sweep = std::f32::consts::PI * 1.50;
260
261    for segment in 0..arc_segments {
262        let theta0 = start + sweep * segment as f32 / arc_segments as f32;
263        let theta1 = start + sweep * (segment + 1) as f32 / arc_segments as f32;
264        let radial0 = u * theta0.cos() + v * theta0.sin();
265        let radial1 = u * theta1.cos() + v * theta1.sin();
266        let center0 = center + radial0 * radius;
267        let center1 = center + radial1 * radius;
268
269        for side in 0..tube_sides {
270            let phi0 = std::f32::consts::TAU * side as f32 / tube_sides as f32;
271            let phi1 = std::f32::consts::TAU * (side + 1) as f32 / tube_sides as f32;
272            let ring00 = center0 + (radial0 * phi0.cos() + axis * phi0.sin()) * tube_radius;
273            let ring01 = center0 + (radial0 * phi1.cos() + axis * phi1.sin()) * tube_radius;
274            let ring10 = center1 + (radial1 * phi0.cos() + axis * phi0.sin()) * tube_radius;
275            let ring11 = center1 + (radial1 * phi1.cos() + axis * phi1.sin()) * tube_radius;
276            append_triangle(positions, normals, ring00, ring10, ring11);
277            append_triangle(positions, normals, ring00, ring11, ring01);
278        }
279    }
280
281    let end = start + sweep;
282    let radial = u * end.cos() + v * end.sin();
283    let tangent = (-u * end.sin() + v * end.cos()).normalize();
284    let base_center = center + radial * radius;
285    append_oriented_cone(
286        positions,
287        normals,
288        base_center,
289        base_center + tangent * radius * 0.42,
290        tube_radius * 2.4,
291        tube_sides.max(6),
292    );
293}
294
295fn perpendicular_basis(axis: Vec3) -> Option<(Vec3, Vec3)> {
296    let helper = if axis.dot(Vec3::Y).abs() < 0.9 {
297        Vec3::Y
298    } else {
299        Vec3::X
300    };
301    let u = axis.cross(helper).try_normalize()?;
302    let v = axis.cross(u).try_normalize()?;
303    Some((u, v))
304}
305
306fn append_oriented_cone(
307    positions: &mut Vec<[f32; 3]>,
308    normals: &mut Vec<[f32; 3]>,
309    base_center: Vec3,
310    tip: Vec3,
311    radius: f32,
312    sides: usize,
313) {
314    let Some(direction) = (tip - base_center).try_normalize() else {
315        return;
316    };
317    let Some((u, v)) = perpendicular_basis(direction) else {
318        return;
319    };
320    for side in 0..sides {
321        let angle0 = std::f32::consts::TAU * side as f32 / sides as f32;
322        let angle1 = std::f32::consts::TAU * (side + 1) as f32 / sides as f32;
323        let ring0 = base_center + (u * angle0.cos() + v * angle0.sin()) * radius;
324        let ring1 = base_center + (u * angle1.cos() + v * angle1.sin()) * radius;
325        append_triangle(positions, normals, tip, ring0, ring1);
326        append_triangle(positions, normals, base_center, ring1, ring0);
327    }
328}
329
330/// (Re)spawns constraint cones and load arrows whenever [`AnalysisSetup`]
331/// or [`BoundaryVisualSettings`] changes.
332///
333/// Despawns the previous set first — this mirrors
334/// [`crate::demo_mesh::respawn_visuals_on_reload`]'s approach rather than
335/// trying to diff individual BCs/loads, since `.cnt` files are typically
336/// loaded once per session and the set count is small (tens to low
337/// hundreds), so a full rebuild is cheap and far simpler than incremental
338/// updates.
339pub fn spawn_boundary_visuals(
340    mut commands: Commands,
341    model: Option<Res<FemModel>>,
342    setup: Option<Res<AnalysisSetup>>,
343    settings: Res<BoundaryVisualSettings>,
344    mut meshes: ResMut<Assets<Mesh>>,
345    mut materials: ResMut<Assets<StandardMaterial>>,
346    existing: Query<Entity, With<BoundaryVisual>>,
347) {
348    let setup_changed = setup.as_ref().is_some_and(|s| s.is_changed());
349    let settings_changed = settings.is_changed();
350
351    if !setup_changed && !settings_changed {
352        return;
353    }
354
355    for entity in &existing {
356        commands.entity(entity).despawn();
357    }
358
359    let Some(model) = model.as_deref() else {
360        return;
361    };
362    let Some(setup) = setup.as_deref() else {
363        return;
364    };
365
366    if setup.is_empty() {
367        return;
368    }
369
370    let symbol_size = boundary_symbol_size(model);
371
372    let constraint_material = materials.add(StandardMaterial {
373        base_color: CONSTRAINT_COLOR,
374        unlit: true,
375        cull_mode: None,
376        ..default()
377    });
378    let rotation_constraint_material = materials.add(StandardMaterial {
379        base_color: ROTATION_CONSTRAINT_COLOR,
380        unlit: true,
381        cull_mode: None,
382        ..default()
383    });
384    let load_material = materials.add(StandardMaterial {
385        base_color: LOAD_COLOR,
386        unlit: true,
387        cull_mode: None,
388        ..default()
389    });
390    let pressure_material = materials.add(StandardMaterial {
391        base_color: PRESSURE_COLOR,
392        unlit: true,
393        ..default()
394    });
395    let gravity_material = materials.add(StandardMaterial {
396        base_color: GRAVITY_COLOR,
397        unlit: true,
398        ..default()
399    });
400
401    if settings.show_constraints {
402        spawn_constraint_symbols(
403            &mut commands,
404            &mut meshes,
405            model,
406            setup,
407            symbol_size,
408            constraint_material,
409            rotation_constraint_material,
410        );
411    }
412
413    if settings.show_loads {
414        spawn_load_arrows(
415            &mut commands,
416            &mut meshes,
417            model,
418            setup,
419            symbol_size,
420            load_material,
421        );
422        spawn_dload_arrows(
423            &mut commands,
424            &mut meshes,
425            model,
426            setup,
427            symbol_size,
428            pressure_material,
429            gravity_material,
430        );
431    }
432}
433
434/// Cones represent restrained translations; magenta rings represent
435/// restrained rotations about their normal axis.
436fn spawn_constraint_symbols(
437    commands: &mut Commands,
438    meshes: &mut Assets<Mesh>,
439    model: &FemModel,
440    setup: &AnalysisSetup,
441    size: f32,
442    translation_material: Handle<StandardMaterial>,
443    rotation_material: Handle<StandardMaterial>,
444) {
445    if let Some(mesh) = build_constraint_mesh(model, setup, size) {
446        commands.spawn((
447            Mesh3d(meshes.add(mesh)),
448            MeshMaterial3d(translation_material),
449            Transform::default(),
450            BoundaryVisual,
451            Name::new("Translational constraint symbols"),
452        ));
453    }
454    if let Some(mesh) = build_rotation_constraint_mesh(model, setup, size) {
455        commands.spawn((
456            Mesh3d(meshes.add(mesh)),
457            MeshMaterial3d(rotation_material),
458            Transform::default(),
459            BoundaryVisual,
460            Name::new("Rotational constraint symbols"),
461        ));
462    }
463}
464
465/// Builds every translational constraint marker into one low-poly mesh.
466///
467/// A large FrontISTR node group can contain several thousand nodes. Spawning
468/// one Bevy entity per node and constrained axis made opening `conrod` create
469/// more than 21,000 entities just for the red cones. One triangle mesh keeps
470/// the same complete visual coverage while reducing that to a single entity.
471fn build_constraint_mesh(model: &FemModel, setup: &AnalysisSetup, size: f32) -> Option<Mesh> {
472    const CONE_SIDES: usize = 6;
473
474    let estimated_symbols: usize = setup
475        .boundary_conditions
476        .iter()
477        .map(|bc| {
478            let axis_count = (1u8..=3)
479                .filter(|dof| *dof >= bc.dof_start && *dof <= bc.dof_end)
480                .count();
481            bc.nodes.len() * axis_count
482        })
483        .sum();
484
485    let vertices_per_symbol = CONE_SIDES * 6;
486    let mut positions = Vec::with_capacity(estimated_symbols * vertices_per_symbol);
487    let mut normals = Vec::with_capacity(estimated_symbols * vertices_per_symbol);
488
489    for bc in &setup.boundary_conditions {
490        if !bc.constrains_translation() {
491            continue;
492        }
493
494        let Some(mesh) = model.meshes.get(bc.mesh_index) else {
495            continue;
496        };
497
498        for &node_id in &bc.nodes {
499            let Some(position) = mesh.node_position(node_id) else {
500                continue;
501            };
502
503            for (dof, axis) in [(1u8, Vec3::X), (2, Vec3::Y), (3, Vec3::Z)] {
504                if dof < bc.dof_start || dof > bc.dof_end {
505                    continue;
506                }
507
508                append_constraint_cone(
509                    &mut positions,
510                    &mut normals,
511                    position,
512                    axis,
513                    size,
514                    CONE_SIDES,
515                );
516            }
517        }
518    }
519
520    (!positions.is_empty()).then(|| {
521        Mesh::new(
522            PrimitiveTopology::TriangleList,
523            RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
524        )
525        .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
526        .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
527    })
528}
529
530/// Builds rotational restraints as lightweight annular ribbons. The ring
531/// plane identifies the restrained axis while a distinct magenta color keeps
532/// it separate from translational restraint cones.
533fn build_rotation_constraint_mesh(
534    model: &FemModel,
535    setup: &AnalysisSetup,
536    size: f32,
537) -> Option<Mesh> {
538    const RING_SEGMENTS: usize = 12;
539    let estimated_symbols: usize = setup
540        .boundary_conditions
541        .iter()
542        .map(|bc| {
543            let axis_count = (4u8..=6)
544                .filter(|dof| *dof >= bc.dof_start && *dof <= bc.dof_end)
545                .count();
546            bc.nodes.len() * axis_count
547        })
548        .sum();
549    let mut positions = Vec::with_capacity(estimated_symbols * RING_SEGMENTS * 6);
550    let mut normals = Vec::with_capacity(estimated_symbols * RING_SEGMENTS * 6);
551
552    for bc in &setup.boundary_conditions {
553        if !bc.constrains_rotation() {
554            continue;
555        }
556        if let Some(center) = &bc.rotation_center {
557            let Some(center_node) = center.node else {
558                continue;
559            };
560            let Some(position) = model
561                .meshes
562                .get(center.mesh_index)
563                .and_then(|mesh| mesh.node_position(center_node))
564            else {
565                continue;
566            };
567            for (dof, axis) in [(1u8, Vec3::X), (2, Vec3::Y), (3, Vec3::Z)] {
568                if dof < bc.dof_start || dof > bc.dof_end {
569                    continue;
570                }
571                append_rotation_constraint_ring(
572                    &mut positions,
573                    &mut normals,
574                    position,
575                    axis,
576                    size * 0.82,
577                    size * 0.10,
578                    RING_SEGMENTS,
579                );
580            }
581            continue;
582        }
583        let Some(mesh) = model.meshes.get(bc.mesh_index) else {
584            continue;
585        };
586        for &node_id in &bc.nodes {
587            let Some(position) = mesh.node_position(node_id) else {
588                continue;
589            };
590            for (dof, axis) in [(4u8, Vec3::X), (5, Vec3::Y), (6, Vec3::Z)] {
591                if dof < bc.dof_start || dof > bc.dof_end {
592                    continue;
593                }
594                append_rotation_constraint_ring(
595                    &mut positions,
596                    &mut normals,
597                    position,
598                    axis,
599                    size * 0.82,
600                    size * 0.10,
601                    RING_SEGMENTS,
602                );
603            }
604        }
605    }
606
607    (!positions.is_empty()).then(|| {
608        Mesh::new(
609            PrimitiveTopology::TriangleList,
610            RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
611        )
612        .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
613        .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
614    })
615}
616
617fn append_rotation_constraint_ring(
618    positions: &mut Vec<[f32; 3]>,
619    normals: &mut Vec<[f32; 3]>,
620    center: Vec3,
621    axis: Vec3,
622    radius: f32,
623    half_width: f32,
624    segments: usize,
625) {
626    let Some(axis) = axis.try_normalize() else {
627        return;
628    };
629    let Some((u, v)) = perpendicular_basis(axis) else {
630        return;
631    };
632    for segment in 0..segments {
633        let angle0 = std::f32::consts::TAU * segment as f32 / segments as f32;
634        let angle1 = std::f32::consts::TAU * (segment + 1) as f32 / segments as f32;
635        let radial0 = u * angle0.cos() + v * angle0.sin();
636        let radial1 = u * angle1.cos() + v * angle1.sin();
637        let inner0 = center + radial0 * (radius - half_width);
638        let outer0 = center + radial0 * (radius + half_width);
639        let inner1 = center + radial1 * (radius - half_width);
640        let outer1 = center + radial1 * (radius + half_width);
641        append_triangle(positions, normals, inner0, outer0, outer1);
642        append_triangle(positions, normals, inner0, outer1, inner1);
643    }
644}
645
646fn append_constraint_cone(
647    positions: &mut Vec<[f32; 3]>,
648    normals: &mut Vec<[f32; 3]>,
649    node_position: Vec3,
650    axis: Vec3,
651    size: f32,
652    sides: usize,
653) {
654    let direction = axis.normalize();
655    let helper = if direction.dot(Vec3::Y).abs() < 0.9 {
656        Vec3::Y
657    } else {
658        Vec3::X
659    };
660    let tangent = direction.cross(helper).normalize();
661    let bitangent = direction.cross(tangent).normalize();
662    // Match Bevy's original midpoint-anchored Cone transform: the circular
663    // base is anchored exactly at the constrained node and the tip extends
664    // one symbol length in the negative constrained-axis direction.
665    let base_center = node_position;
666    let tip = node_position - direction * size;
667    let radius = size * 0.5;
668
669    for side in 0..sides {
670        let angle0 = std::f32::consts::TAU * side as f32 / sides as f32;
671        let angle1 = std::f32::consts::TAU * (side + 1) as f32 / sides as f32;
672        let ring0 = base_center + radius * (tangent * angle0.cos() + bitangent * angle0.sin());
673        let ring1 = base_center + radius * (tangent * angle1.cos() + bitangent * angle1.sin());
674
675        append_triangle(positions, normals, tip, ring0, ring1);
676        append_triangle(positions, normals, base_center, ring1, ring0);
677    }
678}
679
680fn append_triangle(
681    positions: &mut Vec<[f32; 3]>,
682    normals: &mut Vec<[f32; 3]>,
683    a: Vec3,
684    b: Vec3,
685    c: Vec3,
686) {
687    let normal = (b - a).cross(c - a).normalize_or_zero().to_array();
688
689    positions.extend([a.to_array(), b.to_array(), c.to_array()]);
690    normals.extend([normal; 3]);
691}
692
693/// Chooses a glyph size from the local mesh resolution instead of only the
694/// model's overall diagonal. Long, finely meshed parts such as `conrod`
695/// otherwise receive cones several edge lengths wide, which overlap into a
696/// displaced-looking solid annulus.
697fn boundary_symbol_size(model: &FemModel) -> f32 {
698    let model_scale = model_visual_scale(model);
699    let mut edge_lengths: Vec<f32> = model
700        .meshes
701        .iter()
702        .flat_map(|mesh| {
703            mesh.cached_boundary_edges().iter().filter_map(|edge| {
704                let a = mesh.node_position(edge.nodes[0])?;
705                let b = mesh.node_position(edge.nodes[1])?;
706                let length = a.distance(b);
707                (length.is_finite() && length > 1.0e-6).then_some(length)
708            })
709        })
710        .collect();
711
712    if edge_lengths.is_empty() {
713        return (model_scale * 0.02).max(1.0e-3);
714    }
715
716    let middle = edge_lengths.len() / 2;
717    let (_, median, _) = edge_lengths.select_nth_unstable_by(middle, f32::total_cmp);
718
719    (*median * 0.45)
720        .clamp(model_scale * 0.002, model_scale * 0.02)
721        .max(1.0e-3)
722}
723
724/// One arrow (cylinder shaft + cone head) per nodal load, scaled by its
725/// magnitude relative to the largest load in the set so the visualization
726/// communicates *relative* load intensity at a glance.
727fn spawn_load_arrows(
728    commands: &mut Commands,
729    meshes: &mut Assets<Mesh>,
730    model: &FemModel,
731    setup: &AnalysisSetup,
732    base_size: f32,
733    material: Handle<StandardMaterial>,
734) {
735    let max_magnitude = setup
736        .nodal_loads
737        .iter()
738        .filter(|load| load.rotation_center.is_none() && (1..=3).contains(&load.dof))
739        .map(|load| load.value.abs())
740        .fold(0.0f32, f32::max)
741        .max(1.0e-9);
742
743    for load in &setup.nodal_loads {
744        if load.rotation_center.is_some() {
745            continue;
746        }
747        let Some(mesh) = model.meshes.get(load.mesh_index) else {
748            continue;
749        };
750        let Some(position) = mesh.node_position(load.node) else {
751            continue;
752        };
753
754        let axis = match load.dof {
755            1 => Vec3::X,
756            2 => Vec3::Y,
757            3 => Vec3::Z,
758            _ => continue,
759        };
760
761        let direction = axis * load.value.signum();
762        // Arrow length scales between 1x and 3x the base symbol size by
763        // relative magnitude, so a small load doesn't visually disappear
764        // and a dominant load doesn't dwarf the model.
765        let length = base_size * (1.0 + 2.0 * (load.value.abs() / max_magnitude));
766
767        let shaft_len = length * 0.7;
768        let head_len = length * 0.3;
769        let radius = base_size * 0.12;
770
771        let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
772
773        // Shaft: cylinder from the node outward.
774        let shaft_mesh = meshes.add(Cylinder {
775            radius: radius * 0.6,
776            half_height: shaft_len * 0.5,
777        });
778        let shaft_center = position + direction * (shaft_len * 0.5);
779
780        commands.spawn((
781            Mesh3d(shaft_mesh),
782            MeshMaterial3d(material.clone()),
783            Transform {
784                translation: shaft_center,
785                rotation,
786                ..default()
787            },
788            BoundaryVisual,
789            Name::new(format!("Load shaft {} @ node {}", load.name, load.node.0)),
790        ));
791
792        // Head: cone at the tip, pointing in the load direction.
793        let head_mesh = meshes.add(Cone {
794            radius,
795            height: head_len,
796        });
797        let head_center = position + direction * (shaft_len + head_len * 0.5);
798
799        commands.spawn((
800            Mesh3d(head_mesh),
801            MeshMaterial3d(material.clone()),
802            Transform {
803                translation: head_center,
804                rotation,
805                ..default()
806            },
807            BoundaryVisual,
808            Name::new(format!("Load head {} @ node {}", load.name, load.node.0)),
809        ));
810    }
811
812    spawn_nodal_moment_arcs(commands, meshes, model, setup, base_size, material);
813}
814
815/// Draws `Mx/My/Mz` as combined curved arrows. Force and moment magnitudes
816/// are scaled independently because they have different physical units.
817fn spawn_nodal_moment_arcs(
818    commands: &mut Commands,
819    meshes: &mut Assets<Mesh>,
820    model: &FemModel,
821    setup: &AnalysisSetup,
822    base_size: f32,
823    material: Handle<StandardMaterial>,
824) {
825    const MAX_MOMENT_GLYPHS: usize = 2_000;
826    let moments: Vec<_> = setup
827        .nodal_loads
828        .iter()
829        .filter(|load| {
830            let direct = load.rotation_center.is_none() && (4..=6).contains(&load.dof);
831            let about_center = load.rotation_center.is_some() && (1..=3).contains(&load.dof);
832            (direct || about_center) && load.value.abs() > f32::EPSILON
833        })
834        .collect();
835    if moments.is_empty() {
836        return;
837    }
838    let max_magnitude = moments
839        .iter()
840        .map(|load| load.value.abs())
841        .fold(0.0f32, f32::max)
842        .max(1.0e-9);
843    let stride = moments.len().div_ceil(MAX_MOMENT_GLYPHS).max(1);
844    let mut positions = Vec::new();
845    let mut normals = Vec::new();
846    let mut seen_center_moments = HashSet::new();
847
848    for load in moments.into_iter().step_by(stride).take(MAX_MOMENT_GLYPHS) {
849        let (position, axis) = if let Some(center) = &load.rotation_center {
850            let key = (load.name.as_str(), center.mesh_index, center.node, load.dof);
851            if !seen_center_moments.insert(key) {
852                continue;
853            }
854            let Some(center_node) = center.node else {
855                continue;
856            };
857            let Some(position) = model
858                .meshes
859                .get(center.mesh_index)
860                .and_then(|mesh| mesh.node_position(center_node))
861            else {
862                continue;
863            };
864            let axis = match load.dof {
865                1 => Vec3::X,
866                2 => Vec3::Y,
867                3 => Vec3::Z,
868                _ => continue,
869            };
870            (position, axis)
871        } else {
872            let Some(mesh) = model.meshes.get(load.mesh_index) else {
873                continue;
874            };
875            let Some(position) = mesh.node_position(load.node) else {
876                continue;
877            };
878            let axis = match load.dof {
879                4 => Vec3::X,
880                5 => Vec3::Y,
881                6 => Vec3::Z,
882                _ => continue,
883            };
884            (position, axis)
885        };
886        let axis = axis * load.value.signum();
887        let relative = load.value.abs() / max_magnitude;
888        append_moment_arc(
889            &mut positions,
890            &mut normals,
891            position,
892            axis,
893            base_size * (1.05 + 0.80 * relative),
894            base_size * 0.13,
895            12,
896            3,
897        );
898    }
899    if positions.is_empty() {
900        return;
901    }
902    let mesh = Mesh::new(
903        PrimitiveTopology::TriangleList,
904        RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
905    )
906    .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
907    .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
908    commands.spawn((
909        Mesh3d(meshes.add(mesh)),
910        MeshMaterial3d(material),
911        Transform::default(),
912        BoundaryVisual,
913        Name::new("Nodal moment arcs"),
914    ));
915}
916
917/// One arrow per pressure [`fem_core::DistributedLoad`] face, drawn from
918/// the face centroid along its outward normal (reversed for a negative
919/// magnitude, matching the FrontISTR/Abaqus convention that positive
920/// pressure acts *into* the surface) — the surface-load counterpart of
921/// [`spawn_load_arrows`]'s nodal-load arrows.
922///
923/// Gravity loads get one schematic arrow from the centroid of their
924/// targeted elements, oriented using the direction cosine stored with the
925/// load.
926fn spawn_dload_arrows(
927    commands: &mut Commands,
928    meshes: &mut Assets<Mesh>,
929    model: &FemModel,
930    setup: &AnalysisSetup,
931    base_size: f32,
932    pressure_material: Handle<StandardMaterial>,
933    gravity_material: Handle<StandardMaterial>,
934) {
935    let max_magnitude = setup
936        .distributed_loads
937        .iter()
938        .map(|dl| dl.value.abs())
939        .fold(0.0f32, f32::max)
940        .max(1.0e-9);
941
942    // Element-face → geometry lookups, built lazily per mesh (most models
943    // won't have distributed loads on every mesh in an assembly).
944    let mut face_lookup: std::collections::HashMap<
945        usize,
946        std::collections::HashMap<fem_core::ElementFaceRef, fem_core::FaceGeometry>,
947    > = std::collections::HashMap::new();
948
949    for dl in &setup.distributed_loads {
950        let Some(mesh) = model.meshes.get(dl.mesh_index) else {
951            continue;
952        };
953
954        let length = base_size * (1.0 + 2.0 * (dl.value.abs() / max_magnitude));
955        let shaft_len = length * 0.7;
956        let head_len = length * 0.3;
957        let radius = base_size * 0.10;
958
959        match (dl.kind, &dl.target) {
960            (
961                fem_core::DistributedLoadKind::Pressure,
962                fem_core::DistributedLoadTarget::Faces(faces),
963            ) => {
964                let lookup = face_lookup.entry(dl.mesh_index).or_insert_with(|| {
965                    mesh.cached_boundary_faces()
966                        .iter()
967                        .filter_map(|face| {
968                            let face_ref = face.element_face_ref()?;
969                            let geom = mesh.face_geometry(face)?;
970                            Some((face_ref, geom))
971                        })
972                        .collect()
973                });
974
975                for face_ref in faces {
976                    let Some(geom) = lookup.get(face_ref) else {
977                        continue;
978                    };
979
980                    let direction = -geom.normal * dl.value.signum();
981                    let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
982
983                    let shaft_mesh = meshes.add(Cylinder {
984                        radius: radius * 0.6,
985                        half_height: shaft_len * 0.5,
986                    });
987                    let shaft_center = geom.centroid + direction * (shaft_len * 0.5);
988
989                    commands.spawn((
990                        Mesh3d(shaft_mesh),
991                        MeshMaterial3d(pressure_material.clone()),
992                        Transform {
993                            translation: shaft_center,
994                            rotation,
995                            ..default()
996                        },
997                        BoundaryVisual,
998                        Name::new(format!(
999                            "DLoad shaft {} @ elem {}",
1000                            dl.name, face_ref.element.0
1001                        )),
1002                    ));
1003
1004                    let head_mesh = meshes.add(Cone {
1005                        radius,
1006                        height: head_len,
1007                    });
1008                    let head_center = geom.centroid + direction * (shaft_len + head_len * 0.5);
1009
1010                    commands.spawn((
1011                        Mesh3d(head_mesh),
1012                        MeshMaterial3d(pressure_material.clone()),
1013                        Transform {
1014                            translation: head_center,
1015                            rotation,
1016                            ..default()
1017                        },
1018                        BoundaryVisual,
1019                        Name::new(format!(
1020                            "DLoad head {} @ elem {}",
1021                            dl.name, face_ref.element.0
1022                        )),
1023                    ));
1024                }
1025            }
1026            (fem_core::DistributedLoadKind::Gravity, target) => {
1027                let elements: std::collections::HashSet<fem_core::ElementId> =
1028                    target.element_ids().into_iter().collect();
1029
1030                if elements.is_empty() {
1031                    continue;
1032                }
1033
1034                let mut centroid = Vec3::ZERO;
1035                let mut count = 0u32;
1036
1037                for element in &mesh.elements {
1038                    if !elements.contains(&element.id) {
1039                        continue;
1040                    }
1041                    if let Some(positions) = mesh.node_positions(&element.nodes) {
1042                        for p in positions {
1043                            centroid += p;
1044                            count += 1;
1045                        }
1046                    }
1047                }
1048
1049                if count == 0 {
1050                    continue;
1051                }
1052                centroid /= count as f32;
1053
1054                let direction = dl
1055                    .direction
1056                    .filter(|direction| direction.length_squared() > f32::EPSILON)
1057                    .map(|direction| direction.normalize())
1058                    .unwrap_or(Vec3::NEG_Y)
1059                    * dl.value.signum();
1060                let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
1061
1062                let shaft_mesh = meshes.add(Cylinder {
1063                    radius: radius * 0.6,
1064                    half_height: shaft_len * 0.5,
1065                });
1066                commands.spawn((
1067                    Mesh3d(shaft_mesh),
1068                    MeshMaterial3d(gravity_material.clone()),
1069                    Transform {
1070                        translation: centroid + direction * (shaft_len * 0.5),
1071                        rotation,
1072                        ..default()
1073                    },
1074                    BoundaryVisual,
1075                    Name::new(format!("DLoad(gravity) shaft {}", dl.name)),
1076                ));
1077
1078                let head_mesh = meshes.add(Cone {
1079                    radius,
1080                    height: head_len,
1081                });
1082                commands.spawn((
1083                    Mesh3d(head_mesh),
1084                    MeshMaterial3d(gravity_material.clone()),
1085                    Transform {
1086                        translation: centroid + direction * (shaft_len + head_len * 0.5),
1087                        rotation,
1088                        ..default()
1089                    },
1090                    BoundaryVisual,
1091                    Name::new(format!("DLoad(gravity) head {}", dl.name)),
1092                ));
1093            }
1094            // A pressure load stored with `DistributedLoadTarget::Elements`
1095            // (no face info — e.g. hand-built from a bare element group
1096            // rather than a picked surface) has nothing to anchor an arrow
1097            // to; skip it rather than guessing a face.
1098            (
1099                fem_core::DistributedLoadKind::Pressure,
1100                fem_core::DistributedLoadTarget::Elements(_),
1101            ) => {}
1102        }
1103    }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109    use fem_core::{BoundaryCondition, FemMesh, FemNode, NodeId, RotationCenter};
1110
1111    #[test]
1112    fn constraint_markers_are_combined_into_one_mesh() {
1113        let mesh = FemMesh::new(
1114            vec![
1115                FemNode::new(NodeId(1), Vec3::ZERO),
1116                FemNode::new(NodeId(2), Vec3::ONE),
1117            ],
1118            Vec::new(),
1119        );
1120        let model = FemModel::single_mesh("test", mesh);
1121        let mut setup = AnalysisSetup::default();
1122        setup.boundary_conditions.push(BoundaryCondition {
1123            name: "FIX".to_string(),
1124            mesh_index: 0,
1125            nodes: vec![NodeId(1), NodeId(2)],
1126            ngrp_name: Some("FIX".to_string()),
1127            rotation_center: None,
1128            dof_start: 1,
1129            dof_end: 3,
1130            value: 0.0,
1131        });
1132
1133        let mesh = build_constraint_mesh(&model, &setup, 0.1).unwrap();
1134
1135        // 2 nodes * 3 axes * 6 cone sides * (side + base) * 3 vertices.
1136        assert_eq!(mesh.count_vertices(), 2 * 3 * 6 * 2 * 3);
1137    }
1138
1139    #[test]
1140    fn constraint_cone_base_is_anchored_at_the_node() {
1141        let mut positions = Vec::new();
1142        let mut normals = Vec::new();
1143
1144        append_constraint_cone(&mut positions, &mut normals, Vec3::ZERO, Vec3::X, 2.0, 4);
1145
1146        // The first side triangle is [tip, base ring 0, base ring 1].
1147        assert_eq!(positions[0][0], -2.0);
1148        assert_eq!(positions[1][0], 0.0);
1149        assert_eq!(positions[2][0], 0.0);
1150    }
1151
1152    #[test]
1153    fn load_preview_combines_arrow_geometry_into_one_mesh() {
1154        let arrows = [BoundaryLoadPreviewArrow {
1155            origin: Vec3::ZERO,
1156            direction: Vec3::X,
1157        }];
1158
1159        let mesh = build_load_preview_mesh(&arrows, &[], 1.0).unwrap();
1160
1161        // Six sides: 12 shaft triangles + 6 head triangles.
1162        assert_eq!(mesh.count_vertices(), 18 * 3);
1163    }
1164
1165    #[test]
1166    fn load_preview_skips_zero_length_directions() {
1167        let arrows = [BoundaryLoadPreviewArrow {
1168            origin: Vec3::ZERO,
1169            direction: Vec3::ZERO,
1170        }];
1171
1172        assert!(build_load_preview_mesh(&arrows, &[], 1.0).is_none());
1173    }
1174
1175    #[test]
1176    fn rotational_constraint_rings_are_combined_into_one_mesh() {
1177        let mesh = FemMesh::new(vec![FemNode::new(NodeId(1), Vec3::ZERO)], Vec::new());
1178        let model = FemModel::single_mesh("test", mesh);
1179        let mut setup = AnalysisSetup::default();
1180        setup.boundary_conditions.push(BoundaryCondition {
1181            name: "FIX_ROT".to_string(),
1182            mesh_index: 0,
1183            nodes: vec![NodeId(1)],
1184            ngrp_name: None,
1185            rotation_center: None,
1186            dof_start: 4,
1187            dof_end: 6,
1188            value: 0.0,
1189        });
1190
1191        let mesh = build_rotation_constraint_mesh(&model, &setup, 1.0).unwrap();
1192
1193        // 3 axes * 12 ring segments * 2 triangles * 3 vertices.
1194        assert_eq!(mesh.count_vertices(), 3 * 12 * 2 * 3);
1195    }
1196
1197    #[test]
1198    fn rot_center_constraint_draws_one_ring_at_the_center_not_each_target() {
1199        let mesh = FemMesh::new(
1200            vec![
1201                FemNode::new(NodeId(1), Vec3::ZERO),
1202                FemNode::new(NodeId(2), Vec3::ONE),
1203                FemNode::new(NodeId(7), Vec3::X),
1204            ],
1205            Vec::new(),
1206        );
1207        let model = FemModel::single_mesh("test", mesh);
1208        let mut setup = AnalysisSetup::default();
1209        setup.boundary_conditions.push(BoundaryCondition {
1210            name: "CENTER_ROT".to_string(),
1211            mesh_index: 0,
1212            nodes: vec![NodeId(1), NodeId(2)],
1213            ngrp_name: None,
1214            rotation_center: Some(RotationCenter::from_node(0, NodeId(7))),
1215            dof_start: 1,
1216            dof_end: 1,
1217            value: 0.25,
1218        });
1219
1220        let mesh = build_rotation_constraint_mesh(&model, &setup, 1.0).unwrap();
1221
1222        assert_eq!(mesh.count_vertices(), 12 * 2 * 3);
1223        assert!(build_constraint_mesh(&model, &setup, 1.0).is_none());
1224    }
1225
1226    #[test]
1227    fn moment_preview_generates_a_curved_arrow() {
1228        let moments = [BoundaryLoadPreviewMoment {
1229            origin: Vec3::ZERO,
1230            axis: Vec3::Z,
1231        }];
1232
1233        let mesh = build_load_preview_mesh(&[], &moments, 1.0).unwrap();
1234
1235        assert!(mesh.count_vertices() > 16 * 2 * 3);
1236    }
1237}