Modules | C++

I/O

File I/O operations for reading and writing mesh data.

The I/O module provides functions for reading and writing mesh data in STL format (both ASCII and binary) and OBJ format (ASCII).

Include the module with:

#include <trueform/io.hpp>

Reading

STL Files

Read an STL file and return triangular polygons:

#include <trueform/io.hpp>

// Read STL file (Index defaults to int)
auto polygons = tf::read_stl("model.stl");
// Returns: tf::polygons_buffer<int, float, 3, 3>

// Access polygon data
auto faces = polygons.faces();  // (N, 3) connectivity
auto points = polygons.points();     // (M, 3) vertex positions

// For large meshes (> 2 billion vertices), specify int64_t
auto large_polygons = tf::read_stl<int64_t>("large_model.stl");

Features:

  • Automatically detects binary vs ASCII format
  • Deduplicates vertices during loading via tf::clean::polygon_soup
  • Returns 3D triangular polygons with float coordinates

OBJ Files

Read an OBJ file and return polygons:

#include <trueform/io.hpp>

// Read with dynamic ngon (Index defaults to int)
auto dynamic_mesh = tf::read_obj("model.obj");
// dynamic_mesh: tf::polygons_buffer<int, float, 3, tf::dynamic_size>

// Read triangular mesh
auto triangles = tf::read_obj<3>("model.obj");
// triangles: tf::polygons_buffer<int, float, 3, 3>

// Read quad mesh
auto quads = tf::read_obj<4>("quad_model.obj");
// quads: tf::polygons_buffer<int, float, 3, 4>

// Access polygon data
auto faces = triangles.faces();  // (N, 3) connectivity
auto points = triangles.points();     // (M, 3) vertex positions

// For large meshes (> 2 billion vertices), specify int64_t
auto large_triangles = tf::read_obj<int64_t, 3>("large_model.obj");
// large_triangles: tf::polygons_buffer<int64_t, float, 3, 3>

Features:

  • Reads ASCII OBJ format
  • Converts 1-based OBJ indices to 0-based
  • Only reads vertex positions (ignores normals and texture coordinates)
  • Returns 3D polygons with float coordinates

Every path-taking tf::read_obj overload maps the file rather than copying it, so the file must not be modified for the duration of the read. The overloads taking a tf::range of bytes read the memory you give them and carry no such precondition.

Complete mode

For full-attribute payloads (positions, normals, texture coordinates, groups, objects), pass the tf::complete tag. The reader deduplicates unique (v, vt, vn) triplets so all per-vertex buffers are aligned [0, n_pts):

#include <trueform/io.hpp>

// Returns tf::obj_file<int>
auto f = tf::read_obj("model.obj", tf::complete);

auto points   = f.polygons.points();   // (M, 3)
auto faces    = f.polygons.faces();    // dynamic-size
auto normals  = f.normals;             // (M, 3) or empty
auto textures = f.textures;            // (M, 2) or empty

// Per-face label arrays + name lists.
auto &face_groups  = f.face_groups;    // [n_faces] or empty
auto &group_names  = f.group_names;    // [n_groups]
auto &face_objects = f.face_objects;   // [n_faces] or empty
auto &object_names = f.object_names;   // [n_objects]

// For large meshes (> 2 billion vertices), specify int64_t
auto large = tf::read_obj<int64_t>("large.obj", tf::complete);
// large: tf::obj_file<int64_t>

// State the arity when every face has it: the faces come back blocked and
// the reader stops discovering the corner count.
auto tris = tf::read_obj<3>("model.obj", tf::complete);
// tris: tf::obj_file<int, float, 3>

Layout:

  • points, normals, textures are aligned [0, n_pts) — the same vertex id indexes into all three.
  • A position with two distinct normals or texture coordinates in the file becomes two distinct output vertices (texture seams, sharp edges).
  • face_groups[i] / face_objects[i] index into group_names / object_names.
  • group_* and object_* arrays are empty when the file has no g / o directives.

Behavior:

  • All-or-nothing per attribute: the first f line locks the format mode (v, v/vt, v//vn, v/vt/vn); inconsistent face refs return an empty obj_file.
  • Multi-name g a b c lines: only the first name is kept.
  • Faces emitted before the first g / o get an implicit "default" label at index 0.
  • mtllib, usemtl, s, # comments, and unknown directives are silently skipped.
  • Negative (relative) indices are not supported — files using f -3 -2 -1 or f 1/-1/... return an empty obj_file.
  • A stated arity is a contract: tf::read_obj<Ngon>(path, tf::complete) returns an empty obj_file when any face has a different corner count. Without one, faces keep their own sizes and a face with fewer than three corners returns an empty obj_file.

NIfTI Volumes

tf::read_nifti<T> reads a NIfTI-1 medical volume — .nii or .nii.gz — into a tf::volume_buffer whose samples stay in the type the call states, float unstated:

#include <trueform/io.hpp>
#include <trueform/volume.hpp>

auto ct = tf::read_nifti<std::int16_t>("scan.nii.gz");
if (!ct) {
  // ct.status names the refusal: truncated bytes, a foreign magic, a
  // two-file header pair, an unsupported dtype, a real fourth dimension,
  // a self-contradictory header (a nonzero bitpix against its datatype).
  return;
}
auto mesh = ct.posed
                ? tf::make_isosurface(ct.volume.volume() | tf::tag(ct.frame),
                                      300.f)
                : tf::make_isosurface(ct.volume.volume(), 300.f);

The file holds one sampled function with one placement, and the read splits that placement canonically: spacing and any axis-aligned translation land on the grid, while an orientation the grid cannot absorb comes back in frame with posed set — a frame tag transforms per point, so consume the volume bare when posed is false. reflecting says the pose flips handedness, and a non-orthogonal sform yields a non-rigid frame, so metric consumers must not assume rigidity. The grid is in the file's stated spatial unit (millimetres in practice), unconverted.

The samples are bit-exact when T is the file's own dtype and no scl scaling is stated; otherwise they convert through one cast, computed in double. tf::read_nifti_header answers the file's facts — dtype, dims, spacing, units, posed, the scl fields verbatim — without reading the samples, and on a gzipped file it inflates only the head, so dispatching on a large scan's dtype costs under a millisecond. A gzipped file must hold one member; trailing input refuses as truncated.

Memory-mapped files

tf::io::mapped_file owns a read-only operating-system mapping and exposes its bytes without an intermediate file copy:

#include <trueform/io.hpp>

#include <string_view>

tf::io::mapped_file file("model.obj");
if (!file) {
  // The path could not be opened, or the file was empty or could not be mapped.
  return;
}

std::string_view bytes(file.data(), file.size());

The owner is move-only. Destroying it, or assigning another mapping to it, releases its native resources. The file must not be modified while the mapping is alive because the mapped bytes are not a snapshot of a concurrently changed file.

Writing

STL Files

Write polygons to binary STL format:

#include <trueform/io.hpp>

// Write polygons to file (.stl extension auto-appended if missing)
auto polygons = tf::read_stl("input.stl");
bool success = tf::write_stl(polygons, "output.stl");

Requirements:

  • Must be 3D triangular polygons
  • Normals are written if available, otherwise zero normals are used

With transformations: tag the view, not the buffer.

#include <trueform/io.hpp>
#include <trueform/random.hpp>

auto polygons = tf::read_stl("input.stl");
auto frame = tf::random_frame<float, 3>();

tf::write_stl(polygons.polygons() | tf::tag(frame), "translated.stl");

// A transformation tags just as a frame does
auto transform = tf::make_transformation_from_translation(
    tf::vector<float, 3>{10.f, 0.f, 5.f});
tf::write_stl(polygons.polygons() | tf::tag(transform), "translated.stl");
Performance: Files < 500MB use parallel buffered writing, files ≥ 500MB use sequential streaming.

OBJ Files

Write polygons to ASCII OBJ format:

#include <trueform/io.hpp>

// Write dynamic mesh (any polygon sizes)
auto mesh = tf::read_obj("input.obj");
bool success = tf::write_obj(mesh, "output.obj");

// Write triangular mesh (.obj extension auto-appended if missing)
auto triangles = tf::read_obj<3>("input.obj");
tf::write_obj(triangles, "output.obj");

// Write quad mesh
auto quads = tf::read_obj<4>("quad_input.obj");
tf::write_obj(quads, "quad_output.obj");

With transformations: tag the view, not the buffer.

#include <trueform/io.hpp>
#include <trueform/random.hpp>

auto triangles = tf::read_obj<3>("input.obj");
auto frame = tf::random_frame<float, 3>();

tf::write_obj(triangles.polygons() | tf::tag(frame), "translated.obj");

NIfTI Volumes

tf::write_nifti(volume, path) writes a volume to .nii, or .nii.gz when the path says so; a posed volume passes its frame, composed with the grid into the file's sform. Samples are written in their own type, unscaled, the spatial unit stated as millimetres. The write refuses — false, nothing created — when NIfTI-1 cannot represent the volume (an extent past 32767). A gzip member's trailer size is modulo 4 GiB, so a payload past that writes a legal file this library's own reader refuses.

tf::write_nifti(ct.volume, ct.frame, "resampled.nii.gz");
For Python bindings, see the Python I/O documentation.