# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Felix Faber /
# Otto von Guericke University Magdeburg, Thermal Process Engineering
"""
Reading and writing of mesh and geometry files.
===============================================
"""
from pathlib import Path
from typing import Mapping, Sequence
import numpy as np
from porescene.utility import Mesh
[docs]
def mesh2obj(pth_obj: Path, mesh: Mesh | Mapping[int, Mesh] | Sequence[Mesh]) -> Path:
"""
Writes one or more meshes to a Wavefront ``.obj`` file in text format.
Parameters
----------
pth_obj : Path
Output path of the OBJ file.
mesh : Mesh | Mapping[int, Mesh] | Sequence[Mesh]
The mesh(es) to write. Pass a single :class:`~porescene.utility.Mesh`, or
a collection of them to store as separate OBJ objects in one file -- e.g.
the ``{label: Mesh}`` mapping returned by
:func:`volume2mesh(..., per_label=True) <porescene.utility.volume2mesh>`. A
mapping is written in ascending key order; a sequence keeps its order. Each
mesh's ``name`` becomes an ``o`` record, and vertices are concatenated with the
running index offset that OBJ's shared, file-global vertex numbering requires.
Faces hold **0-based** vertex indices (as produced by
:func:`~porescene.utility.volume2mesh`); OBJ indices are 1-based, so the values
are offset accordingly on write.
Returns
-------
Path
File path of the written OBJ file.
"""
if isinstance(mesh, Mesh):
meshes: Sequence[Mesh] = [mesh]
elif isinstance(mesh, Mapping):
meshes = [mesh[key] for key in sorted(mesh)]
else:
meshes = list(mesh)
fmt_vertices = "v %.10g %.10g %.10g"
with pth_obj.open("w", encoding="utf-8", newline="\n") as file:
file.write("# Written by PoreScene\n")
offset = 0
for m in meshes:
vertices = m.vertices
faces = m.faces
if vertices.ndim != 2 or vertices.shape[1] != 3:
raise ValueError("vertices must have shape (N, 3)")
if faces.ndim != 2:
raise ValueError("faces must have shape (M, K)")
fmt_faces = "f " + " ".join(["%d"] * faces.shape[1])
file.write(f"o {m.name}\n")
np.savetxt(file, vertices, fmt=fmt_vertices)
np.savetxt(file, faces + offset + 1, fmt=fmt_faces)
offset += len(vertices)
return pth_obj
[docs]
def mesh2ply(pth_ply: Path, mesh: Mesh, binary: bool = True) -> Path:
"""
Writes a mesh to a Stanford ``.ply`` file.
Parameters
----------
pth_ply : Path
Output path of the PLY file.
mesh : Mesh
The mesh to write. Its faces hold **0-based** vertex indices (e.g. from
:func:`porescene.utility.volume2mesh`); PLY indices are 0-based too, so they are
written unchanged. The mesh ``name`` is written as a PLY comment.
binary : bool, optional
If ``True`` (default), the vertex and face data are written as packed
little-endian binary -- far smaller and faster to load than the ``ascii``
variant produced when ``False``. Defaults to ``True``.
Returns
-------
Path
File path of the written PLY file.
"""
vertices = mesh.vertices
faces = mesh.faces
if vertices.ndim != 2 or vertices.shape[1] != 3:
raise ValueError("vertices must have shape (N, 3)")
if faces.ndim != 2:
raise ValueError("faces must have shape (M, K)")
n_corners = faces.shape[1]
header = "\n".join(
[
"ply",
f"format {'binary_little_endian' if binary else 'ascii'} 1.0",
"comment Written by PoreScene",
f"comment name {mesh.name}",
f"element vertex {len(vertices)}",
"property float x",
"property float y",
"property float z",
f"element face {len(faces)}",
"property list uchar int vertex_indices",
"end_header",
"",
]
)
if binary:
face_dtype = np.dtype([("count", "u1"), ("indices", "<i4", (n_corners,))])
faces_bin = np.empty(len(faces), dtype=face_dtype)
faces_bin["count"] = n_corners
faces_bin["indices"] = faces
with pth_ply.open("wb") as file:
file.write(header.encode("ascii"))
file.write(np.ascontiguousarray(vertices, dtype="<f4").tobytes())
file.write(faces_bin.tobytes())
else:
faces_ascii = np.hstack([np.full((len(faces), 1), n_corners, dtype=int), faces])
with pth_ply.open("w", encoding="utf-8", newline="\n") as file:
file.write(header)
np.savetxt(file, vertices, fmt="%.10g %.10g %.10g")
np.savetxt(file, faces_ascii, fmt="%d")
return pth_ply