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. Read-only, core specification.

Added

  • Gltf.read/2 and Gltf.parse/2.gltf JSON and .glb containers, with the format detected from content rather than the extension.
  • Gltf.accessor/2 and Gltf.accessor_binary/2 — decoded elements, or tightly packed little-endian bytes with any interleaving removed and any sparse block applied.
  • Gltf.attribute/4, Gltf.attribute_binary/4, Gltf.indices/3, Gltf.primitives/2 — mesh access by name.
  • Gltf.info/1 — a summary that counts collections without decoding, and reports extensions_required so a caller can refuse a file whose geometry it cannot actually read.
  • Gltf.bounds/2 — the accessor's own declared min/max when present, computed otherwise. Non-finite components are skipped rather than allowed to win a comparison they should not be in.
  • Gltf.NodeT * R * S composition, column-major matrices, and world_transforms/2 with cycle detection.
  • Gltf.Buffer — embedded data: URIs, the GLB binary chunk, and external files confined to the asset's own directory unless permitted otherwise.

Notes on what this gets right

Four behaviours account for most of the difference between a glTF reader that works and one that appears to:

  • Interleaved byteStride is honoured, so attributes sharing a buffer view decode separately rather than as a mixture. Verified by decoding Khronos's Box and BoxInterleaved — the same cube in both layouts — and asserting the results are identical.
  • Sparse accessors are applied, including the case with no base buffer view, where the base is defined to be zeros.
  • Normalised integers map onto 0.0..1.0 and -1.0..1.0, with the signed form clamped at -1.0 as the specification requires.
  • Non-finite floats decode to :nan, :infinity and :neg_infinity instead of crashing. The BEAM has no float term for them, so the bit-syntax match fails rather than raising, and a reader that does not expect that dies on files every other viewer opens.

Fixed — first review pass

Found by an external review panel and reproduced before acting.

  • A symbolic link defeated the path confinement. Path.expand/1 collapses .. lexically; the filesystem does not. A symlink inside the asset's own directory pointing anywhere at all passed the check while File.read/1 followed it — with allow_outside_base at its default. An asset bundle carrying a .gltf next to a symlink is an ordinary way to receive one. Links are now resolved, at every path component, before the comparison. The same bug refused paths that were genuinely inside, since the lexical collapse cancels a symlink hop.
  • Scene traversal was exponential. Cycle detection tracked only the current ancestor chain, so a node reachable twice was walked twice. A chain where each node lists the next one twice is perfectly acyclic and costs 2ⁿ: a 551-byte document took 71 seconds at depth 24, doubling per level. The specification requires the node hierarchy to be a forest, so a second parent is now refused — which is both correct and linear.
  • count was unbounded on an accessor with no bufferView. Such an accessor is materialised entirely from zeros, so a seventy-byte document could ask for gigabytes. Bounded, and configurable through :max_unbacked_elements.
  • Malformed documents raised instead of returning errors. A non-integer byteOffset reached the bounds arithmetic before its own guard and raised ArithmeticError; a collection that was an object rather than an array raised BadMapError; a matrix of sixteen strings passed the length check and raised. The contract is {:ok, _} | {:error, t} on untrusted input, and anything that raises is a caller who cannot defend themselves.
  • Negative indices silently resolved to the last element, because Enum.at/2 counts backwards. "buffer": -1 read the last buffer and produced geometry that was wrong without being invalid.
  • "normalized": null normalised. Only the literal false was treated as false, so every other value — including the routine JSON emission for an unset field — inverted the accessor's meaning.
  • GLB structural constraints went unchecked: a chunk length not divisible by four, a BIN chunk before the JSON chunk, duplicate chunks of either kind (last silently won), and bytes past the header's declared total length. That last one is a smuggling primitive — a validator honouring the declared length and this parser would disagree about what the file contains.
  • Sparse blocks were under-validated: count: 0, indices that were not strictly increasing (duplicates resolved last-wins), and signed or float index component types were all accepted.
  • buffer.byteLength was treated as a minimum rather than a bound, leaving a GLB chunk's padding and an over-long external file's tail addressable.

Fixed — second review pass

Two of these the first fix round introduced. A fuzz over 51 field paths × 17 hostile values × 9 entry points went from 834 raises to 106 after that round, and to none of the reachable ones after this.

  • A GLB header declaring fewer than twelve total bytes raised. The truncation added to stop content smuggling was guarded above but not below, so binary_part got a negative size — a twelve-byte file crashed the parser.
  • The symlink resolver was quadratic in path components, recursing one at a time and re-splitting the whole prefix each level: a sixteen-kilobyte uri pinned a scheduler for twenty-two seconds. That is the same denial of service removed from the scene traversal, reintroduced by the function written to close the symlink hole. It walks once now, and refuses a path more than 256 components deep, since no real directory is.
  • The byteLength bound never applied to a GLB. The BIN chunk path returned before the check ran, so a chunk's padding stayed addressable while the byte-identical .gltf correctly refused.
  • The traversal was quadratic, not linear as claimed. Nodes were reached through Enum.at/2 on a list, twice each, and the ancestor chain was a list whose membership test costs O(depth). Both are constant-time lookups now.
  • :max_unbacked_elements bounded the count, not the memory. An element is 1 to 64 bytes, so a 94-byte document could still materialise 64 MB. Replaced by :max_unbacked_bytes.
  • The error path itself raised, and could be forged. A just-rejected untrusted index was interpolated rather than inspected: a map raised Protocol.UndefinedError out of the error branch, and a JSON array of integers is an Erlang charlist — so it injected attacker-chosen text into a message that gets logged.
  • Collection elements were unguarded, so a string where an object belonged reached Map.get and raised BadMapError from attribute/4, indices/3 and bounds/2.
  • bounds/2 returned whatever the document declared, so {"min": ["a"]} came out of a function whose spec promises [number()].
  • "JSON chunk first" was enforced only against a BIN chunk, so an unknown chunk could precede it — a file this accepted and a conformant reader would refuse.
  • Node.world_transforms/2 and Buffer.resolve/2 raised on a non-map document where their siblings returned an error; caller-supplied :buffers was only checked to be a list; and a bad scenes[0].nodes reported that "children" was wrong.

Fixed — third review pass

  • The memory bound got looser for the case it names. :max_unbacked_bytes measured the packed size, but accessor/2 returns a list — roughly 24 bytes per element whatever the component is. A SCALAR int8 accessor sat exactly on a 16 MB bound and grew the heap by nearly 300 MB, where the element limit it replaced had capped the same case. It now bounds whichever of the two output shapes costs more.
  • A symlink target's .. was collapsed lexically, so link/../file resolved against where the link was written rather than where it points — and the file actually opened was not the one the path named. Confinement held throughout (the collapsed path is inside the base), so this returned the wrong bytes rather than escaping.
  • The path-depth limit applied only to the literal uri, so a link whose target was a thousand-component path was followed anyway. Components are counted across the whole walk now.
  • The symlink hop guard let a chain terminating on its 33rd link through.

Not included

Writing, Draco and meshopt decompression, and extension interpretation.