Skip to main content

oxiphysics_io/
calculix_format.rs

1#![allow(clippy::type_complexity)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! CalculiX `.inp` file format I/O.
6//!
7//! CalculiX uses an input format that is approximately 95% compatible with
8//! the Abaqus `.inp` format.  This module provides a dedicated reader and
9//! writer that operate on the generic [`FeMesh`] type from
10//! [`crate::finite_element_io`], supporting nodes, elements, materials,
11//! boundary conditions, concentrated loads, and analysis steps.
12
13use std::collections::HashMap;
14use std::fmt::Write as FmtWrite;
15use std::fs;
16use std::io::{self, BufRead};
17
18use crate::Error as IoError;
19use crate::finite_element_io::{
20    AnalysisStep, DirichletBc, FeElement, FeElementType, FeMesh, FeNode, LinearElasticMaterial,
21    NodalForce,
22};
23
24// ---------------------------------------------------------------------------
25// Element type mapping (CalculiX <-> FeElementType)
26// ---------------------------------------------------------------------------
27
28/// Map a [`FeElementType`] to its CalculiX string representation.
29fn fe_type_to_calculix(et: &FeElementType) -> &'static str {
30    match et {
31        FeElementType::Tet4 => "C3D4",
32        FeElementType::Tet10 => "C3D10",
33        FeElementType::Hex8 => "C3D8",
34        FeElementType::Hex20 => "C3D20",
35        FeElementType::Tri3 => "S3",
36        FeElementType::Tri6 => "S6",
37        FeElementType::Quad4 => "S4",
38        FeElementType::Quad8 => "S8",
39        FeElementType::Line2 => "T3D2",
40        FeElementType::Unknown(_) => "UNKNOWN",
41    }
42}
43
44/// Map a CalculiX element-type string to [`FeElementType`] (case-insensitive).
45fn calculix_type_to_fe(s: &str) -> FeElementType {
46    match s.trim().to_uppercase().as_str() {
47        "C3D4" => FeElementType::Tet4,
48        "C3D10" => FeElementType::Tet10,
49        "C3D8" => FeElementType::Hex8,
50        "C3D20" => FeElementType::Hex20,
51        "S3" => FeElementType::Tri3,
52        "S6" => FeElementType::Tri6,
53        "S4" => FeElementType::Quad4,
54        "S8" => FeElementType::Quad8,
55        "T3D2" => FeElementType::Line2,
56        other => FeElementType::Unknown(other.to_string()),
57    }
58}
59
60// ---------------------------------------------------------------------------
61// CalculixWriter
62// ---------------------------------------------------------------------------
63
64/// Writes an [`FeMesh`] to a CalculiX `.inp` text file.
65#[derive(Debug, Clone, Default)]
66pub struct CalculixWriter;
67
68impl CalculixWriter {
69    /// Create a new writer.
70    pub fn new() -> Self {
71        Self
72    }
73
74    /// Serialize `mesh` into CalculiX INP format and return the string.
75    pub fn write_string(&self, mesh: &FeMesh) -> Result<String, IoError> {
76        let mut buf = String::new();
77
78        // --- Heading ---
79        writeln!(buf, "*HEADING").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
80        writeln!(buf, "Generated by OxiPhysics CalculixWriter")
81            .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
82
83        // --- Nodes ---
84        writeln!(buf, "*NODE").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
85        for node in &mesh.nodes {
86            writeln!(
87                buf,
88                "{}, {:.15e}, {:.15e}, {:.15e}",
89                node.id, node.coords[0], node.coords[1], node.coords[2]
90            )
91            .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
92        }
93
94        // --- Elements (grouped by type) ---
95        let mut types_seen: Vec<FeElementType> = Vec::new();
96        for el in &mesh.elements {
97            if !types_seen.contains(&el.element_type) {
98                types_seen.push(el.element_type.clone());
99            }
100        }
101
102        for etype in &types_seen {
103            let ccx_type = fe_type_to_calculix(etype);
104            writeln!(buf, "*ELEMENT, TYPE={ccx_type}")
105                .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
106            for el in mesh.elements.iter().filter(|e| &e.element_type == etype) {
107                let ids: Vec<String> = el.connectivity.iter().map(|n| n.to_string()).collect();
108                writeln!(buf, "{}, {}", el.id, ids.join(", "))
109                    .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
110            }
111        }
112
113        // --- Element sets (one per element type for section assignment) ---
114        if !mesh.element_sets.is_empty() {
115            for (set_name, ids) in &mesh.element_sets {
116                writeln!(buf, "*ELSET, ELSET={set_name}")
117                    .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
118                let id_strs: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
119                // Write in lines of up to 16 ids
120                for chunk in id_strs.chunks(16) {
121                    writeln!(buf, "{}", chunk.join(", "))
122                        .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
123                }
124            }
125        }
126
127        // --- Materials ---
128        for mat in &mesh.materials {
129            writeln!(buf, "*MATERIAL, NAME={}", mat.name)
130                .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
131            writeln!(buf, "*DENSITY").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
132            writeln!(buf, "{:.15e}", mat.density)
133                .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
134            writeln!(buf, "*ELASTIC").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
135            writeln!(
136                buf,
137                "{:.15e}, {:.15e}",
138                mat.young_modulus, mat.poisson_ratio
139            )
140            .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
141        }
142
143        // --- Solid sections ---
144        // Generate a SOLID SECTION for each element set that references a material
145        // by convention: element set name == material name
146        for mat in &mesh.materials {
147            if mesh.element_sets.contains_key(&mat.name) {
148                writeln!(
149                    buf,
150                    "*SOLID SECTION, ELSET={}, MATERIAL={}",
151                    mat.name, mat.name
152                )
153                .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
154            }
155        }
156
157        // --- Steps ---
158        for step in &mesh.steps {
159            writeln!(buf, "*STEP").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
160            writeln!(buf, "*STATIC").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
161            writeln!(
162                buf,
163                "{:.6e}, {:.6e}",
164                step.initial_increment, step.time_period
165            )
166            .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
167
168            // Boundary conditions
169            if !step.bcs.is_empty() {
170                writeln!(buf, "*BOUNDARY")
171                    .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
172                for bc in &step.bcs {
173                    writeln!(
174                        buf,
175                        "{}, {}, {}, {:.15e}",
176                        bc.node_id, bc.dof, bc.dof, bc.value
177                    )
178                    .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
179                }
180            }
181
182            // Concentrated loads
183            if !step.forces.is_empty() {
184                writeln!(buf, "*CLOAD").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
185                for f in &step.forces {
186                    writeln!(buf, "{}, {}, {:.15e}", f.node_id, f.dof, f.magnitude)
187                        .map_err(|e| IoError::General(format!("fmt error: {e}")))?;
188                }
189            }
190
191            writeln!(buf, "*END STEP").map_err(|e| IoError::General(format!("fmt error: {e}")))?;
192        }
193
194        Ok(buf)
195    }
196
197    /// Serialize `mesh` and write to the file at `path`.
198    pub fn write(&self, mesh: &FeMesh, path: &str) -> Result<(), IoError> {
199        let content = self.write_string(mesh)?;
200        fs::write(path, content).map_err(IoError::Io)
201    }
202}
203
204// ---------------------------------------------------------------------------
205// CalculixReader
206// ---------------------------------------------------------------------------
207
208/// Internal parse-state for the keyword-block state machine.
209#[derive(Debug, Clone, PartialEq)]
210enum CcxBlock {
211    /// Outside any recognised block.
212    None,
213    /// Inside `*HEADING`.
214    Heading,
215    /// Inside `*NODE`.
216    Node,
217    /// Inside `*ELEMENT` with a particular type.
218    Element(FeElementType),
219    /// Inside `*ELASTIC` for a named material.
220    Elastic(String),
221    /// Inside `*DENSITY` for a named material.
222    Density(String),
223    /// Inside `*BOUNDARY`.
224    Boundary,
225    /// Inside `*CLOAD`.
226    Cload,
227    /// Inside `*STATIC` (time increment line expected).
228    Static,
229    /// Inside `*ELSET`.
230    Elset(String),
231    /// Inside `*NSET`.
232    Nset(String),
233}
234
235/// Parses CalculiX `.inp` files into an [`FeMesh`].
236#[derive(Debug, Clone, Default)]
237pub struct CalculixReader;
238
239impl CalculixReader {
240    /// Create a new reader.
241    pub fn new() -> Self {
242        Self
243    }
244
245    /// Parse a CalculiX `.inp` file from a file path.
246    pub fn parse(&self, path: &str) -> Result<FeMesh, IoError> {
247        let file = fs::File::open(path).map_err(IoError::Io)?;
248        let reader = io::BufReader::new(file);
249        let mut lines = Vec::new();
250        for line_res in reader.lines() {
251            lines.push(line_res.map_err(IoError::Io)?);
252        }
253        self.parse_lines(&lines)
254    }
255
256    /// Parse a CalculiX `.inp` file from a string.
257    pub fn parse_string(&self, source: &str) -> Result<FeMesh, IoError> {
258        let lines: Vec<String> = source.lines().map(|l| l.to_string()).collect();
259        self.parse_lines(&lines)
260    }
261
262    /// Core line-by-line parser.
263    fn parse_lines(&self, lines: &[String]) -> Result<FeMesh, IoError> {
264        let mut mesh = FeMesh::new();
265        let mut block = CcxBlock::None;
266        // Track current material name for multi-keyword material definition
267        let mut current_material: Option<String> = None;
268        // Partial material data: name -> (young, poisson, density)
269        let mut mat_data: HashMap<String, (Option<f64>, Option<f64>, Option<f64>)> = HashMap::new();
270        // Track whether we are inside a *STEP
271        let mut in_step = false;
272
273        for raw_line in lines {
274            let line = raw_line.trim();
275
276            // Skip empty lines and comments
277            if line.is_empty() || line.starts_with("**") {
278                continue;
279            }
280
281            // Keyword line?
282            if line.starts_with('*') {
283                let upper = line.to_uppercase();
284
285                if upper.starts_with("*END STEP") {
286                    in_step = false;
287                    block = CcxBlock::None;
288                    continue;
289                }
290
291                if upper.starts_with("*HEADING") {
292                    block = CcxBlock::Heading;
293                    continue;
294                }
295
296                if upper.starts_with("*NODE") && !upper.starts_with("*NSET") {
297                    block = CcxBlock::Node;
298                    continue;
299                }
300
301                if upper.starts_with("*ELEMENT") {
302                    let etype_str =
303                        extract_param(line, "TYPE").unwrap_or_else(|| "UNKNOWN".to_string());
304                    block = CcxBlock::Element(calculix_type_to_fe(&etype_str));
305                    continue;
306                }
307
308                if upper.starts_with("*NSET") {
309                    let name = extract_param(line, "NSET").unwrap_or_else(|| "DEFAULT".to_string());
310                    block = CcxBlock::Nset(name);
311                    continue;
312                }
313
314                if upper.starts_with("*ELSET") {
315                    let name =
316                        extract_param(line, "ELSET").unwrap_or_else(|| "DEFAULT".to_string());
317                    block = CcxBlock::Elset(name);
318                    continue;
319                }
320
321                if upper.starts_with("*MATERIAL") {
322                    let name = extract_param(line, "NAME").unwrap_or_else(|| "UNNAMED".to_string());
323                    current_material = Some(name.clone());
324                    mat_data.entry(name).or_insert((None, None, None));
325                    block = CcxBlock::None;
326                    continue;
327                }
328
329                if upper.starts_with("*ELASTIC") {
330                    if let Some(ref mat_name) = current_material {
331                        block = CcxBlock::Elastic(mat_name.clone());
332                    } else {
333                        block = CcxBlock::None;
334                    }
335                    continue;
336                }
337
338                if upper.starts_with("*DENSITY") {
339                    if let Some(ref mat_name) = current_material {
340                        block = CcxBlock::Density(mat_name.clone());
341                    } else {
342                        block = CcxBlock::None;
343                    }
344                    continue;
345                }
346
347                if upper.starts_with("*BOUNDARY") {
348                    block = CcxBlock::Boundary;
349                    continue;
350                }
351
352                if upper.starts_with("*CLOAD") {
353                    block = CcxBlock::Cload;
354                    continue;
355                }
356
357                if upper.starts_with("*STEP") {
358                    in_step = true;
359                    let name = extract_param(line, "NAME").unwrap_or_else(|| "Step-1".to_string());
360                    mesh.steps.push(AnalysisStep::new(name));
361                    block = CcxBlock::None;
362                    continue;
363                }
364
365                if upper.starts_with("*STATIC") {
366                    block = CcxBlock::Static;
367                    continue;
368                }
369
370                if upper.starts_with("*SOLID SECTION") {
371                    // We just skip this for now (info is in material + elset)
372                    block = CcxBlock::None;
373                    continue;
374                }
375
376                // Any other keyword: reset block
377                block = CcxBlock::None;
378                continue;
379            }
380
381            // Data line -- dispatch based on current block
382            match &block {
383                CcxBlock::None | CcxBlock::Heading => {
384                    // heading text or unrecognised: ignore
385                }
386                CcxBlock::Node => {
387                    if let Some(node) = parse_node_line(line) {
388                        mesh.nodes.push(node);
389                    }
390                }
391                CcxBlock::Element(etype) => {
392                    if let Some(elem) = parse_element_line(line, etype.clone()) {
393                        mesh.elements.push(elem);
394                    }
395                }
396                CcxBlock::Elastic(mat_name) => {
397                    let parts: Vec<&str> = line.split(',').collect();
398                    if parts.len() >= 2
399                        && let (Some(e), Some(nu)) = (
400                            parts[0].trim().parse::<f64>().ok(),
401                            parts[1].trim().parse::<f64>().ok(),
402                        )
403                    {
404                        let entry = mat_data
405                            .entry(mat_name.clone())
406                            .or_insert((None, None, None));
407                        entry.0 = Some(e);
408                        entry.1 = Some(nu);
409                    }
410                    block = CcxBlock::None;
411                }
412                CcxBlock::Density(mat_name) => {
413                    if let Some(d) = line
414                        .split(',')
415                        .next()
416                        .and_then(|s| s.trim().parse::<f64>().ok())
417                    {
418                        let entry = mat_data
419                            .entry(mat_name.clone())
420                            .or_insert((None, None, None));
421                        entry.2 = Some(d);
422                    }
423                    block = CcxBlock::None;
424                }
425                CcxBlock::Boundary => {
426                    if let Some(bc) = parse_boundary_line(line)
427                        && in_step
428                        && let Some(step) = mesh.steps.last_mut()
429                    {
430                        step.bcs.push(bc);
431                    }
432                    // If outside step, we could store globally; for now skip.
433                }
434                CcxBlock::Cload => {
435                    if let Some(force) = parse_cload_line(line)
436                        && in_step
437                        && let Some(step) = mesh.steps.last_mut()
438                    {
439                        step.forces.push(force);
440                    }
441                }
442                CcxBlock::Static => {
443                    // Parse time increment line: initial_increment, time_period
444                    let parts: Vec<&str> = line.split(',').collect();
445                    if parts.len() >= 2
446                        && let (Some(inc), Some(period)) = (
447                            parts[0].trim().parse::<f64>().ok(),
448                            parts[1].trim().parse::<f64>().ok(),
449                        )
450                        && let Some(step) = mesh.steps.last_mut()
451                    {
452                        step.initial_increment = inc;
453                        step.time_period = period;
454                    }
455                    block = CcxBlock::None;
456                }
457                CcxBlock::Elset(name) => {
458                    let ids: Vec<usize> = line
459                        .split(',')
460                        .filter_map(|s| s.trim().parse::<usize>().ok())
461                        .collect();
462                    mesh.element_sets
463                        .entry(name.clone())
464                        .or_default()
465                        .extend(ids);
466                }
467                CcxBlock::Nset(name) => {
468                    let ids: Vec<usize> = line
469                        .split(',')
470                        .filter_map(|s| s.trim().parse::<usize>().ok())
471                        .collect();
472                    mesh.node_sets.entry(name.clone()).or_default().extend(ids);
473                }
474            }
475        }
476
477        // Assemble materials from partial data
478        for (mat_id, (name, (young, poisson, density))) in (1usize..).zip(mat_data.iter()) {
479            let e = young.unwrap_or(0.0);
480            let nu = poisson.unwrap_or(0.0);
481            let rho = density.unwrap_or(0.0);
482            mesh.materials
483                .push(LinearElasticMaterial::new(mat_id, name.clone(), e, nu, rho));
484        }
485
486        Ok(mesh)
487    }
488}
489
490// ---------------------------------------------------------------------------
491// Helper parse functions
492// ---------------------------------------------------------------------------
493
494/// Extract `KEY=VALUE` from a keyword line (case-insensitive key search).
495fn extract_param(line: &str, key: &str) -> Option<String> {
496    let upper = line.to_uppercase();
497    let key_eq = format!("{}=", key.to_uppercase());
498    let pos = upper.find(&key_eq)?;
499    let rest = &line[pos + key_eq.len()..];
500    let end = rest.find(',').unwrap_or(rest.len());
501    let val = rest[..end].trim();
502    if val.is_empty() {
503        None
504    } else {
505        Some(val.to_string())
506    }
507}
508
509/// Parse a node data line: `id, x, y, z`.
510fn parse_node_line(line: &str) -> Option<FeNode> {
511    let parts: Vec<&str> = line.split(',').collect();
512    if parts.len() < 4 {
513        return None;
514    }
515    let id: usize = parts[0].trim().parse().ok()?;
516    let x: f64 = parts[1].trim().parse().ok()?;
517    let y: f64 = parts[2].trim().parse().ok()?;
518    let z: f64 = parts[3].trim().parse().ok()?;
519    Some(FeNode::new(id, [x, y, z]))
520}
521
522/// Parse an element data line: `id, n1, n2, ...`.
523fn parse_element_line(line: &str, etype: FeElementType) -> Option<FeElement> {
524    let parts: Vec<&str> = line.split(',').collect();
525    if parts.len() < 2 {
526        return None;
527    }
528    let id: usize = parts[0].trim().parse().ok()?;
529    let connectivity: Vec<usize> = parts[1..]
530        .iter()
531        .filter_map(|s| s.trim().parse::<usize>().ok())
532        .collect();
533    if connectivity.is_empty() {
534        return None;
535    }
536    Some(FeElement::new(id, etype, connectivity))
537}
538
539/// Parse a `*BOUNDARY` data line: `node_id, dof_start, dof_end, value`.
540///
541/// CalculiX boundary lines can be:
542/// - `node_id, dof_start, dof_end`          (value = 0)
543/// - `node_id, dof_start, dof_end, value`
544fn parse_boundary_line(line: &str) -> Option<DirichletBc> {
545    let parts: Vec<&str> = line.split(',').collect();
546    if parts.len() < 3 {
547        return None;
548    }
549    let node_id: usize = parts[0].trim().parse().ok()?;
550    let dof_start: u8 = parts[1].trim().parse().ok()?;
551    let dof_end: u8 = parts[2].trim().parse().ok()?;
552    let value: f64 = if parts.len() >= 4 {
553        parts[3].trim().parse().unwrap_or(0.0)
554    } else {
555        0.0
556    };
557
558    // Expand range: create one BC per DOF in [dof_start, dof_end].
559    // For simplicity we return only the first; callers needing the full range
560    // should use `parse_boundary_lines`.  However, the typical round-trip
561    // writes dof_start == dof_end, so this is fine for most cases.
562    // To handle the range properly, we return the first DOF and note that
563    // the writer always emits dof_start == dof_end.
564    if dof_start == dof_end {
565        Some(DirichletBc {
566            node_id,
567            dof: dof_start,
568            value,
569        })
570    } else {
571        // Return just the first DOF of the range
572        Some(DirichletBc {
573            node_id,
574            dof: dof_start,
575            value,
576        })
577    }
578}
579
580/// Parse a full `*BOUNDARY` data line into potentially multiple BCs
581/// (when `dof_start != dof_end`, one [`DirichletBc`] per DOF in the range).
582pub fn parse_boundary_line_expanded(line: &str) -> Vec<DirichletBc> {
583    let parts: Vec<&str> = line.split(',').collect();
584    if parts.len() < 3 {
585        return Vec::new();
586    }
587    let node_id = match parts[0].trim().parse::<usize>() {
588        Ok(v) => v,
589        Err(_) => return Vec::new(),
590    };
591    let dof_start = match parts[1].trim().parse::<u8>() {
592        Ok(v) => v,
593        Err(_) => return Vec::new(),
594    };
595    let dof_end = match parts[2].trim().parse::<u8>() {
596        Ok(v) => v,
597        Err(_) => return Vec::new(),
598    };
599    let value: f64 = if parts.len() >= 4 {
600        parts[3].trim().parse().unwrap_or(0.0)
601    } else {
602        0.0
603    };
604
605    let mut bcs = Vec::new();
606    let lo = dof_start.min(dof_end);
607    let hi = dof_start.max(dof_end);
608    for dof in lo..=hi {
609        bcs.push(DirichletBc {
610            node_id,
611            dof,
612            value,
613        });
614    }
615    bcs
616}
617
618/// Parse a `*CLOAD` data line: `node_id, dof, magnitude`.
619fn parse_cload_line(line: &str) -> Option<NodalForce> {
620    let parts: Vec<&str> = line.split(',').collect();
621    if parts.len() < 3 {
622        return None;
623    }
624    let node_id: usize = parts[0].trim().parse().ok()?;
625    let dof: u8 = parts[1].trim().parse().ok()?;
626    let magnitude: f64 = parts[2].trim().parse().ok()?;
627    Some(NodalForce::new(node_id, dof, magnitude))
628}
629
630// ---------------------------------------------------------------------------
631// Tests
632// ---------------------------------------------------------------------------
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use std::path::Path;
638
639    // ----- Element type mapping -----
640
641    #[test]
642    fn test_fe_type_to_calculix_tet4() {
643        assert_eq!(fe_type_to_calculix(&FeElementType::Tet4), "C3D4");
644    }
645
646    #[test]
647    fn test_fe_type_to_calculix_tet10() {
648        assert_eq!(fe_type_to_calculix(&FeElementType::Tet10), "C3D10");
649    }
650
651    #[test]
652    fn test_fe_type_to_calculix_hex8() {
653        assert_eq!(fe_type_to_calculix(&FeElementType::Hex8), "C3D8");
654    }
655
656    #[test]
657    fn test_fe_type_to_calculix_hex20() {
658        assert_eq!(fe_type_to_calculix(&FeElementType::Hex20), "C3D20");
659    }
660
661    #[test]
662    fn test_fe_type_to_calculix_tri3() {
663        assert_eq!(fe_type_to_calculix(&FeElementType::Tri3), "S3");
664    }
665
666    #[test]
667    fn test_fe_type_to_calculix_tri6() {
668        assert_eq!(fe_type_to_calculix(&FeElementType::Tri6), "S6");
669    }
670
671    #[test]
672    fn test_fe_type_to_calculix_quad4() {
673        assert_eq!(fe_type_to_calculix(&FeElementType::Quad4), "S4");
674    }
675
676    #[test]
677    fn test_fe_type_to_calculix_quad8() {
678        assert_eq!(fe_type_to_calculix(&FeElementType::Quad8), "S8");
679    }
680
681    #[test]
682    fn test_fe_type_to_calculix_line2() {
683        assert_eq!(fe_type_to_calculix(&FeElementType::Line2), "T3D2");
684    }
685
686    #[test]
687    fn test_calculix_type_to_fe_roundtrip() {
688        let types = vec![
689            FeElementType::Tet4,
690            FeElementType::Tet10,
691            FeElementType::Hex8,
692            FeElementType::Hex20,
693            FeElementType::Tri3,
694            FeElementType::Tri6,
695            FeElementType::Quad4,
696            FeElementType::Quad8,
697            FeElementType::Line2,
698        ];
699        for t in types {
700            let ccx_str = fe_type_to_calculix(&t);
701            let back = calculix_type_to_fe(ccx_str);
702            assert_eq!(back, t, "roundtrip failed for {ccx_str}");
703        }
704    }
705
706    #[test]
707    fn test_calculix_type_to_fe_case_insensitive() {
708        assert_eq!(calculix_type_to_fe("c3d4"), FeElementType::Tet4);
709        assert_eq!(calculix_type_to_fe("C3D10"), FeElementType::Tet10);
710        assert_eq!(calculix_type_to_fe("s8"), FeElementType::Quad8);
711    }
712
713    #[test]
714    fn test_calculix_type_to_fe_unknown() {
715        match calculix_type_to_fe("FOOBAR") {
716            FeElementType::Unknown(s) => assert_eq!(s, "FOOBAR"),
717            other => panic!("expected Unknown, got {:?}", other),
718        }
719    }
720
721    // ----- Helper functions -----
722
723    #[test]
724    fn test_extract_param_type() {
725        let line = "*ELEMENT, TYPE=C3D8";
726        assert_eq!(extract_param(line, "TYPE"), Some("C3D8".to_string()));
727    }
728
729    #[test]
730    fn test_extract_param_name() {
731        let line = "*MATERIAL, NAME=Steel";
732        assert_eq!(extract_param(line, "NAME"), Some("Steel".to_string()));
733    }
734
735    #[test]
736    fn test_extract_param_missing() {
737        let line = "*NODE";
738        assert_eq!(extract_param(line, "TYPE"), None);
739    }
740
741    #[test]
742    fn test_parse_node_line_valid() {
743        let n = parse_node_line("1, 0.5, 1.0, 2.0");
744        assert!(n.is_some());
745        let n = n.expect("should parse");
746        assert_eq!(n.id, 1);
747        assert!((n.coords[0] - 0.5).abs() < 1e-12);
748        assert!((n.coords[1] - 1.0).abs() < 1e-12);
749        assert!((n.coords[2] - 2.0).abs() < 1e-12);
750    }
751
752    #[test]
753    fn test_parse_node_line_invalid() {
754        assert!(parse_node_line("1, 0.5").is_none());
755    }
756
757    #[test]
758    fn test_parse_element_line_valid() {
759        let el = parse_element_line("1, 10, 20, 30, 40", FeElementType::Tet4);
760        assert!(el.is_some());
761        let el = el.expect("should parse");
762        assert_eq!(el.id, 1);
763        assert_eq!(el.connectivity, vec![10, 20, 30, 40]);
764    }
765
766    #[test]
767    fn test_parse_element_line_too_short() {
768        assert!(parse_element_line("1", FeElementType::Hex8).is_none());
769    }
770
771    // ----- Boundary condition parsing -----
772
773    #[test]
774    fn test_parse_boundary_line_with_value() {
775        let bc = parse_boundary_line("5, 1, 1, 0.01");
776        assert!(bc.is_some());
777        let bc = bc.expect("should parse");
778        assert_eq!(bc.node_id, 5);
779        assert_eq!(bc.dof, 1);
780        assert!((bc.value - 0.01).abs() < 1e-15);
781    }
782
783    #[test]
784    fn test_parse_boundary_line_zero_value() {
785        let bc = parse_boundary_line("10, 2, 2");
786        assert!(bc.is_some());
787        let bc = bc.expect("should parse");
788        assert_eq!(bc.node_id, 10);
789        assert_eq!(bc.dof, 2);
790        assert!((bc.value).abs() < 1e-15);
791    }
792
793    #[test]
794    fn test_parse_boundary_line_invalid() {
795        assert!(parse_boundary_line("bad, data").is_none());
796    }
797
798    #[test]
799    fn test_parse_boundary_line_expanded_range() {
800        let bcs = parse_boundary_line_expanded("1, 1, 3, 0.0");
801        assert_eq!(bcs.len(), 3);
802        assert_eq!(bcs[0].dof, 1);
803        assert_eq!(bcs[1].dof, 2);
804        assert_eq!(bcs[2].dof, 3);
805    }
806
807    // ----- CLOAD parsing -----
808
809    #[test]
810    fn test_parse_cload_line_valid() {
811        let f = parse_cload_line("7, 2, -1000.0");
812        assert!(f.is_some());
813        let f = f.expect("should parse");
814        assert_eq!(f.node_id, 7);
815        assert_eq!(f.dof, 2);
816        assert!((f.magnitude - (-1000.0)).abs() < 1e-10);
817    }
818
819    #[test]
820    fn test_parse_cload_line_invalid() {
821        assert!(parse_cload_line("7, 2").is_none());
822    }
823
824    // ----- Round-trip: write -> read -> compare -----
825
826    fn sample_mesh() -> FeMesh {
827        let mut mesh = FeMesh::new();
828        mesh.nodes = vec![
829            FeNode::new(1, [0.0, 0.0, 0.0]),
830            FeNode::new(2, [1.0, 0.0, 0.0]),
831            FeNode::new(3, [0.0, 1.0, 0.0]),
832            FeNode::new(4, [0.0, 0.0, 1.0]),
833        ];
834        mesh.elements = vec![FeElement::new(1, FeElementType::Tet4, vec![1, 2, 3, 4])];
835        mesh.materials = vec![LinearElasticMaterial::new(1, "Steel", 210e9, 0.3, 7800.0)];
836
837        let mut step = AnalysisStep::new("LoadStep");
838        step.bcs.push(DirichletBc::fixed(1, 1));
839        step.bcs.push(DirichletBc::fixed(1, 2));
840        step.bcs.push(DirichletBc::fixed(1, 3));
841        step.forces.push(NodalForce::new(4, 2, -1000.0));
842        step.initial_increment = 0.1;
843        step.time_period = 1.0;
844        mesh.steps.push(step);
845
846        mesh
847    }
848
849    fn tmp_path(name: &str) -> String {
850        let dir = std::env::temp_dir();
851        dir.join(name).to_string_lossy().to_string()
852    }
853
854    #[test]
855    fn test_write_creates_file() {
856        let path = tmp_path("oxiphysics_ccx_write.inp");
857        let mesh = sample_mesh();
858        CalculixWriter::new()
859            .write(&mesh, &path)
860            .expect("write failed");
861        assert!(Path::new(&path).exists());
862    }
863
864    #[test]
865    fn test_roundtrip_node_count() {
866        let path = tmp_path("oxiphysics_ccx_rt_nodes.inp");
867        let mesh = sample_mesh();
868        CalculixWriter::new()
869            .write(&mesh, &path)
870            .expect("write failed");
871        let parsed = CalculixReader::new().parse(&path).expect("parse failed");
872        assert_eq!(parsed.nodes.len(), mesh.nodes.len());
873    }
874
875    #[test]
876    fn test_roundtrip_node_ids() {
877        let path = tmp_path("oxiphysics_ccx_rt_nids.inp");
878        let mesh = sample_mesh();
879        CalculixWriter::new().write(&mesh, &path).expect("write");
880        let parsed = CalculixReader::new().parse(&path).expect("parse");
881        let ids: Vec<usize> = parsed.nodes.iter().map(|n| n.id).collect();
882        assert_eq!(ids, vec![1, 2, 3, 4]);
883    }
884
885    #[test]
886    fn test_roundtrip_node_coords() {
887        let path = tmp_path("oxiphysics_ccx_rt_coords.inp");
888        let mesh = sample_mesh();
889        CalculixWriter::new().write(&mesh, &path).expect("write");
890        let parsed = CalculixReader::new().parse(&path).expect("parse");
891        assert!((parsed.nodes[0].coords[0]).abs() < 1e-10);
892        assert!((parsed.nodes[1].coords[0] - 1.0).abs() < 1e-10);
893        assert!((parsed.nodes[2].coords[1] - 1.0).abs() < 1e-10);
894        assert!((parsed.nodes[3].coords[2] - 1.0).abs() < 1e-10);
895    }
896
897    #[test]
898    fn test_roundtrip_element_count() {
899        let path = tmp_path("oxiphysics_ccx_rt_elems.inp");
900        let mesh = sample_mesh();
901        CalculixWriter::new().write(&mesh, &path).expect("write");
902        let parsed = CalculixReader::new().parse(&path).expect("parse");
903        assert_eq!(parsed.elements.len(), 1);
904    }
905
906    #[test]
907    fn test_roundtrip_element_type() {
908        let path = tmp_path("oxiphysics_ccx_rt_etype.inp");
909        let mesh = sample_mesh();
910        CalculixWriter::new().write(&mesh, &path).expect("write");
911        let parsed = CalculixReader::new().parse(&path).expect("parse");
912        assert_eq!(parsed.elements[0].element_type, FeElementType::Tet4);
913    }
914
915    #[test]
916    fn test_roundtrip_element_connectivity() {
917        let path = tmp_path("oxiphysics_ccx_rt_econn.inp");
918        let mesh = sample_mesh();
919        CalculixWriter::new().write(&mesh, &path).expect("write");
920        let parsed = CalculixReader::new().parse(&path).expect("parse");
921        assert_eq!(parsed.elements[0].connectivity, vec![1, 2, 3, 4]);
922    }
923
924    #[test]
925    fn test_roundtrip_material() {
926        let path = tmp_path("oxiphysics_ccx_rt_mat.inp");
927        let mesh = sample_mesh();
928        CalculixWriter::new().write(&mesh, &path).expect("write");
929        let parsed = CalculixReader::new().parse(&path).expect("parse");
930        assert_eq!(parsed.materials.len(), 1);
931        let mat = &parsed.materials[0];
932        assert_eq!(mat.name, "Steel");
933        assert!((mat.young_modulus - 210e9).abs() < 1.0);
934        assert!((mat.poisson_ratio - 0.3).abs() < 1e-10);
935        assert!((mat.density - 7800.0).abs() < 1e-6);
936    }
937
938    #[test]
939    fn test_roundtrip_boundary_conditions() {
940        let path = tmp_path("oxiphysics_ccx_rt_bc.inp");
941        let mesh = sample_mesh();
942        CalculixWriter::new().write(&mesh, &path).expect("write");
943        let parsed = CalculixReader::new().parse(&path).expect("parse");
944        assert_eq!(parsed.steps.len(), 1);
945        assert_eq!(parsed.steps[0].bcs.len(), 3);
946        assert_eq!(parsed.steps[0].bcs[0].node_id, 1);
947        assert_eq!(parsed.steps[0].bcs[0].dof, 1);
948        assert!((parsed.steps[0].bcs[0].value).abs() < 1e-15);
949    }
950
951    #[test]
952    fn test_roundtrip_cload() {
953        let path = tmp_path("oxiphysics_ccx_rt_cload.inp");
954        let mesh = sample_mesh();
955        CalculixWriter::new().write(&mesh, &path).expect("write");
956        let parsed = CalculixReader::new().parse(&path).expect("parse");
957        assert_eq!(parsed.steps[0].forces.len(), 1);
958        let f = &parsed.steps[0].forces[0];
959        assert_eq!(f.node_id, 4);
960        assert_eq!(f.dof, 2);
961        assert!((f.magnitude - (-1000.0)).abs() < 1e-6);
962    }
963
964    #[test]
965    fn test_roundtrip_step_timing() {
966        let path = tmp_path("oxiphysics_ccx_rt_step.inp");
967        let mesh = sample_mesh();
968        CalculixWriter::new().write(&mesh, &path).expect("write");
969        let parsed = CalculixReader::new().parse(&path).expect("parse");
970        let step = &parsed.steps[0];
971        assert!((step.initial_increment - 0.1).abs() < 1e-6);
972        assert!((step.time_period - 1.0).abs() < 1e-6);
973    }
974
975    #[test]
976    fn test_roundtrip_multiple_element_types() {
977        let path = tmp_path("oxiphysics_ccx_rt_multi.inp");
978        let mut mesh = FeMesh::new();
979        mesh.nodes = vec![
980            FeNode::new(1, [0.0, 0.0, 0.0]),
981            FeNode::new(2, [1.0, 0.0, 0.0]),
982            FeNode::new(3, [0.0, 1.0, 0.0]),
983            FeNode::new(4, [0.0, 0.0, 1.0]),
984            FeNode::new(5, [1.0, 1.0, 0.0]),
985        ];
986        mesh.elements = vec![
987            FeElement::new(1, FeElementType::Tet4, vec![1, 2, 3, 4]),
988            FeElement::new(2, FeElementType::Line2, vec![4, 5]),
989        ];
990        CalculixWriter::new().write(&mesh, &path).expect("write");
991        let parsed = CalculixReader::new().parse(&path).expect("parse");
992        assert_eq!(parsed.elements.len(), 2);
993        assert_eq!(parsed.elements[0].element_type, FeElementType::Tet4);
994        assert_eq!(parsed.elements[1].element_type, FeElementType::Line2);
995    }
996
997    #[test]
998    fn test_write_string_contains_heading() {
999        let mesh = sample_mesh();
1000        let content = CalculixWriter::new()
1001            .write_string(&mesh)
1002            .expect("write_string");
1003        assert!(content.contains("*HEADING"));
1004    }
1005
1006    #[test]
1007    fn test_write_string_contains_node() {
1008        let mesh = sample_mesh();
1009        let content = CalculixWriter::new()
1010            .write_string(&mesh)
1011            .expect("write_string");
1012        assert!(content.contains("*NODE"));
1013    }
1014
1015    #[test]
1016    fn test_write_string_contains_element() {
1017        let mesh = sample_mesh();
1018        let content = CalculixWriter::new()
1019            .write_string(&mesh)
1020            .expect("write_string");
1021        assert!(content.contains("*ELEMENT"));
1022    }
1023
1024    #[test]
1025    fn test_write_string_contains_material() {
1026        let mesh = sample_mesh();
1027        let content = CalculixWriter::new()
1028            .write_string(&mesh)
1029            .expect("write_string");
1030        assert!(content.contains("*MATERIAL"));
1031        assert!(content.contains("*DENSITY"));
1032        assert!(content.contains("*ELASTIC"));
1033    }
1034
1035    #[test]
1036    fn test_write_string_contains_step() {
1037        let mesh = sample_mesh();
1038        let content = CalculixWriter::new()
1039            .write_string(&mesh)
1040            .expect("write_string");
1041        assert!(content.contains("*STEP"));
1042        assert!(content.contains("*STATIC"));
1043        assert!(content.contains("*END STEP"));
1044    }
1045
1046    #[test]
1047    fn test_write_string_contains_boundary() {
1048        let mesh = sample_mesh();
1049        let content = CalculixWriter::new()
1050            .write_string(&mesh)
1051            .expect("write_string");
1052        assert!(content.contains("*BOUNDARY"));
1053    }
1054
1055    #[test]
1056    fn test_write_string_contains_cload() {
1057        let mesh = sample_mesh();
1058        let content = CalculixWriter::new()
1059            .write_string(&mesh)
1060            .expect("write_string");
1061        assert!(content.contains("*CLOAD"));
1062    }
1063
1064    #[test]
1065    fn test_parse_empty_file() {
1066        let reader = CalculixReader::new();
1067        let mesh = reader.parse_string("** empty file\n").expect("parse");
1068        assert!(mesh.nodes.is_empty());
1069        assert!(mesh.elements.is_empty());
1070    }
1071
1072    #[test]
1073    fn test_parse_missing_file() {
1074        let result = CalculixReader::new().parse("/tmp/does_not_exist_oxiphysics_ccx.inp");
1075        assert!(result.is_err());
1076    }
1077
1078    #[test]
1079    fn test_roundtrip_precision() {
1080        let path = tmp_path("oxiphysics_ccx_rt_prec.inp");
1081        let mut mesh = FeMesh::new();
1082        mesh.nodes.push(FeNode::new(
1083            1,
1084            [1.23456789012345, -9.87654321098765, 2.89793238462643],
1085        ));
1086        CalculixWriter::new().write(&mesh, &path).expect("write");
1087        let parsed = CalculixReader::new().parse(&path).expect("parse");
1088        let c = parsed.nodes[0].coords;
1089        assert!((c[0] - 1.23456789012345).abs() < 1e-10);
1090        assert!((c[1] - (-9.87654321098765)).abs() < 1e-10);
1091        assert!((c[2] - 2.89793238462643).abs() < 1e-10);
1092    }
1093
1094    #[test]
1095    fn test_large_mesh_roundtrip() {
1096        let path = tmp_path("oxiphysics_ccx_rt_large.inp");
1097        let mut mesh = FeMesh::new();
1098        for i in 1..=100 {
1099            mesh.nodes.push(FeNode::new(i, [i as f64, 0.0, 0.0]));
1100        }
1101        for i in 0..24usize {
1102            mesh.elements.push(FeElement::new(
1103                i + 1,
1104                FeElementType::Tet4,
1105                vec![
1106                    i * 4 % 97 + 1,
1107                    i * 4 % 97 + 2,
1108                    i * 4 % 97 + 3,
1109                    i * 4 % 97 + 4,
1110                ],
1111            ));
1112        }
1113        CalculixWriter::new().write(&mesh, &path).expect("write");
1114        let parsed = CalculixReader::new().parse(&path).expect("parse");
1115        assert_eq!(parsed.nodes.len(), 100);
1116        assert_eq!(parsed.elements.len(), 24);
1117    }
1118
1119    #[test]
1120    fn test_hex8_roundtrip() {
1121        let path = tmp_path("oxiphysics_ccx_rt_hex8.inp");
1122        let mut mesh = FeMesh::new();
1123        for i in 1..=8 {
1124            mesh.nodes.push(FeNode::new(i, [i as f64, 0.0, 0.0]));
1125        }
1126        mesh.elements.push(FeElement::new(
1127            1,
1128            FeElementType::Hex8,
1129            vec![1, 2, 3, 4, 5, 6, 7, 8],
1130        ));
1131        CalculixWriter::new().write(&mesh, &path).expect("write");
1132        let parsed = CalculixReader::new().parse(&path).expect("parse");
1133        assert_eq!(parsed.elements[0].element_type, FeElementType::Hex8);
1134        assert_eq!(
1135            parsed.elements[0].connectivity,
1136            vec![1, 2, 3, 4, 5, 6, 7, 8]
1137        );
1138    }
1139
1140    #[test]
1141    fn test_parse_string_with_nset_and_elset() {
1142        let inp = "\
1143*NODE
11441, 0.0, 0.0, 0.0
11452, 1.0, 0.0, 0.0
1146*ELEMENT, TYPE=T3D2
11471, 1, 2
1148*NSET, NSET=FIXED
11491
1150*ELSET, ELSET=ALL
11511
1152";
1153        let reader = CalculixReader::new();
1154        let mesh = reader.parse_string(inp).expect("parse");
1155        assert_eq!(mesh.nodes.len(), 2);
1156        assert_eq!(mesh.elements.len(), 1);
1157        assert_eq!(mesh.node_sets.get("FIXED"), Some(&vec![1]));
1158        assert_eq!(mesh.element_sets.get("ALL"), Some(&vec![1]));
1159    }
1160
1161    #[test]
1162    fn test_parse_boundary_prescribed() {
1163        let inp = "\
1164*NODE
11651, 0.0, 0.0, 0.0
1166*STEP
1167*STATIC
11680.01, 1.0
1169*BOUNDARY
11701, 1, 1, 0.05
1171*END STEP
1172";
1173        let reader = CalculixReader::new();
1174        let mesh = reader.parse_string(inp).expect("parse");
1175        assert_eq!(mesh.steps.len(), 1);
1176        assert_eq!(mesh.steps[0].bcs.len(), 1);
1177        assert_eq!(mesh.steps[0].bcs[0].node_id, 1);
1178        assert_eq!(mesh.steps[0].bcs[0].dof, 1);
1179        assert!((mesh.steps[0].bcs[0].value - 0.05).abs() < 1e-15);
1180    }
1181}