Ply
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.
| Function | Use for |
|---|---|
Ply.info/1 | Header only — cheap and safe on any file, any size |
Ply.read/2 | Everything, as maps — meshes and ordinary files |
Ply.stream!/3 | One element, lazily — files too large to hold |
Ply.columns!/3 | Packed binaries per property — large numeric data |
Ply.write/4 | Write 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