Skip to main content

oxiphysics_io/
abaqus_format.rs

1#![allow(clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Abaqus INP file format I/O.
6//!
7//! Provides readers and writers for the Abaqus `.inp` finite-element input
8//! format, covering node/element definitions, material properties, section
9//! assignments, and boundary conditions.
10
11use std::fmt::Write as FmtWrite;
12use std::fs;
13use std::io::{self, BufRead};
14
15use crate::Error as IoError;
16
17// ---------------------------------------------------------------------------
18// AbaqusNode
19// ---------------------------------------------------------------------------
20
21/// A single node in an Abaqus mesh.
22#[derive(Debug, Clone, PartialEq)]
23pub struct AbaqusNode {
24    /// 1-based node identifier.
25    pub id: usize,
26    /// Node coordinates `[x, y, z]`.
27    pub coordinates: [f64; 3],
28}
29
30impl AbaqusNode {
31    /// Create a new node.
32    pub fn new(id: usize, coordinates: [f64; 3]) -> Self {
33        Self { id, coordinates }
34    }
35}
36
37// ---------------------------------------------------------------------------
38// ElementType
39// ---------------------------------------------------------------------------
40
41/// Abaqus element type identifier.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ElementType {
44    /// 4-node 3-D tetrahedral element.
45    C3D4,
46    /// 8-node 3-D hexahedral element.
47    C3D8,
48    /// 4-node shell element.
49    S4,
50    /// 2-node truss element.
51    T3D2,
52    /// Unknown / unsupported element type.
53    Unknown(String),
54}
55
56impl ElementType {
57    /// Canonical Abaqus keyword string.
58    pub fn as_str(&self) -> &str {
59        match self {
60            ElementType::C3D4 => "C3D4",
61            ElementType::C3D8 => "C3D8",
62            ElementType::S4 => "S4",
63            ElementType::T3D2 => "T3D2",
64            ElementType::Unknown(s) => s.as_str(),
65        }
66    }
67
68    /// Parse from string (case-insensitive).
69    pub fn from_str(s: &str) -> Self {
70        match s.trim().to_uppercase().as_str() {
71            "C3D4" => ElementType::C3D4,
72            "C3D8" => ElementType::C3D8,
73            "S4" => ElementType::S4,
74            "T3D2" => ElementType::T3D2,
75            other => ElementType::Unknown(other.to_string()),
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// AbaqusElement
82// ---------------------------------------------------------------------------
83
84/// A single finite element in an Abaqus mesh.
85#[derive(Debug, Clone, PartialEq)]
86pub struct AbaqusElement {
87    /// 1-based element identifier.
88    pub id: usize,
89    /// Element topology type.
90    pub element_type: ElementType,
91    /// Node connectivity (1-based node ids).
92    pub node_ids: Vec<usize>,
93}
94
95impl AbaqusElement {
96    /// Create a new element.
97    pub fn new(id: usize, element_type: ElementType, node_ids: Vec<usize>) -> Self {
98        Self {
99            id,
100            element_type,
101            node_ids,
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// AbaqusSection
108// ---------------------------------------------------------------------------
109
110/// An Abaqus section assignment mapping elements to a material.
111#[derive(Debug, Clone)]
112pub struct AbaqusSection {
113    /// Section name.
114    pub name: String,
115    /// Name of the associated material.
116    pub material_name: String,
117    /// Element set indices assigned to this section.
118    pub elements: Vec<usize>,
119}
120
121impl AbaqusSection {
122    /// Create a new section.
123    pub fn new(
124        name: impl Into<String>,
125        material_name: impl Into<String>,
126        elements: Vec<usize>,
127    ) -> Self {
128        Self {
129            name: name.into(),
130            material_name: material_name.into(),
131            elements,
132        }
133    }
134}
135
136// ---------------------------------------------------------------------------
137// AbaqusMaterial
138// ---------------------------------------------------------------------------
139
140/// Elastic material properties used by Abaqus.
141#[derive(Debug, Clone)]
142pub struct ElasticProps {
143    /// Young's modulus (Pa).
144    pub young_modulus: f64,
145    /// Poisson's ratio (dimensionless).
146    pub poisson_ratio: f64,
147}
148
149/// Simple isotropic hardening plasticity.
150#[derive(Debug, Clone)]
151pub struct PlasticProps {
152    /// Initial yield stress (Pa).
153    pub yield_stress: f64,
154    /// Linear hardening modulus (Pa).
155    pub hardening_modulus: f64,
156}
157
158/// Material definition for an Abaqus model.
159#[derive(Debug, Clone)]
160pub struct AbaqusMaterial {
161    /// Material name.
162    pub name: String,
163    /// Elastic properties.
164    pub elastic: ElasticProps,
165    /// Material density (kg/m³).
166    pub density: f64,
167    /// Optional plastic properties.
168    pub plastic: Option<PlasticProps>,
169}
170
171impl AbaqusMaterial {
172    /// Create a purely elastic material.
173    pub fn new_elastic(
174        name: impl Into<String>,
175        young_modulus: f64,
176        poisson_ratio: f64,
177        density: f64,
178    ) -> Self {
179        Self {
180            name: name.into(),
181            elastic: ElasticProps {
182                young_modulus,
183                poisson_ratio,
184            },
185            density,
186            plastic: None,
187        }
188    }
189
190    /// Create an elastoplastic material.
191    pub fn new_plastic(
192        name: impl Into<String>,
193        young_modulus: f64,
194        poisson_ratio: f64,
195        density: f64,
196        yield_stress: f64,
197        hardening_modulus: f64,
198    ) -> Self {
199        Self {
200            name: name.into(),
201            elastic: ElasticProps {
202                young_modulus,
203                poisson_ratio,
204            },
205            density,
206            plastic: Some(PlasticProps {
207                yield_stress,
208                hardening_modulus,
209            }),
210        }
211    }
212}
213
214// ---------------------------------------------------------------------------
215// BoundaryCondition
216// ---------------------------------------------------------------------------
217
218/// Abaqus boundary condition type.
219#[derive(Debug, Clone, PartialEq)]
220pub enum BoundaryCondition {
221    /// All 6 DOFs fixed (clamped).
222    Encastre {
223        /// Node-set name the BC applies to.
224        node_set: String,
225    },
226    /// Translation DOFs fixed, rotations free.
227    Pinned {
228        /// Node-set name the BC applies to.
229        node_set: String,
230    },
231    /// Symmetry plane constraint.
232    SymmetryPlane {
233        /// Node-set name the BC applies to.
234        node_set: String,
235        /// Axis normal to the symmetry plane: 1=X, 2=Y, 3=Z.
236        axis: u8,
237    },
238}
239
240impl BoundaryCondition {
241    /// Return the Abaqus keyword string for this BC type.
242    pub fn keyword(&self) -> &'static str {
243        match self {
244            BoundaryCondition::Encastre { .. } => "ENCASTRE",
245            BoundaryCondition::Pinned { .. } => "PINNED",
246            BoundaryCondition::SymmetryPlane { .. } => "SYMMETRY",
247        }
248    }
249}
250
251// ---------------------------------------------------------------------------
252// AbaqusMesh
253// ---------------------------------------------------------------------------
254
255/// Complete Abaqus mesh model.
256#[derive(Debug, Clone, Default)]
257pub struct AbaqusMesh {
258    /// All nodes in the model.
259    pub nodes: Vec<AbaqusNode>,
260    /// All elements in the model.
261    pub elements: Vec<AbaqusElement>,
262    /// Section assignments.
263    pub sections: Vec<AbaqusSection>,
264    /// Material definitions.
265    pub materials: Vec<AbaqusMaterial>,
266    /// Boundary conditions.
267    pub boundary_conditions: Vec<BoundaryCondition>,
268}
269
270impl AbaqusMesh {
271    /// Create an empty mesh.
272    pub fn new() -> Self {
273        Self::default()
274    }
275}
276
277// ---------------------------------------------------------------------------
278// AbaqusWriter
279// ---------------------------------------------------------------------------
280
281/// Writes an `AbaqusMesh` to an Abaqus `.inp` text file.
282#[derive(Debug, Clone, Default)]
283pub struct AbaqusWriter;
284
285impl AbaqusWriter {
286    /// Create a new writer.
287    pub fn new() -> Self {
288        Self
289    }
290
291    /// Serialize `mesh` to `path` in Abaqus INP format.
292    ///
293    /// Returns an error if the file cannot be created or written.
294    pub fn write(&self, mesh: &AbaqusMesh, path: &str) -> Result<(), IoError> {
295        let mut buf = String::new();
296
297        let _ = writeln!(buf, "** Generated by OxiPhysics AbaqusWriter");
298        let _ = writeln!(buf, "*Heading");
299        let _ = writeln!(buf, "OxiPhysics model");
300
301        // --- Nodes ---
302        let _ = writeln!(buf, "*Node");
303        for node in &mesh.nodes {
304            writeln!(
305                buf,
306                "{}, {:.15e}, {:.15e}, {:.15e}",
307                node.id, node.coordinates[0], node.coordinates[1], node.coordinates[2]
308            )
309            .expect("operation should succeed");
310        }
311
312        // --- Elements (grouped by type) ---
313        // Collect unique types
314        let mut types_seen: Vec<String> = Vec::new();
315        for el in &mesh.elements {
316            let t = el.element_type.as_str().to_string();
317            if !types_seen.contains(&t) {
318                types_seen.push(t);
319            }
320        }
321
322        for etype in &types_seen {
323            let _ = writeln!(buf, "*Element, type={etype}");
324            for el in mesh
325                .elements
326                .iter()
327                .filter(|e| e.element_type.as_str() == etype)
328            {
329                let ids: Vec<String> = el.node_ids.iter().map(|n| n.to_string()).collect();
330                let _ = writeln!(buf, "{}, {}", el.id, ids.join(", "));
331            }
332        }
333
334        // --- Materials ---
335        for mat in &mesh.materials {
336            let _ = writeln!(buf, "*Material, name={}", mat.name);
337            let _ = writeln!(buf, "*Density");
338            let _ = writeln!(buf, "{:.15e}", mat.density);
339            let _ = writeln!(buf, "*Elastic");
340            writeln!(
341                buf,
342                "{:.15e}, {:.15e}",
343                mat.elastic.young_modulus, mat.elastic.poisson_ratio
344            )
345            .expect("operation should succeed");
346            if let Some(p) = &mat.plastic {
347                let _ = writeln!(buf, "*Plastic");
348                let _ = writeln!(buf, "{:.15e}, {:.15e}", p.yield_stress, p.hardening_modulus);
349            }
350        }
351
352        // --- Sections ---
353        for sec in &mesh.sections {
354            let el_set: Vec<String> = sec.elements.iter().map(|e| e.to_string()).collect();
355            writeln!(
356                buf,
357                "*Solid Section, elset={}, material={}",
358                sec.name, sec.material_name
359            )
360            .expect("operation should succeed");
361            if !el_set.is_empty() {
362                let _ = writeln!(buf, "{}", el_set.join(", "));
363            }
364        }
365
366        // --- Boundary Conditions ---
367        for bc in &mesh.boundary_conditions {
368            match bc {
369                BoundaryCondition::Encastre { node_set } => {
370                    let _ = writeln!(buf, "*Boundary");
371                    let _ = writeln!(buf, "{node_set}, ENCASTRE");
372                }
373                BoundaryCondition::Pinned { node_set } => {
374                    let _ = writeln!(buf, "*Boundary");
375                    let _ = writeln!(buf, "{node_set}, PINNED");
376                }
377                BoundaryCondition::SymmetryPlane { node_set, axis } => {
378                    let _ = writeln!(buf, "*Boundary");
379                    let sym = match axis {
380                        1 => "XSYMM",
381                        2 => "YSYMM",
382                        3 => "ZSYMM",
383                        _ => "XSYMM",
384                    };
385                    let _ = writeln!(buf, "{node_set}, {sym}");
386                }
387            }
388        }
389
390        let _ = writeln!(buf, "*End Part");
391
392        fs::write(path, buf).map_err(IoError::Io)
393    }
394}
395
396// ---------------------------------------------------------------------------
397// AbaqusReader
398// ---------------------------------------------------------------------------
399
400/// Parses a subset of Abaqus `.inp` files: `*NODE` and `*ELEMENT` sections.
401#[derive(Debug, Clone, Default)]
402pub struct AbaqusReader;
403
404impl AbaqusReader {
405    /// Create a new reader.
406    pub fn new() -> Self {
407        Self
408    }
409
410    /// Parse `path` and return an `AbaqusMesh` with nodes and elements
411    /// populated.
412    ///
413    /// Only `*Node` and `*Element` keyword blocks are processed; all other
414    /// blocks are silently skipped.
415    pub fn parse(&self, path: &str) -> Result<AbaqusMesh, IoError> {
416        let file = fs::File::open(path).map_err(IoError::Io)?;
417        let reader = io::BufReader::new(file);
418
419        let mut mesh = AbaqusMesh::new();
420        let mut current_block = Block::None;
421
422        for line_res in reader.lines() {
423            let line = line_res.map_err(IoError::Io)?;
424            let trimmed = line.trim();
425
426            if trimmed.is_empty() || trimmed.starts_with("**") {
427                continue;
428            }
429
430            if trimmed.starts_with('*') {
431                // New keyword
432                let upper = trimmed.to_uppercase();
433                if upper.starts_with("*NODE") && !upper.starts_with("*NSET") {
434                    current_block = Block::Node;
435                } else if upper.starts_with("*ELEMENT") {
436                    // Extract type= parameter
437                    let etype = Self::extract_param(trimmed, "TYPE")
438                        .unwrap_or_else(|| "UNKNOWN".to_string());
439                    current_block = Block::Element(ElementType::from_str(&etype));
440                } else {
441                    current_block = Block::None;
442                }
443                continue;
444            }
445
446            match &current_block {
447                Block::Node => {
448                    if let Some(node) = Self::parse_node_line(trimmed) {
449                        mesh.nodes.push(node);
450                    }
451                }
452                Block::Element(etype) => {
453                    if let Some(el) = Self::parse_element_line(trimmed, etype.clone()) {
454                        mesh.elements.push(el);
455                    }
456                }
457                Block::None => {}
458            }
459        }
460
461        Ok(mesh)
462    }
463
464    /// Extract value of `key=value` from a keyword line (case-insensitive).
465    fn extract_param(line: &str, key: &str) -> Option<String> {
466        let upper = line.to_uppercase();
467        let key_eq = format!("{key}=");
468        let pos = upper.find(&key_eq)?;
469        let rest = &line[pos + key_eq.len()..];
470        // Value ends at next comma or end of line
471        let end = rest.find(',').unwrap_or(rest.len());
472        Some(rest[..end].trim().to_string())
473    }
474
475    /// Parse a node data line: `id, x, y, z`
476    fn parse_node_line(line: &str) -> Option<AbaqusNode> {
477        let parts: Vec<&str> = line.split(',').collect();
478        if parts.len() < 4 {
479            return None;
480        }
481        let id: usize = parts[0].trim().parse().ok()?;
482        let x: f64 = parts[1].trim().parse().ok()?;
483        let y: f64 = parts[2].trim().parse().ok()?;
484        let z: f64 = parts[3].trim().parse().ok()?;
485        Some(AbaqusNode::new(id, [x, y, z]))
486    }
487
488    /// Parse an element data line: `id, n1, n2, ...`
489    fn parse_element_line(line: &str, etype: ElementType) -> Option<AbaqusElement> {
490        let parts: Vec<&str> = line.split(',').collect();
491        if parts.len() < 2 {
492            return None;
493        }
494        let id: usize = parts[0].trim().parse().ok()?;
495        let node_ids: Vec<usize> = parts[1..]
496            .iter()
497            .filter_map(|s| s.trim().parse().ok())
498            .collect();
499        if node_ids.is_empty() {
500            return None;
501        }
502        Some(AbaqusElement::new(id, etype, node_ids))
503    }
504}
505
506/// Internal parsing state machine.
507#[derive(Debug, Clone)]
508enum Block {
509    /// Outside any recognized block.
510    None,
511    /// Inside a `*Node` block.
512    Node,
513    /// Inside an `*Element` block with the given type.
514    Element(ElementType),
515}
516
517// ---------------------------------------------------------------------------
518// Tests
519// ---------------------------------------------------------------------------
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    // --- ElementType ---
526
527    #[test]
528    fn test_element_type_from_str_c3d4() {
529        assert_eq!(ElementType::from_str("C3D4"), ElementType::C3D4);
530    }
531
532    #[test]
533    fn test_element_type_from_str_case_insensitive() {
534        assert_eq!(ElementType::from_str("c3d8"), ElementType::C3D8);
535    }
536
537    #[test]
538    fn test_element_type_from_str_s4() {
539        assert_eq!(ElementType::from_str("S4"), ElementType::S4);
540    }
541
542    #[test]
543    fn test_element_type_from_str_t3d2() {
544        assert_eq!(ElementType::from_str("T3D2"), ElementType::T3D2);
545    }
546
547    #[test]
548    fn test_element_type_from_str_unknown() {
549        match ElementType::from_str("FOOBAR") {
550            ElementType::Unknown(s) => assert_eq!(s, "FOOBAR"),
551            _ => panic!("expected Unknown"),
552        }
553    }
554
555    #[test]
556    fn test_element_type_as_str() {
557        assert_eq!(ElementType::C3D4.as_str(), "C3D4");
558        assert_eq!(ElementType::C3D8.as_str(), "C3D8");
559        assert_eq!(ElementType::S4.as_str(), "S4");
560        assert_eq!(ElementType::T3D2.as_str(), "T3D2");
561    }
562
563    // --- AbaqusNode ---
564
565    #[test]
566    fn test_node_new() {
567        let n = AbaqusNode::new(1, [1.0, 2.0, 3.0]);
568        assert_eq!(n.id, 1);
569        assert_eq!(n.coordinates, [1.0, 2.0, 3.0]);
570    }
571
572    // --- AbaqusMaterial ---
573
574    #[test]
575    fn test_material_elastic() {
576        let m = AbaqusMaterial::new_elastic("Steel", 210e9, 0.3, 7800.0);
577        assert_eq!(m.name, "Steel");
578        assert!((m.elastic.young_modulus - 210e9).abs() < 1.0);
579        assert!(m.plastic.is_none());
580    }
581
582    #[test]
583    fn test_material_plastic() {
584        let m = AbaqusMaterial::new_plastic("Steel", 210e9, 0.3, 7800.0, 250e6, 1e9);
585        assert!(m.plastic.is_some());
586        let p = m.plastic.unwrap();
587        assert!((p.yield_stress - 250e6).abs() < 1.0);
588    }
589
590    // --- BoundaryCondition ---
591
592    #[test]
593    fn test_bc_keyword_encastre() {
594        let bc = BoundaryCondition::Encastre {
595            node_set: "FIXED".to_string(),
596        };
597        assert_eq!(bc.keyword(), "ENCASTRE");
598    }
599
600    #[test]
601    fn test_bc_keyword_pinned() {
602        let bc = BoundaryCondition::Pinned {
603            node_set: "PIN".to_string(),
604        };
605        assert_eq!(bc.keyword(), "PINNED");
606    }
607
608    #[test]
609    fn test_bc_keyword_symmetry() {
610        let bc = BoundaryCondition::SymmetryPlane {
611            node_set: "SYM".to_string(),
612            axis: 2,
613        };
614        assert_eq!(bc.keyword(), "SYMMETRY");
615    }
616
617    // --- AbaqusWriter / AbaqusReader roundtrip ---
618
619    fn sample_mesh() -> AbaqusMesh {
620        let mut mesh = AbaqusMesh::new();
621        mesh.nodes = vec![
622            AbaqusNode::new(1, [0.0, 0.0, 0.0]),
623            AbaqusNode::new(2, [1.0, 0.0, 0.0]),
624            AbaqusNode::new(3, [0.0, 1.0, 0.0]),
625            AbaqusNode::new(4, [0.0, 0.0, 1.0]),
626        ];
627        mesh.elements = vec![AbaqusElement::new(1, ElementType::C3D4, vec![1, 2, 3, 4])];
628        mesh
629    }
630
631    #[test]
632    fn test_write_creates_file() {
633        let path = "/tmp/oxiphysics_abaqus_test_write.inp";
634        let mesh = sample_mesh();
635        let writer = AbaqusWriter::new();
636        writer.write(&mesh, path).expect("write failed");
637        assert!(std::path::Path::new(path).exists());
638    }
639
640    #[test]
641    fn test_roundtrip_node_count() {
642        let path = "/tmp/oxiphysics_abaqus_roundtrip.inp";
643        let mesh = sample_mesh();
644        let writer = AbaqusWriter::new();
645        writer.write(&mesh, path).expect("write failed");
646
647        let reader = AbaqusReader::new();
648        let parsed = reader.parse(path).expect("parse failed");
649        assert_eq!(parsed.nodes.len(), 4);
650    }
651
652    #[test]
653    fn test_roundtrip_element_count() {
654        let path = "/tmp/oxiphysics_abaqus_rt_elem.inp";
655        let mesh = sample_mesh();
656        AbaqusWriter::new().write(&mesh, path).expect("write");
657        let parsed = AbaqusReader::new().parse(path).expect("parse");
658        assert_eq!(parsed.elements.len(), 1);
659    }
660
661    #[test]
662    fn test_roundtrip_node_ids() {
663        let path = "/tmp/oxiphysics_abaqus_rt_nodeids.inp";
664        let mesh = sample_mesh();
665        AbaqusWriter::new().write(&mesh, path).expect("write");
666        let parsed = AbaqusReader::new().parse(path).expect("parse");
667        let ids: Vec<usize> = parsed.nodes.iter().map(|n| n.id).collect();
668        assert_eq!(ids, vec![1, 2, 3, 4]);
669    }
670
671    #[test]
672    fn test_roundtrip_node_coordinates() {
673        let path = "/tmp/oxiphysics_abaqus_rt_coords.inp";
674        let mesh = sample_mesh();
675        AbaqusWriter::new().write(&mesh, path).expect("write");
676        let parsed = AbaqusReader::new().parse(path).expect("parse");
677        let n1 = &parsed.nodes[0];
678        assert!((n1.coordinates[0]).abs() < 1e-10);
679        let n2 = &parsed.nodes[1];
680        assert!((n2.coordinates[0] - 1.0).abs() < 1e-10);
681    }
682
683    #[test]
684    fn test_roundtrip_element_type() {
685        let path = "/tmp/oxiphysics_abaqus_rt_etype.inp";
686        let mesh = sample_mesh();
687        AbaqusWriter::new().write(&mesh, path).expect("write");
688        let parsed = AbaqusReader::new().parse(path).expect("parse");
689        assert_eq!(parsed.elements[0].element_type, ElementType::C3D4);
690    }
691
692    #[test]
693    fn test_roundtrip_element_nodes() {
694        let path = "/tmp/oxiphysics_abaqus_rt_enodes.inp";
695        let mesh = sample_mesh();
696        AbaqusWriter::new().write(&mesh, path).expect("write");
697        let parsed = AbaqusReader::new().parse(path).expect("parse");
698        assert_eq!(parsed.elements[0].node_ids, vec![1, 2, 3, 4]);
699    }
700
701    #[test]
702    fn test_roundtrip_element_id() {
703        let path = "/tmp/oxiphysics_abaqus_rt_eid.inp";
704        let mesh = sample_mesh();
705        AbaqusWriter::new().write(&mesh, path).expect("write");
706        let parsed = AbaqusReader::new().parse(path).expect("parse");
707        assert_eq!(parsed.elements[0].id, 1);
708    }
709
710    #[test]
711    fn test_multiple_element_types() {
712        let path = "/tmp/oxiphysics_abaqus_multtype.inp";
713        let mut mesh = AbaqusMesh::new();
714        mesh.nodes = vec![
715            AbaqusNode::new(1, [0.0, 0.0, 0.0]),
716            AbaqusNode::new(2, [1.0, 0.0, 0.0]),
717            AbaqusNode::new(3, [0.0, 1.0, 0.0]),
718            AbaqusNode::new(4, [0.0, 0.0, 1.0]),
719            AbaqusNode::new(5, [1.0, 1.0, 0.0]),
720        ];
721        mesh.elements = vec![
722            AbaqusElement::new(1, ElementType::C3D4, vec![1, 2, 3, 4]),
723            AbaqusElement::new(2, ElementType::T3D2, vec![4, 5]),
724        ];
725        AbaqusWriter::new().write(&mesh, path).expect("write");
726        let parsed = AbaqusReader::new().parse(path).expect("parse");
727        assert_eq!(parsed.elements.len(), 2);
728    }
729
730    #[test]
731    fn test_write_contains_heading() {
732        let path = "/tmp/oxiphysics_abaqus_heading.inp";
733        let mesh = sample_mesh();
734        AbaqusWriter::new().write(&mesh, path).expect("write");
735        let content = std::fs::read_to_string(path).unwrap();
736        assert!(content.contains("*Heading"), "no *Heading found");
737    }
738
739    #[test]
740    fn test_write_contains_node_keyword() {
741        let path = "/tmp/oxiphysics_abaqus_nkw.inp";
742        let mesh = sample_mesh();
743        AbaqusWriter::new().write(&mesh, path).expect("write");
744        let content = std::fs::read_to_string(path).unwrap();
745        assert!(content.contains("*Node"), "no *Node found");
746    }
747
748    #[test]
749    fn test_write_contains_element_keyword() {
750        let path = "/tmp/oxiphysics_abaqus_ekw.inp";
751        let mesh = sample_mesh();
752        AbaqusWriter::new().write(&mesh, path).expect("write");
753        let content = std::fs::read_to_string(path).unwrap();
754        assert!(content.contains("*Element"), "no *Element found");
755    }
756
757    #[test]
758    fn test_write_material() {
759        let path = "/tmp/oxiphysics_abaqus_mat.inp";
760        let mut mesh = sample_mesh();
761        mesh.materials
762            .push(AbaqusMaterial::new_elastic("Steel", 210e9, 0.3, 7800.0));
763        AbaqusWriter::new().write(&mesh, path).expect("write");
764        let content = std::fs::read_to_string(path).unwrap();
765        assert!(content.contains("*Material"), "no *Material found");
766        assert!(content.contains("Steel"));
767    }
768
769    #[test]
770    fn test_write_plastic_material() {
771        let path = "/tmp/oxiphysics_abaqus_plastic.inp";
772        let mut mesh = sample_mesh();
773        mesh.materials.push(AbaqusMaterial::new_plastic(
774            "Steel", 210e9, 0.3, 7800.0, 250e6, 1e9,
775        ));
776        AbaqusWriter::new().write(&mesh, path).expect("write");
777        let content = std::fs::read_to_string(path).unwrap();
778        assert!(content.contains("*Plastic"), "no *Plastic found");
779    }
780
781    #[test]
782    fn test_write_section() {
783        let path = "/tmp/oxiphysics_abaqus_sec.inp";
784        let mut mesh = sample_mesh();
785        mesh.sections
786            .push(AbaqusSection::new("SEC1", "Steel", vec![1]));
787        AbaqusWriter::new().write(&mesh, path).expect("write");
788        let content = std::fs::read_to_string(path).unwrap();
789        assert!(content.contains("*Solid Section"), "no *Solid Section");
790    }
791
792    #[test]
793    fn test_write_bc_encastre() {
794        let path = "/tmp/oxiphysics_abaqus_bc_enc.inp";
795        let mut mesh = sample_mesh();
796        mesh.boundary_conditions.push(BoundaryCondition::Encastre {
797            node_set: "FIXED".to_string(),
798        });
799        AbaqusWriter::new().write(&mesh, path).expect("write");
800        let content = std::fs::read_to_string(path).unwrap();
801        assert!(content.contains("ENCASTRE"), "no ENCASTRE");
802    }
803
804    #[test]
805    fn test_write_bc_pinned() {
806        let path = "/tmp/oxiphysics_abaqus_bc_pin.inp";
807        let mut mesh = sample_mesh();
808        mesh.boundary_conditions.push(BoundaryCondition::Pinned {
809            node_set: "PINSET".to_string(),
810        });
811        AbaqusWriter::new().write(&mesh, path).expect("write");
812        let content = std::fs::read_to_string(path).unwrap();
813        assert!(content.contains("PINNED"), "no PINNED");
814    }
815
816    #[test]
817    fn test_write_bc_symmetry() {
818        let path = "/tmp/oxiphysics_abaqus_bc_sym.inp";
819        let mut mesh = sample_mesh();
820        mesh.boundary_conditions
821            .push(BoundaryCondition::SymmetryPlane {
822                node_set: "SYMSET".to_string(),
823                axis: 2,
824            });
825        AbaqusWriter::new().write(&mesh, path).expect("write");
826        let content = std::fs::read_to_string(path).unwrap();
827        assert!(content.contains("YSYMM"), "no YSYMM");
828    }
829
830    #[test]
831    fn test_parse_empty_file() {
832        let path = "/tmp/oxiphysics_abaqus_empty.inp";
833        std::fs::write(path, "** empty\n").unwrap();
834        let parsed = AbaqusReader::new().parse(path).expect("parse");
835        assert!(parsed.nodes.is_empty());
836        assert!(parsed.elements.is_empty());
837    }
838
839    #[test]
840    fn test_parse_missing_file() {
841        let result = AbaqusReader::new().parse("/tmp/does_not_exist_oxiphysics.inp");
842        assert!(result.is_err());
843    }
844
845    #[test]
846    fn test_abaqus_mesh_default() {
847        let m = AbaqusMesh::default();
848        assert!(m.nodes.is_empty());
849        assert!(m.elements.is_empty());
850    }
851
852    #[test]
853    fn test_section_new() {
854        let s = AbaqusSection::new("S1", "Mat1", vec![1, 2, 3]);
855        assert_eq!(s.name, "S1");
856        assert_eq!(s.elements, vec![1, 2, 3]);
857    }
858
859    #[test]
860    fn test_abaqus_element_new() {
861        let e = AbaqusElement::new(5, ElementType::S4, vec![10, 11, 12, 13]);
862        assert_eq!(e.id, 5);
863        assert_eq!(e.element_type, ElementType::S4);
864        assert_eq!(e.node_ids.len(), 4);
865    }
866
867    #[test]
868    fn test_large_mesh_roundtrip() {
869        let path = "/tmp/oxiphysics_abaqus_large.inp";
870        let mut mesh = AbaqusMesh::new();
871        // 100 nodes
872        for i in 1..=100 {
873            mesh.nodes.push(AbaqusNode::new(i, [i as f64, 0.0, 0.0]));
874        }
875        // 24 tetrahedral elements
876        for i in 0..24usize {
877            mesh.elements.push(AbaqusElement::new(
878                i + 1,
879                ElementType::C3D4,
880                vec![
881                    i * 4 % 97 + 1,
882                    i * 4 % 97 + 2,
883                    i * 4 % 97 + 3,
884                    i * 4 % 97 + 4,
885                ],
886            ));
887        }
888        AbaqusWriter::new().write(&mesh, path).expect("write");
889        let parsed = AbaqusReader::new().parse(path).expect("parse");
890        assert_eq!(parsed.nodes.len(), 100);
891        assert_eq!(parsed.elements.len(), 24);
892    }
893
894    #[test]
895    fn test_node_roundtrip_precision() {
896        let path = "/tmp/oxiphysics_abaqus_prec.inp";
897        let mut mesh = AbaqusMesh::new();
898        mesh.nodes.push(AbaqusNode::new(
899            1,
900            [1.23456789012345, -9.87654321098765, 2.89793238462643],
901        ));
902        AbaqusWriter::new().write(&mesh, path).expect("write");
903        let parsed = AbaqusReader::new().parse(path).expect("parse");
904        let c = parsed.nodes[0].coordinates;
905        assert!((c[0] - 1.23456789012345).abs() < 1e-10);
906        assert!((c[1] - (-9.87654321098765)).abs() < 1e-10);
907        assert!((c[2] - 2.89793238462643).abs() < 1e-10);
908    }
909}