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