forked from 1b-agent/fieldcraft-upstream
2D elements: plane stress, plane strain and axisymmetric sections on the DOF framework; mesh.planar (M4) (#39)
Model: planar section kinds with Extent, element edges in sets, edge-weighted 2D loads,
centrifugal load, 2D model checks. fem: Plane kernels on Basis::{Tri3,Tri6,Quad4,Quad8}
declaring ElementDofs::new(DofSet::PLANE); QM6 incompatible modes with the
Wilson-Ibrahimbegovic correction. Solver: Kernel enum on the DofLayout; 3D results
bit-identical (hash-checked). Decks: Abaqus CPS/CPE/CAX, Nastran membranes and RFORCE,
LS-DYNA ELFORM 12/13. Mesh: free triangle mesher, planar region meshing, face boundaries.
Engine: mesh.planar with tagged edges. Analytic verification suite in solver/tests/plane.rs.
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
42 files changed
+3270
-128
No files matched your search
+165
-20
@@ -10,7 +10,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::loads::Distribution;
|
||||
use crate::{BoundaryCondition, Elastic, Error, Load, Model, Procedure, Result, SectionKind, Target};
|
||||
use crate::{BoundaryCondition, Elastic, ElementKind, Error, Extent, Load, Model, Procedure, Result, SectionKind, Target};
|
||||
|
||||
/// How serious a [`Diagnostic`] is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -47,11 +47,14 @@ pub struct ResolvedSet {
|
||||
pub nodes: Vec<u32>,
|
||||
pub elements: Vec<u32>,
|
||||
pub faces: Vec<crate::ElementFace>,
|
||||
/// Element edges (every element kind; 2D models load the edges of their 2D elements).
|
||||
#[serde(default)]
|
||||
pub edges: Vec<crate::ElementEdge>,
|
||||
}
|
||||
|
||||
impl ResolvedSet {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty()
|
||||
self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty() && self.edges.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +73,30 @@ pub struct Resolved {
|
||||
pub nodal_loads: BTreeMap<String, Vec<(u32, [f64; 3])>>,
|
||||
/// The check's warnings (a resolved model has no errors).
|
||||
pub warnings: Vec<Diagnostic>,
|
||||
/// How each element of a 2D section (plane stress, plane strain, axisymmetric) extends out
|
||||
/// of its plane; empty for a model without 2D sections. A model is 2D or not as a whole.
|
||||
pub planar: BTreeMap<u32, Extent>,
|
||||
}
|
||||
|
||||
impl Resolved {
|
||||
/// Whether the model is a 2D model (its sections are plane stress, plane strain or
|
||||
/// axisymmetric).
|
||||
pub fn is_planar(&self) -> bool {
|
||||
!self.planar.is_empty()
|
||||
}
|
||||
|
||||
/// How a 2D model extends out of its plane: revolved when any of its sections is
|
||||
/// axisymmetric (the check refuses a mix), else the first element's thickness; `None` for a
|
||||
/// model without 2D sections.
|
||||
pub fn extent(&self) -> Option<Extent> {
|
||||
if self.planar.values().any(|e| *e == Extent::Revolved) { Some(Extent::Revolved) } else { self.planar.values().next().copied() }
|
||||
}
|
||||
|
||||
/// The edges of a set that belong to 2D elements: where a 2D model's surface loads act.
|
||||
pub fn planar_edges(&self, set: &str) -> Vec<crate::ElementEdge> {
|
||||
self.sets.get(set).map(|s| s.edges.iter().filter(|e| self.planar.contains_key(&e.element)).copied().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The nodes a boundary condition, force or moment acts on.
|
||||
pub fn target_nodes(&self, target: &Target) -> &[u32] {
|
||||
match target {
|
||||
@@ -160,6 +184,14 @@ impl Model {
|
||||
}
|
||||
resolved.sets.insert(name.clone(), r);
|
||||
}
|
||||
// The elements of 2D sections.
|
||||
for sec in self.sections.values() {
|
||||
if let (Some(extent), Some(r)) = (sec.kind.extent(), resolved.sets.get(&sec.set)) {
|
||||
for &e in &r.elements {
|
||||
resolved.planar.insert(e, extent);
|
||||
}
|
||||
}
|
||||
}
|
||||
let empty = crate::ResolvedSet::default();
|
||||
let set_of = |name: &str| resolved.sets.get(name).unwrap_or(&empty);
|
||||
|
||||
@@ -191,12 +223,16 @@ impl Model {
|
||||
if r.elements.is_empty() {
|
||||
d.error(&subject, format!("set `{}` has no elements", sec.set));
|
||||
}
|
||||
let fits = |k: crate::ElementKind| match sec.kind {
|
||||
let fits = |k: ElementKind| match sec.kind {
|
||||
SectionKind::Solid {} => k.dimension() == 3,
|
||||
SectionKind::Shell { .. } => k.dimension() == 2,
|
||||
SectionKind::Beam { .. } => k == crate::ElementKind::Beam2,
|
||||
SectionKind::Rod { .. } => k == crate::ElementKind::Rod2,
|
||||
SectionKind::Shell { .. } | SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => {
|
||||
k.dimension() == 2
|
||||
}
|
||||
SectionKind::Beam { .. } => k == ElementKind::Beam2,
|
||||
SectionKind::Rod { .. } => k == ElementKind::Rod2,
|
||||
};
|
||||
// The linear element with an incompatible-modes formulation for this kind of section.
|
||||
let incompatible_kind = if sec.kind.is_planar() { ElementKind::Quad4 } else { ElementKind::Hex8 };
|
||||
let mut wrong = Vec::new();
|
||||
let mut not_hex8 = Vec::new();
|
||||
let mut along = Vec::new();
|
||||
@@ -206,7 +242,7 @@ impl Model {
|
||||
if !fits(el.kind) {
|
||||
wrong.push(e);
|
||||
}
|
||||
if sec.formulation.incompatible_modes && el.kind != crate::ElementKind::Hex8 {
|
||||
if sec.formulation.incompatible_modes && el.kind != incompatible_kind {
|
||||
not_hex8.push(e);
|
||||
}
|
||||
if let SectionKind::Beam { orientation, .. } = sec.kind
|
||||
@@ -225,8 +261,17 @@ impl Model {
|
||||
d.error(&subject, format!("a {} section cannot hold elements {}", sec.kind.name(), first_labels(&wrong)));
|
||||
}
|
||||
if !not_hex8.is_empty() {
|
||||
d.error(&subject, format!("incompatible modes apply to hex8 elements only, not elements {}", first_labels(¬_hex8)));
|
||||
d.error(
|
||||
&subject,
|
||||
format!(
|
||||
"incompatible modes apply to {} elements only, not elements {} (in a {} section)",
|
||||
incompatible_kind.name(),
|
||||
first_labels(¬_hex8),
|
||||
sec.kind.name()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !along.is_empty() {
|
||||
d.error(&subject, format!("the orientation vector is parallel to elements {}", first_labels(&along)));
|
||||
}
|
||||
@@ -241,6 +286,7 @@ impl Model {
|
||||
if !unsectioned.is_empty() {
|
||||
d.error("model", format!("elements {} have no section", first_labels(&unsectioned)));
|
||||
}
|
||||
self.check_planar(&resolved, &mut d);
|
||||
|
||||
// Mass: frequency steps and gravity need a density in every material in use.
|
||||
let needs_mass = self
|
||||
@@ -254,7 +300,13 @@ impl Model {
|
||||
};
|
||||
Some(format!("step `{}` {why}", s.name))
|
||||
})
|
||||
.or_else(|| self.loads.iter().find(|(_, l)| matches!(l, Load::Gravity { .. })).map(|(n, _)| format!("load `{n}` is gravity")));
|
||||
.or_else(|| {
|
||||
self.loads.iter().find_map(|(n, l)| match l {
|
||||
Load::Gravity { .. } => Some(format!("load `{n}` is gravity")),
|
||||
Load::Centrifugal { .. } => Some(format!("load `{n}` is centrifugal")),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(why) = needs_mass {
|
||||
let used: BTreeSet<&str> = self.sections.values().map(|s| s.material.as_str()).collect();
|
||||
for m in used {
|
||||
@@ -285,9 +337,9 @@ impl Model {
|
||||
Target::Set(s) => {
|
||||
let rotational: &BTreeSet<u32> = rotational.get_or_init(|| {
|
||||
self.elements
|
||||
.values()
|
||||
.filter(|e| e.kind.dimension() == 2 || e.kind == crate::ElementKind::Beam2)
|
||||
.flat_map(|e| e.nodes.iter().copied())
|
||||
.iter()
|
||||
.filter(|(l, e)| (e.kind.dimension() == 2 && !resolved.planar.contains_key(l)) || e.kind == ElementKind::Beam2)
|
||||
.flat_map(|(_, e)| e.nodes.iter().copied())
|
||||
.collect()
|
||||
});
|
||||
set_of(s).nodes.iter().any(|n| rotational.contains(n))
|
||||
@@ -325,8 +377,8 @@ impl Model {
|
||||
Load::Moment { target, .. } if !has_rotations(target) => {
|
||||
d.error(&subject, format!("{target} has no nodes with rotations; apply the moment to a reference point coupled to it"))
|
||||
}
|
||||
Load::Pressure { set, .. } if set_of(set).faces.is_empty() => {
|
||||
d.error(&subject, format!("set `{set}` has no element faces to press on"))
|
||||
Load::Pressure { set, .. } if set_of(set).faces.is_empty() && resolved.planar_edges(set).is_empty() => {
|
||||
d.error(&subject, format!("set `{set}` has no element faces (or 2D element edges) to press on"))
|
||||
}
|
||||
Load::BodyForce { set, .. } if set_of(set).elements.is_empty() => d.error(&subject, format!("set `{set}` has no elements")),
|
||||
Load::Gravity { acceleration, set } => {
|
||||
@@ -338,8 +390,12 @@ impl Model {
|
||||
d.error(&subject, format!("set `{s}` has no elements"));
|
||||
}
|
||||
}
|
||||
Load::Centrifugal { set: Some(s), .. } if set_of(s).elements.is_empty() => d.error(&subject, format!("set `{s}` has no elements")),
|
||||
_ => {}
|
||||
}
|
||||
if resolved.is_planar() {
|
||||
Self::check_planar_load(load, resolved.extent(), &subject, &mut d);
|
||||
}
|
||||
}
|
||||
|
||||
// Steps and the activity table.
|
||||
@@ -406,11 +462,12 @@ impl Model {
|
||||
Load::Force { target, vector, distribution } | Load::Moment { target, vector, distribution } => (target, *vector, *distribution),
|
||||
Load::Traction { set, vector } => {
|
||||
let faces = r.sets.get(set).map(|s| s.faces.as_slice()).unwrap_or_default();
|
||||
if faces.is_empty() {
|
||||
return Err(format!("set `{set}` has no element faces to load"));
|
||||
let edges = r.planar_edges(set);
|
||||
if faces.is_empty() && edges.is_empty() {
|
||||
return Err(format!("set `{set}` has no element faces (or 2D element edges) to load"));
|
||||
}
|
||||
let w = self.surface_weights(faces).map_err(|e| e.to_string())?;
|
||||
return Ok(Some(w.into_iter().map(|(n, a)| (n, scale(*vector, a))).collect()));
|
||||
let w = if faces.is_empty() { self.edge_weights(&edges, &r.planar) } else { self.surface_weights(faces) };
|
||||
return Ok(Some(w.map_err(|e| e.to_string())?.into_iter().map(|(n, a)| (n, scale(*vector, a))).collect()));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
@@ -429,14 +486,16 @@ impl Model {
|
||||
if set.nodes.is_empty() {
|
||||
return Err(format!("set `{name}` has no nodes"));
|
||||
}
|
||||
let edges = if set.faces.is_empty() { r.planar_edges(name) } else { Vec::new() };
|
||||
Ok(Some(match distribution {
|
||||
Distribution::PerNode => set.nodes.iter().map(|n| (*n, vector)).collect(),
|
||||
Distribution::Total if set.faces.is_empty() => {
|
||||
Distribution::Total if set.faces.is_empty() && edges.is_empty() => {
|
||||
let share = 1.0 / set.nodes.len() as f64;
|
||||
set.nodes.iter().map(|n| (*n, scale(vector, share))).collect()
|
||||
}
|
||||
Distribution::Total => {
|
||||
let w = self.surface_weights(&set.faces).map_err(|e| e.to_string())?;
|
||||
let w = if edges.is_empty() { self.surface_weights(&set.faces) } else { self.edge_weights(&edges, &r.planar) };
|
||||
let w = w.map_err(|e| e.to_string())?;
|
||||
let area: f64 = w.values().sum();
|
||||
if !(area > 0.0 && area.is_finite()) {
|
||||
return Err(format!("the faces of set `{name}` have no area to share the load by"));
|
||||
@@ -446,6 +505,92 @@ impl Model {
|
||||
}))
|
||||
}
|
||||
|
||||
/// The rules of a 2D model: one kind of 2D model (plane or axisymmetric) and nothing else, in
|
||||
/// the XY plane (x ≥ 0 when axisymmetric), elements numbered counter-clockwise.
|
||||
fn check_planar(&self, r: &Resolved, d: &mut Diagnostics) {
|
||||
if !r.is_planar() {
|
||||
return;
|
||||
}
|
||||
let others: Vec<&str> = self.sections.iter().filter(|(_, s)| !s.kind.is_planar()).map(|(n, _)| n.as_str()).collect();
|
||||
if !others.is_empty() {
|
||||
d.error(
|
||||
"model",
|
||||
format!(
|
||||
"2D sections (plane stress, plane strain, axisymmetric) and other sections ({}) cannot be mixed in one model yet",
|
||||
others.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
let axisymmetric = r.extent() == Some(Extent::Revolved);
|
||||
if axisymmetric && r.planar.values().any(|e| *e != Extent::Revolved) {
|
||||
d.error("model", "axisymmetric and plane (stress or strain) sections cannot be mixed in one model");
|
||||
}
|
||||
let (mut off_plane, mut negative_radius, mut clockwise) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for &label in r.planar.keys() {
|
||||
let Some(e) = self.elements.get(&label) else { continue };
|
||||
let xyz: Vec<[f64; 3]> = e.nodes.iter().filter_map(|n| self.nodes.get(n)).map(|n| n.xyz).collect();
|
||||
if xyz.iter().any(|p| p[2] != 0.0) {
|
||||
off_plane.push(label);
|
||||
}
|
||||
if axisymmetric && xyz.iter().any(|p| p[0] < 0.0) {
|
||||
negative_radius.push(label);
|
||||
}
|
||||
// Twice the signed area of the corner polygon (shoelace).
|
||||
let corners = xyz.get(..e.kind.corner_count()).unwrap_or_default();
|
||||
let area2: f64 = corners.iter().zip(corners.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum();
|
||||
if !(area2 > 0.0) {
|
||||
clockwise.push(label);
|
||||
}
|
||||
}
|
||||
if !off_plane.is_empty() {
|
||||
d.error("model", format!("2D elements {} have nodes off the XY plane (z ≠ 0); 2D models lie in the XY plane", first_labels(&off_plane)));
|
||||
}
|
||||
if !negative_radius.is_empty() {
|
||||
d.error(
|
||||
"model",
|
||||
format!(
|
||||
"axisymmetric elements {} have nodes at negative x; x is the radius, so the model lies in x ≥ 0",
|
||||
first_labels(&negative_radius)
|
||||
),
|
||||
);
|
||||
}
|
||||
if !clockwise.is_empty() {
|
||||
d.error(
|
||||
"model",
|
||||
format!(
|
||||
"2D elements {} are clockwise or have no area in the XY plane (2D elements number their nodes counter-clockwise)",
|
||||
first_labels(&clockwise)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A load in a 2D model: in the XY plane, and a rotation about the axis the model can carry.
|
||||
fn check_planar_load(load: &Load, extent: Option<Extent>, subject: &str, d: &mut Diagnostics) {
|
||||
let in_plane = match load {
|
||||
Load::Force { vector, .. } | Load::Traction { vector, .. } | Load::BodyForce { vector, .. } => vector[2] == 0.0,
|
||||
Load::Gravity { acceleration, .. } => acceleration[2] == 0.0,
|
||||
Load::Moment { .. } | Load::Pressure { .. } => true,
|
||||
Load::Centrifugal { point, axis, .. } => {
|
||||
let ok = if extent == Some(Extent::Revolved) {
|
||||
axis[0] == 0.0 && axis[2] == 0.0 && point[0] == 0.0 && point[2] == 0.0
|
||||
} else {
|
||||
axis[0] == 0.0 && axis[1] == 0.0
|
||||
};
|
||||
if !ok {
|
||||
d.error(
|
||||
subject,
|
||||
"in a 2D model a rotation is about the Y axis (axisymmetric: through x = 0, z = 0) or about an axis along Z (plane models)",
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
};
|
||||
if !in_plane {
|
||||
d.error(subject, "a 2D model is loaded in the XY plane; this load has a Z component");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a vector is parallel to the axis of a two-node element (or the element has no
|
||||
/// length).
|
||||
fn parallel_to_axis(&self, nodes: &[u32], v: [f64; 3]) -> bool {
|
||||
|
||||
@@ -53,10 +53,10 @@ pub use check::{Diagnostic, Resolved, ResolvedSet, Severity};
|
||||
pub use constraints::{Coupling, CouplingKind, ReferencePoint};
|
||||
pub use fluids::{BoxFace, BoxFaces, FaceCondition, FluidBoundary, FluidDomain, Profile, SurfaceBoundary, SurfaceCondition};
|
||||
pub use geometry::{Classification, Entity, GeometryRef};
|
||||
pub use loads::{Distribution, Load};
|
||||
pub use loads::{Distribution, Load, centrifugal_acceleration};
|
||||
pub use materials::{Elastic, Fluid, Material};
|
||||
pub use mesh::{Element, ElementFace, ElementKind, Node, check_coordinates};
|
||||
pub use sections::{BeamProfile, Formulation, Integration, Section, SectionKind};
|
||||
pub use mesh::{Element, ElementEdge, ElementFace, ElementKind, Node, check_coordinates};
|
||||
pub use sections::{BeamProfile, Extent, Formulation, Integration, Section, SectionKind};
|
||||
pub use sets::Set;
|
||||
pub use steps::{BulkViscosity, ExplicitDynamic, Hourglass, HourglassKind, InitialVelocity, OutputVariable, Procedure, RigidWall, Step};
|
||||
pub use units::UnitSystem;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! Loads. Which steps a load is active in is listed explicitly by each step ([`crate::steps`]).
|
||||
//!
|
||||
//! In a 2D model (plane stress, plane strain, axisymmetric sections) loads lie in the XY plane:
|
||||
//! surface loads act on the element edges of the set, forces are totals over the thickness (or
|
||||
//! over the full circumference for axisymmetric models), and a vector with a Z component is an
|
||||
//! error.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -55,6 +60,27 @@ pub enum Load {
|
||||
},
|
||||
/// A force per volume on the set's elements.
|
||||
BodyForce { set: String, vector: [f64; 3] },
|
||||
/// The centrifugal load of a steady rotation at `angular_velocity` (radians per time unit)
|
||||
/// about the axis through `point` along `axis`: a force per volume ρ ω² r on the mass of the
|
||||
/// set's elements (or the whole model), r the distance from the axis, pointing away from it.
|
||||
Centrifugal {
|
||||
angular_velocity: f64,
|
||||
point: [f64; 3],
|
||||
axis: [f64; 3],
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
set: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The centrifugal acceleration ω² r at `x` of a rotation at `angular_velocity` about the axis
|
||||
/// through `point` along `axis` (any length, not zero): r is the part of `x − point` normal to
|
||||
/// the axis, so the acceleration points away from the axis.
|
||||
pub fn centrifugal_acceleration(angular_velocity: f64, point: [f64; 3], axis: [f64; 3], x: [f64; 3]) -> [f64; 3] {
|
||||
let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
|
||||
let d = [x[0] - point[0], x[1] - point[1], x[2] - point[2]];
|
||||
let along = if len2 > 0.0 { (d[0] * axis[0] + d[1] * axis[1] + d[2] * axis[2]) / len2 } else { 0.0 };
|
||||
let w2 = angular_velocity * angular_velocity;
|
||||
[0, 1, 2].map(|k| w2 * (d[k] - along * axis[k]))
|
||||
}
|
||||
|
||||
impl Load {
|
||||
@@ -67,6 +93,7 @@ impl Load {
|
||||
Load::Pressure { .. } => "pressure",
|
||||
Load::Gravity { .. } => "gravity",
|
||||
Load::BodyForce { .. } => "body_force",
|
||||
Load::Centrifugal { .. } => "centrifugal",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +102,7 @@ impl Load {
|
||||
match self {
|
||||
Load::Force { target, .. } | Load::Moment { target, .. } => target.set(),
|
||||
Load::Pressure { set, .. } | Load::BodyForce { set, .. } | Load::Traction { set, .. } => Some(set),
|
||||
Load::Gravity { set, .. } => set.as_deref(),
|
||||
Load::Gravity { set, .. } | Load::Centrifugal { set, .. } => set.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +122,21 @@ impl Load {
|
||||
finite("pressure", *magnitude)?;
|
||||
return Ok(());
|
||||
}
|
||||
Load::Centrifugal { angular_velocity, point, axis, .. } => {
|
||||
let w = finite("angular velocity", *angular_velocity)?;
|
||||
if w == 0.0 || !(w * w).is_finite() {
|
||||
return Err(Error::Invalid(format!(
|
||||
"a centrifugal load's angular velocity must be a non-zero number whose square is finite, got {w}"
|
||||
)));
|
||||
}
|
||||
for c in point.iter().chain(axis) {
|
||||
finite("centrifugal axis", *c)?;
|
||||
}
|
||||
if axis.iter().all(|c| *c == 0.0) {
|
||||
return Err(Error::Invalid("a centrifugal load's axis direction must not be zero".into()));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
for c in vector {
|
||||
finite(self.kind(), *c)?;
|
||||
|
||||
@@ -195,6 +195,16 @@ pub struct ElementFace {
|
||||
pub face: u8,
|
||||
}
|
||||
|
||||
/// One edge of an element: the element's label and its edge number (1-based,
|
||||
/// [`ElementKind::edges`]). In a 2D model (plane stress, plane strain, axisymmetric) the edges on
|
||||
/// the boundary are what pressures and tractions act on, as faces are for solids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ElementEdge {
|
||||
pub element: u32,
|
||||
pub edge: u8,
|
||||
}
|
||||
|
||||
/// An element: a topology and its node labels.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Element {
|
||||
|
||||
+103
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{ElementFace, Error, Model, Result};
|
||||
use crate::{ElementEdge, ElementFace, Error, Extent, Model, Result};
|
||||
|
||||
/// Shape functions and their derivatives at one point of a reference face.
|
||||
struct Shape {
|
||||
@@ -137,7 +137,82 @@ pub fn face_normal_weights(xyz: &[[f64; 3]]) -> Result<Vec<[f64; 3]>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Visit the 3-point Gauss–Legendre points of an element edge (its ends, then the mid-side node
|
||||
/// of a quadratic edge): the shape function values, the position and the weighted tangent
|
||||
/// (∂x/∂s) w, whose length is the point's share of the edge's length.
|
||||
fn integrate_edge(xyz: &[[f64; 3]], mut visit: impl FnMut(&[f64], [f64; 3], [f64; 3])) -> Result<()> {
|
||||
if xyz.iter().flatten().any(|c| !c.is_finite()) {
|
||||
return Err(Error::Invalid("edge coordinates must be finite".into()));
|
||||
}
|
||||
if !matches!(xyz.len(), 2 | 3) {
|
||||
return Err(Error::Invalid(format!("an edge has 2 or 3 nodes, not {}", xyz.len())));
|
||||
}
|
||||
let g = (0.6f64).sqrt();
|
||||
for (s, w) in [(-g, 5.0 / 9.0), (0.0, 8.0 / 9.0), (g, 5.0 / 9.0)] {
|
||||
let (n, dn) = if xyz.len() == 2 {
|
||||
([(1.0 - s) / 2.0, (1.0 + s) / 2.0, 0.0], [-0.5, 0.5, 0.0])
|
||||
} else {
|
||||
([s * (s - 1.0) / 2.0, s * (s + 1.0) / 2.0, 1.0 - s * s], [s - 0.5, s + 0.5, -2.0 * s])
|
||||
};
|
||||
let (mut x, mut t) = ([0.0; 3], [0.0; 3]);
|
||||
for ((p, ni), di) in xyz.iter().zip(&n).zip(&dn) {
|
||||
for k in 0..3 {
|
||||
x[k] += ni * p[k];
|
||||
t[k] += di * p[k];
|
||||
}
|
||||
}
|
||||
visit(n.get(..xyz.len()).unwrap_or_default(), x, t.map(|v| v * w));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ∫ Nᵢ n e dL for every node of an element edge in the XY plane (node coordinates in the edge's
|
||||
/// order), with n the normal to the right of the edge's direction (outward for an edge of a
|
||||
/// counter-clockwise 2D element) and e the out-of-plane extent (thickness, or 2πr when
|
||||
/// revolved): the consistent nodal forces of a unit pressure pulling outward.
|
||||
pub fn edge_normal_weights(xyz: &[[f64; 3]], extent: Extent) -> Result<Vec<[f64; 3]>> {
|
||||
let mut out = vec![[0.0; 3]; xyz.len()];
|
||||
integrate_edge(xyz, |n, x, t| {
|
||||
let e = extent.at(x);
|
||||
for (o, ni) in out.iter_mut().zip(n) {
|
||||
o[0] += ni * t[1] * e;
|
||||
o[1] -= ni * t[0] * e;
|
||||
}
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Consistent nodal forces of a uniform pressure on an element edge in the XY plane (positive
|
||||
/// pushes into a counter-clockwise element): `−p` times [`edge_normal_weights`].
|
||||
pub fn edge_pressure(xyz: &[[f64; 3]], extent: Extent, magnitude: f64) -> Result<Vec<[f64; 3]>> {
|
||||
Ok(edge_normal_weights(xyz, extent)?.into_iter().map(|w| w.map(|c| -magnitude * c)).collect())
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// The nodal weights `∫ N_i e dL` of a set of 2D element edges (e the out-of-plane extent of
|
||||
/// each element in `planar`), summed per node. Their sum is the area of the surface the
|
||||
/// edges stand for: length times thickness, or the surface of revolution.
|
||||
pub fn edge_weights(&self, edges: &[ElementEdge], planar: &BTreeMap<u32, Extent>) -> Result<BTreeMap<u32, f64>> {
|
||||
let mut out: BTreeMap<u32, f64> = BTreeMap::new();
|
||||
for &g in edges {
|
||||
let extent = planar.get(&g.element).copied().ok_or_else(|| Error::Invalid(format!("element {} is not a 2D element", g.element)))?;
|
||||
let labels = self.edge_nodes(g).ok_or_else(|| Error::NotFound(format!("element {} edge {}", g.element, g.edge)))?;
|
||||
let xyz: Vec<[f64; 3]> =
|
||||
labels.iter().map(|n| self.node(*n).map(|p| p.xyz).ok_or_else(|| Error::NotFound(format!("node {n}")))).collect::<Result<_>>()?;
|
||||
integrate_edge(&xyz, |n, x, t| {
|
||||
let dl = (t[0] * t[0] + t[1] * t[1] + t[2] * t[2]).sqrt() * extent.at(x);
|
||||
for (label, ni) in labels.iter().zip(n) {
|
||||
*out.entry(*label).or_default() += ni * dl;
|
||||
}
|
||||
})
|
||||
.map_err(|e| match e {
|
||||
Error::Invalid(m) => Error::Invalid(format!("element {} edge {}: {m}", g.element, g.edge)),
|
||||
e => e,
|
||||
})?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The nodal weights `∫ N_i dA` of a set of element faces, summed per node. Their sum is the
|
||||
/// faces' area.
|
||||
pub fn surface_weights(&self, faces: &[ElementFace]) -> Result<BTreeMap<u32, f64>> {
|
||||
@@ -165,6 +240,7 @@ impl Model {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ElementKind;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A flat 2 × 3 face of a hex8 or hex20 (face 1, z = 0), and its weights.
|
||||
fn face_weights(kind: ElementKind) -> Vec<f64> {
|
||||
@@ -211,4 +287,30 @@ mod tests {
|
||||
let tri6: Vec<f64> = m.face_nodes(ElementFace { element: 2, face: 1 }).unwrap().iter().map(|n| w[n]).collect();
|
||||
assert!(close(&tri6, &[0.0, 0.0, 0.0, 1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0]), "{tri6:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_weights_are_the_consistent_nodal_loads() {
|
||||
let close = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-12);
|
||||
// A quad8 2 × 1 in the XY plane: edge 1 (y = 0, x from 0 to 2) of thickness 3 shares
|
||||
// its area 6 as 1/6, 1/6, 2/3; revolved about Y it shares the annulus π(2² − 0) = 4π
|
||||
// as ∫ N 2π x dx.
|
||||
let mut m = Model::default();
|
||||
let p = [[0., 0., 0.], [2., 0., 0.], [2., 1., 0.], [0., 1., 0.], [1., 0., 0.], [2., 0.5, 0.], [1., 1., 0.], [0., 0.5, 0.]];
|
||||
for (i, q) in p.iter().enumerate() {
|
||||
m.set_node(i as u32 + 1, *q).unwrap();
|
||||
}
|
||||
m.set_element(1, ElementKind::Quad8, (1..=8).collect()).unwrap();
|
||||
let edge = [ElementEdge { element: 1, edge: 1 }];
|
||||
let w = m.edge_weights(&edge, &BTreeMap::from([(1, Extent::Thickness(3.0))])).unwrap();
|
||||
assert!(close(&[w[&1], w[&2], w[&5]], &[1.0, 1.0, 4.0]), "{w:?}");
|
||||
let w = m.edge_weights(&edge, &BTreeMap::from([(1, Extent::Revolved)])).unwrap();
|
||||
// ∫₀² N 2π x dx with N on [0, 2]: corner at 0 → 0, corner at 2 → 2π·2/3·... summed: 4π.
|
||||
let pi = std::f64::consts::PI;
|
||||
assert!(close(&[w[&1], w[&2], w[&5]], &[0.0, 4.0 * pi / 3.0, 8.0 * pi / 3.0]), "{w:?}");
|
||||
assert!(m.edge_weights(&edge, &BTreeMap::new()).is_err(), "not a 2D element");
|
||||
// The outward normal of a counter-clockwise element's bottom edge is −Y.
|
||||
let n = edge_normal_weights(&[p[0], p[1], p[4]], Extent::Thickness(1.0)).unwrap();
|
||||
assert!(close(&n.iter().map(|v| v[1]).collect::<Vec<_>>(), &[-1.0 / 3.0, -1.0 / 3.0, -4.0 / 3.0]) && n.iter().all(|v| v[0] == 0.0), "{n:?}");
|
||||
assert!(edge_normal_weights(&[p[0]], Extent::Revolved).is_err());
|
||||
}
|
||||
}
|
||||
@@ -120,29 +120,90 @@ pub enum SectionKind {
|
||||
Beam { profile: BeamProfile, orientation: [f64; 3] },
|
||||
/// Axial members (rods, trusses) with a cross-sectional area.
|
||||
Rod { area: f64 },
|
||||
/// 2D continuum in plane stress (σzz = 0: thin plates loaded in their plane), of the given
|
||||
/// thickness. The elements lie in the XY plane.
|
||||
PlaneStress { thickness: f64 },
|
||||
/// 2D continuum in plane strain (εzz = 0: a slice of a long body), of the given thickness
|
||||
/// (1 by default, so results are per unit length). The elements lie in the XY plane.
|
||||
PlaneStrain {
|
||||
#[serde(default = "unit_thickness")]
|
||||
thickness: f64,
|
||||
},
|
||||
/// Axisymmetric continuum: the elements lie in the XY half plane x ≥ 0, which is the r–z
|
||||
/// plane of a body of revolution about the Y axis (x = radius, y = axial position; the hoop
|
||||
/// direction is the third). Forces, reactions and masses are totals over the full
|
||||
/// circumference (2π radians).
|
||||
Axisymmetric {},
|
||||
}
|
||||
|
||||
fn default_shell_points() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
fn unit_thickness() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// How a 2D continuum section extends out of its plane: what turns a length along an element
|
||||
/// edge into an area, and an element's area into a volume.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Extent {
|
||||
/// Plane stress and plane strain: a uniform thickness.
|
||||
Thickness(f64),
|
||||
/// Axisymmetric: the full circumference 2πr at radius r = x.
|
||||
Revolved,
|
||||
}
|
||||
|
||||
impl Extent {
|
||||
/// The out-of-plane measure at a point: the thickness, or 2π x.
|
||||
pub fn at(&self, xyz: [f64; 3]) -> f64 {
|
||||
match *self {
|
||||
Extent::Thickness(t) => t,
|
||||
Extent::Revolved => 2.0 * std::f64::consts::PI * xyz[0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionKind {
|
||||
/// The element dimension this section applies to.
|
||||
pub fn dimension(&self) -> u8 {
|
||||
match self {
|
||||
SectionKind::Solid {} => 3,
|
||||
SectionKind::Shell { .. } => 2,
|
||||
SectionKind::Shell { .. } | SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => 2,
|
||||
SectionKind::Beam { .. } | SectionKind::Rod { .. } => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// `solid`, `shell`, `beam` or `rod`.
|
||||
/// Whether this is a continuum section (solid or 2D), the kinds with incompatible-mode
|
||||
/// formulations.
|
||||
pub fn is_continuum(&self) -> bool {
|
||||
self.dimension() == 3 || self.is_planar()
|
||||
}
|
||||
|
||||
/// Whether this is a 2D continuum section (plane stress, plane strain or axisymmetric).
|
||||
pub fn is_planar(&self) -> bool {
|
||||
self.extent().is_some()
|
||||
}
|
||||
|
||||
/// How a 2D continuum section extends out of its plane (`None` for other kinds).
|
||||
pub fn extent(&self) -> Option<Extent> {
|
||||
match *self {
|
||||
SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => Some(Extent::Thickness(thickness)),
|
||||
SectionKind::Axisymmetric {} => Some(Extent::Revolved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `solid`, `shell`, `beam`, `rod`, `plane_stress`, `plane_strain` or `axisymmetric`.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
SectionKind::Solid {} => "solid",
|
||||
SectionKind::Shell { .. } => "shell",
|
||||
SectionKind::Beam { .. } => "beam",
|
||||
SectionKind::Rod { .. } => "rod",
|
||||
SectionKind::PlaneStress { .. } => "plane_stress",
|
||||
SectionKind::PlaneStrain { .. } => "plane_strain",
|
||||
SectionKind::Axisymmetric {} => "axisymmetric",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +233,10 @@ impl SectionKind {
|
||||
SectionKind::Rod { area } => {
|
||||
positive("area", *area)?;
|
||||
}
|
||||
SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => {
|
||||
positive("thickness", *thickness)?;
|
||||
}
|
||||
SectionKind::Axisymmetric {} => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -194,7 +259,8 @@ pub enum Integration {
|
||||
pub struct Formulation {
|
||||
#[serde(default)]
|
||||
pub integration: Integration,
|
||||
/// Enhanced (incompatible) bending modes: linear hexahedra only.
|
||||
/// Enhanced (incompatible) bending modes: linear hexahedra (solid sections) and linear
|
||||
/// quadrilaterals (2D sections) only.
|
||||
#[serde(default)]
|
||||
pub incompatible_modes: bool,
|
||||
/// Mixed displacement–pressure formulation for nearly incompressible materials: solids only.
|
||||
@@ -209,8 +275,11 @@ impl Formulation {
|
||||
if self.incompatible_modes && self.integration == Integration::Reduced {
|
||||
return Err(Error::Invalid("incompatible modes use full integration".into()));
|
||||
}
|
||||
if kind.dimension() != 3 && (self.incompatible_modes || self.hybrid) {
|
||||
return Err(Error::Invalid(format!("incompatible modes and hybrid formulations are for solid sections, not {}", kind.name())));
|
||||
if !kind.is_continuum() && self.incompatible_modes {
|
||||
return Err(Error::Invalid(format!("incompatible modes are for solid and 2D sections, not {}", kind.name())));
|
||||
}
|
||||
if kind.dimension() != 3 && self.hybrid {
|
||||
return Err(Error::Invalid(format!("hybrid formulations are for solid sections, not {}", kind.name())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -303,4 +372,26 @@ mod tests {
|
||||
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "solid", "x": 1})).is_err());
|
||||
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "membrane"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_sections() {
|
||||
let strain: SectionKind = serde_json::from_value(serde_json::json!({"type": "plane_strain"})).unwrap();
|
||||
assert_eq!(strain, SectionKind::PlaneStrain { thickness: 1.0 });
|
||||
let stress: SectionKind = serde_json::from_value(serde_json::json!({"type": "plane_stress", "thickness": 2.5})).unwrap();
|
||||
assert_eq!(stress.extent(), Some(Extent::Thickness(2.5)));
|
||||
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "plane_stress"})).is_err(), "plane stress needs a thickness");
|
||||
let axi: SectionKind = serde_json::from_value(serde_json::json!({"type": "axisymmetric"})).unwrap();
|
||||
assert_eq!(axi.extent(), Some(Extent::Revolved));
|
||||
assert!((Extent::Revolved.at([2.0, 7.0, 0.0]) - 4.0 * std::f64::consts::PI).abs() < 1e-15);
|
||||
assert!(axi.is_planar() && stress.is_planar() && !SectionKind::Solid {}.is_planar());
|
||||
assert!(!SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }.is_planar());
|
||||
for bad in [SectionKind::PlaneStress { thickness: 0.0 }, SectionKind::PlaneStrain { thickness: f64::NAN }] {
|
||||
assert!(bad.validate().is_err(), "{bad:?}");
|
||||
}
|
||||
let im = Formulation { incompatible_modes: true, ..Formulation::default() };
|
||||
im.validate_for(&stress).unwrap();
|
||||
im.validate_for(&axi).unwrap();
|
||||
assert!(Formulation { hybrid: true, ..Formulation::default() }.validate_for(&strain).is_err());
|
||||
assert!(im.validate_for(&SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }).is_err());
|
||||
}
|
||||
}
|
||||
+39
-10
@@ -10,10 +10,11 @@
|
||||
//! What each member contributes:
|
||||
//! - a body: the elements filling it, their nodes, and the element faces on the body's faces;
|
||||
//! - a face: the element faces on it, the shell elements filling it, and their nodes;
|
||||
//! - an edge: the nodes of element edges on it, the line elements filling it, and their nodes;
|
||||
//! - an edge: the element edges on it (what 2D models load), their nodes, the line elements
|
||||
//! filling it, and their nodes;
|
||||
//! - a vertex: the node on it;
|
||||
//! - node labels: those nodes; element labels: those elements and their nodes; element faces:
|
||||
//! those faces and their nodes.
|
||||
//! those faces and their nodes; element edges: those edges and their nodes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -22,7 +23,8 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::check::ResolvedSet;
|
||||
use crate::mesh::check_label;
|
||||
use crate::{
|
||||
ElementFace, Error, GeometryRef, MAX_ELEMENTS, MAX_NODES, Model, Result, context, insert_new, not_in_use, remove_existing, replace_existing,
|
||||
ElementEdge, ElementFace, Error, GeometryRef, MAX_ELEMENTS, MAX_NODES, Model, Result, context, insert_new, not_in_use, remove_existing,
|
||||
replace_existing,
|
||||
};
|
||||
|
||||
/// Most geometry references in one set.
|
||||
@@ -40,6 +42,8 @@ pub struct Set {
|
||||
pub elements: Vec<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub faces: Vec<ElementFace>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edges: Vec<ElementEdge>,
|
||||
}
|
||||
|
||||
impl Set {
|
||||
@@ -49,35 +53,40 @@ impl Set {
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.geometry.is_empty() && self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty()
|
||||
self.geometry.is_empty() && self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty() && self.edges.is_empty()
|
||||
}
|
||||
|
||||
/// Check the definition's size and labels, and sort its members.
|
||||
pub fn normalized(mut self) -> Result<Set> {
|
||||
if self.is_empty() {
|
||||
return Err(Error::Invalid("a set needs geometry, nodes, elements or element faces".into()));
|
||||
return Err(Error::Invalid("a set needs geometry, nodes, elements, element faces or element edges".into()));
|
||||
}
|
||||
if self.geometry.len() > MAX_SET_REFERENCES {
|
||||
return Err(Error::Limit(format!("a set names at most {MAX_SET_REFERENCES} geometry entities")));
|
||||
}
|
||||
if self.nodes.len() > MAX_NODES || self.elements.len() > MAX_ELEMENTS || self.faces.len() > MAX_ELEMENTS {
|
||||
if self.nodes.len() > MAX_NODES || self.elements.len() > MAX_ELEMENTS || self.faces.len() > MAX_ELEMENTS || self.edges.len() > MAX_ELEMENTS {
|
||||
return Err(Error::Limit("a set lists at most as many members as a model holds".into()));
|
||||
}
|
||||
for &l in &self.nodes {
|
||||
check_label("node", l)?;
|
||||
}
|
||||
for &l in self.elements.iter().chain(self.faces.iter().map(|f| &f.element)) {
|
||||
for &l in self.elements.iter().chain(self.faces.iter().map(|f| &f.element)).chain(self.edges.iter().map(|e| &e.element)) {
|
||||
check_label("element", l)?;
|
||||
}
|
||||
if let Some(f) = self.faces.iter().find(|f| f.face == 0) {
|
||||
return Err(Error::Invalid(format!("element {} face 0: faces are numbered from 1", f.element)));
|
||||
}
|
||||
if let Some(e) = self.edges.iter().find(|e| e.edge == 0) {
|
||||
return Err(Error::Invalid(format!("element {} edge 0: edges are numbered from 1", e.element)));
|
||||
}
|
||||
for v in [&mut self.nodes, &mut self.elements] {
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
}
|
||||
self.faces.sort_unstable();
|
||||
self.faces.dedup();
|
||||
self.edges.sort_unstable();
|
||||
self.edges.dedup();
|
||||
self.geometry.sort();
|
||||
self.geometry.dedup();
|
||||
Ok(self)
|
||||
@@ -134,6 +143,7 @@ impl Model {
|
||||
let mut nodes = Vec::new();
|
||||
let mut elements = Vec::new();
|
||||
let mut faces = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
|
||||
// Geometry: mark the selected tags by role, then make one pass over the tagged elements.
|
||||
@@ -192,8 +202,7 @@ impl Model {
|
||||
}
|
||||
for (&edge, &t) in &tags.edges {
|
||||
if on(&edge_tags, t) {
|
||||
let local = usize::from(edge).checked_sub(1).and_then(|i| element.kind.edges().get(i)).copied().unwrap_or_default();
|
||||
nodes.extend(local.iter().filter_map(|&i| element.nodes.get(i)));
|
||||
edges.push(ElementEdge { element: label, edge });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,16 +233,28 @@ impl Model {
|
||||
None => missing.push(format!("element {} is not in the mesh", f.element)),
|
||||
}
|
||||
}
|
||||
for &g in &set.edges {
|
||||
match self.elements.get(&g.element) {
|
||||
Some(e) if usize::from(g.edge) <= e.kind.edges().len() => edges.push(g),
|
||||
Some(e) => missing.push(format!("element {} ({}) has no edge {}", g.element, e.kind.name(), g.edge)),
|
||||
None => missing.push(format!("element {} is not in the mesh", g.element)),
|
||||
}
|
||||
}
|
||||
elements.sort_unstable();
|
||||
elements.dedup();
|
||||
faces.sort_unstable();
|
||||
faces.dedup();
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
for f in &faces {
|
||||
if let Some(e) = self.elements.get(&f.element) {
|
||||
let local = usize::from(f.face).checked_sub(1).and_then(|i| e.kind.faces().get(i)).copied().unwrap_or_default();
|
||||
nodes.extend(local.iter().filter_map(|&i| e.nodes.get(i)));
|
||||
}
|
||||
}
|
||||
for g in &edges {
|
||||
nodes.extend(self.edge_nodes(*g).unwrap_or_default());
|
||||
}
|
||||
nodes.sort_unstable();
|
||||
nodes.dedup();
|
||||
// Long lists of missing labels are summarised.
|
||||
@@ -242,7 +263,15 @@ impl Model {
|
||||
missing.truncate(20);
|
||||
missing.push(format!("and {more} more"));
|
||||
}
|
||||
(ResolvedSet { nodes, elements, faces }, missing)
|
||||
(ResolvedSet { nodes, elements, faces, edges }, missing)
|
||||
}
|
||||
|
||||
/// The node labels of an element edge, in the edge's order ([`crate::ElementKind::edges`]:
|
||||
/// its ends, then the mid-side node of a quadratic element).
|
||||
pub fn edge_nodes(&self, edge: ElementEdge) -> Option<Vec<u32>> {
|
||||
let e = self.elements.get(&edge.element)?;
|
||||
let local = e.kind.edges().get(usize::from(edge.edge).checked_sub(1)?)?;
|
||||
local.iter().map(|&i| e.nodes.get(i).copied()).collect()
|
||||
}
|
||||
|
||||
/// The node labels of an element face, in the face's order ([`crate::ElementKind::faces`]).
|
||||
|
||||
Reference in new issue
Block a user