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
CHANGELOG.md
# Changelog
All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] — 2026-08-21
Initial release.
### Added
- **`columns/3` and `columns!/3` take a `:properties` option.** They already
accepted an options list and ignored it, so a caller wanting three of a
splat's sixty columns got all sixty and discarded the rest — most of a
gigabyte of packed binary on a real capture. Unwanted properties are now
walked for their offsets but never sliced. An unknown name is reported as
`:no_such_property` rather than surfacing as a missing key further on.
### Fixed — third review pass
- **A float too small for `float32` was accepted and flushed to zero.** The
exact mirror of the overflow case, which was already rejected with its own
message — a nonzero value was written as `0.0` and the write reported
success. `finite?/1` cannot detect this, because `0.0` is an ordinary float.
Ordinary rounding (`0.1` losing digits) is still accepted; only total loss
of the value is an error.
- **A comment containing a line break fabricated header directives.** The
header is line-oriented, so `comments: ["hi\nelement ghost 99"]` wrote a
header declaring an element the caller never asked for — and the reader
believed it. Comments now reject line breaks, and element and property
names reject whitespace and emptiness, since header fields are
whitespace-delimited.
- A blank line before the `format` line is now tolerated, matching the
existing behaviour after it.
### Fixed — second review pass
The first fix round introduced two of these, which is the argument for
reviewing again after fixing.
- **`stream!/3` leaked a file descriptor on every call.** A file opened with
`:raw` is a `{:file_descriptor, :prim_file, _}` tuple — neither a pid nor a
port — so the close guard never matched and a long-running process would
reach `:emfile`. Descriptors are now closed on the normal path and on the
raising one, which `Stream.resource/3` does not cover.
- **An ASCII float beyond float32 range raised `MatchError`.** Narrowing fails
on the left-hand match, not with the `ArgumentError` the rescue expected, so
the rescue was dead code. Magnitude is checked before converting.
- **`columns/3` on ASCII truncated out-of-range values**, resurrecting the
300-becomes-44 defect on the read side. ASCII integers are now range-checked
against their declared type at parse time, so a text file can no longer hold
a value the binary format cannot express.
- **`Types.check/2` raised `ArithmeticError`** on an integer too large to
convert to a float, and had no float64 bound at all.
- **A failed write left its temporary file behind** when the failure was an
exception rather than an error tuple.
- **A header with mixed terminators was off by one.** The style was locked by
the first line; it is now re-detected per line, which still resolves the
ambiguous lone-CR case against what the previous line did.
- **A header larger than the 1 MiB probe window mis-decoded silently.** An
unterminated `end_header` is no longer accepted from a bounded probe.
- Reading past the end of an in-memory source returned a short read instead of
raising `ArgumentError`, matching `:file.pread/3`.
- `:only` given a bare string returns an error instead of a
`FunctionClauseError`.
### Fixed — from external code review
- `stream!/3` no longer reads the whole file. Binary elements with a
computable offset and a fixed row size are now read incrementally via
`pread`; the previous implementation read everything up front while the
documentation promised the opposite.
- Writes are validated before encoding. Out-of-range integers wrapped
silently (`300` as `uchar` became `44`), oversized floats became infinity,
and a list longer than its count type wrote a length of zero — which then
read back as an empty list, losing the data with no error reported.
- Writes are atomic. A rejected write previously left a truncated file whose
header lied, destroying whatever was already at that path.
- A header terminated by a lone CR whose body begins with `0x0A` is no longer
off by one byte. The two are indistinguishable read in isolation, so the
parser now tracks the terminator style the file has established.
- ASCII `float32` is narrowed to float32 precision, so a value reads the same
from an ASCII file as from a binary one.
- Non-finite and negative list counts return errors instead of raising
`ArithmeticError` or consuming the rest of the file.
- Extra values on an ASCII row, data past the last element, duplicate element
or property names, and text after `end_header` are all reported rather than
silently absorbed.
- Errors from a missing or unreadable file are `:io_error`, not `:not_ply`.
- Row numbers stay absolute across streaming chunks.
- Header strings no longer retain the entire source binary.
- An element positioned past the end of a short file reports `:truncated`
rather than raising `ArgumentError`.
### Changed
- A plain string source is now **always a path**; in-memory contents must be
passed as `{:binary, contents}`. Sniffing for the magic number misrouted
every path beginning with `ply`.
- `read/2` returns a `%Ply{header: _, elements: _}` struct instead of a map
with a magic `:__header__` key.
- `write/4` count mismatches report `:count_mismatch` rather than
`:truncated`, which described the opposite condition.
- `:only` rejects unknown element names and genuinely skips unwanted ones.
- Added `Ply.finite?/1`. Term ordering places every number below every atom,
so `:nan > 0.5` is `true` and comparison-based filters silently keep the
rows they meant to drop.
## [0.1.0] - 2026-08-21
Initial release.
### Added
- `Ply.info/1` — parse only the header, reading at most 1 MiB regardless of
file size. Exposes `data_offset` so callers can read the body themselves.
- `Ply.read/2` — decode a whole file into string-keyed maps, with `:only` to
skip unwanted elements.
- `Ply.stream!/3` — lazily stream one element's rows, with a configurable
chunk size.
- `Ply.columns!/3` — decode a fixed-width element into one packed binary per
property, normalised to little-endian, for handoff to `Nx` without a copy.
- `Ply.write/4` — write ASCII or binary files from any Enumerable of rows.
- ASCII, binary little-endian, and binary big-endian bodies.
- Accepts both specification type names (`char`, `float`, …) and the
explicit-width aliases real tools emit (`int8`, `float32`, …).
- Non-finite floats decode to `:nan` / `:infinity` / `:neg_infinity` rather
than raising, since BEAM floats cannot represent them and real
Gaussian-splat exports contain them.
- Header terminators are located byte-exactly across LF, CRLF, and lone CR,
so binary bodies are not shifted by a stray carriage return.
- `Ply.Error` carries byte offset, element, row, and property.