Skip to main content

oxiphysics_io/
ensight_format.rs

1#![allow(clippy::manual_strip)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! EnSight Gold format reader/writer for physics simulation data.
6//!
7//! Supports ASCII EnSight Gold case files, geometry files, and per-node
8//! scalar and vector variable files.  Covers unstructured element types:
9//! point, tria3 (triangle), tetra4 (tetrahedron), and hexa8 (hexahedron).
10
11use std::fmt;
12#[cfg(test)]
13use std::io::BufReader;
14use std::io::{self, BufRead, Write};
15use std::path::Path;
16
17// ---------------------------------------------------------------------------
18// NodeIdMode
19// ---------------------------------------------------------------------------
20
21/// EnSight node ID mode, written in the geometry file header.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum NodeIdMode {
24    /// No node IDs stored — EnSight assigns them internally.
25    #[default]
26    Off,
27    /// Node IDs are given explicitly in the file.
28    Given,
29    /// EnSight assigns node IDs automatically.
30    Assign,
31}
32
33impl fmt::Display for NodeIdMode {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            NodeIdMode::Off => write!(f, "off"),
37            NodeIdMode::Given => write!(f, "given"),
38            NodeIdMode::Assign => write!(f, "assign"),
39        }
40    }
41}
42
43// ---------------------------------------------------------------------------
44// EnsightPart
45// ---------------------------------------------------------------------------
46
47/// A named part within an EnSight geometry: holds element type + connectivity.
48///
49/// Connectivity is stored as a flat `Vec`u32` where each group of
50/// `nodes_per_element()` values represents one element.
51#[derive(Debug, Clone)]
52pub struct EnsightPart {
53    /// Part number (1-based in EnSight convention).
54    pub part_id: u32,
55    /// Human-readable part description.
56    pub description: String,
57    /// Element type keyword (e.g. `"point"`, `"tria3"`, `"tetra4"`, `"hexa8"`).
58    pub element_type: String,
59    /// Flat connectivity list: `nodes_per_element * n_elements` entries (0-based node indices).
60    pub connectivity: Vec<u32>,
61}
62
63impl EnsightPart {
64    /// Create a new part.
65    pub fn new(
66        part_id: u32,
67        description: impl Into<String>,
68        element_type: impl Into<String>,
69    ) -> Self {
70        Self {
71            part_id,
72            description: description.into(),
73            element_type: element_type.into(),
74            connectivity: Vec::new(),
75        }
76    }
77
78    /// Number of nodes per element for this element type.
79    pub fn nodes_per_element(&self) -> usize {
80        match self.element_type.as_str() {
81            "point" => 1,
82            "bar2" => 2,
83            "tria3" => 3,
84            "quad4" => 4,
85            "tetra4" => 4,
86            "pyramid5" => 5,
87            "penta6" => 6,
88            "hexa8" => 8,
89            _ => 0,
90        }
91    }
92
93    /// Number of elements stored in this part.
94    pub fn n_elements(&self) -> usize {
95        let npe = self.nodes_per_element();
96        if npe == 0 {
97            return 0;
98        }
99        self.connectivity.len() / npe
100    }
101}
102
103// ---------------------------------------------------------------------------
104// EnsightGeometry
105// ---------------------------------------------------------------------------
106
107/// EnSight Gold geometry: node coordinates + one or more parts.
108#[derive(Debug, Clone)]
109pub struct EnsightGeometry {
110    /// Description line 1 (appears in .geo header).
111    pub description1: String,
112    /// Description line 2 (appears in .geo header).
113    pub description2: String,
114    /// Node ID mode.
115    pub node_id_mode: NodeIdMode,
116    /// Element ID mode (`"off"` | `"given"` | `"assign"`).
117    pub element_id_mode: NodeIdMode,
118    /// X coordinates of all nodes (0-based).
119    pub x: Vec<f32>,
120    /// Y coordinates of all nodes (0-based).
121    pub y: Vec<f32>,
122    /// Z coordinates of all nodes (0-based).
123    pub z: Vec<f32>,
124    /// Parts in this geometry.
125    pub parts: Vec<EnsightPart>,
126}
127
128impl EnsightGeometry {
129    /// Create an empty geometry with default metadata.
130    pub fn new() -> Self {
131        Self {
132            description1: String::from("EnSight Gold Geometry"),
133            description2: String::from("Created by OxiPhysics"),
134            node_id_mode: NodeIdMode::Off,
135            element_id_mode: NodeIdMode::Off,
136            x: Vec::new(),
137            y: Vec::new(),
138            z: Vec::new(),
139            parts: Vec::new(),
140        }
141    }
142
143    /// Number of nodes.
144    pub fn n_nodes(&self) -> usize {
145        self.x.len()
146    }
147
148    /// Add a node, returning its 0-based index.
149    pub fn add_node(&mut self, xi: f32, yi: f32, zi: f32) -> u32 {
150        let idx = self.x.len() as u32;
151        self.x.push(xi);
152        self.y.push(yi);
153        self.z.push(zi);
154        idx
155    }
156
157    /// Add a part.
158    pub fn add_part(&mut self, part: EnsightPart) {
159        self.parts.push(part);
160    }
161}
162
163impl Default for EnsightGeometry {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169// ---------------------------------------------------------------------------
170// EnsightScalarVar
171// ---------------------------------------------------------------------------
172
173/// Per-node scalar variable (e.g. pressure, temperature).
174#[derive(Debug, Clone)]
175pub struct EnsightScalarVar {
176    /// Variable name (used in .case file and as filename stem).
177    pub name: String,
178    /// Per-node scalar values, length == number of nodes.
179    pub values: Vec<f32>,
180}
181
182impl EnsightScalarVar {
183    /// Create a new scalar variable.
184    pub fn new(name: impl Into<String>, values: Vec<f32>) -> Self {
185        Self {
186            name: name.into(),
187            values,
188        }
189    }
190}
191
192// ---------------------------------------------------------------------------
193// EnsightVectorVar
194// ---------------------------------------------------------------------------
195
196/// Per-node vector variable (e.g. velocity, displacement) with 3 components.
197#[derive(Debug, Clone)]
198pub struct EnsightVectorVar {
199    /// Variable name (used in .case file and as filename stem).
200    pub name: String,
201    /// X components, length == number of nodes.
202    pub vx: Vec<f32>,
203    /// Y components, length == number of nodes.
204    pub vy: Vec<f32>,
205    /// Z components, length == number of nodes.
206    pub vz: Vec<f32>,
207}
208
209impl EnsightVectorVar {
210    /// Create a new vector variable.
211    pub fn new(name: impl Into<String>, vx: Vec<f32>, vy: Vec<f32>, vz: Vec<f32>) -> Self {
212        Self {
213            name: name.into(),
214            vx,
215            vy,
216            vz,
217        }
218    }
219
220    /// Number of nodes this variable covers.
221    pub fn n_nodes(&self) -> usize {
222        self.vx.len()
223    }
224}
225
226// ---------------------------------------------------------------------------
227// EnsightCase
228// ---------------------------------------------------------------------------
229
230/// EnSight Gold case file descriptor.
231#[derive(Debug, Clone)]
232pub struct EnsightCase {
233    /// Path to the geometry file (relative to the case file directory).
234    pub geometry_file: String,
235    /// Scalar variable file paths (parallel to variable names).
236    pub scalar_files: Vec<(String, String)>,
237    /// Vector variable file paths (parallel to variable names).
238    pub vector_files: Vec<(String, String)>,
239}
240
241impl EnsightCase {
242    /// Create an empty case.
243    pub fn new(geometry_file: impl Into<String>) -> Self {
244        Self {
245            geometry_file: geometry_file.into(),
246            scalar_files: Vec::new(),
247            vector_files: Vec::new(),
248        }
249    }
250
251    /// Register a scalar variable file.
252    pub fn add_scalar(&mut self, name: impl Into<String>, file: impl Into<String>) {
253        self.scalar_files.push((name.into(), file.into()));
254    }
255
256    /// Register a vector variable file.
257    pub fn add_vector(&mut self, name: impl Into<String>, file: impl Into<String>) {
258        self.vector_files.push((name.into(), file.into()));
259    }
260
261    /// Write the `.case` file contents to a writer.
262    pub fn write_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
263        writeln!(writer, "FORMAT")?;
264        writeln!(writer, "type: ensight gold")?;
265        writeln!(writer)?;
266        writeln!(writer, "GEOMETRY")?;
267        writeln!(writer, "model: {}", self.geometry_file)?;
268        if !self.scalar_files.is_empty() || !self.vector_files.is_empty() {
269            writeln!(writer)?;
270            writeln!(writer, "VARIABLE")?;
271            for (name, file) in &self.scalar_files {
272                writeln!(writer, "scalar per node: {name} {file}")?;
273            }
274            for (name, file) in &self.vector_files {
275                writeln!(writer, "vector per node: {name} {file}")?;
276            }
277        }
278        Ok(())
279    }
280}
281
282// ---------------------------------------------------------------------------
283// EnsightTimeSeries
284// ---------------------------------------------------------------------------
285
286/// Time-varying EnSight case: a sequence of geometry/variable file sets.
287#[derive(Debug, Clone)]
288pub struct EnsightTimeSeries {
289    /// Time values for each step.
290    pub times: Vec<f64>,
291    /// Geometry files per time step.
292    pub geo_files: Vec<String>,
293}
294
295impl EnsightTimeSeries {
296    /// Create an empty time series.
297    pub fn new() -> Self {
298        Self {
299            times: Vec::new(),
300            geo_files: Vec::new(),
301        }
302    }
303
304    /// Add a time step.
305    pub fn push(&mut self, time: f64, geo_file: impl Into<String>) {
306        self.times.push(time);
307        self.geo_files.push(geo_file.into());
308    }
309
310    /// Number of time steps.
311    pub fn n_steps(&self) -> usize {
312        self.times.len()
313    }
314
315    /// Write a transient `.case` file section to a writer.
316    pub fn write_transient_case<W: Write>(&self, writer: &mut W) -> io::Result<()> {
317        writeln!(writer, "FORMAT")?;
318        writeln!(writer, "type: ensight gold")?;
319        writeln!(writer)?;
320        writeln!(writer, "GEOMETRY")?;
321        writeln!(
322            writer,
323            "model: 1 {}",
324            if self.geo_files.is_empty() {
325                "geometry"
326            } else {
327                &self.geo_files[0]
328            }
329        )?;
330        writeln!(writer)?;
331        writeln!(writer, "TIME")?;
332        writeln!(writer, "time set: 1")?;
333        writeln!(writer, "number of steps: {}", self.times.len())?;
334        writeln!(writer, "time values:")?;
335        for &t in &self.times {
336            writeln!(writer, "  {t:.6e}")?;
337        }
338        Ok(())
339    }
340}
341
342impl Default for EnsightTimeSeries {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348// ---------------------------------------------------------------------------
349// EnsightWriter
350// ---------------------------------------------------------------------------
351
352/// ASCII EnSight Gold writer: writes `.case`, `.geo`, and variable files.
353pub struct EnsightWriter;
354
355impl EnsightWriter {
356    /// Write an EnSight Gold geometry file (ASCII) to a writer.
357    pub fn write_geo<W: Write>(writer: &mut W, geo: &EnsightGeometry) -> io::Result<()> {
358        writeln!(writer, "{}", geo.description1)?;
359        writeln!(writer, "{}", geo.description2)?;
360        writeln!(writer, "node id {}", geo.node_id_mode)?;
361        writeln!(writer, "element id {}", geo.element_id_mode)?;
362
363        for part in &geo.parts {
364            writeln!(writer, "part")?;
365            writeln!(writer, "{:10}", part.part_id)?;
366            writeln!(writer, "{}", part.description)?;
367            writeln!(writer, "coordinates")?;
368            writeln!(writer, "{:10}", geo.n_nodes())?;
369            for &v in &geo.x {
370                writeln!(writer, "{v:12.5e}")?;
371            }
372            for &v in &geo.y {
373                writeln!(writer, "{v:12.5e}")?;
374            }
375            for &v in &geo.z {
376                writeln!(writer, "{v:12.5e}")?;
377            }
378            writeln!(writer, "{}", part.element_type)?;
379            writeln!(writer, "{:10}", part.n_elements())?;
380            let npe = part.nodes_per_element();
381            for chunk in part.connectivity.chunks(npe) {
382                let s: Vec<String> = chunk.iter().map(|&c| format!("{:10}", c + 1)).collect();
383                writeln!(writer, "{}", s.join(""))?;
384            }
385        }
386        Ok(())
387    }
388
389    /// Write a scalar variable file (ASCII EnSight Gold).
390    pub fn write_scalar<W: Write>(writer: &mut W, var: &EnsightScalarVar) -> io::Result<()> {
391        writeln!(writer, "{}", var.name)?;
392        for part_id in 1u32..=1 {
393            writeln!(writer, "part")?;
394            writeln!(writer, "{part_id:10}")?;
395            writeln!(writer, "coordinates")?;
396            for &v in &var.values {
397                writeln!(writer, "{v:12.5e}")?;
398            }
399        }
400        Ok(())
401    }
402
403    /// Write a vector variable file (ASCII EnSight Gold).
404    pub fn write_vector<W: Write>(writer: &mut W, var: &EnsightVectorVar) -> io::Result<()> {
405        writeln!(writer, "{}", var.name)?;
406        for part_id in 1u32..=1 {
407            writeln!(writer, "part")?;
408            writeln!(writer, "{part_id:10}")?;
409            writeln!(writer, "coordinates")?;
410            for i in 0..var.vx.len() {
411                writeln!(writer, "{:12.5e}", var.vx[i])?;
412            }
413            for i in 0..var.vy.len() {
414                writeln!(writer, "{:12.5e}", var.vy[i])?;
415            }
416            for i in 0..var.vz.len() {
417                writeln!(writer, "{:12.5e}", var.vz[i])?;
418            }
419        }
420        Ok(())
421    }
422}
423
424// ---------------------------------------------------------------------------
425// EnsightReader
426// ---------------------------------------------------------------------------
427
428/// ASCII EnSight Gold reader.
429pub struct EnsightReader;
430
431impl EnsightReader {
432    /// Parse an EnSight Gold case file from a reader.
433    ///
434    /// Returns an [`EnsightCase`] with paths filled in but no data loaded yet.
435    pub fn read_case<R: BufRead>(reader: R) -> io::Result<EnsightCase> {
436        let mut geo_file = String::new();
437        let mut case = EnsightCase::new("");
438
439        for line in reader.lines() {
440            let line = line?;
441            let trimmed = line.trim();
442            if trimmed.starts_with("model:") {
443                let rest = trimmed["model:".len()..].trim();
444                // May be "1 filename" (transient) or just "filename"
445                let parts: Vec<&str> = rest.splitn(2, ' ').collect();
446                geo_file = parts.last().unwrap_or(&"").to_string();
447            } else if trimmed.starts_with("scalar per node:") {
448                let rest = trimmed["scalar per node:".len()..].trim();
449                let parts: Vec<&str> = rest.splitn(2, ' ').collect();
450                if parts.len() == 2 {
451                    case.scalar_files
452                        .push((parts[0].to_string(), parts[1].to_string()));
453                }
454            } else if trimmed.starts_with("vector per node:") {
455                let rest = trimmed["vector per node:".len()..].trim();
456                let parts: Vec<&str> = rest.splitn(2, ' ').collect();
457                if parts.len() == 2 {
458                    case.vector_files
459                        .push((parts[0].to_string(), parts[1].to_string()));
460                }
461            }
462        }
463        case.geometry_file = geo_file;
464        Ok(case)
465    }
466
467    /// Parse an EnSight Gold geometry file from a reader.
468    ///
469    /// Returns an [`EnsightGeometry`] with nodes and parts populated.
470    pub fn read_geo<R: BufRead>(reader: R) -> io::Result<EnsightGeometry> {
471        let mut geo = EnsightGeometry::new();
472        let mut lines = reader.lines();
473
474        // Header lines
475        if let Some(l) = lines.next() {
476            geo.description1 = l?.trim().to_string();
477        }
478        if let Some(l) = lines.next() {
479            geo.description2 = l?.trim().to_string();
480        }
481        if let Some(l) = lines.next() {
482            let s = l?;
483            if s.contains("given") {
484                geo.node_id_mode = NodeIdMode::Given;
485            } else if s.contains("assign") {
486                geo.node_id_mode = NodeIdMode::Assign;
487            }
488        }
489        if let Some(l) = lines.next() {
490            let s = l?;
491            if s.contains("given") {
492                geo.element_id_mode = NodeIdMode::Given;
493            } else if s.contains("assign") {
494                geo.element_id_mode = NodeIdMode::Assign;
495            }
496        }
497
498        let mut line_buf: Vec<String> = lines.map(|l| l.unwrap_or_default()).collect();
499        let mut pos = 0;
500
501        while pos < line_buf.len() {
502            let line = line_buf[pos].trim().to_string();
503            pos += 1;
504
505            if line == "part" {
506                let part_id: u32 = line_buf
507                    .get(pos)
508                    .map(|l| l.trim().parse().unwrap_or(1))
509                    .unwrap_or(1);
510                pos += 1;
511                let description = line_buf
512                    .get(pos)
513                    .map(|l| l.trim().to_string())
514                    .unwrap_or_default();
515                pos += 1;
516                let keyword = line_buf
517                    .get(pos)
518                    .map(|l| l.trim().to_string())
519                    .unwrap_or_default();
520                pos += 1;
521
522                if keyword == "coordinates" {
523                    let n_nodes: usize = line_buf
524                        .get(pos)
525                        .map(|l| l.trim().parse().unwrap_or(0))
526                        .unwrap_or(0);
527                    pos += 1;
528                    let mut xs = Vec::with_capacity(n_nodes);
529                    let mut ys = Vec::with_capacity(n_nodes);
530                    let mut zs = Vec::with_capacity(n_nodes);
531                    for _ in 0..n_nodes {
532                        let v: f32 = line_buf
533                            .get(pos)
534                            .map(|l| l.trim().parse().unwrap_or(0.0))
535                            .unwrap_or(0.0);
536                        xs.push(v);
537                        pos += 1;
538                    }
539                    for _ in 0..n_nodes {
540                        let v: f32 = line_buf
541                            .get(pos)
542                            .map(|l| l.trim().parse().unwrap_or(0.0))
543                            .unwrap_or(0.0);
544                        ys.push(v);
545                        pos += 1;
546                    }
547                    for _ in 0..n_nodes {
548                        let v: f32 = line_buf
549                            .get(pos)
550                            .map(|l| l.trim().parse().unwrap_or(0.0))
551                            .unwrap_or(0.0);
552                        zs.push(v);
553                        pos += 1;
554                    }
555                    geo.x = xs;
556                    geo.y = ys;
557                    geo.z = zs;
558
559                    // Next: element type
560                    let elem_type = line_buf
561                        .get(pos)
562                        .map(|l| l.trim().to_string())
563                        .unwrap_or_default();
564                    pos += 1;
565                    let n_elements: usize = line_buf
566                        .get(pos)
567                        .map(|l| l.trim().parse().unwrap_or(0))
568                        .unwrap_or(0);
569                    pos += 1;
570
571                    let mut part = EnsightPart::new(part_id, description, &elem_type);
572                    let npe = part.nodes_per_element();
573                    for _ in 0..n_elements {
574                        let row = line_buf
575                            .get(pos)
576                            .map(|l| l.trim().to_string())
577                            .unwrap_or_default();
578                        pos += 1;
579                        // Parse space-separated or fixed-width (10-char) node ids
580                        let tokens: Vec<u32> = row
581                            .split_whitespace()
582                            .filter_map(|t| t.parse::<u32>().ok())
583                            .map(|v| v - 1) // convert to 0-based
584                            .collect();
585                        for k in 0..npe {
586                            part.connectivity.push(*tokens.get(k).unwrap_or(&0));
587                        }
588                    }
589                    geo.parts.push(part);
590                }
591            }
592        }
593        line_buf.clear();
594        Ok(geo)
595    }
596}
597
598// ---------------------------------------------------------------------------
599// Convenience write function
600// ---------------------------------------------------------------------------
601
602/// Write a complete EnSight Gold dataset to disk (ASCII).
603///
604/// Creates `path`.case`, `path`.geo`, and per-variable files.
605/// All files are placed in the directory of `path` (the stem is used as a
606/// base name).
607pub fn write_ensight_case(
608    path: &str,
609    geo: &EnsightGeometry,
610    scalars: &[EnsightScalarVar],
611    vectors: &[EnsightVectorVar],
612) -> io::Result<()> {
613    let stem = Path::new(path)
614        .file_stem()
615        .and_then(|s| s.to_str())
616        .unwrap_or("output");
617    let dir = Path::new(path).parent().unwrap_or(Path::new("."));
618
619    let geo_name = format!("{stem}.geo");
620    let case_name = format!("{stem}.case");
621
622    // Write geometry file
623    let geo_path = dir.join(&geo_name);
624    let mut geo_file = std::fs::File::create(&geo_path)?;
625    EnsightWriter::write_geo(&mut geo_file, geo)?;
626
627    // Build case
628    let mut case = EnsightCase::new(&geo_name);
629    let mut scalar_bufs: Vec<Vec<u8>> = Vec::new();
630    let mut vector_bufs: Vec<Vec<u8>> = Vec::new();
631
632    for sv in scalars {
633        let fname = format!("{stem}_{}.escl", sv.name);
634        let fpath = dir.join(&fname);
635        let mut buf = Vec::new();
636        EnsightWriter::write_scalar(&mut buf, sv)?;
637        scalar_bufs.push(buf.clone());
638        std::fs::write(&fpath, &buf)?;
639        case.add_scalar(&sv.name, &fname);
640    }
641
642    for vv in vectors {
643        let fname = format!("{stem}_{}.evec", vv.name);
644        let fpath = dir.join(&fname);
645        let mut buf = Vec::new();
646        EnsightWriter::write_vector(&mut buf, vv)?;
647        vector_bufs.push(buf.clone());
648        std::fs::write(&fpath, &buf)?;
649        case.add_vector(&vv.name, &fname);
650    }
651
652    // Write case file
653    let case_path = dir.join(&case_name);
654    let mut case_file = std::fs::File::create(&case_path)?;
655    case.write_to(&mut case_file)?;
656
657    Ok(())
658}
659
660// ---------------------------------------------------------------------------
661// Tests
662// ---------------------------------------------------------------------------
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    // ── NodeIdMode ────────────────────────────────────────────────────────
669
670    #[test]
671    fn test_node_id_mode_display_off() {
672        assert_eq!(NodeIdMode::Off.to_string(), "off");
673    }
674
675    #[test]
676    fn test_node_id_mode_display_given() {
677        assert_eq!(NodeIdMode::Given.to_string(), "given");
678    }
679
680    #[test]
681    fn test_node_id_mode_display_assign() {
682        assert_eq!(NodeIdMode::Assign.to_string(), "assign");
683    }
684
685    #[test]
686    fn test_node_id_mode_default() {
687        assert_eq!(NodeIdMode::default(), NodeIdMode::Off);
688    }
689
690    #[test]
691    fn test_node_id_mode_equality() {
692        assert_eq!(NodeIdMode::Given, NodeIdMode::Given);
693        assert_ne!(NodeIdMode::Given, NodeIdMode::Off);
694    }
695
696    // ── EnsightPart ───────────────────────────────────────────────────────
697
698    #[test]
699    fn test_part_nodes_per_element_point() {
700        let p = EnsightPart::new(1, "test", "point");
701        assert_eq!(p.nodes_per_element(), 1);
702    }
703
704    #[test]
705    fn test_part_nodes_per_element_tria3() {
706        let p = EnsightPart::new(1, "test", "tria3");
707        assert_eq!(p.nodes_per_element(), 3);
708    }
709
710    #[test]
711    fn test_part_nodes_per_element_tetra4() {
712        let p = EnsightPart::new(1, "test", "tetra4");
713        assert_eq!(p.nodes_per_element(), 4);
714    }
715
716    #[test]
717    fn test_part_nodes_per_element_hexa8() {
718        let p = EnsightPart::new(1, "test", "hexa8");
719        assert_eq!(p.nodes_per_element(), 8);
720    }
721
722    #[test]
723    fn test_part_n_elements_tria3() {
724        let mut p = EnsightPart::new(1, "mesh", "tria3");
725        p.connectivity = vec![0, 1, 2, 1, 2, 3];
726        assert_eq!(p.n_elements(), 2);
727    }
728
729    #[test]
730    fn test_part_n_elements_hexa8() {
731        let mut p = EnsightPart::new(1, "vol", "hexa8");
732        p.connectivity = vec![0; 8];
733        assert_eq!(p.n_elements(), 1);
734    }
735
736    #[test]
737    fn test_part_n_elements_empty() {
738        let p = EnsightPart::new(1, "empty", "tetra4");
739        assert_eq!(p.n_elements(), 0);
740    }
741
742    // ── EnsightGeometry ───────────────────────────────────────────────────
743
744    #[test]
745    fn test_geo_new_empty() {
746        let geo = EnsightGeometry::new();
747        assert_eq!(geo.n_nodes(), 0);
748        assert!(geo.parts.is_empty());
749    }
750
751    #[test]
752    fn test_geo_add_node() {
753        let mut geo = EnsightGeometry::new();
754        let idx = geo.add_node(1.0, 2.0, 3.0);
755        assert_eq!(idx, 0);
756        assert_eq!(geo.n_nodes(), 1);
757        assert!((geo.x[0] - 1.0).abs() < 1e-7);
758        assert!((geo.y[0] - 2.0).abs() < 1e-7);
759        assert!((geo.z[0] - 3.0).abs() < 1e-7);
760    }
761
762    #[test]
763    fn test_geo_add_multiple_nodes() {
764        let mut geo = EnsightGeometry::new();
765        geo.add_node(0.0, 0.0, 0.0);
766        geo.add_node(1.0, 0.0, 0.0);
767        geo.add_node(0.0, 1.0, 0.0);
768        assert_eq!(geo.n_nodes(), 3);
769    }
770
771    #[test]
772    fn test_geo_add_part() {
773        let mut geo = EnsightGeometry::new();
774        let part = EnsightPart::new(1, "surf", "tria3");
775        geo.add_part(part);
776        assert_eq!(geo.parts.len(), 1);
777    }
778
779    #[test]
780    fn test_geo_default() {
781        let geo = EnsightGeometry::default();
782        assert_eq!(geo.n_nodes(), 0);
783    }
784
785    // ── EnsightScalarVar ──────────────────────────────────────────────────
786
787    #[test]
788    fn test_scalar_var_new() {
789        let sv = EnsightScalarVar::new("pressure", vec![1.0, 2.0, 3.0]);
790        assert_eq!(sv.name, "pressure");
791        assert_eq!(sv.values.len(), 3);
792    }
793
794    #[test]
795    fn test_scalar_var_values() {
796        let sv = EnsightScalarVar::new("temp", vec![300.0, 350.0]);
797        assert!((sv.values[0] - 300.0).abs() < 1e-6);
798        assert!((sv.values[1] - 350.0).abs() < 1e-6);
799    }
800
801    // ── EnsightVectorVar ──────────────────────────────────────────────────
802
803    #[test]
804    fn test_vector_var_new() {
805        let vv = EnsightVectorVar::new("velocity", vec![1.0, 2.0], vec![0.0, 0.0], vec![0.0, 0.0]);
806        assert_eq!(vv.name, "velocity");
807        assert_eq!(vv.n_nodes(), 2);
808    }
809
810    #[test]
811    fn test_vector_var_n_nodes() {
812        let vv = EnsightVectorVar::new("disp", vec![0.1; 5], vec![0.2; 5], vec![0.3; 5]);
813        assert_eq!(vv.n_nodes(), 5);
814    }
815
816    // ── EnsightCase ───────────────────────────────────────────────────────
817
818    #[test]
819    fn test_case_new() {
820        let c = EnsightCase::new("out.geo");
821        assert_eq!(c.geometry_file, "out.geo");
822        assert!(c.scalar_files.is_empty());
823        assert!(c.vector_files.is_empty());
824    }
825
826    #[test]
827    fn test_case_add_scalar() {
828        let mut c = EnsightCase::new("out.geo");
829        c.add_scalar("pressure", "out_pressure.escl");
830        assert_eq!(c.scalar_files.len(), 1);
831        assert_eq!(c.scalar_files[0].0, "pressure");
832    }
833
834    #[test]
835    fn test_case_add_vector() {
836        let mut c = EnsightCase::new("out.geo");
837        c.add_vector("velocity", "out_velocity.evec");
838        assert_eq!(c.vector_files.len(), 1);
839    }
840
841    #[test]
842    fn test_case_write_to_contains_format() {
843        let c = EnsightCase::new("geom.geo");
844        let mut buf = Vec::new();
845        c.write_to(&mut buf).unwrap();
846        let s = String::from_utf8(buf).unwrap();
847        assert!(s.contains("FORMAT"), "missing FORMAT section");
848        assert!(s.contains("type: ensight gold"));
849        assert!(s.contains("geom.geo"));
850    }
851
852    #[test]
853    fn test_case_write_to_with_scalar() {
854        let mut c = EnsightCase::new("g.geo");
855        c.add_scalar("p", "p.escl");
856        let mut buf = Vec::new();
857        c.write_to(&mut buf).unwrap();
858        let s = String::from_utf8(buf).unwrap();
859        assert!(s.contains("scalar per node: p p.escl"), "output: {s}");
860    }
861
862    #[test]
863    fn test_case_write_to_with_vector() {
864        let mut c = EnsightCase::new("g.geo");
865        c.add_vector("vel", "vel.evec");
866        let mut buf = Vec::new();
867        c.write_to(&mut buf).unwrap();
868        let s = String::from_utf8(buf).unwrap();
869        assert!(s.contains("vector per node: vel vel.evec"), "output: {s}");
870    }
871
872    // ── EnsightWriter geo ─────────────────────────────────────────────────
873
874    #[test]
875    fn test_writer_geo_single_point_part() {
876        let mut geo = EnsightGeometry::new();
877        geo.add_node(0.0, 0.0, 0.0);
878        let mut part = EnsightPart::new(1, "pts", "point");
879        part.connectivity = vec![0];
880        geo.add_part(part);
881
882        let mut buf = Vec::new();
883        EnsightWriter::write_geo(&mut buf, &geo).unwrap();
884        let s = String::from_utf8(buf).unwrap();
885        assert!(s.contains("part"), "missing 'part' keyword");
886        assert!(s.contains("coordinates"));
887        assert!(s.contains("point"));
888    }
889
890    #[test]
891    fn test_writer_geo_tria3_part() {
892        let mut geo = EnsightGeometry::new();
893        geo.add_node(0.0, 0.0, 0.0);
894        geo.add_node(1.0, 0.0, 0.0);
895        geo.add_node(0.0, 1.0, 0.0);
896        let mut part = EnsightPart::new(1, "surf", "tria3");
897        part.connectivity = vec![0, 1, 2];
898        geo.add_part(part);
899
900        let mut buf = Vec::new();
901        EnsightWriter::write_geo(&mut buf, &geo).unwrap();
902        let s = String::from_utf8(buf).unwrap();
903        assert!(s.contains("tria3"));
904    }
905
906    // ── EnsightWriter scalar ──────────────────────────────────────────────
907
908    #[test]
909    fn test_writer_scalar_output() {
910        let sv = EnsightScalarVar::new("pressure", vec![1.5, 2.5, 3.5]);
911        let mut buf = Vec::new();
912        EnsightWriter::write_scalar(&mut buf, &sv).unwrap();
913        let s = String::from_utf8(buf).unwrap();
914        assert!(s.contains("pressure"));
915        assert!(s.contains("coordinates"));
916    }
917
918    // ── EnsightWriter vector ──────────────────────────────────────────────
919
920    #[test]
921    fn test_writer_vector_output() {
922        let vv = EnsightVectorVar::new("vel", vec![1.0, 2.0], vec![0.0, 0.0], vec![0.0, 0.0]);
923        let mut buf = Vec::new();
924        EnsightWriter::write_vector(&mut buf, &vv).unwrap();
925        let s = String::from_utf8(buf).unwrap();
926        assert!(s.contains("vel"));
927        assert!(s.contains("coordinates"));
928    }
929
930    // ── EnsightReader case ────────────────────────────────────────────────
931
932    #[test]
933    fn test_reader_case_minimal() {
934        let input = "FORMAT\ntype: ensight gold\n\nGEOMETRY\nmodel: geom.geo\n";
935        let reader = BufReader::new(input.as_bytes());
936        let case = EnsightReader::read_case(reader).unwrap();
937        assert_eq!(case.geometry_file, "geom.geo");
938    }
939
940    #[test]
941    fn test_reader_case_with_scalar() {
942        let input = "FORMAT\ntype: ensight gold\n\nGEOMETRY\nmodel: g.geo\n\nVARIABLE\nscalar per node: pressure p.escl\n";
943        let reader = BufReader::new(input.as_bytes());
944        let case = EnsightReader::read_case(reader).unwrap();
945        assert_eq!(case.scalar_files.len(), 1);
946        assert_eq!(case.scalar_files[0].0, "pressure");
947    }
948
949    #[test]
950    fn test_reader_case_with_vector() {
951        let input = "FORMAT\ntype: ensight gold\n\nGEOMETRY\nmodel: g.geo\n\nVARIABLE\nvector per node: velocity v.evec\n";
952        let reader = BufReader::new(input.as_bytes());
953        let case = EnsightReader::read_case(reader).unwrap();
954        assert_eq!(case.vector_files.len(), 1);
955        assert_eq!(case.vector_files[0].0, "velocity");
956    }
957
958    #[test]
959    fn test_reader_case_roundtrip() {
960        let mut case_written = EnsightCase::new("out.geo");
961        case_written.add_scalar("p", "out_p.escl");
962        case_written.add_vector("v", "out_v.evec");
963        let mut buf = Vec::new();
964        case_written.write_to(&mut buf).unwrap();
965        let reader = BufReader::new(buf.as_slice());
966        let case_read = EnsightReader::read_case(reader).unwrap();
967        assert_eq!(case_read.geometry_file, "out.geo");
968        assert_eq!(case_read.scalar_files.len(), 1);
969        assert_eq!(case_read.vector_files.len(), 1);
970    }
971
972    // ── EnsightTimeSeries ─────────────────────────────────────────────────
973
974    #[test]
975    fn test_time_series_new_empty() {
976        let ts = EnsightTimeSeries::new();
977        assert_eq!(ts.n_steps(), 0);
978    }
979
980    #[test]
981    fn test_time_series_push() {
982        let mut ts = EnsightTimeSeries::new();
983        ts.push(0.0, "geo_0.geo");
984        ts.push(1.0, "geo_1.geo");
985        assert_eq!(ts.n_steps(), 2);
986        assert!((ts.times[1] - 1.0).abs() < 1e-12);
987    }
988
989    #[test]
990    fn test_time_series_write_transient() {
991        let mut ts = EnsightTimeSeries::new();
992        ts.push(0.0, "g0.geo");
993        ts.push(0.5, "g1.geo");
994        let mut buf = Vec::new();
995        ts.write_transient_case(&mut buf).unwrap();
996        let s = String::from_utf8(buf).unwrap();
997        assert!(s.contains("number of steps: 2"));
998        assert!(s.contains("TIME"));
999    }
1000
1001    #[test]
1002    fn test_time_series_default() {
1003        let ts = EnsightTimeSeries::default();
1004        assert_eq!(ts.n_steps(), 0);
1005    }
1006
1007    // ── write_ensight_case integration ────────────────────────────────────
1008
1009    #[test]
1010    fn test_write_ensight_case_creates_files() {
1011        let tmp_dir = std::env::temp_dir();
1012        let base = tmp_dir.join("test_ensight_case_output");
1013        let path = base.to_str().unwrap();
1014
1015        let mut geo = EnsightGeometry::new();
1016        geo.add_node(0.0, 0.0, 0.0);
1017        geo.add_node(1.0, 0.0, 0.0);
1018        geo.add_node(0.0, 1.0, 0.0);
1019        let mut part = EnsightPart::new(1, "surf", "tria3");
1020        part.connectivity = vec![0, 1, 2];
1021        geo.add_part(part);
1022
1023        let scalars = vec![EnsightScalarVar::new("pressure", vec![1.0, 2.0, 3.0])];
1024        let vectors = vec![EnsightVectorVar::new(
1025            "velocity",
1026            vec![1.0, 0.0, 0.0],
1027            vec![0.0, 1.0, 0.0],
1028            vec![0.0, 0.0, 1.0],
1029        )];
1030
1031        write_ensight_case(path, &geo, &scalars, &vectors).unwrap();
1032
1033        assert!(tmp_dir.join("test_ensight_case_output.case").exists());
1034        assert!(tmp_dir.join("test_ensight_case_output.geo").exists());
1035    }
1036
1037    #[test]
1038    fn test_write_ensight_case_no_vars() {
1039        let tmp_dir = std::env::temp_dir();
1040        let base = tmp_dir.join("test_ensight_novar");
1041        let path = base.to_str().unwrap();
1042
1043        let mut geo = EnsightGeometry::new();
1044        geo.add_node(0.0, 0.0, 0.0);
1045        let mut part = EnsightPart::new(1, "pt", "point");
1046        part.connectivity = vec![0];
1047        geo.add_part(part);
1048
1049        write_ensight_case(path, &geo, &[], &[]).unwrap();
1050        assert!(tmp_dir.join("test_ensight_novar.case").exists());
1051        assert!(tmp_dir.join("test_ensight_novar.geo").exists());
1052    }
1053
1054    // ── Reader geo roundtrip ──────────────────────────────────────────────
1055
1056    #[test]
1057    fn test_reader_geo_roundtrip_nodes() {
1058        let mut geo = EnsightGeometry::new();
1059        geo.add_node(1.0, 2.0, 3.0);
1060        geo.add_node(4.0, 5.0, 6.0);
1061        let mut part = EnsightPart::new(1, "pts", "point");
1062        part.connectivity = vec![0, 1];
1063        geo.add_part(part);
1064
1065        let mut buf = Vec::new();
1066        EnsightWriter::write_geo(&mut buf, &geo).unwrap();
1067
1068        let reader = BufReader::new(buf.as_slice());
1069        let geo2 = EnsightReader::read_geo(reader).unwrap();
1070        assert_eq!(geo2.n_nodes(), 2);
1071        assert!((geo2.x[0] - 1.0).abs() < 1e-4, "x0={}", geo2.x[0]);
1072        assert!((geo2.y[0] - 2.0).abs() < 1e-4, "y0={}", geo2.y[0]);
1073        assert!((geo2.z[0] - 3.0).abs() < 1e-4, "z0={}", geo2.z[0]);
1074    }
1075}