Skip to main content

oxiphysics_io/
plot3d_format.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! PLOT3D format for CFD grid and solution files.
5//!
6//! Supports multi-block structured grids in ASCII PLOT3D `.xyz` and `.q`
7//! formats as commonly used in CFD solvers.
8//!
9//! # File layout
10//!
11//! ## Grid file (.xyz)
12//! ```text
13//! `nblocks`
14//! `ni1` `nj1` `nk1`  [`ni2` `nj2` `nk2` …]
15//! <x values block 1> <y values block 1> <z values block 1>
16//! …
17//! ```
18//!
19//! ## Solution file (.q)
20//! ```text
21//! `nblocks`
22//! `ni1` `nj1` `nk1`  [`ni2` `nj2` `nk2` …]
23//! `mach` `alpha` `rey` `time`   (per block)
24//! `rho` <rho*u> <rho*v> <rho*w> `e`  (per point, per block)
25//! ```
26
27use std::fmt::Write as FmtWrite;
28use std::str::SplitAsciiWhitespace;
29
30use crate::Error as IoError;
31
32// ── Helper ───────────────────────────────────────────────────────────────────
33
34/// Parse the next f64 token from an iterator.
35fn next_f64<'a>(iter: &mut SplitAsciiWhitespace<'a>) -> Result<f64, IoError> {
36    iter.next()
37        .ok_or_else(|| IoError::Parse("unexpected end of PLOT3D data".into()))?
38        .parse::<f64>()
39        .map_err(|e| IoError::Parse(e.to_string()))
40}
41
42/// Parse the next usize token from an iterator.
43fn next_usize<'a>(iter: &mut SplitAsciiWhitespace<'a>) -> Result<usize, IoError> {
44    iter.next()
45        .ok_or_else(|| IoError::Parse("unexpected end of PLOT3D data".into()))?
46        .parse::<usize>()
47        .map_err(|e| IoError::Parse(e.to_string()))
48}
49
50// ── Grid ─────────────────────────────────────────────────────────────────────
51
52/// A single structured block in a PLOT3D grid.
53///
54/// Coordinates are stored in row-major order with the fastest index being
55/// `i` (x-direction), then `j` (y-direction), then `k` (z-direction).
56#[allow(dead_code)]
57#[derive(Debug, Clone, PartialEq)]
58pub struct Plot3dBlock {
59    /// Number of grid points in the i-direction.
60    pub ni: usize,
61    /// Number of grid points in the j-direction.
62    pub nj: usize,
63    /// Number of grid points in the k-direction.
64    pub nk: usize,
65    /// x-coordinates (`ni × nj × nk` values).
66    pub x: Vec<f64>,
67    /// y-coordinates (`ni × nj × nk` values).
68    pub y: Vec<f64>,
69    /// z-coordinates (`ni × nj × nk` values).
70    pub z: Vec<f64>,
71}
72
73impl Plot3dBlock {
74    /// Total number of grid points in this block.
75    pub fn n_points(&self) -> usize {
76        self.ni * self.nj * self.nk
77    }
78}
79
80/// A multi-block PLOT3D structured grid.
81#[allow(dead_code)]
82#[derive(Debug, Clone)]
83pub struct Plot3dGrid {
84    /// Ordered list of structured blocks.
85    pub blocks: Vec<Plot3dBlock>,
86}
87
88impl Plot3dGrid {
89    /// Create a new empty grid.
90    pub fn new() -> Self {
91        Self { blocks: Vec::new() }
92    }
93
94    /// Number of blocks.
95    pub fn n_blocks(&self) -> usize {
96        self.blocks.len()
97    }
98
99    /// Dimensions `(ni, nj, nk)` of block `b`.
100    ///
101    /// Returns `None` if `b` is out of range.
102    pub fn block_dimensions(&self, b: usize) -> Option<(usize, usize, usize)> {
103        self.blocks.get(b).map(|blk| (blk.ni, blk.nj, blk.nk))
104    }
105}
106
107impl Default for Plot3dGrid {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113/// Parse an ASCII PLOT3D `.xyz` file from a string.
114///
115/// # Errors
116/// Returns `Error::Parse` if the input is malformed.
117pub fn read_plot3d_grid(src: &str) -> Result<Plot3dGrid, IoError> {
118    let mut iter = src.split_ascii_whitespace();
119    let nblocks = next_usize(&mut iter)?;
120    let mut dims: Vec<(usize, usize, usize)> = Vec::with_capacity(nblocks);
121    for _ in 0..nblocks {
122        let ni = next_usize(&mut iter)?;
123        let nj = next_usize(&mut iter)?;
124        let nk = next_usize(&mut iter)?;
125        dims.push((ni, nj, nk));
126    }
127    let mut blocks = Vec::with_capacity(nblocks);
128    for (ni, nj, nk) in dims {
129        let n = ni * nj * nk;
130        let mut x = Vec::with_capacity(n);
131        let mut y = Vec::with_capacity(n);
132        let mut z = Vec::with_capacity(n);
133        for _ in 0..n {
134            x.push(next_f64(&mut iter)?);
135        }
136        for _ in 0..n {
137            y.push(next_f64(&mut iter)?);
138        }
139        for _ in 0..n {
140            z.push(next_f64(&mut iter)?);
141        }
142        blocks.push(Plot3dBlock {
143            ni,
144            nj,
145            nk,
146            x,
147            y,
148            z,
149        });
150    }
151    Ok(Plot3dGrid { blocks })
152}
153
154/// Write a PLOT3D grid to an ASCII string.
155pub fn write_plot3d_grid(grid: &Plot3dGrid) -> String {
156    let mut out = String::new();
157    let _ = writeln!(out, "{}", grid.n_blocks());
158    for blk in &grid.blocks {
159        let _ = writeln!(out, "{} {} {}", blk.ni, blk.nj, blk.nk);
160    }
161    for blk in &grid.blocks {
162        let n = blk.n_points();
163        for k in 0..n {
164            let _ = writeln!(out, "{:.15e}", blk.x[k]);
165        }
166        for k in 0..n {
167            let _ = writeln!(out, "{:.15e}", blk.y[k]);
168        }
169        for k in 0..n {
170            let _ = writeln!(out, "{:.15e}", blk.z[k]);
171        }
172    }
173    out
174}
175
176// ── Solution ─────────────────────────────────────────────────────────────────
177
178/// Free-stream reference parameters stored per block in a `.q` file.
179#[allow(dead_code)]
180#[derive(Debug, Clone, PartialEq)]
181pub struct Plot3dFreestream {
182    /// Mach number.
183    pub mach: f64,
184    /// Angle of attack (degrees).
185    pub alpha: f64,
186    /// Reynolds number.
187    pub reynolds: f64,
188    /// Non-dimensional time.
189    pub time: f64,
190}
191
192/// Solution variables at a single grid point.
193///
194/// The five conservative variables used in the PLOT3D `.q` format are
195/// stored: density, three momentum components, and total energy.
196#[allow(dead_code)]
197#[derive(Debug, Clone, PartialEq)]
198pub struct Plot3dQPoint {
199    /// Density ρ.
200    pub rho: f64,
201    /// x-momentum ρ·u.
202    pub rho_u: f64,
203    /// y-momentum ρ·v.
204    pub rho_v: f64,
205    /// z-momentum ρ·w.
206    pub rho_w: f64,
207    /// Total energy per unit volume e.
208    pub e: f64,
209}
210
211/// A single solution block in a PLOT3D `.q` file.
212#[allow(dead_code)]
213#[derive(Debug, Clone)]
214pub struct Plot3dSolutionBlock {
215    /// Grid dimensions matching the corresponding [`Plot3dBlock`].
216    pub ni: usize,
217    /// Grid dimensions matching the corresponding [`Plot3dBlock`].
218    pub nj: usize,
219    /// Grid dimensions matching the corresponding [`Plot3dBlock`].
220    pub nk: usize,
221    /// Free-stream reference parameters.
222    pub freestream: Plot3dFreestream,
223    /// Solution data (one entry per grid point).
224    pub q: Vec<Plot3dQPoint>,
225}
226
227impl Plot3dSolutionBlock {
228    /// Total number of solution points.
229    pub fn n_points(&self) -> usize {
230        self.ni * self.nj * self.nk
231    }
232}
233
234/// A multi-block PLOT3D solution.
235#[allow(dead_code)]
236#[derive(Debug, Clone)]
237pub struct Plot3dSolution {
238    /// Ordered list of solution blocks.
239    pub blocks: Vec<Plot3dSolutionBlock>,
240}
241
242impl Plot3dSolution {
243    /// Number of blocks in this solution.
244    pub fn n_blocks(&self) -> usize {
245        self.blocks.len()
246    }
247}
248
249/// Parse an ASCII PLOT3D `.q` solution file from a string.
250///
251/// # Errors
252/// Returns `Error::Parse` if the input is malformed.
253pub fn read_plot3d_solution(src: &str) -> Result<Plot3dSolution, IoError> {
254    let mut iter = src.split_ascii_whitespace();
255    let nblocks = next_usize(&mut iter)?;
256    let mut dims: Vec<(usize, usize, usize)> = Vec::with_capacity(nblocks);
257    for _ in 0..nblocks {
258        let ni = next_usize(&mut iter)?;
259        let nj = next_usize(&mut iter)?;
260        let nk = next_usize(&mut iter)?;
261        dims.push((ni, nj, nk));
262    }
263    let mut blocks = Vec::with_capacity(nblocks);
264    for (ni, nj, nk) in dims {
265        let mach = next_f64(&mut iter)?;
266        let alpha = next_f64(&mut iter)?;
267        let reynolds = next_f64(&mut iter)?;
268        let time = next_f64(&mut iter)?;
269        let n = ni * nj * nk;
270        let mut q = Vec::with_capacity(n);
271        for _ in 0..n {
272            let rho = next_f64(&mut iter)?;
273            let rho_u = next_f64(&mut iter)?;
274            let rho_v = next_f64(&mut iter)?;
275            let rho_w = next_f64(&mut iter)?;
276            let e = next_f64(&mut iter)?;
277            q.push(Plot3dQPoint {
278                rho,
279                rho_u,
280                rho_v,
281                rho_w,
282                e,
283            });
284        }
285        blocks.push(Plot3dSolutionBlock {
286            ni,
287            nj,
288            nk,
289            freestream: Plot3dFreestream {
290                mach,
291                alpha,
292                reynolds,
293                time,
294            },
295            q,
296        });
297    }
298    Ok(Plot3dSolution { blocks })
299}
300
301/// Write a PLOT3D solution to an ASCII string.
302pub fn write_plot3d_solution(sol: &Plot3dSolution) -> String {
303    let mut out = String::new();
304    let _ = writeln!(out, "{}", sol.n_blocks());
305    for blk in &sol.blocks {
306        let _ = writeln!(out, "{} {} {}", blk.ni, blk.nj, blk.nk);
307    }
308    for blk in &sol.blocks {
309        let fs = &blk.freestream;
310        let _ = writeln!(
311            out,
312            "{:.15e} {:.15e} {:.15e} {:.15e}",
313            fs.mach, fs.alpha, fs.reynolds, fs.time
314        );
315        for pt in &blk.q {
316            let _ = writeln!(
317                out,
318                "{:.15e} {:.15e} {:.15e} {:.15e} {:.15e}",
319                pt.rho, pt.rho_u, pt.rho_v, pt.rho_w, pt.e
320            );
321        }
322    }
323    out
324}
325
326// ── Tests ────────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn make_single_block_grid() -> Plot3dGrid {
333        let blk = Plot3dBlock {
334            ni: 2,
335            nj: 2,
336            nk: 1,
337            x: vec![0.0, 1.0, 0.0, 1.0],
338            y: vec![0.0, 0.0, 1.0, 1.0],
339            z: vec![0.0, 0.0, 0.0, 0.0],
340        };
341        Plot3dGrid { blocks: vec![blk] }
342    }
343
344    fn make_single_block_solution() -> Plot3dSolution {
345        let fs = Plot3dFreestream {
346            mach: 0.5,
347            alpha: 0.0,
348            reynolds: 1e6,
349            time: 0.0,
350        };
351        let pts: Vec<Plot3dQPoint> = (0..4)
352            .map(|i| Plot3dQPoint {
353                rho: 1.0 + i as f64 * 0.1,
354                rho_u: 0.5,
355                rho_v: 0.0,
356                rho_w: 0.0,
357                e: 2.5,
358            })
359            .collect();
360        Plot3dSolution {
361            blocks: vec![Plot3dSolutionBlock {
362                ni: 2,
363                nj: 2,
364                nk: 1,
365                freestream: fs,
366                q: pts,
367            }],
368        }
369    }
370
371    #[test]
372    fn test_grid_n_blocks() {
373        let g = make_single_block_grid();
374        assert_eq!(g.n_blocks(), 1);
375    }
376
377    #[test]
378    fn test_grid_n_points() {
379        let g = make_single_block_grid();
380        assert_eq!(g.blocks[0].n_points(), 4);
381    }
382
383    #[test]
384    fn test_block_dimensions() {
385        let g = make_single_block_grid();
386        assert_eq!(g.block_dimensions(0), Some((2, 2, 1)));
387    }
388
389    #[test]
390    fn test_block_dimensions_out_of_range() {
391        let g = make_single_block_grid();
392        assert_eq!(g.block_dimensions(5), None);
393    }
394
395    #[test]
396    fn test_write_read_grid_roundtrip() {
397        let original = make_single_block_grid();
398        let s = write_plot3d_grid(&original);
399        let parsed = read_plot3d_grid(&s).expect("parse should succeed");
400        assert_eq!(parsed.n_blocks(), original.n_blocks());
401        let orig_blk = &original.blocks[0];
402        let pars_blk = &parsed.blocks[0];
403        assert_eq!(pars_blk.ni, orig_blk.ni);
404        assert_eq!(pars_blk.nj, orig_blk.nj);
405        assert_eq!(pars_blk.nk, orig_blk.nk);
406        for i in 0..orig_blk.n_points() {
407            assert!((orig_blk.x[i] - pars_blk.x[i]).abs() < 1e-10);
408            assert!((orig_blk.y[i] - pars_blk.y[i]).abs() < 1e-10);
409            assert!((orig_blk.z[i] - pars_blk.z[i]).abs() < 1e-10);
410        }
411    }
412
413    #[test]
414    fn test_read_grid_error_on_empty() {
415        assert!(read_plot3d_grid("").is_err());
416    }
417
418    #[test]
419    fn test_read_grid_error_on_truncated() {
420        // Only block count with no dimensions
421        assert!(read_plot3d_grid("1").is_err());
422    }
423
424    #[test]
425    fn test_write_grid_contains_nblocks() {
426        let g = make_single_block_grid();
427        let s = write_plot3d_grid(&g);
428        assert!(s.starts_with("1\n"));
429    }
430
431    #[test]
432    fn test_solution_n_blocks() {
433        let sol = make_single_block_solution();
434        assert_eq!(sol.n_blocks(), 1);
435    }
436
437    #[test]
438    fn test_solution_n_points() {
439        let sol = make_single_block_solution();
440        assert_eq!(sol.blocks[0].n_points(), 4);
441    }
442
443    #[test]
444    fn test_write_read_solution_roundtrip() {
445        let original = make_single_block_solution();
446        let s = write_plot3d_solution(&original);
447        let parsed = read_plot3d_solution(&s).expect("parse should succeed");
448        assert_eq!(parsed.n_blocks(), original.n_blocks());
449        let ob = &original.blocks[0];
450        let pb = &parsed.blocks[0];
451        assert!((ob.freestream.mach - pb.freestream.mach).abs() < 1e-10);
452        assert!((ob.freestream.reynolds - pb.freestream.reynolds).abs() < 1.0);
453        for i in 0..ob.n_points() {
454            assert!((ob.q[i].rho - pb.q[i].rho).abs() < 1e-10);
455            assert!((ob.q[i].rho_u - pb.q[i].rho_u).abs() < 1e-10);
456            assert!((ob.q[i].e - pb.q[i].e).abs() < 1e-10);
457        }
458    }
459
460    #[test]
461    fn test_read_solution_error_on_empty() {
462        assert!(read_plot3d_solution("").is_err());
463    }
464
465    #[test]
466    fn test_read_solution_error_on_truncated() {
467        assert!(read_plot3d_solution("1").is_err());
468    }
469
470    #[test]
471    fn test_write_solution_contains_mach() {
472        let sol = make_single_block_solution();
473        let s = write_plot3d_solution(&sol);
474        assert!(s.contains("5.000000000000000e-1") || s.contains("5.0000000000000"));
475    }
476
477    #[test]
478    fn test_multi_block_grid_roundtrip() {
479        let blk1 = Plot3dBlock {
480            ni: 2,
481            nj: 1,
482            nk: 1,
483            x: vec![0.0, 1.0],
484            y: vec![0.0, 0.0],
485            z: vec![0.0, 0.0],
486        };
487        let blk2 = Plot3dBlock {
488            ni: 3,
489            nj: 1,
490            nk: 1,
491            x: vec![1.0, 2.0, 3.0],
492            y: vec![0.0, 0.0, 0.0],
493            z: vec![0.0, 0.0, 0.0],
494        };
495        let grid = Plot3dGrid {
496            blocks: vec![blk1, blk2],
497        };
498        let s = write_plot3d_grid(&grid);
499        let parsed = read_plot3d_grid(&s).unwrap();
500        assert_eq!(parsed.n_blocks(), 2);
501        assert_eq!(parsed.blocks[1].ni, 3);
502    }
503
504    #[test]
505    fn test_plot3d_default_grid() {
506        let g = Plot3dGrid::default();
507        assert_eq!(g.n_blocks(), 0);
508    }
509
510    #[test]
511    fn test_plot3d_block_clone() {
512        let blk = Plot3dBlock {
513            ni: 1,
514            nj: 1,
515            nk: 1,
516            x: vec![0.0],
517            y: vec![0.0],
518            z: vec![0.0],
519        };
520        let blk2 = blk.clone();
521        assert_eq!(blk2.ni, 1);
522    }
523
524    #[test]
525    fn test_freestream_clone() {
526        let fs = Plot3dFreestream {
527            mach: 1.0,
528            alpha: 2.0,
529            reynolds: 3.0,
530            time: 4.0,
531        };
532        let fs2 = fs.clone();
533        assert!((fs2.mach - 1.0).abs() < 1e-12);
534    }
535
536    #[test]
537    fn test_qpoint_clone() {
538        let pt = Plot3dQPoint {
539            rho: 1.0,
540            rho_u: 2.0,
541            rho_v: 3.0,
542            rho_w: 4.0,
543            e: 5.0,
544        };
545        let pt2 = pt.clone();
546        assert!((pt2.e - 5.0).abs() < 1e-12);
547    }
548
549    #[test]
550    fn test_grid_debug_format() {
551        let g = make_single_block_grid();
552        let s = format!("{g:?}");
553        assert!(s.contains("Plot3dGrid"));
554    }
555
556    #[test]
557    fn test_solution_debug_format() {
558        let sol = make_single_block_solution();
559        let s = format!("{sol:?}");
560        assert!(s.contains("Plot3dSolution"));
561    }
562
563    #[test]
564    fn test_write_grid_has_coordinates() {
565        let g = make_single_block_grid();
566        let s = write_plot3d_grid(&g);
567        // The x=1.0 value must be present as a floating-point token
568        assert!(s.contains("1.000000000000000e0") || s.contains("1.0000000000000"));
569    }
570
571    #[test]
572    fn test_solution_q_length() {
573        let sol = make_single_block_solution();
574        assert_eq!(sol.blocks[0].q.len(), 4);
575    }
576
577    #[test]
578    fn test_block_dimensions_3d() {
579        let blk = Plot3dBlock {
580            ni: 3,
581            nj: 4,
582            nk: 5,
583            x: vec![0.0; 60],
584            y: vec![0.0; 60],
585            z: vec![0.0; 60],
586        };
587        let g = Plot3dGrid { blocks: vec![blk] };
588        assert_eq!(g.block_dimensions(0), Some((3, 4, 5)));
589        assert_eq!(g.blocks[0].n_points(), 60);
590    }
591
592    #[test]
593    fn test_write_read_3d_block() {
594        let n = 8; // 2×2×2
595        let blk = Plot3dBlock {
596            ni: 2,
597            nj: 2,
598            nk: 2,
599            x: (0..n).map(|i| i as f64 * 0.1).collect(),
600            y: (0..n).map(|i| i as f64 * 0.2).collect(),
601            z: (0..n).map(|i| i as f64 * 0.3).collect(),
602        };
603        let grid = Plot3dGrid { blocks: vec![blk] };
604        let s = write_plot3d_grid(&grid);
605        let parsed = read_plot3d_grid(&s).unwrap();
606        for i in 0..n {
607            assert!((grid.blocks[0].z[i] - parsed.blocks[0].z[i]).abs() < 1e-10);
608        }
609    }
610}