Ply

Hex.pmDocs

Reader and writer for the PLY (Stanford Polygon) 3D format in Elixir. ASCII and binary, both byte orders, with lazy streaming for files too large to hold in memory.

No runtime dependencies. PLY parsing is binary pattern matching and integer arithmetic; anything this pulled in would be a liability for your project rather than a convenience.

def deps do
[{:ply, "~> 0.1"}]
end

Why this exists

PLY is small, old, well-specified, and still everywhere — 3D scanning, 3D printing, research datasets, and the output format of Gaussian-splat training. There was no Elixir library for it.

Usage

PLY spans two workloads that want opposite handling, so the API splits along that line rather than pretending one call fits both.

FunctionUse for
Ply.info/1Header only — cheap and safe on any file, any size
Ply.read/2Everything, as maps — meshes and ordinary files
Ply.stream!/3One element, lazily — files too large to hold
Ply.columns!/3Packed binaries per property — large numeric data
Ply.write/4Write a header plus rows

Inspect a file without reading it

{:ok, header} = Ply.info("model.ply")
header.format #=> :binary_little_endian
Enum.map(header.elements, & &1.name) #=> ["vertex", "face"]
header.data_offset #=> 269

At most 1 MiB is read regardless of file size.

Read a mesh

{:ok, ply} = Ply.read("cube.ply")
hd(ply.elements["vertex"]) #=> %{"x" => 0.0, "y" => 0.0, "z" => 0.0, "red" => 255, ...}
hd(ply.elements["face"]) #=> %{"vertex_indices" => [0, 1, 2, 3]}
ply.header.format #=> :ascii

Pass only: ["vertex"] to skip elements you do not need.

Stream a large file

"capture.ply"
|> Ply.stream!("vertex")
|> Stream.filter(&Ply.finite?(&1["opacity"]))
|> Enum.count()

Extract packed columns

For large numeric elements, decoding millions of boxed floats is the wrong move. columns!/3 returns raw bytes per property, ready to hand to Nx without a copy:

columns = Ply.columns!("capture.ply", "vertex")
byte_size(columns["x"]) #=> 4 bytes × vertex count
Nx.from_binary(columns["x"], :f32) # zero-copy

Output is little-endian regardless of the source file's byte order, so you get one predictable layout. Big-endian inputs are byte-swapped on the way out.

Ask for only the properties you need. A Gaussian splat carries around sixty columns and most callers want three, so on a gigabyte capture this is the difference between a few megabytes of output and most of a gigabyte:

Ply.columns!("capture.ply", "vertex", properties: ["x", "y", "z"])

Write a file

PLY requires element counts and a property schema up front, so writing takes an explicit header — map key order is not a schema.

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}, %{"x" => 3.0, "y" => 4.0}]
})

Rows may be any Enumerable, so large writes can stream.

Sources

A plain string is always a filesystem path. To read bytes you already hold, wrap them:

Ply.info("model.ply") # reads the file
Ply.info({:binary, contents}) # reads what you pass

PLY files begin with the letters ply and so do plenty of paths (plymouth.ply), so guessing between the two opens the wrong thing.

Things worth knowing

Non-finite floats decode to atoms. BEAM floats cannot represent IEEE NaN or infinity — a bit-syntax match against a NaN pattern raises MatchError rather than producing a value. Since real Gaussian-splat exports contain NaN, those decode to :nan, :infinity, and :neg_infinity instead of crashing.

Comparisons will mislead you here: BEAM term ordering puts every number below every atom, so :nan > 0.5 is true and a naive filter keeps exactly the rows it meant to drop. Use Ply.finite?/1.

Writes are validated and atomic. Values are range-checked before encoding, because binary encoding silently truncates — 300 as uchar becomes 44, and a 256-item list with a uchar count writes a length of zero that reads back as an empty list. Output is written to a temporary file and renamed into place, so a rejected write leaves any existing file untouched.

Property names stay strings. They come from the file and are arbitrary, so converting them to atoms would let an untrusted file exhaust the VM's atom table.

Type aliases are accepted. The specification defines eight scalar types under C names (char, uchar, float, double, …). Real tools also emit explicit-width aliases (int8, uint8, float32, …), which are not in the spec. Both are accepted and normalised; char is a signed byte, not a character.

Reaching a late element is not free. PLY has no offset table, so reaching element N means walking everything before it. That is arithmetic for binary files whose preceding elements are all fixed width, and a full decode otherwise — including for every ASCII file, where a "fixed-width" schema still occupies a variable number of bytes because its values are text.

stream!/3 reads incrementally only when it can compute where the element starts and the rows have a fixed size — the common case for a large capture. ASCII bodies and variable-width elements are read in full first, because neither can be seeked.

Errors carry a location.{:error, %Ply.Error{}} records the byte offset, element, row, and property, because "malformed float" is not an actionable report on a gigabyte file. Bang variants raise instead. stream!/3 raises during enumeration — a lazy stream cannot hand back an error tuple once it has started producing rows.

Development

mix deps.get
mix test
mix test --cover
mix precommit # compile --warnings-as-errors, deps check, hex.audit,
# format, credo --strict, dialyzer, test

Test fixtures are hand-specified rather than round-tripped through this library's own writer — a fixture produced by the code under test proves nothing. Regenerate with mix run test/support/fixture_generator.exs.

License

MIT