All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

[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.