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 rows

A 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 pass

Examples

# 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.

t()

A decoded file: its header, and each element's rows by name.

Functions

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.

Types

row()

@type row() :: Ply.Decoder.row()

A decoded row: property name to value (or list of values).

source()

@type source() :: Path.t() | {:binary, binary()}

A filesystem path, or {:binary, contents} for bytes already in memory.

t()

@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

columns(source, element_name, opts \\ [])

@spec columns(source(), String.t(), keyword()) ::
  {:ok, %{required(String.t()) => binary()}} | {:error, Ply.Error.t()}

Non-raising form of columns!/3.

columns!(source, element_name, opts \\ [])

@spec columns!(source(), String.t(), keyword()) :: %{required(String.t()) => binary()}

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"])

finite?(value)

@spec finite?(term()) :: boolean()

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

info(source)

@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

info!(source)

@spec info!(source()) :: Ply.Header.t()

Same as info/1, raising on error.

read(source, opts \\ [])

@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

read!(source, opts \\ [])

@spec read!(
  source(),
  keyword()
) :: t()

Same as read/2, raising on error.

stream!(source, element_name, opts \\ [])

@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)

write(path, header, data, opts \\ [])

@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}, ...]})