Reader and writer for the PLY (Stanford Polygon) 3D format.
PLY stores named, counted elements — typically vertex and face — each
with a declared property schema. The header is always ASCII; the body is
ASCII, binary little-endian, or binary big-endian.
Choosing a function
PLY spans two workloads that want opposite handling, so the API splits along that line rather than pretending one call fits both:
Ply.info/1 header only — cheap and safe on any file, any size
Ply.read/2 everything, as maps — for meshes and ordinary files
Ply.stream!/3 one element, lazily — for files too large to hold
Ply.columns!/3 packed binaries per property — for large numeric data
Ply.write/4 write a header plus rowsA mesh is kilobytes and wants read/2. A Gaussian-splat capture is 62
float32 per vertex — 248 bytes each, so a gigabyte file holds about 4.2
million rows and 260 million values — and wants stream!/3 or columns!/3.
Sources
A plain string is always a filesystem path. To read bytes you already
hold, wrap them: {:binary, contents}. PLY files begin with the letters
ply, and so do plenty of paths (plymouth.ply), so guessing between the
two silently opens the wrong thing.
Ply.info("model.ply") # reads the file
Ply.info({:binary, contents}) # reads what you passExamples
# What's in this file?
{:ok, header} = Ply.info("model.ply")
Enum.map(header.elements, & &1.name)
#=> ["vertex", "face"]
# Read a mesh
{:ok, ply} = Ply.read("model.ply")
hd(ply.elements["vertex"])
#=> %{"x" => 0.0, "y" => 0.0, "z" => 0.0}
# Walk a large file without loading it
"capture.ply"
|> Ply.stream!("vertex")
|> Stream.reject(&(&1["opacity"] == :nan))
|> Enum.count()Non-finite floats
BEAM floats cannot represent IEEE NaN or infinity, and real captures contain
both. Rather than crashing, those values decode to the atoms :nan,
:infinity, and :neg_infinity.
Numeric code must expect them, and comparison operators will not do what you
want: BEAM term ordering places every number below every atom, so :nan > 0.5
is true. Filter with Ply.finite?/1 rather than relying on comparisons.
Errors
Reading functions return {:ok, _} | {:error, %Ply.Error{}}; the bang
variants raise instead. Errors carry the byte offset, element, row, and
property, because "malformed float" is not an actionable report on a
gigabyte file.
stream!/3 raises during enumeration — a lazy stream has no way to hand
back an error tuple once it has started producing rows.
Summary
Types
A decoded row: property name to value (or list of values).
A filesystem path, or {:binary, contents} for bytes already in memory.
A decoded file: its header, and each element's rows by name.
Functions
Non-raising form of columns!/3.
Decodes a fixed-width element into one packed binary per property.
Whether a decoded value is an ordinary finite number.
Reads only the header.
Same as info/1, raising on error.
Reads an entire file into memory.
Same as read/2, raising on error.
Streams one element's rows lazily.
Writes a PLY file.
Types
@type row() :: Ply.Decoder.row()
A decoded row: property name to value (or list of values).
A filesystem path, or {:binary, contents} for bytes already in memory.
@type t() :: %Ply{ elements: %{required(String.t()) => [row()]}, header: Ply.Header.t() }
A decoded file: its header, and each element's rows by name.
Functions
@spec columns(source(), String.t(), keyword()) :: {:ok, %{required(String.t()) => binary()}} | {:error, Ply.Error.t()}
Non-raising form of columns!/3.
Decodes a fixed-width element into one packed binary per property.
This is the path for large numeric data. Instead of millions of boxed floats
it returns raw bytes per property, ready for Nx.from_binary/2:
columns = Ply.columns!("capture.ply", "vertex")
Nx.from_binary(columns["x"], :f32)Output is little-endian regardless of the source file's byte order, so consumers get one predictable layout.
Raises for elements containing list properties — they have no columnar layout.
Options
:properties— property names to extract. Others are skipped without building a binary for them. A Gaussian splat carries around sixty columns and a caller usually wants three, so this is the difference between a few megabytes and most of a gigabyte:Ply.columns!("capture.ply", "vertex", properties: ["x", "y", "z"])
Whether a decoded value is an ordinary finite number.
Non-finite floats decode to atoms, and comparing an atom against a number does not raise in Elixir — it returns a confident wrong answer, since every number sorts below every atom. This is the guard to use instead.
iex> Ply.finite?(1.5)
true
iex> Ply.finite?(:nan)
false
@spec info(source()) :: {:ok, Ply.Header.t()} | {:error, Ply.Error.t()}
Reads only the header.
Cheap and safe on any file: at most 1048576 bytes are read regardless of file size.
{:ok, header} = Ply.info("capture.ply")
header.format #=> :binary_little_endian
header.data_offset #=> 411
@spec info!(source()) :: Ply.Header.t()
Same as info/1, raising on error.
@spec read( source(), keyword() ) :: {:ok, t()} | {:error, Ply.Error.t()}
Reads an entire file into memory.
Suitable for meshes and ordinary files. For very large numeric elements,
prefer stream!/3 or columns!/3 — see the module docs.
Options
:only— element names to decode. Others are skipped without building their rows, though variable-width elements must still be walked because PLY has no offset table. Unknown names are an error rather than a silently empty result.
Examples
{:ok, ply} = Ply.read("cube.ply", only: ["vertex"])
length(ply.elements["vertex"])
#=> 8
Same as read/2, raising on error.
@spec stream!(source(), String.t(), keyword()) :: Enumerable.t()
Streams one element's rows lazily.
Binary files are read incrementally: only :chunk_size rows are held at a
time, and the file is opened once and closed when enumeration ends. Reaching
element N requires knowing where it starts, which is arithmetic when every
preceding element is fixed width, and a sequential walk otherwise.
Raises Ply.Error during enumeration rather than returning an error
tuple, because a stream cannot signal failure once it has begun.
"capture.ply"
|> Ply.stream!("vertex")
|> Stream.map(& &1["x"])
|> Enum.take(10)
@spec write( Path.t(), Ply.Header.t(), %{required(String.t()) => Enumerable.t()}, keyword() ) :: :ok | {:error, Ply.Error.t() | File.posix()}
Writes a PLY file.
Takes an explicit header because PLY requires element counts and a property schema up front — neither can be inferred reliably from data (map key order is not a schema).
data maps element names to their rows. Rows may be any Enumerable, so
large writes can stream. Values are range-checked before encoding, and the
file is written atomically: a validation failure leaves any existing file at
path untouched.
header = Ply.Header.build(:binary_little_endian, [
Ply.Element.new("vertex", 2, [
Ply.Property.scalar("x", :float32),
Ply.Property.scalar("y", :float32)
])
])
Ply.write("out.ply", header, %{"vertex" => [%{"x" => 1.0, "y" => 2.0}, ...]})