Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -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, &sections)?;
}
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(&section_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"));
}
}