Skip to main content

oxiphysics_io/
foam_io.rs

1#![allow(clippy::manual_strip, clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Simplified OpenFOAM polyMesh format I/O.
6//!
7//! Supports reading polyMesh directories and writing basic OpenFOAM cases.
8//! Uses plain `f64` arrays and `Vec` — no external linear-algebra crates.
9
10#![allow(dead_code)]
11
12use std::collections::HashMap;
13
14// ---------------------------------------------------------------------------
15// FoamHeader
16// ---------------------------------------------------------------------------
17
18/// FoamFile header block, common to every OpenFOAM dictionary file.
19pub struct FoamHeader {
20    /// OpenFOAM dictionary version, e.g. `"2.0"`.
21    pub foam_file_version: String,
22    /// Data format: `"ascii"` or `"binary"`.
23    pub format: String,
24    /// Dictionary class, e.g. `"polyBoundaryMesh"`.
25    pub class_name: String,
26    /// Object name, e.g. `"boundary"`.
27    pub object_name: String,
28}
29
30impl FoamHeader {
31    /// Serialise to the OpenFOAM `FoamFile { … }` block.
32    #[allow(clippy::inherent_to_string)]
33    pub fn to_string(&self) -> String {
34        format!(
35            "FoamFile\n{{\n    version     {};\n    format      {};\n    class       {};\n    object      {};\n}}\n",
36            self.foam_file_version, self.format, self.class_name, self.object_name,
37        )
38    }
39
40    /// Parse a string that contains a `FoamFile { … }` block.
41    pub fn from_str(s: &str) -> Result<Self, String> {
42        let start = s
43            .find("FoamFile")
44            .ok_or_else(|| "No FoamFile block found".to_string())?;
45        let brace_open = s[start..]
46            .find('{')
47            .ok_or_else(|| "No opening brace for FoamFile".to_string())?
48            + start;
49        let brace_close = s[brace_open..]
50            .find('}')
51            .ok_or_else(|| "No closing brace for FoamFile".to_string())?
52            + brace_open;
53
54        let block = &s[brace_open + 1..brace_close];
55
56        let mut kv: HashMap<&str, &str> = HashMap::new();
57        for line in block.lines() {
58            let line = line.trim().trim_end_matches(';');
59            let mut parts = line.splitn(2, char::is_whitespace);
60            if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
61                kv.insert(k.trim(), v.trim());
62            }
63        }
64
65        Ok(FoamHeader {
66            foam_file_version: kv.get("version").unwrap_or(&"2.0").to_string(),
67            format: kv.get("format").unwrap_or(&"ascii").to_string(),
68            class_name: kv.get("class").unwrap_or(&"").to_string(),
69            object_name: kv.get("object").unwrap_or(&"").to_string(),
70        })
71    }
72}
73
74// ---------------------------------------------------------------------------
75// Basic mesh types
76// ---------------------------------------------------------------------------
77
78/// A point in 3-D space.
79pub type FoamPoint = [f64; 3];
80
81/// A polygonal face described by an ordered list of node indices.
82pub struct FoamFace {
83    /// Indices into the `points` array (variable count per face).
84    pub node_indices: Vec<usize>,
85}
86
87/// One patch (boundary region) in an OpenFOAM polyMesh.
88pub struct FoamBoundaryPatch {
89    /// Patch name, e.g. `"inlet"`.
90    pub name: String,
91    /// Patch type, e.g. `"wall"`, `"inlet"`, `"outlet"`, `"symmetryPlane"`.
92    pub patch_type: String,
93    /// Number of boundary faces in this patch.
94    pub n_faces: usize,
95    /// Index of the first boundary face in the global face list.
96    pub start_face: usize,
97}
98
99// ---------------------------------------------------------------------------
100// FoamPolyMesh
101// ---------------------------------------------------------------------------
102
103/// An OpenFOAM polyMesh: points, faces, owner/neighbour connectivity, boundary.
104pub struct FoamPolyMesh {
105    /// All mesh points.
106    pub points: Vec<FoamPoint>,
107    /// All faces (internal + boundary).
108    pub faces: Vec<FoamFace>,
109    /// Owner cell index for every face.
110    pub owner: Vec<usize>,
111    /// Neighbour cell index for internal faces only.
112    pub neighbour: Vec<usize>,
113    /// Boundary patch descriptors.
114    pub boundary: Vec<FoamBoundaryPatch>,
115}
116
117impl FoamPolyMesh {
118    /// Number of cells = max owner index + 1.
119    pub fn n_cells(&self) -> usize {
120        self.owner.iter().copied().max().map(|m| m + 1).unwrap_or(0)
121    }
122
123    /// Number of internal faces = length of `neighbour`.
124    pub fn n_internal_faces(&self) -> usize {
125        self.neighbour.len()
126    }
127
128    /// Number of boundary faces = total faces − internal faces.
129    pub fn n_boundary_faces(&self) -> usize {
130        self.faces.len().saturating_sub(self.n_internal_faces())
131    }
132
133    /// Centroid of each face (average of its node positions).
134    pub fn face_centers(&self) -> Vec<[f64; 3]> {
135        self.faces
136            .iter()
137            .map(|f| {
138                let n = f.node_indices.len() as f64;
139                let mut c = [0.0_f64; 3];
140                for &idx in &f.node_indices {
141                    let p = self.points[idx];
142                    c[0] += p[0];
143                    c[1] += p[1];
144                    c[2] += p[2];
145                }
146                [c[0] / n, c[1] / n, c[2] / n]
147            })
148            .collect()
149    }
150
151    /// Approximate area of each face (magnitude of cross-product for triangles/quads).
152    pub fn face_areas(&self) -> Vec<f64> {
153        self.faces
154            .iter()
155            .map(|f| face_area(&f.node_indices, &self.points))
156            .collect()
157    }
158
159    /// Approximate cell centres as the average of their face centres.
160    pub fn cell_centers(&self) -> Vec<[f64; 3]> {
161        let fc = self.face_centers();
162        let nc = self.n_cells();
163        let mut sums = vec![[0.0_f64; 3]; nc];
164        let mut counts = vec![0usize; nc];
165
166        for (fi, &ow) in self.owner.iter().enumerate() {
167            if ow < nc {
168                let c = fc[fi];
169                sums[ow][0] += c[0];
170                sums[ow][1] += c[1];
171                sums[ow][2] += c[2];
172                counts[ow] += 1;
173            }
174        }
175        for (fi, &nb) in self.neighbour.iter().enumerate() {
176            if nb < nc {
177                let c = fc[fi];
178                sums[nb][0] += c[0];
179                sums[nb][1] += c[1];
180                sums[nb][2] += c[2];
181                counts[nb] += 1;
182            }
183        }
184
185        sums.iter()
186            .zip(counts.iter())
187            .map(|(s, &cnt)| {
188                if cnt == 0 {
189                    [0.0; 3]
190                } else {
191                    let n = cnt as f64;
192                    [s[0] / n, s[1] / n, s[2] / n]
193                }
194            })
195            .collect()
196    }
197
198    /// Approximate cell volumes (sum of (face area × distance face-centre to cell-centre) / 3).
199    pub fn cell_volumes(&self) -> Vec<f64> {
200        let fc = self.face_centers();
201        let fa = self.face_areas();
202        let cc = self.cell_centers();
203        let nc = self.n_cells();
204        let mut vols = vec![0.0_f64; nc];
205
206        let contribute = |vols: &mut Vec<f64>, cell: usize, fi: usize| {
207            let c = cc[cell];
208            let f = fc[fi];
209            let d = ((f[0] - c[0]).powi(2) + (f[1] - c[1]).powi(2) + (f[2] - c[2]).powi(2)).sqrt();
210            vols[cell] += fa[fi] * d / 3.0;
211        };
212
213        for (fi, &ow) in self.owner.iter().enumerate() {
214            if ow < nc {
215                contribute(&mut vols, ow, fi);
216            }
217        }
218        for (fi, &nb) in self.neighbour.iter().enumerate() {
219            if nb < nc {
220                contribute(&mut vols, nb, fi);
221            }
222        }
223        vols
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Internal geometry helpers
229// ---------------------------------------------------------------------------
230
231fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
232    [
233        a[1] * b[2] - a[2] * b[1],
234        a[2] * b[0] - a[0] * b[2],
235        a[0] * b[1] - a[1] * b[0],
236    ]
237}
238
239fn norm(v: [f64; 3]) -> f64 {
240    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
241}
242
243fn face_area(indices: &[usize], points: &[FoamPoint]) -> f64 {
244    if indices.len() < 3 {
245        return 0.0;
246    }
247    // Fan-triangulation from the first vertex.
248    let p0 = points[indices[0]];
249    let mut area = 0.0_f64;
250    for i in 1..indices.len() - 1 {
251        let p1 = points[indices[i]];
252        let p2 = points[indices[i + 1]];
253        let ab = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
254        let ac = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
255        area += norm(cross(ab, ac)) * 0.5;
256    }
257    area
258}
259
260// ---------------------------------------------------------------------------
261// Parsing helpers
262// ---------------------------------------------------------------------------
263
264/// Extract the data block that follows the `FoamFile` header.
265/// Returns the content between the first `(` and its matching `)`.
266fn extract_data_block(content: &str) -> Option<&str> {
267    // Skip FoamFile block if present.
268    let search_from = if let Some(pos) = content.find("FoamFile") {
269        // find the closing brace
270        let brace_open = content[pos..].find('{')? + pos;
271        content[brace_open..].find('}')? + brace_open + 1
272    } else {
273        0
274    };
275    let rest = &content[search_from..];
276    let start = rest.find('(')?;
277    let end = rest.rfind(')')?;
278    if end > start {
279        Some(&rest[start..=end])
280    } else {
281        None
282    }
283}
284
285/// Parse an OpenFOAM list of `usize` values.
286///
287/// Accepts the standard format:
288/// ```text
289/// N
290/// (
291///   v1
292///   v2
293///   …
294/// )
295/// ```
296pub fn parse_foam_list_usize(s: &str) -> Result<Vec<usize>, String> {
297    let block =
298        extract_data_block(s).ok_or_else(|| "Could not find list block '(' … ')'".to_string())?;
299    let inner = &block[1..block.len() - 1]; // strip outer parens
300    let mut result = Vec::new();
301    for token in inner.split_whitespace() {
302        let v: usize = token
303            .parse()
304            .map_err(|_| format!("Cannot parse usize: {:?}", token))?;
305        result.push(v);
306    }
307    Ok(result)
308}
309
310/// Parse an OpenFOAM list of `f64` values.
311pub fn parse_foam_list_f64(s: &str) -> Result<Vec<f64>, String> {
312    let block =
313        extract_data_block(s).ok_or_else(|| "Could not find list block '(' … ')'".to_string())?;
314    let inner = &block[1..block.len() - 1];
315    let mut result = Vec::new();
316    for token in inner.split_whitespace() {
317        let v: f64 = token
318            .parse()
319            .map_err(|_| format!("Cannot parse f64: {:?}", token))?;
320        result.push(v);
321    }
322    Ok(result)
323}
324
325/// Parse an OpenFOAM `points` file.
326///
327/// Format inside the data block:
328/// ```text
329/// (x y z)
330/// (x y z)
331/// …
332/// ```
333pub fn parse_foam_points(content: &str) -> Result<Vec<FoamPoint>, String> {
334    let block =
335        extract_data_block(content).ok_or_else(|| "Could not find points block".to_string())?;
336
337    let mut points = Vec::new();
338    // Each point looks like   (x y z)
339    let inner = &block[1..block.len() - 1];
340    let chars = inner.char_indices().peekable();
341    for (i, ch) in chars {
342        if ch == '(' {
343            // find matching ')'
344            let sub = &inner[i..];
345            let end = sub
346                .find(')')
347                .ok_or_else(|| "Unmatched '(' in points".to_string())?;
348            let triplet = &sub[1..end];
349            let vals: Vec<f64> = triplet
350                .split_whitespace()
351                .map(|t| t.parse::<f64>().map_err(|_| format!("Bad f64: {:?}", t)))
352                .collect::<Result<_, _>>()?;
353            if vals.len() < 3 {
354                return Err(format!("Point needs 3 coords, got {:?}", vals));
355            }
356            points.push([vals[0], vals[1], vals[2]]);
357        }
358    }
359    Ok(points)
360}
361
362/// Parse an OpenFOAM `faces` file.
363///
364/// Each face is encoded as `N(i0 i1 … iN-1)`.
365pub fn parse_foam_faces(content: &str) -> Result<Vec<FoamFace>, String> {
366    let block =
367        extract_data_block(content).ok_or_else(|| "Could not find faces block".to_string())?;
368    let inner = &block[1..block.len() - 1];
369
370    let mut faces = Vec::new();
371    let mut i = 0;
372    let bytes = inner.as_bytes();
373    while i < bytes.len() {
374        // Skip whitespace
375        if bytes[i].is_ascii_whitespace() {
376            i += 1;
377            continue;
378        }
379        // Collect digits (face node count)
380        if bytes[i].is_ascii_digit() {
381            let start = i;
382            while i < bytes.len() && bytes[i].is_ascii_digit() {
383                i += 1;
384            }
385            let _count: usize = inner[start..i]
386                .parse()
387                .map_err(|_| "Bad face count".to_string())?;
388            // Expect '('
389            while i < bytes.len() && bytes[i] != b'(' {
390                i += 1;
391            }
392            if i >= bytes.len() {
393                return Err("Missing '(' after face count".to_string());
394            }
395            i += 1; // skip '('
396            // Collect until ')'
397            let j = i;
398            while i < bytes.len() && bytes[i] != b')' {
399                i += 1;
400            }
401            let indices: Vec<usize> = inner[j..i]
402                .split_whitespace()
403                .map(|t| {
404                    t.parse::<usize>()
405                        .map_err(|_| format!("Bad index: {:?}", t))
406                })
407                .collect::<Result<_, _>>()?;
408            faces.push(FoamFace {
409                node_indices: indices,
410            });
411            i += 1; // skip ')'
412        } else {
413            i += 1;
414        }
415    }
416    Ok(faces)
417}
418
419/// Parse an OpenFOAM `boundary` file.
420pub fn parse_foam_boundary(content: &str) -> Result<Vec<FoamBoundaryPatch>, String> {
421    // Skip FoamFile header.
422    let search_from = if let Some(pos) = content.find("FoamFile") {
423        let brace_open = content[pos..]
424            .find('{')
425            .ok_or_else(|| "No '{' in FoamFile".to_string())?
426            + pos;
427        content[brace_open..]
428            .find('}')
429            .ok_or_else(|| "No '}' in FoamFile".to_string())?
430            + brace_open
431            + 1
432    } else {
433        0
434    };
435    let rest = &content[search_from..];
436
437    // Skip leading count and outer parentheses/braces.
438    // boundary format uses { } not ( )
439    let outer_open = rest
440        .find('(')
441        .unwrap_or_else(|| rest.find('{').unwrap_or(0));
442    let body = &rest[outer_open..];
443
444    let mut patches = Vec::new();
445    // Pattern: name\n{\n  type X;\n  nFaces N;\n  startFace S;\n}
446    let mut chars = body.char_indices().peekable();
447    while let Some((i, ch)) = chars.next() {
448        if ch == '{' || ch == '(' || ch == ')' || ch == '}' {
449            continue;
450        }
451        if ch.is_alphabetic() || ch == '_' {
452            // Collect patch name
453            let sub = &body[i..];
454            let name_end = sub.find(|c: char| c.is_whitespace()).unwrap_or(sub.len());
455            let name = sub[..name_end].to_string();
456            if name == "FoamFile" {
457                continue;
458            }
459            // Find opening brace for this patch
460            let brace_start = match sub.find('{') {
461                Some(p) => p,
462                None => continue,
463            };
464            let brace_body_start = brace_start + 1;
465            let brace_end = match sub[brace_body_start..].find('}') {
466                Some(p) => p + brace_body_start,
467                None => continue,
468            };
469            let patch_body = &sub[brace_body_start..brace_end];
470
471            let mut patch_type = String::new();
472            let mut n_faces = 0usize;
473            let mut start_face = 0usize;
474
475            for line in patch_body.lines() {
476                let line = line.trim().trim_end_matches(';');
477                let mut parts = line.splitn(2, char::is_whitespace);
478                match (parts.next(), parts.next()) {
479                    (Some("type"), Some(v)) => patch_type = v.trim().to_string(),
480                    (Some("nFaces"), Some(v)) => n_faces = v.trim().parse().unwrap_or(0),
481                    (Some("startFace"), Some(v)) => start_face = v.trim().parse().unwrap_or(0),
482                    _ => {}
483                }
484            }
485
486            if !name.is_empty() && !patch_type.is_empty() {
487                patches.push(FoamBoundaryPatch {
488                    name,
489                    patch_type,
490                    n_faces,
491                    start_face,
492                });
493            }
494
495            // Advance the outer iterator past the consumed region.
496            let consumed = i + brace_end + 1;
497            // Skip chars until we've advanced past 'consumed'.
498            while chars.peek().map(|&(j, _)| j < consumed).unwrap_or(false) {
499                chars.next();
500            }
501        }
502    }
503    Ok(patches)
504}
505
506// ---------------------------------------------------------------------------
507// Writing functions
508// ---------------------------------------------------------------------------
509
510/// Generate a minimal FoamFile header block.
511pub fn write_foam_header(class: &str, object: &str) -> String {
512    FoamHeader {
513        foam_file_version: "2.0".to_string(),
514        format: "ascii".to_string(),
515        class_name: class.to_string(),
516        object_name: object.to_string(),
517    }
518    .to_string()
519}
520
521/// Serialise a points list to OpenFOAM ASCII format.
522pub fn write_foam_points(points: &[FoamPoint]) -> String {
523    let mut s = write_foam_header("vectorField", "points");
524    s.push('\n');
525    s.push_str(&format!("{}\n(\n", points.len()));
526    for p in points {
527        s.push_str(&format!("    ({} {} {})\n", p[0], p[1], p[2]));
528    }
529    s.push_str(")\n");
530    s
531}
532
533/// Serialise a faces list to OpenFOAM ASCII format.
534pub fn write_foam_faces(faces: &[FoamFace]) -> String {
535    let mut s = write_foam_header("faceList", "faces");
536    s.push('\n');
537    s.push_str(&format!("{}\n(\n", faces.len()));
538    for f in faces {
539        let indices: Vec<String> = f.node_indices.iter().map(|i| i.to_string()).collect();
540        s.push_str(&format!(
541            "    {}({})\n",
542            f.node_indices.len(),
543            indices.join(" ")
544        ));
545    }
546    s.push_str(")\n");
547    s
548}
549
550/// Serialise owner and neighbour lists to OpenFOAM ASCII format.
551/// Returns `(owner_string, neighbour_string)`.
552pub fn write_foam_owner_neighbour(owner: &[usize], neighbour: &[usize]) -> (String, String) {
553    let mut o = write_foam_header("labelList", "owner");
554    o.push('\n');
555    o.push_str(&format!("{}\n(\n", owner.len()));
556    for &v in owner {
557        o.push_str(&format!("    {}\n", v));
558    }
559    o.push_str(")\n");
560
561    let mut n = write_foam_header("labelList", "neighbour");
562    n.push('\n');
563    n.push_str(&format!("{}\n(\n", neighbour.len()));
564    for &v in neighbour {
565        n.push_str(&format!("    {}\n", v));
566    }
567    n.push_str(")\n");
568
569    (o, n)
570}
571
572/// Write a `volScalarField` file (e.g. pressure `p` or temperature `T`).
573pub fn write_scalar_field(name: &str, values: &[f64], boundary: &[FoamBoundaryPatch]) -> String {
574    let mut s = write_foam_header("volScalarField", name);
575    s.push('\n');
576    s.push_str("dimensions      [0 0 0 0 0 0 0];\n\n");
577    s.push_str(&format!(
578        "internalField   nonuniform List<scalar>\n{}\n(\n",
579        values.len()
580    ));
581    for &v in values {
582        s.push_str(&format!("    {}\n", v));
583    }
584    s.push_str(");\n\nboundaryField\n{\n");
585    for patch in boundary {
586        s.push_str(&format!(
587            "    {}\n    {{\n        type            zeroGradient;\n    }}\n",
588            patch.name
589        ));
590    }
591    s.push_str("}\n");
592    s
593}
594
595// ---------------------------------------------------------------------------
596// FoamScalarField
597// ---------------------------------------------------------------------------
598
599/// A named scalar field on the mesh (internal + boundary).
600pub struct FoamScalarField {
601    /// Field name, e.g. `"p"` or `"T"`.
602    pub name: String,
603    /// Internal-cell values (one per cell).
604    pub internal_field: Vec<f64>,
605    /// Boundary values (one `Vec`f64` per patch; may be empty for zeroGradient).
606    pub boundary_values: Vec<Vec<f64>>,
607}
608
609impl FoamScalarField {
610    /// Create a uniform field with all cells set to `value`.
611    pub fn uniform(name: &str, n_cells: usize, value: f64) -> Self {
612        FoamScalarField {
613            name: name.to_string(),
614            internal_field: vec![value; n_cells],
615            boundary_values: Vec::new(),
616        }
617    }
618
619    /// Serialise to an OpenFOAM `volScalarField` string.
620    pub fn to_foam_string(&self) -> String {
621        let mut s = write_foam_header("volScalarField", &self.name);
622        s.push('\n');
623        s.push_str("dimensions      [0 0 0 0 0 0 0];\n\n");
624        if self.internal_field.is_empty() {
625            s.push_str("internalField   uniform 0;\n");
626        } else {
627            s.push_str(&format!(
628                "internalField   nonuniform List<scalar>\n{}\n(\n",
629                self.internal_field.len()
630            ));
631            for &v in &self.internal_field {
632                s.push_str(&format!("    {}\n", v));
633            }
634            s.push_str(");\n");
635        }
636        s.push_str("\nboundaryField\n{\n");
637        for (i, bv) in self.boundary_values.iter().enumerate() {
638            let patch_name = format!("patch{}", i);
639            if bv.is_empty() {
640                s.push_str(&format!(
641                    "    {}\n    {{\n        type            zeroGradient;\n    }}\n",
642                    patch_name
643                ));
644            } else {
645                s.push_str(&format!(
646                    "    {}\n    {{\n        type            fixedValue;\n        value           nonuniform List<scalar>\n{}\n(\n",
647                    patch_name, bv.len()
648                ));
649                for &v in bv {
650                    s.push_str(&format!("            {}\n", v));
651                }
652                s.push_str("        );\n    }\n");
653            }
654        }
655        s.push_str("}\n");
656        s
657    }
658}
659
660// ---------------------------------------------------------------------------
661// SimpleCaseWriter
662// ---------------------------------------------------------------------------
663
664/// Writes a minimal OpenFOAM case directory structure in memory.
665pub struct SimpleCaseWriter {
666    /// Path to the case directory (used for `file_list` output).
667    pub case_dir: String,
668    /// The polyMesh to write.
669    pub mesh: FoamPolyMesh,
670}
671
672impl SimpleCaseWriter {
673    /// Create a new writer for `case_dir` with the given mesh.
674    pub fn new(case_dir: &str, mesh: FoamPolyMesh) -> Self {
675        SimpleCaseWriter {
676            case_dir: case_dir.to_string(),
677            mesh,
678        }
679    }
680
681    /// Return all mesh file contents concatenated (useful for testing without disk I/O).
682    pub fn write_mesh_to_string(&self) -> String {
683        let mut out = String::new();
684        out.push_str("=== constant/polyMesh/points ===\n");
685        out.push_str(&write_foam_points(&self.mesh.points));
686        out.push_str("\n=== constant/polyMesh/faces ===\n");
687        out.push_str(&write_foam_faces(&self.mesh.faces));
688        let (owner_str, neighbour_str) =
689            write_foam_owner_neighbour(&self.mesh.owner, &self.mesh.neighbour);
690        out.push_str("\n=== constant/polyMesh/owner ===\n");
691        out.push_str(&owner_str);
692        out.push_str("\n=== constant/polyMesh/neighbour ===\n");
693        out.push_str(&neighbour_str);
694        out
695    }
696
697    /// List the files that would be written to disk.
698    pub fn file_list(&self) -> Vec<String> {
699        let base = &self.case_dir;
700        vec![
701            format!("{}/constant/polyMesh/points", base),
702            format!("{}/constant/polyMesh/faces", base),
703            format!("{}/constant/polyMesh/owner", base),
704            format!("{}/constant/polyMesh/neighbour", base),
705            format!("{}/constant/polyMesh/boundary", base),
706            format!("{}/system/controlDict", base),
707            format!("{}/system/fvSchemes", base),
708            format!("{}/system/fvSolution", base),
709        ]
710    }
711}
712
713// ---------------------------------------------------------------------------
714// OpenFOAM field reading
715// ---------------------------------------------------------------------------
716
717/// Parse an OpenFOAM `volVectorField` internal field (list of 3-vectors).
718///
719/// Accepts blocks like:
720/// ```text
721/// internalField   nonuniform List`vector`
722/// N
723/// (
724///   (x y z)
725///   …
726/// )
727/// ```
728pub fn parse_foam_vector_field(content: &str) -> Result<Vec<[f64; 3]>, String> {
729    parse_foam_points(content)
730}
731
732/// Parse an OpenFOAM `volScalarField` internal field from its text representation.
733///
734/// Handles both `uniform VALUE` and `nonuniform List`scalar` forms.
735pub fn parse_foam_scalar_field_internal(content: &str) -> Result<Vec<f64>, String> {
736    // Check for "uniform VALUE".
737    for line in content.lines() {
738        let line = line.trim();
739        if line.starts_with("internalField") {
740            let rest = &line["internalField".len()..].trim_start();
741            if let Some(rest2) = rest.strip_prefix("uniform") {
742                let v: f64 = rest2
743                    .trim()
744                    .trim_end_matches(';')
745                    .parse()
746                    .map_err(|_| format!("Cannot parse uniform scalar: {:?}", rest2))?;
747                return Ok(vec![v]);
748            }
749        }
750    }
751    // Fall back to list parsing.
752    parse_foam_list_f64(content)
753}
754
755// ---------------------------------------------------------------------------
756// OpenFOAM mesh export helpers
757// ---------------------------------------------------------------------------
758
759/// Generate the `constant/polyMesh/boundary` file content for a mesh.
760pub fn write_foam_boundary(patches: &[FoamBoundaryPatch]) -> String {
761    let mut s = write_foam_header("polyBoundaryMesh", "boundary");
762    s.push('\n');
763    s.push_str(&format!("{}\n(\n", patches.len()));
764    for p in patches {
765        s.push_str(&format!(
766            "    {}\n    {{\n        type            {};\n        nFaces          {};\n        startFace       {};\n    }}\n",
767            p.name, p.patch_type, p.n_faces, p.start_face
768        ));
769    }
770    s.push_str(")\n");
771    s
772}
773
774/// Generate a minimal `system/controlDict` content for an OpenFOAM case.
775pub fn write_foam_control_dict(
776    start_time: f64,
777    end_time: f64,
778    delta_t: f64,
779    write_interval: f64,
780) -> String {
781    let mut s = write_foam_header("dictionary", "controlDict");
782    s.push('\n');
783    s.push_str("application     icoFoam;\n\n");
784    s.push_str(&format!(
785        "startFrom       latestTime;\nstartTime       {};\n",
786        start_time
787    ));
788    s.push_str(&format!(
789        "stopAt          endTime;\nendTime         {};\n",
790        end_time
791    ));
792    s.push_str(&format!("deltaT          {};\n", delta_t));
793    s.push_str(&format!(
794        "writeControl    timeStep;\nwriteInterval   {};\n",
795        write_interval
796    ));
797    s.push_str("purgeWrite      0;\nwriteFormat     ascii;\nwritePrecision  6;\n");
798    s
799}
800
801/// Generate a minimal `system/fvSchemes` dictionary.
802pub fn write_foam_fv_schemes() -> String {
803    let mut s = write_foam_header("dictionary", "fvSchemes");
804    s.push('\n');
805    s.push_str("ddtSchemes\n{\n    default         Euler;\n}\n\n");
806    s.push_str("gradSchemes\n{\n    default         Gauss linear;\n}\n\n");
807    s.push_str("divSchemes\n{\n    default         none;\n    div(phi,U)      Gauss linearUpwind grad(U);\n}\n\n");
808    s.push_str("laplacianSchemes\n{\n    default         Gauss linear corrected;\n}\n\n");
809    s.push_str("interpolationSchemes\n{\n    default         linear;\n}\n\n");
810    s.push_str("snGradSchemes\n{\n    default         corrected;\n}\n");
811    s
812}
813
814/// Generate a minimal `system/fvSolution` dictionary.
815pub fn write_foam_fv_solution() -> String {
816    let mut s = write_foam_header("dictionary", "fvSolution");
817    s.push('\n');
818    s.push_str("solvers\n{\n");
819    s.push_str("    p\n    {\n        solver          PCG;\n        preconditioner  DIC;\n        tolerance       1e-06;\n        relTol          0.05;\n    }\n\n");
820    s.push_str("    U\n    {\n        solver          smoothSolver;\n        smoother        symGaussSeidel;\n        tolerance       1e-05;\n        relTol          0;\n    }\n}\n\n");
821    s.push_str("PISO\n{\n    nCorrectors     2;\n    nNonOrthogonalCorrectors 0;\n}\n");
822    s
823}
824
825// ---------------------------------------------------------------------------
826// Boundary condition writing
827// ---------------------------------------------------------------------------
828
829/// Standard OpenFOAM boundary condition types.
830#[allow(dead_code)]
831#[derive(Debug, Clone, PartialEq)]
832pub enum FoamBcType {
833    /// Zero-gradient (Neumann) boundary condition.
834    ZeroGradient,
835    /// Fixed scalar value (Dirichlet) boundary condition.
836    FixedValue(f64),
837    /// Fixed vector value boundary condition.
838    FixedVectorValue([f64; 3]),
839    /// Inlet-outlet condition with a given reference value.
840    InletOutlet(f64),
841    /// No-slip wall condition for velocity.
842    NoSlip,
843    /// Slip wall condition.
844    Slip,
845    /// Empty (2-D / unused patch) condition.
846    Empty,
847}
848
849impl FoamBcType {
850    /// Serialise to the OpenFOAM dictionary sub-block (without patch name).
851    pub fn to_foam_block(&self) -> String {
852        match self {
853            FoamBcType::ZeroGradient => "        type            zeroGradient;\n".to_string(),
854            FoamBcType::FixedValue(v) => format!(
855                "        type            fixedValue;\n        value           uniform {};\n",
856                v
857            ),
858            FoamBcType::FixedVectorValue(v) => format!(
859                "        type            fixedValue;\n        value           uniform ({} {} {});\n",
860                v[0], v[1], v[2]
861            ),
862            FoamBcType::InletOutlet(v) => format!(
863                "        type            inletOutlet;\n        inletValue      uniform {};\n        value           uniform {};\n",
864                v, v
865            ),
866            FoamBcType::NoSlip => "        type            noSlip;\n".to_string(),
867            FoamBcType::Slip => "        type            slip;\n".to_string(),
868            FoamBcType::Empty => "        type            empty;\n".to_string(),
869        }
870    }
871}
872
873/// Write an OpenFOAM scalar field file with per-patch boundary conditions.
874pub fn write_scalar_field_with_bc(
875    name: &str,
876    dimensions: &str,
877    internal_value: &str,
878    patches: &[(&str, FoamBcType)],
879) -> String {
880    let mut s = write_foam_header("volScalarField", name);
881    s.push('\n');
882    s.push_str(&format!("dimensions      {};\n\n", dimensions));
883    s.push_str(&format!(
884        "internalField   {};\n\nboundaryField\n{{\n",
885        internal_value
886    ));
887    for (patch_name, bc) in patches {
888        s.push_str(&format!(
889            "    {}\n    {{\n{}",
890            patch_name,
891            bc.to_foam_block()
892        ));
893        s.push_str("    }\n");
894    }
895    s.push_str("}\n");
896    s
897}
898
899/// Write an OpenFOAM vector field file.
900pub fn write_vector_field(
901    name: &str,
902    dimensions: &str,
903    values: &[[f64; 3]],
904    patches: &[(&str, FoamBcType)],
905) -> String {
906    let mut s = write_foam_header("volVectorField", name);
907    s.push('\n');
908    s.push_str(&format!("dimensions      {};\n\n", dimensions));
909    if values.is_empty() {
910        s.push_str("internalField   uniform (0 0 0);\n");
911    } else {
912        s.push_str(&format!(
913            "internalField   nonuniform List<vector>\n{}\n(\n",
914            values.len()
915        ));
916        for v in values {
917            s.push_str(&format!("    ({} {} {})\n", v[0], v[1], v[2]));
918        }
919        s.push_str(");\n");
920    }
921    s.push_str("\nboundaryField\n{\n");
922    for (patch_name, bc) in patches {
923        s.push_str(&format!(
924            "    {}\n    {{\n{}",
925            patch_name,
926            bc.to_foam_block()
927        ));
928        s.push_str("    }\n");
929    }
930    s.push_str("}\n");
931    s
932}
933
934// ---------------------------------------------------------------------------
935// Parallel foam decomposition
936// ---------------------------------------------------------------------------
937
938/// Decompose a scalar field into `n_procs` subdomains.
939///
940/// Uses a simple contiguous partitioning: processor `p` owns cells
941/// `[p * chunk_size, (p+1) * chunk_size)`.  Returns one `Vec`f64` per process.
942pub fn decompose_scalar_field(values: &[f64], n_procs: usize) -> Vec<Vec<f64>> {
943    if n_procs == 0 || values.is_empty() {
944        return vec![values.to_vec()];
945    }
946    let n = values.len();
947    let chunk = n.div_ceil(n_procs);
948    (0..n_procs)
949        .map(|p| {
950            let start = (p * chunk).min(n);
951            let end = ((p + 1) * chunk).min(n);
952            values[start..end].to_vec()
953        })
954        .collect()
955}
956
957/// Reconstruct a scalar field from decomposed sub-domain slices.
958pub fn reconstruct_scalar_field(parts: &[Vec<f64>]) -> Vec<f64> {
959    parts.iter().flat_map(|v| v.iter().copied()).collect()
960}
961
962/// Decompose cell ownership for parallel processing.
963///
964/// Returns `n_procs` Vec`usize` each containing the global cell indices
965/// that belong to processor `p`.
966pub fn decompose_cell_indices(n_cells: usize, n_procs: usize) -> Vec<Vec<usize>> {
967    if n_procs == 0 {
968        return vec![(0..n_cells).collect()];
969    }
970    let chunk = n_cells.div_ceil(n_procs);
971    (0..n_procs)
972        .map(|p| {
973            let start = (p * chunk).min(n_cells);
974            let end = ((p + 1) * chunk).min(n_cells);
975            (start..end).collect()
976        })
977        .collect()
978}
979
980// ---------------------------------------------------------------------------
981// Foam case structure
982// ---------------------------------------------------------------------------
983
984/// Full in-memory representation of an OpenFOAM case.
985pub struct FoamCase {
986    /// Case directory path.
987    pub case_dir: String,
988    /// The polyMesh.
989    pub mesh: FoamPolyMesh,
990    /// Named scalar fields at time 0.
991    pub scalar_fields: Vec<FoamScalarField>,
992    /// Simulation start time (s).
993    pub start_time: f64,
994    /// Simulation end time (s).
995    pub end_time: f64,
996    /// Time step size (s).
997    pub delta_t: f64,
998    /// Write interval (in time steps or seconds, depending on writeControl).
999    pub write_interval: f64,
1000}
1001
1002impl FoamCase {
1003    /// Create a new case.
1004    pub fn new(case_dir: &str, mesh: FoamPolyMesh) -> Self {
1005        Self {
1006            case_dir: case_dir.to_string(),
1007            mesh,
1008            scalar_fields: Vec::new(),
1009            start_time: 0.0,
1010            end_time: 1.0,
1011            delta_t: 0.001,
1012            write_interval: 0.1,
1013        }
1014    }
1015
1016    /// Add a scalar field to the initial conditions.
1017    pub fn add_scalar_field(&mut self, field: FoamScalarField) {
1018        self.scalar_fields.push(field);
1019    }
1020
1021    /// Return a list of all files this case would write to disk.
1022    pub fn file_list(&self) -> Vec<String> {
1023        let base = &self.case_dir;
1024        let mut files = vec![
1025            format!("{}/constant/polyMesh/points", base),
1026            format!("{}/constant/polyMesh/faces", base),
1027            format!("{}/constant/polyMesh/owner", base),
1028            format!("{}/constant/polyMesh/neighbour", base),
1029            format!("{}/constant/polyMesh/boundary", base),
1030            format!("{}/system/controlDict", base),
1031            format!("{}/system/fvSchemes", base),
1032            format!("{}/system/fvSolution", base),
1033        ];
1034        for sf in &self.scalar_fields {
1035            files.push(format!("{}/0/{}", base, sf.name));
1036        }
1037        files
1038    }
1039
1040    /// Generate the controlDict content.
1041    pub fn control_dict_content(&self) -> String {
1042        write_foam_control_dict(
1043            self.start_time,
1044            self.end_time,
1045            self.delta_t,
1046            self.write_interval,
1047        )
1048    }
1049
1050    /// Generate the boundary file content.
1051    pub fn boundary_content(&self) -> String {
1052        write_foam_boundary(&self.mesh.boundary)
1053    }
1054}
1055
1056// ---------------------------------------------------------------------------
1057// Tests
1058// ---------------------------------------------------------------------------
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063
1064    fn simple_mesh() -> FoamPolyMesh {
1065        // Two tetrahedra sharing one internal face.
1066        // 5 points, 7 faces (1 internal + 6 boundary), 2 cells.
1067        let points = vec![
1068            [0.0, 0.0, 0.0],
1069            [1.0, 0.0, 0.0],
1070            [0.5, 1.0, 0.0],
1071            [0.5, 0.5, 1.0],
1072            [1.5, 0.5, 1.0],
1073        ];
1074        let faces = vec![
1075            FoamFace {
1076                node_indices: vec![0, 1, 3],
1077            }, // internal face
1078            FoamFace {
1079                node_indices: vec![0, 2, 3],
1080            },
1081            FoamFace {
1082                node_indices: vec![1, 2, 3],
1083            },
1084            FoamFace {
1085                node_indices: vec![0, 1, 2],
1086            },
1087            FoamFace {
1088                node_indices: vec![1, 4, 3],
1089            },
1090            FoamFace {
1091                node_indices: vec![1, 2, 4],
1092            },
1093            FoamFace {
1094                node_indices: vec![3, 4, 2],
1095            },
1096        ];
1097        let owner = vec![0, 0, 0, 0, 1, 1, 1];
1098        let neighbour = vec![1]; // face 0 is shared
1099        let boundary = vec![FoamBoundaryPatch {
1100            name: "wall".to_string(),
1101            patch_type: "wall".to_string(),
1102            n_faces: 6,
1103            start_face: 1,
1104        }];
1105        FoamPolyMesh {
1106            points,
1107            faces,
1108            owner,
1109            neighbour,
1110            boundary,
1111        }
1112    }
1113
1114    #[test]
1115    fn test_foam_header_to_string_contains_foamfile() {
1116        let h = FoamHeader {
1117            foam_file_version: "2.0".to_string(),
1118            format: "ascii".to_string(),
1119            class_name: "polyBoundaryMesh".to_string(),
1120            object_name: "boundary".to_string(),
1121        };
1122        let s = h.to_string();
1123        assert!(s.contains("FoamFile"));
1124        assert!(s.contains("polyBoundaryMesh"));
1125    }
1126
1127    #[test]
1128    fn test_parse_foam_list_usize() {
1129        let input = "3\n(\n1\n2\n3\n)";
1130        let result = parse_foam_list_usize(input).expect("parse failed");
1131        assert_eq!(result, vec![1, 2, 3]);
1132    }
1133
1134    #[test]
1135    fn test_parse_foam_points() {
1136        let input = "1\n(\n    (3.0 2.0 1.0)\n)";
1137        let pts = parse_foam_points(input).expect("parse failed");
1138        assert_eq!(pts.len(), 1);
1139        assert!((pts[0][0] - 3.0).abs() < 1e-12);
1140        assert!((pts[0][1] - 2.0).abs() < 1e-12);
1141        assert!((pts[0][2] - 1.0).abs() < 1e-12);
1142    }
1143
1144    #[test]
1145    fn test_n_cells() {
1146        let mesh = simple_mesh();
1147        assert_eq!(mesh.n_cells(), 2);
1148    }
1149
1150    #[test]
1151    fn test_write_foam_header_contains_class() {
1152        let s = write_foam_header("volScalarField", "p");
1153        assert!(s.contains("FoamFile"));
1154        assert!(s.contains("volScalarField"));
1155        assert!(s.contains("p"));
1156    }
1157
1158    #[test]
1159    fn test_foam_scalar_field_uniform() {
1160        let f = FoamScalarField::uniform("T", 4, 300.0);
1161        assert_eq!(f.internal_field.len(), 4);
1162        assert!(f.internal_field.iter().all(|&v| (v - 300.0).abs() < 1e-12));
1163    }
1164
1165    #[test]
1166    fn test_face_centers_centroid() {
1167        let mesh = simple_mesh();
1168        let fc = mesh.face_centers();
1169        // Face 0: nodes 0,1,3 → ([0,0,0],[1,0,0],[0.5,0.5,1.0]) → centre=(0.5, 1/6, 1/3)
1170        // Actually: (0+1+0.5)/3, (0+0+0.5)/3, (0+0+1)/3
1171        assert!((fc[0][0] - (0.0 + 1.0 + 0.5) / 3.0).abs() < 1e-9);
1172        assert!((fc[0][1] - (0.0 + 0.0 + 0.5) / 3.0).abs() < 1e-9);
1173        assert!((fc[0][2] - (0.0 + 0.0 + 1.0) / 3.0).abs() < 1e-9);
1174    }
1175
1176    #[test]
1177    fn test_simple_case_writer_file_list_nonempty() {
1178        let writer = SimpleCaseWriter::new("/tmp/test_case", simple_mesh());
1179        let files = writer.file_list();
1180        assert!(!files.is_empty());
1181        assert!(files.iter().any(|f| f.contains("points")));
1182    }
1183
1184    // -----------------------------------------------------------------------
1185    // Boundary writing tests
1186    // -----------------------------------------------------------------------
1187
1188    #[test]
1189    fn test_write_foam_boundary_contains_patch_name() {
1190        let mesh = simple_mesh();
1191        let s = write_foam_boundary(&mesh.boundary);
1192        assert!(
1193            s.contains("wall"),
1194            "Boundary output should contain patch name 'wall'"
1195        );
1196        assert!(
1197            s.contains("FoamFile"),
1198            "Boundary output should contain FoamFile header"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_write_foam_control_dict() {
1204        let s = write_foam_control_dict(0.0, 1.0, 0.001, 0.1);
1205        assert!(s.contains("endTime"), "controlDict should contain endTime");
1206        assert!(s.contains("deltaT"), "controlDict should contain deltaT");
1207    }
1208
1209    #[test]
1210    fn test_write_foam_fv_schemes() {
1211        let s = write_foam_fv_schemes();
1212        assert!(
1213            s.contains("ddtSchemes"),
1214            "fvSchemes should contain ddtSchemes"
1215        );
1216        assert!(
1217            s.contains("gradSchemes"),
1218            "fvSchemes should contain gradSchemes"
1219        );
1220    }
1221
1222    #[test]
1223    fn test_write_foam_fv_solution() {
1224        let s = write_foam_fv_solution();
1225        assert!(s.contains("solvers"), "fvSolution should contain solvers");
1226        assert!(s.contains("PISO"), "fvSolution should contain PISO block");
1227    }
1228
1229    // -----------------------------------------------------------------------
1230    // Boundary condition tests
1231    // -----------------------------------------------------------------------
1232
1233    #[test]
1234    fn test_foam_bc_zero_gradient() {
1235        let bc = FoamBcType::ZeroGradient;
1236        let s = bc.to_foam_block();
1237        assert!(s.contains("zeroGradient"));
1238    }
1239
1240    #[test]
1241    fn test_foam_bc_fixed_value() {
1242        let bc = FoamBcType::FixedValue(42.0);
1243        let s = bc.to_foam_block();
1244        assert!(s.contains("fixedValue"));
1245        assert!(s.contains("42"));
1246    }
1247
1248    #[test]
1249    fn test_foam_bc_no_slip() {
1250        let bc = FoamBcType::NoSlip;
1251        let s = bc.to_foam_block();
1252        assert!(s.contains("noSlip"));
1253    }
1254
1255    #[test]
1256    fn test_write_scalar_field_with_bc() {
1257        let patches = vec![
1258            ("inlet", FoamBcType::FixedValue(1.0)),
1259            ("outlet", FoamBcType::ZeroGradient),
1260        ];
1261        let s = write_scalar_field_with_bc("p", "[0 2 -2 0 0 0 0]", "uniform 0", &patches);
1262        assert!(s.contains("inlet"));
1263        assert!(s.contains("outlet"));
1264        assert!(s.contains("fixedValue"));
1265        assert!(s.contains("zeroGradient"));
1266    }
1267
1268    #[test]
1269    fn test_write_vector_field() {
1270        let vals = vec![[1.0, 0.0, 0.0], [0.5, 0.5, 0.0]];
1271        let patches: Vec<(&str, FoamBcType)> = vec![("walls", FoamBcType::NoSlip)];
1272        let s = write_vector_field("U", "[0 1 -1 0 0 0 0]", &vals, &patches);
1273        assert!(s.contains("volVectorField"));
1274        assert!(s.contains("noSlip"));
1275    }
1276
1277    // -----------------------------------------------------------------------
1278    // Parallel decomposition tests
1279    // -----------------------------------------------------------------------
1280
1281    #[test]
1282    fn test_decompose_scalar_field_total_count() {
1283        let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
1284        let parts = decompose_scalar_field(&values, 4);
1285        let total: usize = parts.iter().map(|p| p.len()).sum();
1286        assert_eq!(total, 100, "Decomposed parts should cover all cells");
1287    }
1288
1289    #[test]
1290    fn test_reconstruct_scalar_field() {
1291        let original: Vec<f64> = (0..50).map(|i| i as f64).collect();
1292        let parts = decompose_scalar_field(&original, 5);
1293        let reconstructed = reconstruct_scalar_field(&parts);
1294        assert_eq!(reconstructed, original);
1295    }
1296
1297    #[test]
1298    fn test_decompose_cell_indices() {
1299        let n_cells = 100;
1300        let n_procs = 4;
1301        let parts = decompose_cell_indices(n_cells, n_procs);
1302        assert_eq!(parts.len(), n_procs);
1303        let total: usize = parts.iter().map(|p| p.len()).sum();
1304        assert_eq!(total, n_cells);
1305        // All indices should be unique and cover 0..n_cells.
1306        let mut all: Vec<usize> = parts.into_iter().flatten().collect();
1307        all.sort_unstable();
1308        let expected: Vec<usize> = (0..n_cells).collect();
1309        assert_eq!(all, expected);
1310    }
1311
1312    #[test]
1313    fn test_decompose_single_proc() {
1314        let values = vec![1.0, 2.0, 3.0];
1315        let parts = decompose_scalar_field(&values, 1);
1316        assert_eq!(parts.len(), 1);
1317        assert_eq!(parts[0], values);
1318    }
1319
1320    // -----------------------------------------------------------------------
1321    // FoamCase tests
1322    // -----------------------------------------------------------------------
1323
1324    #[test]
1325    fn test_foam_case_file_list() {
1326        let mesh = simple_mesh();
1327        let case = FoamCase::new("/tmp/mycase", mesh);
1328        let files = case.file_list();
1329        assert!(files.iter().any(|f| f.contains("controlDict")));
1330        assert!(files.iter().any(|f| f.contains("fvSchemes")));
1331    }
1332
1333    #[test]
1334    fn test_foam_case_with_scalar_field() {
1335        let mesh = simple_mesh();
1336        let mut case = FoamCase::new("/tmp/mycase2", mesh);
1337        case.add_scalar_field(FoamScalarField::uniform("p", 10, 0.0));
1338        let files = case.file_list();
1339        assert!(files.iter().any(|f| f.ends_with("/0/p")));
1340    }
1341
1342    #[test]
1343    fn test_foam_case_control_dict_content() {
1344        let mesh = simple_mesh();
1345        let mut case = FoamCase::new("/tmp/mycase3", mesh);
1346        case.end_time = 2.5;
1347        let s = case.control_dict_content();
1348        assert!(s.contains("2.5"), "controlDict should contain the end time");
1349    }
1350
1351    #[test]
1352    fn test_foam_case_boundary_content() {
1353        let mesh = simple_mesh();
1354        let case = FoamCase::new("/tmp/mycase4", mesh);
1355        let s = case.boundary_content();
1356        assert!(
1357            s.contains("wall"),
1358            "boundary content should include patch name"
1359        );
1360    }
1361
1362    // -----------------------------------------------------------------------
1363    // Field parsing tests
1364    // -----------------------------------------------------------------------
1365
1366    #[test]
1367    fn test_parse_foam_vector_field() {
1368        let input = "1\n(\n    (1.0 2.0 3.0)\n)";
1369        let vecs = parse_foam_vector_field(input).expect("parse failed");
1370        assert_eq!(vecs.len(), 1);
1371        assert!((vecs[0][0] - 1.0).abs() < 1e-12);
1372    }
1373
1374    #[test]
1375    fn test_parse_foam_scalar_field_internal_uniform() {
1376        let content = "internalField   uniform 101325;\n";
1377        let vals = parse_foam_scalar_field_internal(content).expect("parse failed");
1378        assert_eq!(vals.len(), 1);
1379        assert!((vals[0] - 101325.0).abs() < 1e-8);
1380    }
1381
1382    #[test]
1383    fn test_write_foam_faces_round_trip() {
1384        let faces = vec![
1385            FoamFace {
1386                node_indices: vec![0, 1, 2],
1387            },
1388            FoamFace {
1389                node_indices: vec![1, 3, 2],
1390            },
1391        ];
1392        let s = write_foam_faces(&faces);
1393        // Parse back.
1394        let parsed = parse_foam_faces(&s).expect("round-trip parse failed");
1395        assert_eq!(parsed.len(), 2);
1396        assert_eq!(parsed[0].node_indices, vec![0, 1, 2]);
1397        assert_eq!(parsed[1].node_indices, vec![1, 3, 2]);
1398    }
1399}