Files
OneCAD-Simulation/crates/model/src/mesh.rs
T
f4a0333d28 Nastran bulk data writer (SOL 101/103) and deck.write steps (M3) (#24)
* Nastran bulk data writer: fixed-format entries, exact large-field reals, SOL 101/103 decks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Nastran writer tests: golden decks, a re-reader of our output, node order against Nastran's conventions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: Nastran writer in the roadmap, parity table and deck notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Nastran writer: simplify planning (subcases, one property table), stream cards into the deck, write through the VFS

ElementKind::corner_count/is_quadratic and Dofs::bits/from_bits join the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Nastran joins the shared deck formats; deck.write chooses steps for every format

nastran::write_resolved is the FORMATS row; Format::write_steps and with_steps write a subset of
steps (a Nastran run is static or frequency steps); problems go through the shared unsupported().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
2026-10-13 17:34:26 -04:00

421 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Nodes and elements.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{Error, MAX_COORD, MAX_ELEMENTS, MAX_LABEL, MAX_NODES, Model, Result};
/// A node: a point in model space.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub xyz: [f64; 3],
}
/// Element topology. Node order is corners first, then mid-side nodes, with positive volume for
/// a right-handed ordering:
/// - tet10: mid-side nodes 5–10 on edges 1-2, 2-3, 3-1, 1-4, 2-4, 3-4;
/// - wedge6: triangle 1-2-3, then 4-5-6 on the side its right-hand normal points to;
/// - hex8/hex20: face 1-2-3-4, then 5-6-7-8 above it; hex20's mid-side nodes 9–12 lie on the
/// edges of 1-2-3-4, 13–16 on those of 5-6-7-8, and 17–20 on edges 1-5, 2-6, 3-7, 4-8.
///
/// This is the Abaqus (and VTK) order. Formats that order nodes differently, such as Nastran's
/// CHEXA with 20 nodes (vertical edges before the top face's edges), are reordered by their deck
/// readers and writers; the model never stores another order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ElementKind {
/// Two-node axial member.
Rod2,
/// Two-node beam.
Beam2,
/// Three-node triangle (shell or membrane).
Tri3,
/// Six-node triangle.
Tri6,
/// Four-node quadrilateral.
Quad4,
/// Eight-node quadrilateral.
Quad8,
/// Four-node tetrahedron.
Tet4,
/// Ten-node tetrahedron.
Tet10,
/// Six-node wedge (pentahedron).
Wedge6,
/// Eight-node hexahedron.
Hex8,
/// Twenty-node hexahedron.
Hex20,
}
impl ElementKind {
pub const ALL: [ElementKind; 11] = [
ElementKind::Rod2,
ElementKind::Beam2,
ElementKind::Tri3,
ElementKind::Tri6,
ElementKind::Quad4,
ElementKind::Quad8,
ElementKind::Tet4,
ElementKind::Tet10,
ElementKind::Wedge6,
ElementKind::Hex8,
ElementKind::Hex20,
];
pub fn node_count(self) -> usize {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => 2,
ElementKind::Tri3 => 3,
ElementKind::Quad4 | ElementKind::Tet4 => 4,
ElementKind::Tri6 | ElementKind::Wedge6 => 6,
ElementKind::Quad8 | ElementKind::Hex8 => 8,
ElementKind::Tet10 => 10,
ElementKind::Hex20 => 20,
}
}
/// Corner nodes: the first ones in the node list (the rest are mid-side nodes).
pub fn corner_count(self) -> usize {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => 2,
ElementKind::Tri3 | ElementKind::Tri6 => 3,
ElementKind::Quad4 | ElementKind::Quad8 | ElementKind::Tet4 | ElementKind::Tet10 => 4,
ElementKind::Wedge6 => 6,
ElementKind::Hex8 | ElementKind::Hex20 => 8,
}
}
/// Whether the element has mid-side nodes.
pub fn is_quadratic(self) -> bool {
self.node_count() > self.corner_count()
}
/// Topological dimension: 1 for line elements, 2 for shells, 3 for solids.
pub fn dimension(self) -> u8 {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => 1,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => 2,
ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 | ElementKind::Hex20 => 3,
}
}
/// The name used in commands and `.modelcraft` files (`hex8`, `tet10`, …).
pub fn name(self) -> &'static str {
match self {
ElementKind::Rod2 => "rod2",
ElementKind::Beam2 => "beam2",
ElementKind::Tri3 => "tri3",
ElementKind::Tri6 => "tri6",
ElementKind::Quad4 => "quad4",
ElementKind::Quad8 => "quad8",
ElementKind::Tet4 => "tet4",
ElementKind::Tet10 => "tet10",
ElementKind::Wedge6 => "wedge6",
ElementKind::Hex8 => "hex8",
ElementKind::Hex20 => "hex20",
}
}
pub fn from_name(name: &str) -> Option<ElementKind> {
ElementKind::ALL.into_iter().find(|k| k.name() == name)
}
/// The faces, numbered from 1 in this order, as positions in the element's node list: the
/// corners, then (quadratic kinds) the mid-side nodes in the same cyclic order.
///
/// - Solids: each face's corners run counter-clockwise seen from outside, so the right-hand
/// normal points out of the element. Hexahedra: 1 is the face of nodes 1-2-3-4, 2 the face
/// of 5-6-7-8, then the four sides along edges 1-2, 2-3, 3-4 and 4-1. Tetrahedra: 1 is
/// 1-2-3, then the faces opposite node 3, node 1 and node 2. Wedges: the two triangles, then
/// the sides along edges 1-2, 2-3 and 3-1.
/// - Shells: face 1 is the side the element's right-hand normal points to, face 2 the other.
/// - Line elements have none.
pub fn faces(self) -> &'static [&'static [usize]] {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => &[],
ElementKind::Tri3 => &[&[0, 1, 2], &[0, 2, 1]],
ElementKind::Tri6 => &[&[0, 1, 2, 3, 4, 5], &[0, 2, 1, 5, 4, 3]],
ElementKind::Quad4 => &[&[0, 1, 2, 3], &[0, 3, 2, 1]],
ElementKind::Quad8 => &[&[0, 1, 2, 3, 4, 5, 6, 7], &[0, 3, 2, 1, 7, 6, 5, 4]],
ElementKind::Tet4 => &[&[0, 2, 1], &[0, 1, 3], &[1, 2, 3], &[0, 3, 2]],
ElementKind::Tet10 => &[&[0, 2, 1, 6, 5, 4], &[0, 1, 3, 4, 8, 7], &[1, 2, 3, 5, 9, 8], &[0, 3, 2, 7, 9, 6]],
ElementKind::Wedge6 => &[&[0, 2, 1], &[3, 4, 5], &[0, 1, 4, 3], &[1, 2, 5, 4], &[2, 0, 3, 5]],
ElementKind::Hex8 => &[&[0, 3, 2, 1], &[4, 5, 6, 7], &[0, 1, 5, 4], &[1, 2, 6, 5], &[2, 3, 7, 6], &[3, 0, 4, 7]],
ElementKind::Hex20 => &[
&[0, 3, 2, 1, 11, 10, 9, 8],
&[4, 5, 6, 7, 12, 13, 14, 15],
&[0, 1, 5, 4, 8, 17, 12, 16],
&[1, 2, 6, 5, 9, 18, 13, 17],
&[2, 3, 7, 6, 10, 19, 14, 18],
&[3, 0, 4, 7, 11, 16, 15, 19],
],
}
}
/// The edges, numbered from 1 in this order, as positions in the element's node list: the two
/// corners, then the mid-side node for quadratic kinds. The order follows the mid-side nodes
/// (see [`ElementKind`]); linear kinds use the same order.
pub fn edges(self) -> &'static [&'static [usize]] {
match self {
ElementKind::Rod2 | ElementKind::Beam2 => &[&[0, 1]],
ElementKind::Tri3 => &[&[0, 1], &[1, 2], &[2, 0]],
ElementKind::Tri6 => &[&[0, 1, 3], &[1, 2, 4], &[2, 0, 5]],
ElementKind::Quad4 => &[&[0, 1], &[1, 2], &[2, 3], &[3, 0]],
ElementKind::Quad8 => &[&[0, 1, 4], &[1, 2, 5], &[2, 3, 6], &[3, 0, 7]],
ElementKind::Tet4 => &[&[0, 1], &[1, 2], &[2, 0], &[0, 3], &[1, 3], &[2, 3]],
ElementKind::Tet10 => &[&[0, 1, 4], &[1, 2, 5], &[2, 0, 6], &[0, 3, 7], &[1, 3, 8], &[2, 3, 9]],
ElementKind::Wedge6 => &[&[0, 1], &[1, 2], &[2, 0], &[3, 4], &[4, 5], &[5, 3], &[0, 3], &[1, 4], &[2, 5]],
ElementKind::Hex8 => &[&[0, 1], &[1, 2], &[2, 3], &[3, 0], &[4, 5], &[5, 6], &[6, 7], &[7, 4], &[0, 4], &[1, 5], &[2, 6], &[3, 7]],
ElementKind::Hex20 => &[
&[0, 1, 8],
&[1, 2, 9],
&[2, 3, 10],
&[3, 0, 11],
&[4, 5, 12],
&[5, 6, 13],
&[6, 7, 14],
&[7, 4, 15],
&[0, 4, 16],
&[1, 5, 17],
&[2, 6, 18],
&[3, 7, 19],
],
}
}
}
/// One face of an element, a member of a surface: the element's label and its face number
/// (1-based, [`ElementKind::faces`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ElementFace {
pub element: u32,
pub face: u8,
}
/// An element: a topology and its node labels.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Element {
pub kind: ElementKind,
pub nodes: Vec<u32>,
}
/// Check that every coordinate is a finite number within ±[`MAX_COORD`] (the rule for nodes,
/// shared by everything that takes coordinates from outside).
pub fn check_coordinates(xyz: &[f64]) -> Result<()> {
match xyz.iter().find(|c| !(c.abs() <= MAX_COORD)) {
Some(c) => Err(Error::Invalid(format!("coordinate {c} is not a finite number within ±{MAX_COORD}"))),
None => Ok(()),
}
}
pub(crate) fn check_label(what: &str, label: u32) -> Result<()> {
if label == 0 || label > MAX_LABEL {
return Err(Error::Invalid(format!("{what} label {label} is outside 1..={MAX_LABEL}")));
}
Ok(())
}
impl Model {
pub fn nodes(&self) -> &BTreeMap<u32, Node> {
&self.nodes
}
pub fn elements(&self) -> &BTreeMap<u32, Element> {
&self.elements
}
pub fn node(&self, label: u32) -> Option<&Node> {
self.nodes.get(&label)
}
pub fn element(&self, label: u32) -> Option<&Element> {
self.elements.get(&label)
}
/// Add a node, or move an existing one (decks redefine nodes the same way).
pub fn set_node(&mut self, label: u32, xyz: [f64; 3]) -> Result<()> {
check_label("node", label)?;
check_coordinates(&xyz).map_err(|e| match e {
Error::Invalid(m) => Error::Invalid(format!("node {label}: {m}")),
e => e,
})?;
if !self.nodes.contains_key(&label) && self.nodes.len() >= MAX_NODES {
return Err(Error::Limit(format!("a model holds at most {MAX_NODES} nodes")));
}
self.nodes.insert(label, Node { xyz });
Ok(())
}
/// Add an element, or replace an existing one. Its nodes must exist and be distinct.
pub fn set_element(&mut self, label: u32, kind: ElementKind, nodes: Vec<u32>) -> Result<()> {
check_label("element", label)?;
if nodes.len() != kind.node_count() {
return Err(Error::Invalid(format!("element {label}: {} needs {} nodes, got {}", kind.name(), kind.node_count(), nodes.len())));
}
for (i, n) in nodes.iter().enumerate() {
if !self.nodes.contains_key(n) {
return Err(Error::NotFound(format!("element {label}: node {n}")));
}
if nodes.get(..i).is_some_and(|before| before.contains(n)) {
return Err(Error::Invalid(format!("element {label}: node {n} appears twice")));
}
}
if !self.elements.contains_key(&label) && self.elements.len() >= MAX_ELEMENTS {
return Err(Error::Limit(format!("a model holds at most {MAX_ELEMENTS} elements")));
}
// A replaced element's face and edge numbers no longer mean what its tags recorded.
self.classification.forget_element(label);
self.elements.insert(label, Element { kind, nodes });
Ok(())
}
/// Remove a node that no element uses.
pub fn remove_node(&mut self, label: u32) -> Result<()> {
if let Some((e, _)) = self.elements.iter().find(|(_, e)| e.nodes.contains(&label)) {
return Err(Error::Invalid(format!("node {label} is used by element {e}")));
}
self.nodes.remove(&label).ok_or_else(|| Error::NotFound(format!("node {label}")))?;
self.classification.forget_node(label);
Ok(())
}
pub fn remove_element(&mut self, label: u32) -> Result<()> {
self.elements.remove(&label).ok_or_else(|| Error::NotFound(format!("element {label}")))?;
self.classification.forget_element(label);
Ok(())
}
/// Element counts by kind, in [`ElementKind::ALL`] order, kinds with none left out.
pub fn element_counts(&self) -> Vec<(ElementKind, usize)> {
ElementKind::ALL.into_iter().map(|k| (k, self.elements.values().filter(|e| e.kind == k).count())).filter(|(_, n)| *n > 0).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cube() -> Model {
let mut m = Model::default();
let corners = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
for (i, c) in corners.into_iter().enumerate() {
m.set_node(i as u32 + 1, c).unwrap();
}
m.set_element(1, ElementKind::Hex8, (1..=8).collect()).unwrap();
m
}
#[test]
fn builds_a_hex() {
let m = cube();
assert_eq!(m.nodes().len(), 8);
assert_eq!(m.element_counts(), vec![(ElementKind::Hex8, 1)]);
}
#[test]
fn rejects_bad_labels_and_coordinates() {
let mut m = Model::default();
assert!(m.set_node(0, [0.; 3]).is_err());
assert!(m.set_node(MAX_LABEL + 1, [0.; 3]).is_err());
assert!(m.set_node(1, [f64::NAN, 0., 0.]).is_err());
assert!(m.set_node(1, [f64::INFINITY, 0., 0.]).is_err());
assert!(m.set_node(1, [2e9, 0., 0.]).is_err());
assert!(m.nodes().is_empty());
}
#[test]
fn rejects_bad_connectivity() {
let mut m = cube();
assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3]), Err(Error::Invalid(_))));
assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3, 99]), Err(Error::NotFound(_))));
assert!(matches!(m.set_element(2, ElementKind::Tet4, vec![1, 2, 3, 3]), Err(Error::Invalid(_))));
assert_eq!(m.elements().len(), 1);
}
#[test]
fn used_nodes_cannot_be_removed() {
let mut m = cube();
assert!(m.remove_node(1).is_err());
m.remove_element(1).unwrap();
m.remove_node(1).unwrap();
assert!(m.remove_node(1).is_err());
}
/// Reference coordinates with positive volume for each kind (mid-side nodes at edge midpoints).
fn reference(kind: ElementKind) -> Vec<[f64; 3]> {
let corners: Vec<[f64; 3]> = match kind {
ElementKind::Rod2 | ElementKind::Beam2 => vec![[0., 0., 0.], [1., 0., 0.]],
ElementKind::Tri3 | ElementKind::Tri6 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
ElementKind::Quad4 | ElementKind::Quad8 => vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]],
ElementKind::Tet4 | ElementKind::Tet10 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]],
ElementKind::Wedge6 => vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]],
ElementKind::Hex8 | ElementKind::Hex20 => {
vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]]
}
};
let mut xyz = corners.clone();
xyz.resize(kind.node_count(), [f64::NAN; 3]);
for e in kind.edges() {
if let [a, b, m] = **e {
xyz[m] = [0, 1, 2].map(|i| (corners[a][i] + corners[b][i]) / 2.0);
}
}
xyz
}
#[test]
fn edges_cover_every_node_and_faces_point_out() {
for kind in ElementKind::ALL {
let xyz = reference(kind);
assert!(xyz.iter().flatten().all(|c| c.is_finite()), "{kind:?}: every mid-side node is on an edge");
let centroid = [0, 1, 2].map(|i| xyz.iter().map(|p| p[i]).sum::<f64>() / xyz.len() as f64);
for (f, face) in kind.faces().iter().enumerate() {
let n = if face.len() > 4 { face.len() / 2 } else { face.len() };
let c: Vec<[f64; 3]> = face[..n].iter().map(|&i| xyz[i]).collect();
// Newell normal of the corner polygon, against the direction out of the centroid.
let mut normal = [0.0; 3];
for k in 0..n {
let (p, q) = (c[k], c[(k + 1) % n]);
normal = [normal[0] + p[1] * q[2] - p[2] * q[1], normal[1] + p[2] * q[0] - p[0] * q[2], normal[2] + p[0] * q[1] - p[1] * q[0]];
}
let out = [0, 1, 2].map(|i| c[0][i] - centroid[i]);
if kind.dimension() == 3 {
assert!(normal[0] * out[0] + normal[1] * out[1] + normal[2] * out[2] > 0.0, "{kind:?} face {} points in", f + 1);
}
// A quadratic face's mid-side nodes sit on its own corner edges, in cyclic order.
for (k, &m) in face[n..].iter().enumerate() {
let (a, b) = (face[k], face[(k + 1) % n]);
assert_eq!(xyz[m], [0, 1, 2].map(|i| (xyz[a][i] + xyz[b][i]) / 2.0), "{kind:?} face {} mid-side {}", f + 1, k + 1);
}
}
if kind.dimension() == 2 {
let [p, q] = kind.faces() else { panic!("{kind:?}: two sides") };
assert_eq!(p.len(), kind.node_count());
let mut sorted = q.to_vec();
sorted.sort_unstable();
assert_eq!(sorted, (0..kind.node_count()).collect::<Vec<_>>());
}
}
assert_eq!(ElementKind::Hex8.faces().len(), 6);
assert_eq!(ElementKind::Tet4.faces().len(), 4);
assert_eq!(ElementKind::Wedge6.faces().len(), 5);
assert_eq!(ElementKind::Hex20.edges().len(), 12);
for kind in ElementKind::ALL {
let mid_sides = kind.edges().iter().filter(|e| e.len() == 3).count();
assert_eq!(kind.corner_count() + mid_sides, kind.node_count(), "{kind:?}");
}
}
#[test]
fn kind_names_round_trip() {
for k in ElementKind::ALL {
assert_eq!(ElementKind::from_name(k.name()), Some(k));
assert_eq!(serde_json::to_value(k).unwrap(), serde_json::json!(k.name()));
}
assert_eq!(ElementKind::from_name("C3D8"), None);
}
}