Packages
Reader and writer for the PLY (Stanford Polygon) 3D format — ASCII and binary, with lazy streaming for large files. Pure Elixir, no NIFs, no dependencies.
Current section
Files
Jump to
Current section
Files
README.md
# Ply
[](https://hex.pm/packages/ply)
[](https://hexdocs.pm/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.
```elixir
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
```elixir
{: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
```elixir
{: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
```elixir
"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:
```elixir
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:
```elixir
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.
```elixir
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:
```elixir
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
```bash
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