Initial commit
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
//! LS-DYNA keyword definitions and parsing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// LS-DYNA keyword types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Keyword {
|
||||
/// Main keyword header
|
||||
Keyword,
|
||||
/// Title card
|
||||
Title,
|
||||
/// Node definitions
|
||||
Node,
|
||||
/// Solid element definitions
|
||||
ElementSolid,
|
||||
/// Shell element definitions
|
||||
ElementShell,
|
||||
/// Part definitions
|
||||
Part,
|
||||
/// Solid section properties
|
||||
SectionSolid,
|
||||
/// Shell section properties
|
||||
SectionShell,
|
||||
/// Linear elastic material
|
||||
MatElastic,
|
||||
/// Kelvin-Maxwell viscoelastic material
|
||||
MatKelvinMaxwellViscoelastic,
|
||||
/// Node set definitions
|
||||
SetNode,
|
||||
/// Element set definitions
|
||||
SetElement,
|
||||
/// Control termination
|
||||
ControlTermination,
|
||||
/// Database binary output
|
||||
DatabaseBinaryD3plot,
|
||||
/// End of file marker
|
||||
End,
|
||||
/// Unknown/unsupported keyword
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl Keyword {
|
||||
/// Parse a keyword from a string.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
let s = s.trim().to_uppercase();
|
||||
match s.as_str() {
|
||||
"*KEYWORD" => Keyword::Keyword,
|
||||
"*TITLE" => Keyword::Title,
|
||||
"*NODE" => Keyword::Node,
|
||||
"*ELEMENT_SOLID" => Keyword::ElementSolid,
|
||||
"*ELEMENT_SHELL" => Keyword::ElementShell,
|
||||
"*PART" => Keyword::Part,
|
||||
"*SECTION_SOLID" => Keyword::SectionSolid,
|
||||
"*SECTION_SHELL" => Keyword::SectionShell,
|
||||
"*MAT_ELASTIC" | "*MAT_001" => Keyword::MatElastic,
|
||||
"*MAT_KELVIN-MAXWELL_VISCOELASTIC" | "*MAT_076" => {
|
||||
Keyword::MatKelvinMaxwellViscoelastic
|
||||
}
|
||||
"*SET_NODE" | "*SET_NODE_LIST" => Keyword::SetNode,
|
||||
"*SET_ELEMENT" | "*SET_ELEMENT_LIST" => Keyword::SetElement,
|
||||
"*CONTROL_TERMINATION" => Keyword::ControlTermination,
|
||||
"*DATABASE_BINARY_D3PLOT" => Keyword::DatabaseBinaryD3plot,
|
||||
"*END" => Keyword::End,
|
||||
_ => Keyword::Unknown(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the keyword string for output.
|
||||
pub fn to_keyword_str(&self) -> &str {
|
||||
match self {
|
||||
Keyword::Keyword => "*KEYWORD",
|
||||
Keyword::Title => "*TITLE",
|
||||
Keyword::Node => "*NODE",
|
||||
Keyword::ElementSolid => "*ELEMENT_SOLID",
|
||||
Keyword::ElementShell => "*ELEMENT_SHELL",
|
||||
Keyword::Part => "*PART",
|
||||
Keyword::SectionSolid => "*SECTION_SOLID",
|
||||
Keyword::SectionShell => "*SECTION_SHELL",
|
||||
Keyword::MatElastic => "*MAT_ELASTIC",
|
||||
Keyword::MatKelvinMaxwellViscoelastic => "*MAT_KELVIN-MAXWELL_VISCOELASTIC",
|
||||
Keyword::SetNode => "*SET_NODE_LIST",
|
||||
Keyword::SetElement => "*SET_ELEMENT_LIST",
|
||||
Keyword::ControlTermination => "*CONTROL_TERMINATION",
|
||||
Keyword::DatabaseBinaryD3plot => "*DATABASE_BINARY_D3PLOT",
|
||||
Keyword::End => "*END",
|
||||
Keyword::Unknown(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data associated with a keyword block.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeywordData {
|
||||
/// The keyword type.
|
||||
pub keyword: Keyword,
|
||||
/// Raw data lines (excluding the keyword line).
|
||||
pub lines: Vec<String>,
|
||||
/// Parsed parameters (if applicable).
|
||||
pub parameters: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl KeywordData {
|
||||
/// Create a new keyword data block.
|
||||
pub fn new(keyword: Keyword) -> Self {
|
||||
Self {
|
||||
keyword,
|
||||
lines: Vec::new(),
|
||||
parameters: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a data line.
|
||||
pub fn add_line(&mut self, line: impl Into<String>) {
|
||||
self.lines.push(line.into());
|
||||
}
|
||||
|
||||
/// Set a parameter.
|
||||
pub fn set_param(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||
self.parameters.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a floating point number for LS-DYNA output.
|
||||
///
|
||||
/// Uses scientific notation for very large or small numbers,
|
||||
/// otherwise uses fixed precision.
|
||||
pub fn format_float(value: f64, width: usize) -> String {
|
||||
let abs = value.abs();
|
||||
|
||||
if abs == 0.0 {
|
||||
format!("{:>width$.6}", 0.0, width = width)
|
||||
} else if !(1e-3..1e6).contains(&abs) {
|
||||
// Use scientific notation
|
||||
format!("{:>width$.5e}", value, width = width)
|
||||
} else {
|
||||
// Use fixed notation
|
||||
format!("{:>width$.6}", value, width = width)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an integer for LS-DYNA output.
|
||||
pub fn format_int(value: i64, width: usize) -> String {
|
||||
format!("{:>width$}", value, width = width)
|
||||
}
|
||||
|
||||
/// Parse a fixed-width field from an LS-DYNA line.
|
||||
pub fn parse_field<T: std::str::FromStr>(line: &str, start: usize, width: usize) -> Option<T> {
|
||||
if start >= line.len() {
|
||||
return None;
|
||||
}
|
||||
let end = (start + width).min(line.len());
|
||||
line[start..end].trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_keyword_parsing() {
|
||||
assert_eq!(Keyword::from_str("*KEYWORD"), Keyword::Keyword);
|
||||
assert_eq!(Keyword::from_str("*NODE"), Keyword::Node);
|
||||
assert_eq!(Keyword::from_str("*MAT_001"), Keyword::MatElastic);
|
||||
assert_eq!(
|
||||
Keyword::from_str("*MAT_KELVIN-MAXWELL_VISCOELASTIC"),
|
||||
Keyword::MatKelvinMaxwellViscoelastic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_float() {
|
||||
assert!(format_float(1.5, 16).contains("1.5"));
|
||||
assert!(format_float(1e-10, 16).contains("e"));
|
||||
assert!(format_float(1e10, 16).contains("e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_int() {
|
||||
assert_eq!(format_int(123, 8).trim(), "123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_field() {
|
||||
let line = " 1 1.000000 2.000000 3.000000";
|
||||
let id: i64 = parse_field(line, 0, 8).unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! LS-DYNA format support.
|
||||
//!
|
||||
//! This module provides reading and writing of LS-DYNA keyword (.k) files.
|
||||
//!
|
||||
//! # Supported Keywords
|
||||
//!
|
||||
//! - `*NODE` - Node definitions
|
||||
//! - `*ELEMENT_SOLID` - Solid element connectivity
|
||||
//! - `*PART` - Part definitions
|
||||
//! - `*SECTION_SOLID` - Solid section properties
|
||||
//! - `*MAT_ELASTIC` - Linear elastic material (MAT_001)
|
||||
//! - `*MAT_KELVIN-MAXWELL_VISCOELASTIC` - Viscoelastic material (MAT_076)
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use rtx_fem_export::lsdyna::write_k_file;
|
||||
//! use rtx_fem_export::FEModel;
|
||||
//!
|
||||
//! let model = FEModel::new("Brain Model", "Study 001");
|
||||
//! write_k_file(&model, "output.k")?;
|
||||
//! ```
|
||||
|
||||
mod keyword;
|
||||
mod reader;
|
||||
mod writer;
|
||||
|
||||
pub use keyword::{Keyword, KeywordData};
|
||||
pub use reader::read_k_file;
|
||||
pub use writer::{LsDynaOptions, write_k_file};
|
||||
@@ -0,0 +1,399 @@
|
||||
//! LS-DYNA keyword file reader.
|
||||
|
||||
use crate::error::{FemExportError, Result};
|
||||
use crate::lsdyna::keyword::{Keyword, KeywordData, parse_field};
|
||||
use crate::model::{Element, ElementType, FEModel, Material, Node, Part};
|
||||
use rtx_materials::{KelvinMaxwell, LinearElastic};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
|
||||
/// Read an FE model from an LS-DYNA keyword file.
|
||||
pub fn read_k_file(path: impl AsRef<Path>) -> Result<FEModel> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let keyword_blocks = parse_keyword_blocks(reader)?;
|
||||
let model = build_model_from_blocks(keyword_blocks)?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Parse keyword blocks from the file.
|
||||
fn parse_keyword_blocks<R: BufRead>(reader: R) -> Result<Vec<KeywordData>> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut current_block: Option<KeywordData> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for keyword line
|
||||
if trimmed.starts_with('*') {
|
||||
// Save previous block
|
||||
if let Some(block) = current_block.take() {
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
// Start new block
|
||||
let keyword = Keyword::from_str(trimmed);
|
||||
if keyword != Keyword::End {
|
||||
current_block = Some(KeywordData::new(keyword));
|
||||
}
|
||||
} else if trimmed.starts_with('$') {
|
||||
// Comment line - skip or extract parameters
|
||||
continue;
|
||||
} else if let Some(ref mut block) = current_block {
|
||||
// Data line
|
||||
block.add_line(&line);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last block
|
||||
if let Some(block) = current_block {
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// Build an FE model from parsed keyword blocks.
|
||||
fn build_model_from_blocks(blocks: Vec<KeywordData>) -> Result<FEModel> {
|
||||
let mut model = FEModel::new("", "");
|
||||
let mut materials: HashMap<u64, Material> = HashMap::new();
|
||||
let mut sections: HashMap<u64, ElementType> = HashMap::new();
|
||||
|
||||
for block in blocks {
|
||||
match block.keyword {
|
||||
Keyword::Title => {
|
||||
if let Some(title) = block.lines.first() {
|
||||
model.title = title.trim().to_string();
|
||||
}
|
||||
}
|
||||
Keyword::Node => {
|
||||
parse_nodes(&block, &mut model)?;
|
||||
}
|
||||
Keyword::ElementSolid => {
|
||||
parse_solid_elements(&block, &mut model)?;
|
||||
}
|
||||
Keyword::Part => {
|
||||
parse_parts(&block, &mut model, §ions)?;
|
||||
}
|
||||
Keyword::SectionSolid => {
|
||||
parse_section_solid(&block, &mut sections)?;
|
||||
}
|
||||
Keyword::MatElastic => {
|
||||
parse_mat_elastic(&block, &mut materials)?;
|
||||
}
|
||||
Keyword::MatKelvinMaxwellViscoelastic => {
|
||||
parse_mat_kelvin_maxwell(&block, &mut materials)?;
|
||||
}
|
||||
_ => {
|
||||
// Ignore unsupported keywords
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model.materials = materials;
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Parse *NODE section.
|
||||
fn parse_nodes(block: &KeywordData, model: &mut FEModel) -> Result<()> {
|
||||
for line in &block.lines {
|
||||
if line.trim().is_empty() || line.trim().starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// LS-DYNA NODE format: nid, x, y, z, tc, rc
|
||||
// Fields are typically: 8, 16, 16, 16, 8, 8
|
||||
let id: u64 = parse_field(line, 0, 8).ok_or_else(|| {
|
||||
FemExportError::ParseError(format!("Failed to parse node ID from: {}", line))
|
||||
})?;
|
||||
|
||||
let x: f64 = parse_field(line, 8, 16).unwrap_or(0.0);
|
||||
let y: f64 = parse_field(line, 24, 16).unwrap_or(0.0);
|
||||
let z: f64 = parse_field(line, 40, 16).unwrap_or(0.0);
|
||||
|
||||
model.add_node(Node::new(id, x, y, z));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse *ELEMENT_SOLID section.
|
||||
fn parse_solid_elements(block: &KeywordData, model: &mut FEModel) -> Result<()> {
|
||||
for line in &block.lines {
|
||||
if line.trim().is_empty() || line.trim().starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// LS-DYNA ELEMENT_SOLID format: eid, pid, n1-n8 (or more for higher order)
|
||||
// Fields are typically 8 characters each
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
|
||||
if parts.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id: u64 = parts[0].parse().map_err(|_| {
|
||||
FemExportError::ParseError(format!("Failed to parse element ID: {}", parts[0]))
|
||||
})?;
|
||||
|
||||
let part_id: u64 = parts[1].parse().map_err(|_| {
|
||||
FemExportError::ParseError(format!("Failed to parse part ID: {}", parts[1]))
|
||||
})?;
|
||||
|
||||
let node_ids: Vec<u64> = parts[2..].iter().filter_map(|s| s.parse().ok()).collect();
|
||||
|
||||
// Determine element type from connectivity
|
||||
let element_type = match node_ids.len() {
|
||||
4 => ElementType::Tet4,
|
||||
8 => {
|
||||
// Check if it's a collapsed tet (all last 4 nodes same)
|
||||
if node_ids[4] == node_ids[5]
|
||||
&& node_ids[5] == node_ids[6]
|
||||
&& node_ids[6] == node_ids[7]
|
||||
{
|
||||
ElementType::Tet4
|
||||
} else {
|
||||
ElementType::Hex8
|
||||
}
|
||||
}
|
||||
10 => ElementType::Tet10,
|
||||
20 => ElementType::Hex20,
|
||||
_ => {
|
||||
// Default to treating it as whatever nodes we have
|
||||
if node_ids.len() <= 4 {
|
||||
ElementType::Tet4
|
||||
} else {
|
||||
ElementType::Hex8
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// For collapsed tets, only keep unique nodes
|
||||
let final_nodes = if element_type == ElementType::Tet4 && node_ids.len() > 4 {
|
||||
node_ids[0..4].to_vec()
|
||||
} else {
|
||||
node_ids
|
||||
};
|
||||
|
||||
model.add_element(Element::new(id, part_id, element_type, final_nodes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse *PART section.
|
||||
fn parse_parts(
|
||||
block: &KeywordData,
|
||||
model: &mut FEModel,
|
||||
sections: &HashMap<u64, ElementType>,
|
||||
) -> Result<()> {
|
||||
let mut name = String::new();
|
||||
let mut line_idx = 0;
|
||||
|
||||
for line in &block.lines {
|
||||
if line.trim().is_empty() || line.trim().starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line_idx == 0 {
|
||||
// First line is the part title
|
||||
name = line.trim().to_string();
|
||||
} else {
|
||||
// Second line has: pid, secid, mid, eosid, hgid, grav, adpopt, tmid
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
let id: u64 = parts[0].parse().unwrap_or(0);
|
||||
let section_id: u64 = parts[1].parse().unwrap_or(0);
|
||||
let material_id: u64 = parts[2].parse().unwrap_or(0);
|
||||
|
||||
let element_type = sections
|
||||
.get(§ion_id)
|
||||
.copied()
|
||||
.unwrap_or(ElementType::Tet4);
|
||||
|
||||
model.add_part(Part::new(id, &name, section_id, material_id, element_type));
|
||||
}
|
||||
}
|
||||
line_idx += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse *SECTION_SOLID.
|
||||
fn parse_section_solid(
|
||||
block: &KeywordData,
|
||||
sections: &mut HashMap<u64, ElementType>,
|
||||
) -> Result<()> {
|
||||
for line in &block.lines {
|
||||
if line.trim().is_empty() || line.trim().starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let id: u64 = parts[0].parse().unwrap_or(0);
|
||||
let elform: i32 = parts[1].parse().unwrap_or(0);
|
||||
|
||||
let element_type = match elform {
|
||||
10 | 13 => ElementType::Tet4,
|
||||
16 | 17 => ElementType::Tet10,
|
||||
1..=3 => ElementType::Hex8,
|
||||
_ => ElementType::Tet4,
|
||||
};
|
||||
|
||||
sections.insert(id, element_type);
|
||||
}
|
||||
break; // Only process first data line
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse *MAT_ELASTIC.
|
||||
fn parse_mat_elastic(block: &KeywordData, materials: &mut HashMap<u64, Material>) -> Result<()> {
|
||||
for line in &block.lines {
|
||||
if line.trim().is_empty() || line.trim().starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
let id: u64 = parts[0].parse().unwrap_or(0);
|
||||
let density: f64 = parts[1].parse().unwrap_or(0.0);
|
||||
let youngs_modulus: f64 = parts[2].parse().unwrap_or(0.0);
|
||||
let poissons_ratio: f64 = parts[3].parse().unwrap_or(0.3);
|
||||
|
||||
// LinearElastic::new takes (youngs_modulus, poissons_ratio, density)
|
||||
let material = LinearElastic::new(youngs_modulus, poissons_ratio, density);
|
||||
materials.insert(id, Material::Elastic(material));
|
||||
}
|
||||
break; // Only process first data line
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse *MAT_KELVIN-MAXWELL_VISCOELASTIC.
|
||||
fn parse_mat_kelvin_maxwell(
|
||||
block: &KeywordData,
|
||||
materials: &mut HashMap<u64, Material>,
|
||||
) -> Result<()> {
|
||||
let mut id: u64 = 0;
|
||||
let mut density: f64 = 0.0;
|
||||
let mut bulk: f64 = 0.0;
|
||||
let mut g0: f64 = 0.0;
|
||||
let mut gi: f64 = 0.0;
|
||||
let mut beta_i: f64 = 0.0;
|
||||
|
||||
let mut data_line = 0;
|
||||
for line in &block.lines {
|
||||
if line.trim().is_empty() || line.trim().starts_with('$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
|
||||
match data_line {
|
||||
0 => {
|
||||
// First data line: mid, ro, bulk, g0
|
||||
if parts.len() >= 4 {
|
||||
id = parts[0].parse().unwrap_or(0);
|
||||
density = parts[1].parse().unwrap_or(0.0);
|
||||
bulk = parts[2].parse().unwrap_or(0.0);
|
||||
g0 = parts[3].parse().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Second data line: gi, betai
|
||||
if parts.len() >= 2 {
|
||||
gi = parts[0].parse().unwrap_or(0.0);
|
||||
beta_i = parts[1].parse().unwrap_or(0.0);
|
||||
}
|
||||
|
||||
let material = KelvinMaxwell::new(density, bulk, g0, gi, beta_i);
|
||||
materials.insert(id, Material::KelvinMaxwell(material));
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
data_line += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_k_file() {
|
||||
let k_content = r#"*KEYWORD
|
||||
*TITLE
|
||||
Test Model
|
||||
*NODE
|
||||
1 0.000000 0.000000 0.000000
|
||||
2 1.000000 0.000000 0.000000
|
||||
3 0.500000 1.000000 0.000000
|
||||
4 0.500000 0.500000 1.000000
|
||||
*MAT_ELASTIC
|
||||
1 1.0400e+03 3.0000e+03 4.9000e-01
|
||||
*SECTION_SOLID
|
||||
1 10
|
||||
*PART
|
||||
Brain
|
||||
1 1 1
|
||||
*ELEMENT_SOLID
|
||||
1 1 1 2 3 4 4 4 4 4
|
||||
*END
|
||||
"#;
|
||||
|
||||
let reader = BufReader::new(Cursor::new(k_content));
|
||||
let blocks = parse_keyword_blocks(reader).unwrap();
|
||||
|
||||
// Check we found the expected blocks
|
||||
let keywords: Vec<_> = blocks.iter().map(|b| &b.keyword).collect();
|
||||
assert!(keywords.contains(&&Keyword::Title));
|
||||
assert!(keywords.contains(&&Keyword::Node));
|
||||
assert!(keywords.contains(&&Keyword::MatElastic));
|
||||
assert!(keywords.contains(&&Keyword::ElementSolid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_model() {
|
||||
let k_content = r#"*KEYWORD
|
||||
*TITLE
|
||||
Test Model
|
||||
*NODE
|
||||
1 0.000000 0.000000 0.000000
|
||||
2 1.000000 0.000000 0.000000
|
||||
3 0.500000 1.000000 0.000000
|
||||
4 0.500000 0.500000 1.000000
|
||||
*MAT_ELASTIC
|
||||
1 1040.0 3000.0 0.49
|
||||
*SECTION_SOLID
|
||||
1 10
|
||||
*PART
|
||||
Brain
|
||||
1 1 1
|
||||
*ELEMENT_SOLID
|
||||
1 1 1 2 3 4 4 4 4 4
|
||||
*END
|
||||
"#;
|
||||
|
||||
let reader = BufReader::new(Cursor::new(k_content));
|
||||
let blocks = parse_keyword_blocks(reader).unwrap();
|
||||
let model = build_model_from_blocks(blocks).unwrap();
|
||||
|
||||
assert_eq!(model.title, "Test Model");
|
||||
assert_eq!(model.num_nodes(), 4);
|
||||
assert_eq!(model.num_elements(), 1);
|
||||
assert_eq!(model.materials.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! LS-DYNA keyword file writer.
|
||||
|
||||
use crate::error::{FemExportError, Result};
|
||||
use crate::model::{Element, ElementType, FEModel, Material, Node, NodeSet, Part};
|
||||
use rtx_materials::KelvinMaxwell;
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Options for LS-DYNA file output.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LsDynaOptions {
|
||||
/// Include header comments with model info.
|
||||
pub include_header: bool,
|
||||
/// Termination time for analysis.
|
||||
pub termination_time: Option<f64>,
|
||||
/// D3PLOT output interval.
|
||||
pub d3plot_interval: Option<f64>,
|
||||
/// Include control cards.
|
||||
pub include_control: bool,
|
||||
}
|
||||
|
||||
impl Default for LsDynaOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_header: true,
|
||||
termination_time: None,
|
||||
d3plot_interval: None,
|
||||
include_control: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an FE model to an LS-DYNA keyword file.
|
||||
pub fn write_k_file(model: &FEModel, path: impl AsRef<Path>) -> Result<()> {
|
||||
write_k_file_with_options(model, path, &LsDynaOptions::default())
|
||||
}
|
||||
|
||||
/// Write an FE model to an LS-DYNA keyword file with options.
|
||||
pub fn write_k_file_with_options(
|
||||
model: &FEModel,
|
||||
path: impl AsRef<Path>,
|
||||
options: &LsDynaOptions,
|
||||
) -> Result<()> {
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
// Write keyword header
|
||||
writeln!(writer, "*KEYWORD")?;
|
||||
|
||||
// Write title
|
||||
if options.include_header {
|
||||
writeln!(writer, "*TITLE")?;
|
||||
writeln!(writer, "{}", truncate_string(&model.title, 80))?;
|
||||
}
|
||||
|
||||
// Write control cards if requested
|
||||
if options.include_control {
|
||||
if let Some(term_time) = options.termination_time {
|
||||
write_control_termination(&mut writer, term_time)?;
|
||||
}
|
||||
if let Some(interval) = options.d3plot_interval {
|
||||
write_database_d3plot(&mut writer, interval)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Write materials
|
||||
for (id, material) in &model.materials {
|
||||
write_material(&mut writer, *id, material)?;
|
||||
}
|
||||
|
||||
// Write sections
|
||||
for part in &model.parts {
|
||||
write_section(&mut writer, part)?;
|
||||
}
|
||||
|
||||
// Write parts
|
||||
for part in &model.parts {
|
||||
write_part(&mut writer, part)?;
|
||||
}
|
||||
|
||||
// Write nodes
|
||||
write_nodes(&mut writer, &model.nodes)?;
|
||||
|
||||
// Write elements
|
||||
write_elements(&mut writer, &model.elements)?;
|
||||
|
||||
// Write node sets
|
||||
for node_set in &model.node_sets {
|
||||
write_node_set(&mut writer, node_set)?;
|
||||
}
|
||||
|
||||
// Write end marker
|
||||
writeln!(writer, "*END")?;
|
||||
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write control termination card.
|
||||
fn write_control_termination<W: Write>(writer: &mut W, term_time: f64) -> Result<()> {
|
||||
writeln!(writer, "*CONTROL_TERMINATION")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10.4e}{:>10}{:>10}{:>10}{:>10}",
|
||||
term_time, "", "", "", ""
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write database binary d3plot card.
|
||||
fn write_database_d3plot<W: Write>(writer: &mut W, interval: f64) -> Result<()> {
|
||||
writeln!(writer, "*DATABASE_BINARY_D3PLOT")?;
|
||||
writeln!(writer, "{:>10.4e}", interval)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a material definition.
|
||||
fn write_material<W: Write>(writer: &mut W, id: u64, material: &Material) -> Result<()> {
|
||||
match material {
|
||||
Material::Elastic(elastic) => {
|
||||
writeln!(
|
||||
writer,
|
||||
"$---+----1----+----2----+----3----+----4----+----5----+----6----+----7----+----8"
|
||||
)?;
|
||||
writeln!(writer, "*MAT_ELASTIC")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# mid ro e pr da db not used"
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10}{:>10.4e}{:>10.4e}{:>10.6}{:>10}{:>10}{:>10}",
|
||||
id, elastic.density, elastic.youngs_modulus, elastic.poissons_ratio, "", "", ""
|
||||
)?;
|
||||
}
|
||||
Material::KelvinMaxwell(km) => {
|
||||
write_mat_kelvin_maxwell(writer, id, km)?;
|
||||
}
|
||||
Material::UserDefined {
|
||||
mat_type,
|
||||
parameters,
|
||||
} => {
|
||||
writeln!(writer, "$ User-defined material: {}", mat_type)?;
|
||||
writeln!(writer, "*MAT_USER_DEFINED")?;
|
||||
writeln!(writer, "{:>10}", id)?;
|
||||
for (key, value) in parameters {
|
||||
writeln!(writer, "$ {}: {}", key, value)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write MAT_KELVIN-MAXWELL_VISCOELASTIC (MAT_076).
|
||||
fn write_mat_kelvin_maxwell<W: Write>(writer: &mut W, id: u64, km: &KelvinMaxwell) -> Result<()> {
|
||||
writeln!(
|
||||
writer,
|
||||
"$---+----1----+----2----+----3----+----4----+----5----+----6----+----7----+----8"
|
||||
)?;
|
||||
writeln!(writer, "*MAT_KELVIN-MAXWELL_VISCOELASTIC")?;
|
||||
writeln!(writer, "$# mid ro bulk g0")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10}{:>10.4e}{:>10.4e}{:>10.4e}",
|
||||
id, km.density, km.bulk_modulus, km.g0
|
||||
)?;
|
||||
writeln!(writer, "$# gi betai")?;
|
||||
writeln!(writer, "{:>10.4e}{:>10.4e}", km.gi, km.beta_i)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a section definition.
|
||||
fn write_section<W: Write>(writer: &mut W, part: &Part) -> Result<()> {
|
||||
match part.element_type {
|
||||
ElementType::Tet4 | ElementType::Tet10 | ElementType::Hex8 | ElementType::Hex20 => {
|
||||
writeln!(writer, "*SECTION_SOLID")?;
|
||||
writeln!(writer, "$# secid elform aet")?;
|
||||
// elform: 10 = 1-point tetrahedron, 13 = 1-point nodal pressure tetrahedron
|
||||
// Using 13 for better stability with nearly incompressible materials
|
||||
let elform = match part.element_type {
|
||||
ElementType::Tet4 => 10,
|
||||
ElementType::Tet10 => 16, // 10-node tetrahedron
|
||||
ElementType::Hex8 => 1, // Constant stress solid
|
||||
ElementType::Hex20 => 2, // Fully integrated solid
|
||||
_ => 1,
|
||||
};
|
||||
writeln!(writer, "{:>10}{:>10}{:>10}", part.section_id, elform, "")?;
|
||||
}
|
||||
ElementType::Shell4 | ElementType::Tri3 => {
|
||||
writeln!(writer, "*SECTION_SHELL")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# secid elform shrf nip propt qr/irid icomp setyp"
|
||||
)?;
|
||||
let elform = match part.element_type {
|
||||
ElementType::Shell4 => 2, // Belytschko-Tsay
|
||||
ElementType::Tri3 => 17, // Triangular shell
|
||||
_ => 2,
|
||||
};
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10}{:>10}{:>10.4}{:>10}{:>10}{:>10}{:>10}{:>10}",
|
||||
part.section_id, elform, 1.0, 5, 1, 0, 0, 1
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# t1 t2 t3 t4 nloc marea idof edgset"
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10.4}{:>10.4}{:>10.4}{:>10.4}{:>10}{:>10}{:>10}{:>10}",
|
||||
1.0, 1.0, 1.0, 1.0, 0, 0, 0, 0
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a part definition.
|
||||
fn write_part<W: Write>(writer: &mut W, part: &Part) -> Result<()> {
|
||||
writeln!(writer, "*PART")?;
|
||||
writeln!(writer, "{}", truncate_string(&part.name, 80))?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# pid secid mid eosid hgid grav adpopt tmid"
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10}{:>10}{:>10}{:>10}{:>10}{:>10}{:>10}{:>10}",
|
||||
part.id, part.section_id, part.material_id, "", "", "", "", ""
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write node definitions.
|
||||
fn write_nodes<W: Write>(writer: &mut W, nodes: &[Node]) -> Result<()> {
|
||||
if nodes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
writeln!(writer, "*NODE")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# nid x y z tc rc"
|
||||
)?;
|
||||
|
||||
for node in nodes {
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>16.8}{:>16.8}{:>16.8}{:>8}{:>8}",
|
||||
node.id, node.position.x, node.position.y, node.position.z, 0, 0
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write element definitions.
|
||||
fn write_elements<W: Write>(writer: &mut W, elements: &[Element]) -> Result<()> {
|
||||
if elements.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Group elements by type
|
||||
let solid_elements: Vec<_> = elements
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e.element_type,
|
||||
ElementType::Tet4 | ElementType::Tet10 | ElementType::Hex8 | ElementType::Hex20
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let shell_elements: Vec<_> = elements
|
||||
.iter()
|
||||
.filter(|e| matches!(e.element_type, ElementType::Shell4 | ElementType::Tri3))
|
||||
.collect();
|
||||
|
||||
// Write solid elements
|
||||
if !solid_elements.is_empty() {
|
||||
writeln!(writer, "*ELEMENT_SOLID")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# eid pid n1 n2 n3 n4 n5 n6 n7 n8"
|
||||
)?;
|
||||
|
||||
for elem in solid_elements {
|
||||
write_solid_element(writer, elem)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Write shell elements
|
||||
if !shell_elements.is_empty() {
|
||||
writeln!(writer, "*ELEMENT_SHELL")?;
|
||||
writeln!(writer, "$# eid pid n1 n2 n3 n4")?;
|
||||
|
||||
for elem in shell_elements {
|
||||
write_shell_element(writer, elem)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a solid element.
|
||||
fn write_solid_element<W: Write>(writer: &mut W, elem: &Element) -> Result<()> {
|
||||
match elem.element_type {
|
||||
ElementType::Tet4 => {
|
||||
// LS-DYNA expects 8 nodes for solid elements, repeat last node for tets
|
||||
let nodes = &elem.nodes;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
elem.id,
|
||||
elem.part_id,
|
||||
nodes[0],
|
||||
nodes[1],
|
||||
nodes[2],
|
||||
nodes[3],
|
||||
nodes[3],
|
||||
nodes[3],
|
||||
nodes[3],
|
||||
nodes[3]
|
||||
)?;
|
||||
}
|
||||
ElementType::Tet10 => {
|
||||
// 10-node tet - write in two lines
|
||||
let nodes = &elem.nodes;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
elem.id,
|
||||
elem.part_id,
|
||||
nodes[0],
|
||||
nodes[1],
|
||||
nodes[2],
|
||||
nodes[3],
|
||||
nodes[4],
|
||||
nodes[5],
|
||||
nodes[6],
|
||||
nodes[7]
|
||||
)?;
|
||||
writeln!(writer, "{:>16}{:>8}{:>8}", "", nodes[8], nodes[9])?;
|
||||
}
|
||||
ElementType::Hex8 => {
|
||||
let nodes = &elem.nodes;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
elem.id,
|
||||
elem.part_id,
|
||||
nodes[0],
|
||||
nodes[1],
|
||||
nodes[2],
|
||||
nodes[3],
|
||||
nodes[4],
|
||||
nodes[5],
|
||||
nodes[6],
|
||||
nodes[7]
|
||||
)?;
|
||||
}
|
||||
ElementType::Hex20 => {
|
||||
// 20-node hex - write in multiple lines
|
||||
let nodes = &elem.nodes;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
elem.id,
|
||||
elem.part_id,
|
||||
nodes[0],
|
||||
nodes[1],
|
||||
nodes[2],
|
||||
nodes[3],
|
||||
nodes[4],
|
||||
nodes[5],
|
||||
nodes[6],
|
||||
nodes[7]
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>16}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
"",
|
||||
nodes[8],
|
||||
nodes[9],
|
||||
nodes[10],
|
||||
nodes[11],
|
||||
nodes[12],
|
||||
nodes[13],
|
||||
nodes[14],
|
||||
nodes[15]
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>16}{:>8}{:>8}{:>8}{:>8}",
|
||||
"", nodes[16], nodes[17], nodes[18], nodes[19]
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(FemExportError::InvalidElement(format!(
|
||||
"Element type {:?} is not a solid element",
|
||||
elem.element_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a shell element.
|
||||
fn write_shell_element<W: Write>(writer: &mut W, elem: &Element) -> Result<()> {
|
||||
match elem.element_type {
|
||||
ElementType::Shell4 => {
|
||||
let nodes = &elem.nodes;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
elem.id, elem.part_id, nodes[0], nodes[1], nodes[2], nodes[3]
|
||||
)?;
|
||||
}
|
||||
ElementType::Tri3 => {
|
||||
// For triangles, repeat the last node
|
||||
let nodes = &elem.nodes;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}",
|
||||
elem.id, elem.part_id, nodes[0], nodes[1], nodes[2], nodes[2]
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(FemExportError::InvalidElement(format!(
|
||||
"Element type {:?} is not a shell element",
|
||||
elem.element_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a node set definition.
|
||||
fn write_node_set<W: Write>(writer: &mut W, node_set: &NodeSet) -> Result<()> {
|
||||
writeln!(writer, "*SET_NODE_LIST_TITLE")?;
|
||||
writeln!(writer, "{}", truncate_string(&node_set.name, 80))?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# sid da1 da2 da3 da4 solver"
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"{:>10}{:>10}{:>10}{:>10}{:>10}{:>10}",
|
||||
node_set.id, "", "", "", "", ""
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"$# nid1 nid2 nid3 nid4 nid5 nid6 nid7 nid8"
|
||||
)?;
|
||||
|
||||
// Write nodes in groups of 8
|
||||
for chunk in node_set.nodes.chunks(8) {
|
||||
let mut line = String::new();
|
||||
for &node_id in chunk {
|
||||
line.push_str(&format!("{:>10}", node_id));
|
||||
}
|
||||
writeln!(writer, "{}", line)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Truncate a string to a maximum length.
|
||||
fn truncate_string(s: &str, max_len: usize) -> &str {
|
||||
if s.len() <= max_len { s } else { &s[..max_len] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rtx_materials::LinearElastic;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_write_simple_model() {
|
||||
use crate::model::FEModelBuilder;
|
||||
|
||||
let mut builder = FEModelBuilder::new("Test Model", "Test Study");
|
||||
|
||||
// Add nodes for a single tetrahedron
|
||||
let n1 = builder.add_node(0.0, 0.0, 0.0);
|
||||
let n2 = builder.add_node(1.0, 0.0, 0.0);
|
||||
let n3 = builder.add_node(0.5, 1.0, 0.0);
|
||||
let n4 = builder.add_node(0.5, 0.5, 1.0);
|
||||
|
||||
// Add material
|
||||
let mat_id = builder.add_material(Material::Elastic(LinearElastic::brain_tissue()));
|
||||
|
||||
// Add part
|
||||
let part_id = builder.add_part("Brain", mat_id, ElementType::Tet4);
|
||||
|
||||
// Add element
|
||||
builder.add_tet4(part_id, n1, n2, n3, n4);
|
||||
|
||||
let model = builder.build();
|
||||
|
||||
// Write to temp file
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
write_k_file(&model, temp_file.path()).unwrap();
|
||||
|
||||
// Read and verify content
|
||||
let content = std::fs::read_to_string(temp_file.path()).unwrap();
|
||||
assert!(content.contains("*KEYWORD"));
|
||||
assert!(content.contains("*TITLE"));
|
||||
assert!(content.contains("*NODE"));
|
||||
assert!(content.contains("*ELEMENT_SOLID"));
|
||||
assert!(content.contains("*PART"));
|
||||
assert!(content.contains("*MAT_ELASTIC"));
|
||||
assert!(content.contains("*END"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_kelvin_maxwell() {
|
||||
use crate::model::FEModelBuilder;
|
||||
|
||||
let mut builder = FEModelBuilder::new("Viscoelastic Model", "Test");
|
||||
|
||||
let n1 = builder.add_node(0.0, 0.0, 0.0);
|
||||
let n2 = builder.add_node(1.0, 0.0, 0.0);
|
||||
let n3 = builder.add_node(0.5, 1.0, 0.0);
|
||||
let n4 = builder.add_node(0.5, 0.5, 1.0);
|
||||
|
||||
// Add Kelvin-Maxwell material
|
||||
let km = KelvinMaxwell::new(1040.0, 2.19e9, 1500.0, 2500.0, 100.0);
|
||||
let mat_id = builder.add_material(Material::KelvinMaxwell(km));
|
||||
|
||||
let part_id = builder.add_part("Soft Tissue", mat_id, ElementType::Tet4);
|
||||
builder.add_tet4(part_id, n1, n2, n3, n4);
|
||||
|
||||
let model = builder.build();
|
||||
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
write_k_file(&model, temp_file.path()).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(temp_file.path()).unwrap();
|
||||
assert!(content.contains("*MAT_KELVIN-MAXWELL_VISCOELASTIC"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user