Skip to main content

oxiphysics_io/
exodus_format.rs

1#![allow(clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Exodus II mesh format reader and writer (Sierra/SEACAS ASCII subset).
6//!
7//! Implements a readable ASCII representation of the Exodus II format,
8//! including element blocks, node sets, side sets and scalar field variables.
9
10use std::collections::HashMap;
11use std::fs;
12use std::io::{BufRead, BufReader, Write as IoWrite};
13
14use crate::Error;
15use crate::Result;
16
17// ── Element types ─────────────────────────────────────────────────────────────
18
19/// Element types supported by the Exodus II format.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ExodusElementType {
22    /// 3-node linear triangle.
23    Tri3,
24    /// 6-node quadratic triangle.
25    Tri6,
26    /// 4-node bilinear quadrilateral.
27    Quad4,
28    /// 8-node serendipity quadrilateral.
29    Quad8,
30    /// 4-node linear tetrahedron.
31    Tet4,
32    /// 10-node quadratic tetrahedron.
33    Tet10,
34    /// 8-node linear hexahedron.
35    Hex8,
36    /// 20-node serendipity hexahedron.
37    Hex20,
38    /// 2-node bar (beam / truss).
39    Bar2,
40}
41
42impl ExodusElementType {
43    /// Return the canonical Exodus II string name for this element type.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Tri3 => "TRI3",
47            Self::Tri6 => "TRI6",
48            Self::Quad4 => "QUAD4",
49            Self::Quad8 => "QUAD8",
50            Self::Tet4 => "TET4",
51            Self::Tet10 => "TET10",
52            Self::Hex8 => "HEX8",
53            Self::Hex20 => "HEX20",
54            Self::Bar2 => "BAR2",
55        }
56    }
57
58    /// Number of nodes per element for this type.
59    pub fn nodes_per_element(self) -> usize {
60        match self {
61            Self::Tri3 => 3,
62            Self::Tri6 => 6,
63            Self::Quad4 => 4,
64            Self::Quad8 => 8,
65            Self::Tet4 => 4,
66            Self::Tet10 => 10,
67            Self::Hex8 => 8,
68            Self::Hex20 => 20,
69            Self::Bar2 => 2,
70        }
71    }
72
73    /// Parse from the canonical Exodus II string name (case-insensitive).
74    pub fn from_str(s: &str) -> Option<Self> {
75        match s.to_ascii_uppercase().as_str() {
76            "TRI3" => Some(Self::Tri3),
77            "TRI6" => Some(Self::Tri6),
78            "QUAD4" => Some(Self::Quad4),
79            "QUAD8" => Some(Self::Quad8),
80            "TET4" => Some(Self::Tet4),
81            "TET10" => Some(Self::Tet10),
82            "HEX8" => Some(Self::Hex8),
83            "HEX20" => Some(Self::Hex20),
84            "BAR2" => Some(Self::Bar2),
85            _ => None,
86        }
87    }
88}
89
90// ── Node set ─────────────────────────────────────────────────────────────────
91
92/// A named set of nodes (boundary condition set).
93#[derive(Debug, Clone)]
94pub struct ExodusNodeSet {
95    /// Unique identifier for this node set.
96    pub id: usize,
97    /// Human-readable name.
98    pub name: String,
99    /// Node IDs (0-based) in this set.
100    pub node_ids: Vec<usize>,
101}
102
103impl ExodusNodeSet {
104    /// Create a new node set.
105    pub fn new(id: usize, name: impl Into<String>, node_ids: Vec<usize>) -> Self {
106        Self {
107            id,
108            name: name.into(),
109            node_ids,
110        }
111    }
112}
113
114// ── Side set ─────────────────────────────────────────────────────────────────
115
116/// A named set of element sides (face boundary conditions).
117#[derive(Debug, Clone)]
118pub struct ExodusSideSet {
119    /// Unique identifier for this side set.
120    pub id: usize,
121    /// Human-readable name.
122    pub name: String,
123    /// Element IDs (0-based) for each entry.
124    pub elem_ids: Vec<usize>,
125    /// Local side IDs within each element.
126    pub side_ids: Vec<usize>,
127}
128
129impl ExodusSideSet {
130    /// Create a new side set.
131    pub fn new(
132        id: usize,
133        name: impl Into<String>,
134        elem_ids: Vec<usize>,
135        side_ids: Vec<usize>,
136    ) -> Self {
137        Self {
138            id,
139            name: name.into(),
140            elem_ids,
141            side_ids,
142        }
143    }
144}
145
146// ── Element block ─────────────────────────────────────────────────────────────
147
148/// An element block (a group of elements sharing the same type).
149#[derive(Debug, Clone)]
150pub struct ExodusBlock {
151    /// Block identifier.
152    pub id: usize,
153    /// Element type for all elements in this block.
154    pub element_type: ExodusElementType,
155    /// Connectivity: one row per element, listing node IDs (0-based).
156    pub connectivity: Vec<Vec<usize>>,
157}
158
159impl ExodusBlock {
160    /// Create a new element block.
161    pub fn new(id: usize, element_type: ExodusElementType, connectivity: Vec<Vec<usize>>) -> Self {
162        Self {
163            id,
164            element_type,
165            connectivity,
166        }
167    }
168
169    /// Number of elements in this block.
170    pub fn num_elements(&self) -> usize {
171        self.connectivity.len()
172    }
173}
174
175// ── Exodus mesh ───────────────────────────────────────────────────────────────
176
177/// A complete Exodus II mesh.
178#[derive(Debug, Clone)]
179pub struct ExodusMesh {
180    /// Node coordinates: one `[x, y, z]` per node.
181    pub nodes: Vec<[f64; 3]>,
182    /// Element blocks.
183    pub blocks: Vec<ExodusBlock>,
184    /// Named node sets.
185    pub node_sets: Vec<ExodusNodeSet>,
186    /// Named side sets.
187    pub side_sets: Vec<ExodusSideSet>,
188    /// Named scalar field variables (one value per node).
189    pub variables: HashMap<String, Vec<f64>>,
190}
191
192impl ExodusMesh {
193    /// Create an empty mesh.
194    pub fn new() -> Self {
195        Self {
196            nodes: Vec::new(),
197            blocks: Vec::new(),
198            node_sets: Vec::new(),
199            side_sets: Vec::new(),
200            variables: HashMap::new(),
201        }
202    }
203
204    /// Total number of elements across all blocks.
205    pub fn total_elements(&self) -> usize {
206        self.blocks.iter().map(|b| b.num_elements()).sum()
207    }
208}
209
210impl Default for ExodusMesh {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216// ── Writer ────────────────────────────────────────────────────────────────────
217
218/// Writes an `ExodusMesh` to a readable ASCII-like text file.
219pub struct ExodusWriter;
220
221impl ExodusWriter {
222    /// Create a new `ExodusWriter`.
223    pub fn new() -> Self {
224        Self
225    }
226
227    /// Write `mesh` to `path` in ASCII Exodus-like format.
228    ///
229    /// The file uses section headers (`NODES`, `BLOCK`, `NODE_SET`,
230    /// `SIDE_SET`, `VARIABLE`) that `ExodusReader::parse` can read back.
231    pub fn write_text(&self, mesh: &ExodusMesh, path: &str) -> Result<()> {
232        let mut f = fs::File::create(path).map_err(Error::Io)?;
233
234        // Header
235        writeln!(f, "# Exodus II ASCII (OxiPhysics)")?;
236        writeln!(f, "NUM_NODES {}", mesh.nodes.len())?;
237        writeln!(f, "NUM_BLOCKS {}", mesh.blocks.len())?;
238        writeln!(f, "NUM_NODE_SETS {}", mesh.node_sets.len())?;
239        writeln!(f, "NUM_SIDE_SETS {}", mesh.side_sets.len())?;
240        writeln!(f, "NUM_VARIABLES {}", mesh.variables.len())?;
241        writeln!(f)?;
242
243        // Nodes
244        writeln!(f, "NODES")?;
245        for (idx, n) in mesh.nodes.iter().enumerate() {
246            writeln!(f, "{} {} {} {}", idx, n[0], n[1], n[2])?;
247        }
248        writeln!(f)?;
249
250        // Blocks
251        for blk in &mesh.blocks {
252            writeln!(
253                f,
254                "BLOCK {} {} {}",
255                blk.id,
256                blk.element_type.as_str(),
257                blk.connectivity.len()
258            )?;
259            for row in &blk.connectivity {
260                let ids: Vec<String> = row.iter().map(|v| v.to_string()).collect();
261                writeln!(f, "{}", ids.join(" "))?;
262            }
263            writeln!(f)?;
264        }
265
266        // Node sets
267        for ns in &mesh.node_sets {
268            writeln!(f, "NODE_SET {} {} {}", ns.id, ns.name, ns.node_ids.len())?;
269            let ids: Vec<String> = ns.node_ids.iter().map(|v| v.to_string()).collect();
270            writeln!(f, "{}", ids.join(" "))?;
271            writeln!(f)?;
272        }
273
274        // Side sets
275        for ss in &mesh.side_sets {
276            writeln!(f, "SIDE_SET {} {} {}", ss.id, ss.name, ss.elem_ids.len())?;
277            for (e, s) in ss.elem_ids.iter().zip(ss.side_ids.iter()) {
278                writeln!(f, "{} {}", e, s)?;
279            }
280            writeln!(f)?;
281        }
282
283        // Variables
284        let mut var_names: Vec<&String> = mesh.variables.keys().collect();
285        var_names.sort();
286        for name in var_names {
287            let vals = &mesh.variables[name];
288            writeln!(f, "VARIABLE {} {}", name, vals.len())?;
289            for v in vals {
290                writeln!(f, "{}", v)?;
291            }
292            writeln!(f)?;
293        }
294
295        Ok(())
296    }
297}
298
299impl Default for ExodusWriter {
300    fn default() -> Self {
301        Self::new()
302    }
303}
304
305// ── Reader ────────────────────────────────────────────────────────────────────
306
307/// Reads an `ExodusMesh` from an ASCII file written by `ExodusWriter`.
308pub struct ExodusReader;
309
310impl ExodusReader {
311    /// Create a new `ExodusReader`.
312    pub fn new() -> Self {
313        Self
314    }
315
316    /// Parse an Exodus ASCII file at `path` and return an `ExodusMesh`.
317    pub fn parse(&self, path: &str) -> Result<ExodusMesh> {
318        let file = fs::File::open(path).map_err(Error::Io)?;
319        let reader = BufReader::new(file);
320        let mut lines: Vec<String> = Vec::new();
321        for line in reader.lines() {
322            let l = line.map_err(Error::Io)?;
323            let trimmed = l.trim().to_string();
324            if !trimmed.starts_with('#') && !trimmed.is_empty() {
325                lines.push(trimmed);
326            }
327        }
328
329        let mut mesh = ExodusMesh::new();
330        let mut pos = 0usize;
331
332        // Skip header counts (NUM_* lines)
333        while pos < lines.len() {
334            let l = &lines[pos];
335            if l.starts_with("NUM_") {
336                pos += 1;
337            } else {
338                break;
339            }
340        }
341
342        while pos < lines.len() {
343            let l = lines[pos].clone();
344            let parts: Vec<&str> = l.split_whitespace().collect();
345            if parts.is_empty() {
346                pos += 1;
347                continue;
348            }
349            match parts[0] {
350                "NODES" => {
351                    pos += 1;
352                    while pos < lines.len() {
353                        let row: Vec<&str> = lines[pos].split_whitespace().collect();
354                        if row.len() < 4 {
355                            break;
356                        }
357                        // row[0] = index (integer), row[1..4] = xyz (floats)
358                        // If row[0] cannot be parsed as usize this is not a node line.
359                        if row[0].parse::<usize>().is_err() {
360                            break;
361                        }
362                        let x = row[1]
363                            .parse::<f64>()
364                            .map_err(|e| Error::Parse(e.to_string()))?;
365                        let y = row[2]
366                            .parse::<f64>()
367                            .map_err(|e| Error::Parse(e.to_string()))?;
368                        let z = row[3]
369                            .parse::<f64>()
370                            .map_err(|e| Error::Parse(e.to_string()))?;
371                        mesh.nodes.push([x, y, z]);
372                        pos += 1;
373                    }
374                }
375                "BLOCK" => {
376                    if parts.len() < 4 {
377                        return Err(Error::Parse("malformed BLOCK header".into()));
378                    }
379                    let id: usize = parts[1]
380                        .parse()
381                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
382                    let etype = ExodusElementType::from_str(parts[2]).ok_or_else(|| {
383                        Error::Parse(format!("unknown element type: {}", parts[2]))
384                    })?;
385                    let count: usize = parts[3]
386                        .parse()
387                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
388                    pos += 1;
389                    let mut connectivity = Vec::with_capacity(count);
390                    for _ in 0..count {
391                        if pos >= lines.len() {
392                            return Err(Error::Parse("unexpected end in BLOCK".into()));
393                        }
394                        let row: Vec<usize> = lines[pos]
395                            .split_whitespace()
396                            .map(|s| s.parse::<usize>().map_err(|e| Error::Parse(e.to_string())))
397                            .collect::<Result<Vec<_>>>()?;
398                        connectivity.push(row);
399                        pos += 1;
400                    }
401                    mesh.blocks.push(ExodusBlock::new(id, etype, connectivity));
402                }
403                "NODE_SET" => {
404                    if parts.len() < 4 {
405                        return Err(Error::Parse("malformed NODE_SET header".into()));
406                    }
407                    let id: usize = parts[1]
408                        .parse()
409                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
410                    let name = parts[2].to_string();
411                    let count: usize = parts[3]
412                        .parse()
413                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
414                    pos += 1;
415                    let mut node_ids = Vec::with_capacity(count);
416                    if pos < lines.len() && count > 0 {
417                        let row: Vec<usize> = lines[pos]
418                            .split_whitespace()
419                            .map(|s| s.parse::<usize>().map_err(|e| Error::Parse(e.to_string())))
420                            .collect::<Result<Vec<_>>>()?;
421                        node_ids = row;
422                        pos += 1;
423                    } else if count == 0 {
424                        // empty set — nothing to read
425                    }
426                    mesh.node_sets.push(ExodusNodeSet::new(id, name, node_ids));
427                }
428                "SIDE_SET" => {
429                    if parts.len() < 4 {
430                        return Err(Error::Parse("malformed SIDE_SET header".into()));
431                    }
432                    let id: usize = parts[1]
433                        .parse()
434                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
435                    let name = parts[2].to_string();
436                    let count: usize = parts[3]
437                        .parse()
438                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
439                    pos += 1;
440                    let mut elem_ids = Vec::with_capacity(count);
441                    let mut side_ids = Vec::with_capacity(count);
442                    for _ in 0..count {
443                        if pos >= lines.len() {
444                            return Err(Error::Parse("unexpected end in SIDE_SET".into()));
445                        }
446                        let row: Vec<&str> = lines[pos].split_whitespace().collect();
447                        if row.len() < 2 {
448                            return Err(Error::Parse("malformed SIDE_SET entry".into()));
449                        }
450                        elem_ids.push(
451                            row[0]
452                                .parse::<usize>()
453                                .map_err(|e| Error::Parse(e.to_string()))?,
454                        );
455                        side_ids.push(
456                            row[1]
457                                .parse::<usize>()
458                                .map_err(|e| Error::Parse(e.to_string()))?,
459                        );
460                        pos += 1;
461                    }
462                    mesh.side_sets
463                        .push(ExodusSideSet::new(id, name, elem_ids, side_ids));
464                }
465                "VARIABLE" => {
466                    if parts.len() < 3 {
467                        return Err(Error::Parse("malformed VARIABLE header".into()));
468                    }
469                    let name = parts[1].to_string();
470                    let count: usize = parts[2]
471                        .parse()
472                        .map_err(|e: std::num::ParseIntError| Error::Parse(e.to_string()))?;
473                    pos += 1;
474                    let mut vals = Vec::with_capacity(count);
475                    for _ in 0..count {
476                        if pos >= lines.len() {
477                            return Err(Error::Parse("unexpected end in VARIABLE".into()));
478                        }
479                        let v: f64 = lines[pos]
480                            .trim()
481                            .parse()
482                            .map_err(|e: std::num::ParseFloatError| Error::Parse(e.to_string()))?;
483                        vals.push(v);
484                        pos += 1;
485                    }
486                    mesh.variables.insert(name, vals);
487                }
488                _ => {
489                    pos += 1;
490                }
491            }
492        }
493
494        Ok(mesh)
495    }
496}
497
498impl Default for ExodusReader {
499    fn default() -> Self {
500        Self::new()
501    }
502}
503
504// ── Tests ─────────────────────────────────────────────────────────────────────
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn simple_tri_mesh() -> ExodusMesh {
511        let mut m = ExodusMesh::new();
512        m.nodes = vec![
513            [0.0, 0.0, 0.0],
514            [1.0, 0.0, 0.0],
515            [0.5, 1.0, 0.0],
516            [1.5, 1.0, 0.0],
517        ];
518        m.blocks.push(ExodusBlock::new(
519            1,
520            ExodusElementType::Tri3,
521            vec![vec![0, 1, 2], vec![1, 3, 2]],
522        ));
523        m
524    }
525
526    fn tmp_path(name: &str) -> String {
527        format!("/tmp/oxiphysics_exodus_test_{name}.exo")
528    }
529
530    // ── ExodusElementType ────────────────────────────────────────────────────
531
532    #[test]
533    fn element_type_as_str() {
534        assert_eq!(ExodusElementType::Tri3.as_str(), "TRI3");
535        assert_eq!(ExodusElementType::Hex20.as_str(), "HEX20");
536        assert_eq!(ExodusElementType::Bar2.as_str(), "BAR2");
537    }
538
539    #[test]
540    fn element_type_from_str_roundtrip() {
541        for et in [
542            ExodusElementType::Tri3,
543            ExodusElementType::Tri6,
544            ExodusElementType::Quad4,
545            ExodusElementType::Quad8,
546            ExodusElementType::Tet4,
547            ExodusElementType::Tet10,
548            ExodusElementType::Hex8,
549            ExodusElementType::Hex20,
550            ExodusElementType::Bar2,
551        ] {
552            let parsed = ExodusElementType::from_str(et.as_str());
553            assert_eq!(parsed, Some(et), "round-trip failed for {:?}", et);
554        }
555    }
556
557    #[test]
558    fn element_type_from_str_unknown() {
559        assert!(ExodusElementType::from_str("UNKNOWN").is_none());
560        assert!(ExodusElementType::from_str("").is_none());
561    }
562
563    #[test]
564    fn element_type_from_str_case_insensitive() {
565        assert_eq!(
566            ExodusElementType::from_str("tri3"),
567            Some(ExodusElementType::Tri3)
568        );
569        assert_eq!(
570            ExodusElementType::from_str("Hex8"),
571            Some(ExodusElementType::Hex8)
572        );
573    }
574
575    #[test]
576    fn element_type_nodes_per_element() {
577        assert_eq!(ExodusElementType::Tri3.nodes_per_element(), 3);
578        assert_eq!(ExodusElementType::Hex20.nodes_per_element(), 20);
579        assert_eq!(ExodusElementType::Bar2.nodes_per_element(), 2);
580        assert_eq!(ExodusElementType::Tet10.nodes_per_element(), 10);
581    }
582
583    // ── ExodusBlock ──────────────────────────────────────────────────────────
584
585    #[test]
586    fn block_num_elements() {
587        let blk = ExodusBlock::new(
588            1,
589            ExodusElementType::Quad4,
590            vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]],
591        );
592        assert_eq!(blk.num_elements(), 2);
593    }
594
595    #[test]
596    fn block_empty_connectivity() {
597        let blk = ExodusBlock::new(2, ExodusElementType::Tet4, vec![]);
598        assert_eq!(blk.num_elements(), 0);
599    }
600
601    #[test]
602    fn block_stores_element_type() {
603        let blk = ExodusBlock::new(
604            3,
605            ExodusElementType::Hex8,
606            vec![vec![0, 1, 2, 3, 4, 5, 6, 7]],
607        );
608        assert_eq!(blk.element_type, ExodusElementType::Hex8);
609    }
610
611    // ── ExodusNodeSet ────────────────────────────────────────────────────────
612
613    #[test]
614    fn node_set_stores_data() {
615        let ns = ExodusNodeSet::new(10, "inlet", vec![0, 1, 2]);
616        assert_eq!(ns.id, 10);
617        assert_eq!(ns.name, "inlet");
618        assert_eq!(ns.node_ids, vec![0, 1, 2]);
619    }
620
621    #[test]
622    fn node_set_empty() {
623        let ns = ExodusNodeSet::new(1, "empty", vec![]);
624        assert!(ns.node_ids.is_empty());
625    }
626
627    // ── ExodusSideSet ────────────────────────────────────────────────────────
628
629    #[test]
630    fn side_set_stores_data() {
631        let ss = ExodusSideSet::new(5, "wall", vec![0, 1], vec![2, 3]);
632        assert_eq!(ss.id, 5);
633        assert_eq!(ss.elem_ids, vec![0, 1]);
634        assert_eq!(ss.side_ids, vec![2, 3]);
635    }
636
637    #[test]
638    fn side_set_mismatched_lengths_allowed() {
639        // The struct stores them independently — no validation required
640        let ss = ExodusSideSet::new(1, "ss", vec![0], vec![0, 1]);
641        assert_eq!(ss.elem_ids.len(), 1);
642        assert_eq!(ss.side_ids.len(), 2);
643    }
644
645    // ── ExodusMesh ───────────────────────────────────────────────────────────
646
647    #[test]
648    fn mesh_default_is_empty() {
649        let m = ExodusMesh::default();
650        assert!(m.nodes.is_empty());
651        assert!(m.blocks.is_empty());
652        assert!(m.node_sets.is_empty());
653        assert!(m.side_sets.is_empty());
654        assert!(m.variables.is_empty());
655    }
656
657    #[test]
658    fn mesh_total_elements() {
659        let mut m = ExodusMesh::new();
660        m.blocks.push(ExodusBlock::new(
661            1,
662            ExodusElementType::Tri3,
663            vec![vec![0, 1, 2]; 3],
664        ));
665        m.blocks.push(ExodusBlock::new(
666            2,
667            ExodusElementType::Quad4,
668            vec![vec![0, 1, 2, 3]; 5],
669        ));
670        assert_eq!(m.total_elements(), 8);
671    }
672
673    #[test]
674    fn mesh_variable_storage() {
675        let mut m = ExodusMesh::new();
676        m.variables
677            .insert("temperature".into(), vec![1.0, 2.0, 3.0]);
678        assert_eq!(m.variables["temperature"], vec![1.0, 2.0, 3.0]);
679    }
680
681    // ── Write / read roundtrip ───────────────────────────────────────────────
682
683    #[test]
684    fn write_read_nodes_roundtrip() {
685        let m = simple_tri_mesh();
686        let path = tmp_path("nodes_rtrip");
687        ExodusWriter::new().write_text(&m, &path).unwrap();
688        let m2 = ExodusReader::new().parse(&path).unwrap();
689        assert_eq!(m2.nodes.len(), m.nodes.len());
690        for (a, b) in m.nodes.iter().zip(m2.nodes.iter()) {
691            for k in 0..3 {
692                assert!((a[k] - b[k]).abs() < 1e-12);
693            }
694        }
695    }
696
697    #[test]
698    fn write_read_blocks_roundtrip() {
699        let m = simple_tri_mesh();
700        let path = tmp_path("blocks_rtrip");
701        ExodusWriter::new().write_text(&m, &path).unwrap();
702        let m2 = ExodusReader::new().parse(&path).unwrap();
703        assert_eq!(m2.blocks.len(), 1);
704        assert_eq!(m2.blocks[0].element_type, ExodusElementType::Tri3);
705        assert_eq!(m2.blocks[0].connectivity.len(), 2);
706        assert_eq!(m2.blocks[0].connectivity[0], vec![0, 1, 2]);
707    }
708
709    #[test]
710    fn write_read_node_set_roundtrip() {
711        let mut m = simple_tri_mesh();
712        m.node_sets.push(ExodusNodeSet::new(1, "inlet", vec![0, 1]));
713        let path = tmp_path("nset_rtrip");
714        ExodusWriter::new().write_text(&m, &path).unwrap();
715        let m2 = ExodusReader::new().parse(&path).unwrap();
716        assert_eq!(m2.node_sets.len(), 1);
717        assert_eq!(m2.node_sets[0].name, "inlet");
718        assert_eq!(m2.node_sets[0].node_ids, vec![0, 1]);
719    }
720
721    #[test]
722    fn write_read_side_set_roundtrip() {
723        let mut m = simple_tri_mesh();
724        m.side_sets
725            .push(ExodusSideSet::new(1, "wall", vec![0, 1], vec![2, 3]));
726        let path = tmp_path("sset_rtrip");
727        ExodusWriter::new().write_text(&m, &path).unwrap();
728        let m2 = ExodusReader::new().parse(&path).unwrap();
729        assert_eq!(m2.side_sets.len(), 1);
730        assert_eq!(m2.side_sets[0].name, "wall");
731        assert_eq!(m2.side_sets[0].elem_ids, vec![0, 1]);
732        assert_eq!(m2.side_sets[0].side_ids, vec![2, 3]);
733    }
734
735    #[test]
736    fn write_read_variable_roundtrip() {
737        let mut m = simple_tri_mesh();
738        m.variables
739            .insert("pressure".into(), vec![1.0, 2.0, 3.0, 4.0]);
740        let path = tmp_path("var_rtrip");
741        ExodusWriter::new().write_text(&m, &path).unwrap();
742        let m2 = ExodusReader::new().parse(&path).unwrap();
743        assert!(m2.variables.contains_key("pressure"));
744        let v = &m2.variables["pressure"];
745        assert_eq!(v.len(), 4);
746        assert!((v[0] - 1.0).abs() < 1e-12);
747        assert!((v[3] - 4.0).abs() < 1e-12);
748    }
749
750    #[test]
751    fn write_read_multiple_blocks() {
752        let mut m = ExodusMesh::new();
753        m.nodes = vec![[0.0; 3]; 8];
754        m.blocks.push(ExodusBlock::new(
755            1,
756            ExodusElementType::Tri3,
757            vec![vec![0, 1, 2]],
758        ));
759        m.blocks.push(ExodusBlock::new(
760            2,
761            ExodusElementType::Quad4,
762            vec![vec![0, 1, 2, 3]],
763        ));
764        m.blocks.push(ExodusBlock::new(
765            3,
766            ExodusElementType::Bar2,
767            vec![vec![4, 5]],
768        ));
769        let path = tmp_path("multi_blk");
770        ExodusWriter::new().write_text(&m, &path).unwrap();
771        let m2 = ExodusReader::new().parse(&path).unwrap();
772        assert_eq!(m2.blocks.len(), 3);
773        assert_eq!(m2.blocks[1].element_type, ExodusElementType::Quad4);
774        assert_eq!(m2.blocks[2].element_type, ExodusElementType::Bar2);
775    }
776
777    #[test]
778    fn write_read_multiple_variables() {
779        let mut m = simple_tri_mesh();
780        let mut vars: HashMap<String, Vec<f64>> = HashMap::new();
781        vars.insert("vel_x".into(), vec![1.0, 2.0, 3.0, 4.0]);
782        vars.insert("vel_y".into(), vec![0.1, 0.2, 0.3, 0.4]);
783        m.variables = vars;
784        let path = tmp_path("multi_var");
785        ExodusWriter::new().write_text(&m, &path).unwrap();
786        let m2 = ExodusReader::new().parse(&path).unwrap();
787        assert_eq!(m2.variables.len(), 2);
788        assert!(m2.variables.contains_key("vel_x"));
789        assert!(m2.variables.contains_key("vel_y"));
790    }
791
792    #[test]
793    fn write_creates_file() {
794        let m = simple_tri_mesh();
795        let path = tmp_path("create_check");
796        ExodusWriter::new().write_text(&m, &path).unwrap();
797        assert!(std::path::Path::new(&path).exists());
798    }
799
800    #[test]
801    fn read_nonexistent_file_returns_error() {
802        let result = ExodusReader::new().parse("/tmp/nonexistent_oxiphysics_exodus.exo");
803        assert!(result.is_err());
804    }
805
806    #[test]
807    fn write_read_tet_mesh() {
808        let mut m = ExodusMesh::new();
809        m.nodes = vec![
810            [0.0, 0.0, 0.0],
811            [1.0, 0.0, 0.0],
812            [0.0, 1.0, 0.0],
813            [0.0, 0.0, 1.0],
814        ];
815        m.blocks.push(ExodusBlock::new(
816            1,
817            ExodusElementType::Tet4,
818            vec![vec![0, 1, 2, 3]],
819        ));
820        let path = tmp_path("tet_mesh");
821        ExodusWriter::new().write_text(&m, &path).unwrap();
822        let m2 = ExodusReader::new().parse(&path).unwrap();
823        assert_eq!(m2.blocks[0].element_type, ExodusElementType::Tet4);
824        assert_eq!(m2.blocks[0].connectivity[0], vec![0, 1, 2, 3]);
825    }
826
827    #[test]
828    fn write_read_hex_mesh() {
829        let mut m = ExodusMesh::new();
830        m.nodes = vec![[0.0; 3]; 8];
831        m.blocks.push(ExodusBlock::new(
832            1,
833            ExodusElementType::Hex8,
834            vec![vec![0, 1, 2, 3, 4, 5, 6, 7]],
835        ));
836        let path = tmp_path("hex_mesh");
837        ExodusWriter::new().write_text(&m, &path).unwrap();
838        let m2 = ExodusReader::new().parse(&path).unwrap();
839        assert_eq!(m2.blocks[0].element_type, ExodusElementType::Hex8);
840        assert_eq!(m2.blocks[0].connectivity[0].len(), 8);
841    }
842
843    #[test]
844    fn writer_default_works() {
845        let w = ExodusWriter;
846        let m = ExodusMesh::new();
847        let path = tmp_path("writer_default");
848        w.write_text(&m, &path).unwrap();
849    }
850
851    #[test]
852    fn reader_default_works() {
853        let _ = ExodusReader;
854    }
855
856    #[test]
857    fn mesh_clone() {
858        let m = simple_tri_mesh();
859        let m2 = m.clone();
860        assert_eq!(m2.nodes.len(), m.nodes.len());
861        assert_eq!(m2.blocks.len(), m.blocks.len());
862    }
863
864    #[test]
865    fn multiple_node_sets_roundtrip() {
866        let mut m = simple_tri_mesh();
867        m.node_sets.push(ExodusNodeSet::new(1, "inlet", vec![0]));
868        m.node_sets
869            .push(ExodusNodeSet::new(2, "outlet", vec![1, 2]));
870        let path = tmp_path("multi_nset");
871        ExodusWriter::new().write_text(&m, &path).unwrap();
872        let m2 = ExodusReader::new().parse(&path).unwrap();
873        assert_eq!(m2.node_sets.len(), 2);
874    }
875
876    #[test]
877    fn multiple_side_sets_roundtrip() {
878        let mut m = simple_tri_mesh();
879        m.side_sets
880            .push(ExodusSideSet::new(1, "top", vec![0], vec![1]));
881        m.side_sets
882            .push(ExodusSideSet::new(2, "bottom", vec![1], vec![0]));
883        let path = tmp_path("multi_sset");
884        ExodusWriter::new().write_text(&m, &path).unwrap();
885        let m2 = ExodusReader::new().parse(&path).unwrap();
886        assert_eq!(m2.side_sets.len(), 2);
887    }
888
889    #[test]
890    fn empty_mesh_roundtrip() {
891        let m = ExodusMesh::new();
892        let path = tmp_path("empty_mesh");
893        ExodusWriter::new().write_text(&m, &path).unwrap();
894        let m2 = ExodusReader::new().parse(&path).unwrap();
895        assert!(m2.nodes.is_empty());
896        assert!(m2.blocks.is_empty());
897    }
898
899    #[test]
900    fn block_id_preserved() {
901        let mut m = ExodusMesh::new();
902        m.nodes = vec![[0.0; 3]; 3];
903        m.blocks.push(ExodusBlock::new(
904            42,
905            ExodusElementType::Tri3,
906            vec![vec![0, 1, 2]],
907        ));
908        let path = tmp_path("block_id");
909        ExodusWriter::new().write_text(&m, &path).unwrap();
910        let m2 = ExodusReader::new().parse(&path).unwrap();
911        assert_eq!(m2.blocks[0].id, 42);
912    }
913}