//! 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) -> Result { 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(reader: R) -> Result> { let mut blocks = Vec::new(); let mut current_block: Option = 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) -> Result { let mut model = FEModel::new("", ""); let mut materials: HashMap = HashMap::new(); let mut sections: HashMap = 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 = 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, ) -> 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, ) -> 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) -> 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, ) -> 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); } }