Skip to main content

oxiphysics_io/
fluent_format.rs

1#![allow(clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! ANSYS Fluent mesh format I/O (ASCII `.msh`).
6//!
7//! Supports reading and writing Fluent mesh files containing nodes, faces,
8//! cells, and zone assignments.  The implementation covers the major section
9//! types used in Fluent ASCII mesh files:
10//!
11//! * `(0 …)` — comment
12//! * `(2 …)` — dimension
13//! * `(10 …)` — node section
14//! * `(12 …)` — cell section
15//! * `(13 …)` — face section
16//! * `(45 …)` — zone section
17
18use std::fmt::Write as FmtWrite;
19use std::fs;
20use std::io::{self, BufRead};
21
22use crate::Error as IoError;
23
24// ── FluentZoneType ────────────────────────────────────────────────────────────
25
26/// Fluent zone type.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum FluentZoneType {
29    /// Fluid volume zone.
30    Fluid,
31    /// Solid volume zone.
32    Solid,
33    /// Wall boundary.
34    Wall,
35    /// Inlet boundary.
36    Inlet,
37    /// Outlet boundary.
38    Outlet,
39    /// Symmetry boundary.
40    Symmetry,
41    /// Interior (internal) face zone.
42    Interior,
43}
44
45impl FluentZoneType {
46    /// Fluent keyword string for this zone type.
47    pub fn as_str(&self) -> &str {
48        match self {
49            FluentZoneType::Fluid => "fluid",
50            FluentZoneType::Solid => "solid",
51            FluentZoneType::Wall => "wall",
52            FluentZoneType::Inlet => "velocity-inlet",
53            FluentZoneType::Outlet => "pressure-outlet",
54            FluentZoneType::Symmetry => "symmetry",
55            FluentZoneType::Interior => "interior",
56        }
57    }
58
59    /// Parse a Fluent keyword string into a zone type.
60    pub fn from_str(s: &str) -> Self {
61        match s.trim() {
62            "fluid" => FluentZoneType::Fluid,
63            "solid" => FluentZoneType::Solid,
64            "wall" => FluentZoneType::Wall,
65            "velocity-inlet" | "inlet" => FluentZoneType::Inlet,
66            "pressure-outlet" | "outlet" => FluentZoneType::Outlet,
67            "symmetry" => FluentZoneType::Symmetry,
68            "interior" => FluentZoneType::Interior,
69            _ => FluentZoneType::Interior,
70        }
71    }
72}
73
74// ── FluentNode ────────────────────────────────────────────────────────────────
75
76/// A mesh node (vertex) in a Fluent mesh.
77#[derive(Debug, Clone, PartialEq)]
78pub struct FluentNode {
79    /// 1-based node identifier.
80    pub id: usize,
81    /// Node coordinates `[x, y, z]`.
82    pub coordinates: [f64; 3],
83}
84
85impl FluentNode {
86    /// Create a new node.
87    pub fn new(id: usize, coordinates: [f64; 3]) -> Self {
88        Self { id, coordinates }
89    }
90}
91
92// ── FluentFaceType ────────────────────────────────────────────────────────────
93
94/// Face element topology type.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum FluentFaceType {
97    /// 2-node line (2-D edge).
98    Line,
99    /// 3-node triangle.
100    Triangle,
101    /// 4-node quadrilateral.
102    Quad,
103}
104
105impl FluentFaceType {
106    /// Fluent face type integer code.
107    pub fn code(&self) -> u32 {
108        match self {
109            FluentFaceType::Line => 2,
110            FluentFaceType::Triangle => 3,
111            FluentFaceType::Quad => 4,
112        }
113    }
114
115    /// Parse a Fluent face type code.
116    pub fn from_code(code: u32) -> Self {
117        match code {
118            2 => FluentFaceType::Line,
119            3 => FluentFaceType::Triangle,
120            4 => FluentFaceType::Quad,
121            _ => FluentFaceType::Triangle,
122        }
123    }
124
125    /// Number of nodes for this face type.
126    pub fn node_count(&self) -> usize {
127        match self {
128            FluentFaceType::Line => 2,
129            FluentFaceType::Triangle => 3,
130            FluentFaceType::Quad => 4,
131        }
132    }
133}
134
135// ── FluentFace ────────────────────────────────────────────────────────────────
136
137/// A mesh face (edge in 2-D, polygon in 3-D) in a Fluent mesh.
138#[derive(Debug, Clone, PartialEq)]
139pub struct FluentFace {
140    /// 1-based face identifier.
141    pub id: usize,
142    /// Face topology type.
143    pub face_type: FluentFaceType,
144    /// Indices of the nodes forming this face (1-based).
145    pub node_ids: Vec<usize>,
146    /// Left (owner) cell id, or 0 for boundary faces.
147    pub left_cell: usize,
148    /// Right (neighbour) cell id, or 0 for boundary faces.
149    pub right_cell: usize,
150}
151
152impl FluentFace {
153    /// Create a new face.
154    pub fn new(
155        id: usize,
156        face_type: FluentFaceType,
157        node_ids: Vec<usize>,
158        left_cell: usize,
159        right_cell: usize,
160    ) -> Self {
161        Self {
162            id,
163            face_type,
164            node_ids,
165            left_cell,
166            right_cell,
167        }
168    }
169
170    /// True if this is a boundary face (one adjacent cell is 0).
171    pub fn is_boundary(&self) -> bool {
172        self.left_cell == 0 || self.right_cell == 0
173    }
174}
175
176// ── FluentCellType ────────────────────────────────────────────────────────────
177
178/// Cell element topology type.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum FluentCellType {
181    /// 2-D triangle.
182    Tri,
183    /// 3-D tetrahedron.
184    Tet,
185    /// 2-D quadrilateral.
186    Quad,
187    /// 3-D hexahedron.
188    Hex,
189}
190
191impl FluentCellType {
192    /// Fluent cell type integer code.
193    pub fn code(&self) -> u32 {
194        match self {
195            FluentCellType::Tri => 1,
196            FluentCellType::Tet => 2,
197            FluentCellType::Quad => 3,
198            FluentCellType::Hex => 4,
199        }
200    }
201
202    /// Parse a Fluent cell type code.
203    pub fn from_code(code: u32) -> Self {
204        match code {
205            1 => FluentCellType::Tri,
206            2 => FluentCellType::Tet,
207            3 => FluentCellType::Quad,
208            4 => FluentCellType::Hex,
209            _ => FluentCellType::Tet,
210        }
211    }
212}
213
214// ── FluentCell ────────────────────────────────────────────────────────────────
215
216/// A mesh cell (volume element) in a Fluent mesh.
217#[derive(Debug, Clone, PartialEq)]
218pub struct FluentCell {
219    /// 1-based cell identifier.
220    pub id: usize,
221    /// Element topology type.
222    pub cell_type: FluentCellType,
223    /// Zone this cell belongs to (zone id).
224    pub zone_id: usize,
225}
226
227impl FluentCell {
228    /// Create a new cell.
229    pub fn new(id: usize, cell_type: FluentCellType, zone_id: usize) -> Self {
230        Self {
231            id,
232            cell_type,
233            zone_id,
234        }
235    }
236}
237
238// ── FluentMesh ────────────────────────────────────────────────────────────────
239
240/// A complete Fluent mesh.
241#[derive(Debug, Clone, Default)]
242pub struct FluentMesh {
243    /// All nodes.
244    pub nodes: Vec<FluentNode>,
245    /// All faces.
246    pub faces: Vec<FluentFace>,
247    /// All cells.
248    pub cells: Vec<FluentCell>,
249    /// Zone list: `(zone_id, zone_type)`.
250    pub zones: Vec<(usize, FluentZoneType)>,
251}
252
253impl FluentMesh {
254    /// Create an empty mesh.
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Add a zone to the mesh.
260    pub fn add_zone(&mut self, zone_id: usize, zone_type: FluentZoneType) {
261        self.zones.push((zone_id, zone_type));
262    }
263
264    /// Write the mesh to a Fluent ASCII `.msh` file at `path`.
265    pub fn write(&self, path: &str) -> crate::Result<()> {
266        let writer = FluentWriter::new(self);
267        writer.write_to_path(path)
268    }
269
270    /// Read a Fluent ASCII `.msh` file from `path`.
271    pub fn read(path: &str) -> crate::Result<Self> {
272        FluentReader::read_from_path(path)
273    }
274
275    /// Number of nodes in the mesh.
276    pub fn node_count(&self) -> usize {
277        self.nodes.len()
278    }
279
280    /// Number of faces in the mesh.
281    pub fn face_count(&self) -> usize {
282        self.faces.len()
283    }
284
285    /// Number of cells in the mesh.
286    pub fn cell_count(&self) -> usize {
287        self.cells.len()
288    }
289
290    /// Look up a zone type by id.
291    pub fn zone_type(&self, zone_id: usize) -> Option<&FluentZoneType> {
292        self.zones
293            .iter()
294            .find(|(id, _)| *id == zone_id)
295            .map(|(_, t)| t)
296    }
297}
298
299// ── FluentWriter ─────────────────────────────────────────────────────────────
300
301/// Writes a `FluentMesh` to an ASCII Fluent `.msh` file.
302pub struct FluentWriter<'a> {
303    mesh: &'a FluentMesh,
304}
305
306impl<'a> FluentWriter<'a> {
307    /// Create a writer for the given mesh.
308    pub fn new(mesh: &'a FluentMesh) -> Self {
309        Self { mesh }
310    }
311
312    /// Serialize the mesh to a `String`.
313    pub fn to_string(&self) -> crate::Result<String> {
314        let mut buf = String::new();
315        self.write_comment(&mut buf)?;
316        self.write_dimension(&mut buf)?;
317        self.write_nodes(&mut buf)?;
318        self.write_cells(&mut buf)?;
319        self.write_faces(&mut buf)?;
320        self.write_zones(&mut buf)?;
321        Ok(buf)
322    }
323
324    /// Write the mesh to a file.
325    pub fn write_to_path(&self, path: &str) -> crate::Result<()> {
326        let content = self.to_string()?;
327        fs::write(path, content)?;
328        Ok(())
329    }
330
331    fn write_comment(&self, buf: &mut String) -> crate::Result<()> {
332        writeln!(buf, "(0 \"OxiPhysics Fluent mesh export\")")?;
333        Ok(())
334    }
335
336    fn write_dimension(&self, buf: &mut String) -> crate::Result<()> {
337        // Check if any node has non-zero z to decide 2-D vs 3-D
338        let dim = if self
339            .mesh
340            .nodes
341            .iter()
342            .any(|n| n.coordinates[2].abs() > 1e-15)
343        {
344            3
345        } else {
346            2
347        };
348        writeln!(buf, "(2 {dim})")?;
349        Ok(())
350    }
351
352    fn write_nodes(&self, buf: &mut String) -> crate::Result<()> {
353        let n = self.mesh.nodes.len();
354        if n == 0 {
355            return Ok(());
356        }
357        // Header: (10 (zone-id first last type dim))
358        writeln!(buf, "(10 (0 1 {n:x} 0))")?;
359        writeln!(buf, "(10 (1 1 {n:x} 1 3)")?;
360        writeln!(buf, "(")?;
361        for node in &self.mesh.nodes {
362            let [x, y, z] = node.coordinates;
363            writeln!(buf, "{x:.10e} {y:.10e} {z:.10e}")?;
364        }
365        writeln!(buf, "))")?;
366        Ok(())
367    }
368
369    fn write_cells(&self, buf: &mut String) -> crate::Result<()> {
370        let n = self.mesh.cells.len();
371        if n == 0 {
372            return Ok(());
373        }
374        writeln!(buf, "(12 (0 1 {n:x} 0))")?;
375        // Group cells by zone
376        let mut zones: Vec<usize> = self.mesh.cells.iter().map(|c| c.zone_id).collect();
377        zones.sort_unstable();
378        zones.dedup();
379        for zone_id in zones {
380            let zone_cells: Vec<&FluentCell> = self
381                .mesh
382                .cells
383                .iter()
384                .filter(|c| c.zone_id == zone_id)
385                .collect();
386            let first = zone_cells.first().map(|c| c.id).unwrap_or(1);
387            let last = zone_cells.last().map(|c| c.id).unwrap_or(1);
388            let cell_type = zone_cells.first().map(|c| c.cell_type.code()).unwrap_or(1);
389            writeln!(buf, "(12 ({zone_id:x} {first:x} {last:x} 1 {cell_type}))")?;
390        }
391        Ok(())
392    }
393
394    fn write_faces(&self, buf: &mut String) -> crate::Result<()> {
395        let n = self.mesh.faces.len();
396        if n == 0 {
397            return Ok(());
398        }
399        writeln!(buf, "(13 (0 1 {n:x} 0))")?;
400        writeln!(buf, "(13 (1 1 {n:x} 2 0)")?;
401        writeln!(buf, "(")?;
402        for face in &self.mesh.faces {
403            // format: <face_type> <n1> <n2> ... <left_cell> <right_cell>
404            write!(buf, "{:x}", face.face_type.code())?;
405            for &nid in &face.node_ids {
406                write!(buf, " {nid:x}")?;
407            }
408            writeln!(buf, " {:x} {:x}", face.left_cell, face.right_cell)?;
409        }
410        writeln!(buf, "))")?;
411        Ok(())
412    }
413
414    fn write_zones(&self, buf: &mut String) -> crate::Result<()> {
415        for (zone_id, zone_type) in &self.mesh.zones {
416            writeln!(
417                buf,
418                "(45 ({zone_id} {} zone-{zone_id} ()))",
419                zone_type.as_str()
420            )?;
421        }
422        Ok(())
423    }
424}
425
426// ── FluentReader ──────────────────────────────────────────────────────────────
427
428/// Parses an ANSYS Fluent ASCII `.msh` file into a `FluentMesh`.
429pub struct FluentReader;
430
431impl FluentReader {
432    /// Read a mesh from an ASCII Fluent `.msh` file.
433    pub fn read_from_path(path: &str) -> crate::Result<FluentMesh> {
434        let file = fs::File::open(path)?;
435        let reader = io::BufReader::new(file);
436        Self::parse(reader)
437    }
438
439    /// Parse from any `BufRead` source.
440    pub fn parse<R: BufRead>(reader: R) -> crate::Result<FluentMesh> {
441        let mut mesh = FluentMesh::new();
442        let lines: Vec<String> = reader
443            .lines()
444            .collect::<Result<_, _>>()
445            .map_err(IoError::Io)?;
446
447        let mut i = 0;
448        while i < lines.len() {
449            let line = lines[i].trim();
450
451            if line.starts_with("(10") {
452                // Node section
453                i = Self::parse_nodes(&lines, i, &mut mesh)?;
454            } else if line.starts_with("(12") {
455                // Cell section
456                i = Self::parse_cells(&lines, i, &mut mesh)?;
457            } else if line.starts_with("(13") {
458                // Face section
459                i = Self::parse_faces(&lines, i, &mut mesh)?;
460            } else if line.starts_with("(45") {
461                // Zone section
462                Self::parse_zone(line, &mut mesh);
463                i += 1;
464            } else {
465                i += 1;
466            }
467        }
468        Ok(mesh)
469    }
470
471    fn parse_nodes(lines: &[String], start: usize, mesh: &mut FluentMesh) -> crate::Result<usize> {
472        let header = lines[start].trim();
473        // Skip summary lines like (10 (0 1 N 0))
474        if header.contains("(0 ") || !header.contains('(') {
475            return Ok(start + 1);
476        }
477        // Expect data block: look for opening '(' on subsequent line
478        // e.g.: (10 (1 1 N 1 3)\n(\n x y z\n ...))
479        let mut i = start + 1;
480        // Skip until we find the opening data paren
481        while i < lines.len() && !lines[i].trim().starts_with('(') {
482            i += 1;
483        }
484        if i >= lines.len() {
485            return Ok(i);
486        }
487        i += 1; // skip the '(' line
488        let mut node_id = 1usize;
489        while i < lines.len() {
490            let line = lines[i].trim();
491            if line.starts_with(')') {
492                i += 1;
493                break;
494            }
495            let parts: Vec<&str> = line.split_whitespace().collect();
496            if parts.len() >= 3 {
497                let x = parts[0].parse::<f64>().unwrap_or(0.0);
498                let y = parts[1].parse::<f64>().unwrap_or(0.0);
499                let z = parts[2].parse::<f64>().unwrap_or(0.0);
500                mesh.nodes.push(FluentNode::new(node_id, [x, y, z]));
501                node_id += 1;
502            }
503            i += 1;
504        }
505        Ok(i)
506    }
507
508    fn parse_cells(lines: &[String], start: usize, mesh: &mut FluentMesh) -> crate::Result<usize> {
509        let header = lines[start].trim();
510        // Only handle non-summary single-line cell headers
511        // (12 (zone first last 1 cell_type))
512        if !header.contains("(0 ") {
513            // Try to parse: (12 (zone first last 1 type))
514            if let Some(inner) = Self::extract_inner(header, "12") {
515                let parts: Vec<&str> = inner.split_whitespace().collect();
516                if parts.len() >= 5
517                    && let (Ok(zone_id), Ok(first), Ok(last), Ok(cell_type)) = (
518                        usize::from_str_radix(parts[0], 16),
519                        usize::from_str_radix(parts[1], 16),
520                        usize::from_str_radix(parts[2], 16),
521                        u32::from_str_radix(parts[4], 16),
522                    )
523                {
524                    for id in first..=last {
525                        mesh.cells.push(FluentCell::new(
526                            id,
527                            FluentCellType::from_code(cell_type),
528                            zone_id,
529                        ));
530                    }
531                }
532            }
533        }
534        Ok(start + 1)
535    }
536
537    fn parse_faces(lines: &[String], start: usize, mesh: &mut FluentMesh) -> crate::Result<usize> {
538        let header = lines[start].trim();
539        if header.contains("(0 ") {
540            return Ok(start + 1);
541        }
542        // Look for data block
543        let mut i = start + 1;
544        while i < lines.len() && !lines[i].trim().starts_with('(') {
545            i += 1;
546        }
547        if i >= lines.len() {
548            return Ok(i);
549        }
550        i += 1; // skip '('
551        let mut face_id = mesh.faces.len() + 1;
552        while i < lines.len() {
553            let line = lines[i].trim();
554            if line.starts_with(')') {
555                i += 1;
556                break;
557            }
558            let parts: Vec<&str> = line.split_whitespace().collect();
559            if parts.len() >= 4
560                && let Ok(ft_code) = u32::from_str_radix(parts[0], 16)
561            {
562                let face_type = FluentFaceType::from_code(ft_code);
563                let nn = face_type.node_count();
564                if parts.len() >= 1 + nn + 2 {
565                    let node_ids: Vec<usize> = (1..=nn)
566                        .filter_map(|k| usize::from_str_radix(parts[k], 16).ok())
567                        .collect();
568                    let lc = usize::from_str_radix(parts[1 + nn], 16).unwrap_or(0);
569                    let rc = usize::from_str_radix(parts[2 + nn], 16).unwrap_or(0);
570                    mesh.faces
571                        .push(FluentFace::new(face_id, face_type, node_ids, lc, rc));
572                    face_id += 1;
573                }
574            }
575            i += 1;
576        }
577        Ok(i)
578    }
579
580    fn parse_zone(line: &str, mesh: &mut FluentMesh) {
581        // (45 (zone_id type_str name ()))
582        if let Some(inner) = Self::extract_inner(line, "45") {
583            let parts: Vec<&str> = inner.splitn(3, ' ').collect();
584            if parts.len() >= 2
585                && let Ok(zone_id) = parts[0].parse::<usize>()
586            {
587                let zone_type = FluentZoneType::from_str(parts[1]);
588                mesh.add_zone(zone_id, zone_type);
589            }
590        }
591    }
592
593    /// Extract the content inside `(section_code (…))`.
594    fn extract_inner<'a>(line: &'a str, section: &str) -> Option<&'a str> {
595        let prefix = format!("({section} (");
596        if let Some(pos) = line.find(&prefix) {
597            let rest = &line[pos + prefix.len()..];
598            // find closing )
599            let end = rest.rfind(')')?;
600            let end2 = rest[..end].rfind(')')?;
601            Some(rest[..end2].trim())
602        } else {
603            None
604        }
605    }
606}
607
608// ── impl std::fmt::Write passthrough ─────────────────────────────────────────
609
610impl From<std::fmt::Error> for IoError {
611    fn from(e: std::fmt::Error) -> Self {
612        IoError::General(e.to_string())
613    }
614}
615
616// ── Tests ─────────────────────────────────────────────────────────────────────
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    // ── FluentZoneType ───────────────────────────────────────────────────────
623
624    #[test]
625    fn test_zone_type_as_str_fluid() {
626        assert_eq!(FluentZoneType::Fluid.as_str(), "fluid");
627    }
628
629    #[test]
630    fn test_zone_type_as_str_wall() {
631        assert_eq!(FluentZoneType::Wall.as_str(), "wall");
632    }
633
634    #[test]
635    fn test_zone_type_roundtrip_fluid() {
636        let zt = FluentZoneType::Fluid;
637        assert_eq!(FluentZoneType::from_str(zt.as_str()), zt);
638    }
639
640    #[test]
641    fn test_zone_type_roundtrip_all() {
642        let types = [
643            FluentZoneType::Fluid,
644            FluentZoneType::Solid,
645            FluentZoneType::Wall,
646            FluentZoneType::Symmetry,
647            FluentZoneType::Interior,
648        ];
649        for zt in &types {
650            assert_eq!(&FluentZoneType::from_str(zt.as_str()), zt);
651        }
652    }
653
654    #[test]
655    fn test_zone_type_unknown_defaults_to_interior() {
656        assert_eq!(
657            FluentZoneType::from_str("unknown-zone"),
658            FluentZoneType::Interior
659        );
660    }
661
662    // ── FluentNode ───────────────────────────────────────────────────────────
663
664    #[test]
665    fn test_fluent_node_creation() {
666        let node = FluentNode::new(1, [1.0, 2.0, 3.0]);
667        assert_eq!(node.id, 1);
668        assert_eq!(node.coordinates, [1.0, 2.0, 3.0]);
669    }
670
671    #[test]
672    fn test_fluent_node_zero_coords() {
673        let node = FluentNode::new(5, [0.0; 3]);
674        assert_eq!(node.coordinates, [0.0; 3]);
675    }
676
677    // ── FluentFaceType ───────────────────────────────────────────────────────
678
679    #[test]
680    fn test_face_type_code_roundtrip() {
681        let types = [
682            FluentFaceType::Line,
683            FluentFaceType::Triangle,
684            FluentFaceType::Quad,
685        ];
686        for ft in &types {
687            assert_eq!(&FluentFaceType::from_code(ft.code()), ft);
688        }
689    }
690
691    #[test]
692    fn test_face_type_node_count_line() {
693        assert_eq!(FluentFaceType::Line.node_count(), 2);
694    }
695
696    #[test]
697    fn test_face_type_node_count_tri() {
698        assert_eq!(FluentFaceType::Triangle.node_count(), 3);
699    }
700
701    #[test]
702    fn test_face_type_node_count_quad() {
703        assert_eq!(FluentFaceType::Quad.node_count(), 4);
704    }
705
706    // ── FluentFace ───────────────────────────────────────────────────────────
707
708    #[test]
709    fn test_fluent_face_boundary_detection() {
710        let face = FluentFace::new(1, FluentFaceType::Triangle, vec![1, 2, 3], 0, 5);
711        assert!(face.is_boundary());
712    }
713
714    #[test]
715    fn test_fluent_face_interior() {
716        let face = FluentFace::new(2, FluentFaceType::Triangle, vec![1, 2, 3], 4, 5);
717        assert!(!face.is_boundary());
718    }
719
720    #[test]
721    fn test_fluent_face_node_ids_preserved() {
722        let ids = vec![10, 20, 30];
723        let face = FluentFace::new(1, FluentFaceType::Triangle, ids.clone(), 1, 2);
724        assert_eq!(face.node_ids, ids);
725    }
726
727    // ── FluentCellType ───────────────────────────────────────────────────────
728
729    #[test]
730    fn test_cell_type_code_roundtrip() {
731        let types = [
732            FluentCellType::Tri,
733            FluentCellType::Tet,
734            FluentCellType::Quad,
735            FluentCellType::Hex,
736        ];
737        for ct in &types {
738            assert_eq!(&FluentCellType::from_code(ct.code()), ct);
739        }
740    }
741
742    // ── FluentCell ───────────────────────────────────────────────────────────
743
744    #[test]
745    fn test_fluent_cell_creation() {
746        let cell = FluentCell::new(1, FluentCellType::Tet, 2);
747        assert_eq!(cell.id, 1);
748        assert_eq!(cell.cell_type, FluentCellType::Tet);
749        assert_eq!(cell.zone_id, 2);
750    }
751
752    // ── FluentMesh ───────────────────────────────────────────────────────────
753
754    #[test]
755    fn test_fluent_mesh_empty() {
756        let mesh = FluentMesh::new();
757        assert_eq!(mesh.node_count(), 0);
758        assert_eq!(mesh.face_count(), 0);
759        assert_eq!(mesh.cell_count(), 0);
760    }
761
762    #[test]
763    fn test_fluent_mesh_add_zone() {
764        let mut mesh = FluentMesh::new();
765        mesh.add_zone(1, FluentZoneType::Fluid);
766        assert_eq!(mesh.zones.len(), 1);
767        assert_eq!(mesh.zone_type(1), Some(&FluentZoneType::Fluid));
768    }
769
770    #[test]
771    fn test_fluent_mesh_zone_type_not_found() {
772        let mesh = FluentMesh::new();
773        assert_eq!(mesh.zone_type(99), None);
774    }
775
776    #[test]
777    fn test_fluent_mesh_counts() {
778        let mut mesh = FluentMesh::new();
779        mesh.nodes.push(FluentNode::new(1, [0.0; 3]));
780        mesh.cells.push(FluentCell::new(1, FluentCellType::Tet, 1));
781        mesh.faces.push(FluentFace::new(
782            1,
783            FluentFaceType::Triangle,
784            vec![1, 2, 3],
785            0,
786            1,
787        ));
788        assert_eq!(mesh.node_count(), 1);
789        assert_eq!(mesh.cell_count(), 1);
790        assert_eq!(mesh.face_count(), 1);
791    }
792
793    // ── FluentWriter ─────────────────────────────────────────────────────────
794
795    fn make_simple_mesh() -> FluentMesh {
796        let mut mesh = FluentMesh::new();
797        mesh.nodes.push(FluentNode::new(1, [0.0, 0.0, 0.0]));
798        mesh.nodes.push(FluentNode::new(2, [1.0, 0.0, 0.0]));
799        mesh.nodes.push(FluentNode::new(3, [0.5, 1.0, 0.0]));
800        mesh.cells.push(FluentCell::new(1, FluentCellType::Tri, 1));
801        mesh.faces
802            .push(FluentFace::new(1, FluentFaceType::Line, vec![1, 2], 0, 1));
803        mesh.faces
804            .push(FluentFace::new(2, FluentFaceType::Line, vec![2, 3], 0, 1));
805        mesh.faces
806            .push(FluentFace::new(3, FluentFaceType::Line, vec![3, 1], 0, 1));
807        mesh.add_zone(1, FluentZoneType::Fluid);
808        mesh
809    }
810
811    #[test]
812    fn test_writer_produces_comment_section() {
813        let mesh = make_simple_mesh();
814        let writer = FluentWriter::new(&mesh);
815        let out = writer.to_string().unwrap();
816        assert!(out.contains("(0 "));
817    }
818
819    #[test]
820    fn test_writer_produces_node_section() {
821        let mesh = make_simple_mesh();
822        let writer = FluentWriter::new(&mesh);
823        let out = writer.to_string().unwrap();
824        assert!(out.contains("(10 "));
825    }
826
827    #[test]
828    fn test_writer_produces_cell_section() {
829        let mesh = make_simple_mesh();
830        let writer = FluentWriter::new(&mesh);
831        let out = writer.to_string().unwrap();
832        assert!(out.contains("(12 "));
833    }
834
835    #[test]
836    fn test_writer_produces_face_section() {
837        let mesh = make_simple_mesh();
838        let writer = FluentWriter::new(&mesh);
839        let out = writer.to_string().unwrap();
840        assert!(out.contains("(13 "));
841    }
842
843    #[test]
844    fn test_writer_produces_zone_section() {
845        let mesh = make_simple_mesh();
846        let writer = FluentWriter::new(&mesh);
847        let out = writer.to_string().unwrap();
848        assert!(out.contains("(45 "));
849    }
850
851    #[test]
852    fn test_writer_node_count_hex() {
853        // 3 nodes → hex "3" in node header
854        let mesh = make_simple_mesh();
855        let writer = FluentWriter::new(&mesh);
856        let out = writer.to_string().unwrap();
857        // "3" hex = 3 decimal
858        assert!(out.contains("(10 (0 1 3 0))"));
859    }
860
861    // ── Write/Read roundtrip ─────────────────────────────────────────────────
862
863    #[test]
864    fn test_write_read_roundtrip_node_count() {
865        let mesh = make_simple_mesh();
866        let path = "/tmp/oxiphysics_fluent_test_roundtrip.msh";
867        mesh.write(path).expect("write failed");
868        let loaded = FluentMesh::read(path).expect("read failed");
869        assert_eq!(loaded.node_count(), mesh.node_count());
870    }
871
872    #[test]
873    fn test_write_read_roundtrip_node_coords() {
874        let mesh = make_simple_mesh();
875        let path = "/tmp/oxiphysics_fluent_test_coords.msh";
876        mesh.write(path).unwrap();
877        let loaded = FluentMesh::read(path).unwrap();
878        for (orig, loaded_node) in mesh.nodes.iter().zip(loaded.nodes.iter()) {
879            for k in 0..3 {
880                let diff = (orig.coordinates[k] - loaded_node.coordinates[k]).abs();
881                assert!(diff < 1e-6, "coord diff={diff}");
882            }
883        }
884    }
885
886    #[test]
887    fn test_write_read_roundtrip_cell_count() {
888        let mesh = make_simple_mesh();
889        let path = "/tmp/oxiphysics_fluent_test_cells.msh";
890        mesh.write(path).unwrap();
891        let loaded = FluentMesh::read(path).unwrap();
892        assert_eq!(loaded.cell_count(), mesh.cell_count());
893    }
894
895    #[test]
896    fn test_write_read_roundtrip_zone() {
897        let mesh = make_simple_mesh();
898        let path = "/tmp/oxiphysics_fluent_test_zones.msh";
899        mesh.write(path).unwrap();
900        let loaded = FluentMesh::read(path).unwrap();
901        assert!(!loaded.zones.is_empty());
902        assert_eq!(loaded.zones[0].1, FluentZoneType::Fluid);
903    }
904
905    #[test]
906    fn test_write_creates_file() {
907        let mesh = make_simple_mesh();
908        let path = "/tmp/oxiphysics_fluent_write_check.msh";
909        mesh.write(path).unwrap();
910        assert!(std::path::Path::new(path).exists());
911    }
912
913    #[test]
914    fn test_read_nonexistent_file_errors() {
915        let result = FluentMesh::read("/tmp/oxiphysics_does_not_exist.msh");
916        assert!(result.is_err());
917    }
918
919    // ── 3-D mesh ─────────────────────────────────────────────────────────────
920
921    #[test]
922    fn test_3d_mesh_dimension() {
923        let mut mesh = FluentMesh::new();
924        mesh.nodes.push(FluentNode::new(1, [0.0, 0.0, 1.0]));
925        let writer = FluentWriter::new(&mesh);
926        let out = writer.to_string().unwrap();
927        assert!(out.contains("(2 3)"));
928    }
929
930    #[test]
931    fn test_2d_mesh_dimension() {
932        let mut mesh = FluentMesh::new();
933        mesh.nodes.push(FluentNode::new(1, [1.0, 2.0, 0.0]));
934        let writer = FluentWriter::new(&mesh);
935        let out = writer.to_string().unwrap();
936        assert!(out.contains("(2 2)"));
937    }
938
939    #[test]
940    fn test_multiple_zones() {
941        let mut mesh = FluentMesh::new();
942        mesh.add_zone(1, FluentZoneType::Fluid);
943        mesh.add_zone(2, FluentZoneType::Wall);
944        assert_eq!(mesh.zones.len(), 2);
945        assert_eq!(mesh.zone_type(2), Some(&FluentZoneType::Wall));
946    }
947
948    #[test]
949    fn test_hex_cell_type_preserved() {
950        let cell = FluentCell::new(1, FluentCellType::Hex, 1);
951        assert_eq!(cell.cell_type, FluentCellType::Hex);
952        assert_eq!(cell.cell_type.code(), 4);
953    }
954
955    #[test]
956    fn test_quad_face_node_ids() {
957        let face = FluentFace::new(1, FluentFaceType::Quad, vec![1, 2, 3, 4], 1, 2);
958        assert_eq!(face.node_ids.len(), 4);
959        assert!(!face.is_boundary());
960    }
961}