# Ply

[![Hex.pm](https://img.shields.io/hexpm/v/ply.svg)](https://hex.pm/packages/ply)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](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
