Skip to main content

oxiphysics_io/
fluent_format.rs

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