1use 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#[derive(Component)]
23pub struct BoundaryVisual;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BoundaryLoadPreviewKind {
28 Nodal,
29
30 Pressure,
31
32 Gravity,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct BoundaryLoadPreviewArrow {
38 pub origin: Vec3,
39
40 pub direction: Vec3,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct BoundaryLoadPreviewMoment {
46 pub origin: Vec3,
47
48 pub axis: Vec3,
49}
50
51#[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#[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
88pub 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
237fn 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
330pub 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
434fn 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
465fn 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
530fn 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 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
693fn 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
724fn 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 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 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 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
815fn 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
917fn 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 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 (
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 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 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 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 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}