caexfer 0.2.0

Transfer finite-element meshes, voxel occupancy, and results across CAE formats
Documentation
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use crate::core::{Error, Result};
use crate::formats::msh;

/// Volume cell shape requested for closed-surface infill.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfillShape {
    /// Surface-conforming tetrahedra generated by Gmsh.
    Tet4,
    /// Occupancy-grid hexahedra.
    Hex8,
    /// Two triangular prisms per occupied grid cell.
    Wedge6,
    /// Six pyramids per occupied grid cell.
    Pyramid5,
}

impl InfillShape {
    /// Parse a CLI or configuration shape name.
    ///
    /// # Errors
    ///
    /// Returns `E_USAGE` for an unsupported shape.
    pub fn parse(name: &str) -> Result<Self> {
        match name.to_ascii_lowercase().as_str() {
            "tet4" => Ok(Self::Tet4),
            "hex8" => Ok(Self::Hex8),
            "wedge6" => Ok(Self::Wedge6),
            "pyramid5" => Ok(Self::Pyramid5),
            _ => Err(Error::new(
                "E_USAGE",
                "infill shape must be tet4, hex8, wedge6, or pyramid5",
            )),
        }
    }
}

/// Supported conversion format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
    /// Nastran Bulk Data deck.
    Bdf,

    /// VTK XML `UnstructuredGrid`.
    Vtu,

    /// VTK XML `ImageData` binary occupancy grid.
    Vti,

    /// `MagicaVoxel` version 150 single-model occupancy.
    Vox,

    /// ASCII legacy VTK unstructured grid.
    Vtk,

    /// Gmsh MSH 4.1.
    Msh,

    /// Abaqus or `CalculiX` input deck.
    Inp,

    /// `CalculiX` result file.
    Frd,

    /// Nastran displacement result file.
    Op2,

    /// Nastran text punch displacement results (read only).
    Pch,

    /// ASCII or binary triangle surface.
    Stl,

    /// Single-zone SU2 ASCII mesh with named boundary markers.
    Su2,

    /// UNV 2411/2412 ASCII geometry datasets.
    Unv,

    /// NetCDF-3 classic Exodus II mesh and complete scalar results.
    Exodus,
}

impl Format {
    /// Every supported format in the order used by capability listings.
    pub const ALL: [Self; 14] = [
        Self::Bdf,
        Self::Vtu,
        Self::Vti,
        Self::Vox,
        Self::Vtk,
        Self::Msh,
        Self::Inp,
        Self::Frd,
        Self::Op2,
        Self::Pch,
        Self::Stl,
        Self::Su2,
        Self::Unv,
        Self::Exodus,
    ];

    /// Canonical lowercase format name.
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Self::Bdf => "bdf",
            Self::Vtu => "vtu",
            Self::Vti => "vti",
            Self::Vox => "vox",
            Self::Vtk => "vtk",
            Self::Msh => "msh",
            Self::Inp => "inp",
            Self::Frd => "frd",
            Self::Op2 => "op2",
            Self::Pch => "pch",
            Self::Stl => "stl",
            Self::Su2 => "su2",
            Self::Unv => "unv",
            Self::Exodus => "exodus",
        }
    }

    /// Parse a canonical format name, as used by `--from`.
    /// Names are case-insensitive; filename aliases such as `.nas` belong to
    /// [`Self::from_input_path`] instead.
    ///
    /// # Errors
    ///
    /// Returns `E_FORMAT` for an unknown canonical name.
    ///
    /// ```
    /// use caexfer::Format;
    /// assert_eq!(Format::parse("VTU")?, Format::Vtu);
    /// assert_eq!(Format::parse("nas").unwrap_err().code, "E_FORMAT");
    /// # Ok::<(), caexfer::Error>(())
    /// ```
    pub fn parse(name: &str) -> Result<Self> {
        Self::ALL
            .into_iter()
            .find(|format| format.name().eq_ignore_ascii_case(name))
            .ok_or_else(|| Error::new("E_FORMAT", format!("unknown input format {name}")))
    }

    /// Infer a source format from its case-insensitive extension.
    /// `.nas` and `.dat` are accepted BDF input aliases.
    ///
    /// # Errors
    ///
    /// Returns `E_FORMAT` when the suffix has no supported reader.
    ///
    /// ```
    /// use caexfer::Format;
    /// use std::path::Path;
    /// assert_eq!(Format::from_input_path(Path::new("model.NAS"))?, Format::Bdf);
    /// assert_eq!(Format::from_input_path(Path::new("result.op2"))?, Format::Op2);
    /// # Ok::<(), caexfer::Error>(())
    /// ```
    pub fn from_input_path(path: &Path) -> Result<Self> {
        let extension = extension(path);
        match extension.as_str() {
            "nas" | "dat" => Ok(Self::Bdf),
            "exo" | "e" => Ok(Self::Exodus),
            _ => Self::parse(&extension).map_err(|_| {
                Error::new(
                    "E_FORMAT",
                    format!("no reader for extension {extension:?}; use --from"),
                )
            }),
        }
    }

    /// Infer a writable destination from its extension. Only `.bdf` and `.nas`
    /// are BDF output aliases; `.dat` and `.pch` have no writer.
    ///
    /// # Errors
    ///
    /// Returns `E_FORMAT` when the suffix has no supported writer.
    ///
    /// ```
    /// use caexfer::Format;
    /// use std::path::Path;
    /// assert_eq!(Format::from_output_path(Path::new("mesh.NAS"))?, Format::Bdf);
    /// assert_eq!(Format::from_output_path(Path::new("mesh.dat")).unwrap_err().code,
    ///            "E_FORMAT");
    /// # Ok::<(), caexfer::Error>(())
    /// ```
    pub fn from_output_path(path: &Path) -> Result<Self> {
        let extension = extension(path);
        let format = match extension.as_str() {
            "nas" => Some(Self::Bdf),
            "exo" | "e" => Some(Self::Exodus),
            _ => Self::parse(&extension).ok(),
        };
        format
            .filter(|format| *format != Self::Pch)
            .ok_or_else(|| Error::new("E_FORMAT", format!("no writer for extension {extension:?}")))
    }
}

/// Normalize a path suffix for format dispatch without guessing from content.
fn extension(path: &Path) -> String {
    path.extension()
        .and_then(OsStr::to_str)
        .unwrap_or("")
        .to_ascii_lowercase()
}

/// Source selection, limits, and explicit result assumptions.
/// Defaults detect the input format from its extension, bound reads to 256 MiB,
/// and do not silently assert a Nastran result frame
/// or missing rotation values.
#[derive(Debug, Clone, PartialEq)]
#[allow(clippy::struct_excessive_bools)] // Independent conversion assertions and acceptance choices.
pub struct Options {
    /// Override source extension detection.
    pub input_format: Option<Format>,

    /// Companion mesh with original node IDs for OP2/PCH input.
    pub mesh: Option<PathBuf>,

    /// Assert basic-frame coordinates and displacements for a non-BDF result mesh.
    pub assume_basic_frame: bool,

    /// OP2/PCH displacement subcase, if more than one exists.
    pub subcase: Option<i64>,

    /// Zero-based OP2/PCH result step, or FRD step number.
    pub step: Option<usize>,

    /// Maximum source bytes; the same bound applies to a result companion mesh.
    pub max_bytes: usize,

    /// Requested MSH output dialect; `None` uses 4.1.
    pub msh_version: Option<msh::Version>,

    /// Volume cell shape for a closed surface mesh.
    pub infill_shape: Option<InfillShape>,

    /// Positive target size in source coordinate units for the selected infill.
    pub infill_unit_size: Option<f64>,

    /// Extract the external triangle surface of a volume mesh before writing.
    pub surface: bool,

    /// Optional Gmsh executable path for tetrahedralization.
    pub gmsh: Option<PathBuf>,

    /// VTI file whose origin, spacing, and dimensions define mesh sampling.
    pub match_grid: Option<PathBuf>,

    /// Number of Taubin smoothing cycles for voxel-to-STL surfaces (0 disables).
    pub smooth_iterations: usize,

    /// Assert that absent OP2 R1/R2/R3 components are known float zero.
    pub zero_missing_rotations: bool,

    /// Accept reported source and destination omissions when writing a file.
    pub accept_omissions: bool,

    /// Permit a synthetic all-zero OP2 table from a result-free BDF or INP.
    pub accept_synthetic_zero: bool,

    /// Optional geometry companion to write alongside an OP2 result.
    pub mesh_output: Option<PathBuf>,

    /// Fail validation when the projection reports an omission or assumption.
    pub strict: bool,

    /// Accept every reported omission and explicit assumption for file output.
    pub accept_all: bool,
}

impl Default for Options {
    /// Start with conservative read limits and no asserted OP2 assumptions.
    fn default() -> Self {
        Self {
            input_format: None,
            mesh: None,
            assume_basic_frame: false,
            subcase: None,
            step: None,
            max_bytes: 256 * 1024 * 1024,
            msh_version: None,
            infill_shape: None,
            infill_unit_size: None,
            surface: false,
            gmsh: None,
            match_grid: None,
            smooth_iterations: 0,
            zero_missing_rotations: false,
            accept_omissions: false,
            accept_synthetic_zero: false,
            mesh_output: None,
            strict: false,
            accept_all: false,
        }
    }
}