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.

Fixed — first review pass

Found by an external review panel and verified against the real converter before acting; roughly a third of what the panel reported turned out to be wrong, so each item below was reproduced first.

  • A stale file at the destination was accepted as fresh output. A zero exit status is not proof that an invocation wrote anything — given an argument it reads as a flag, the tool prints something and exits zero without touching its input — so a file left by an earlier run was reported as this run's result. Output is now staged through a uniquely named temporary sibling and renamed into place.
  • A second conversion to the same path always failed. splat-transform refuses to overwrite, so any retry was permanently poisoned by its own first attempt's output. Staging fixes this too.
  • A timed-out conversion left the converter running. Task.shutdown/2 only kills the process doing the waiting; the child kept its CPU and kept writing. The command now runs under a Port, so the OS process can actually be signalled — and, incidentally, a crash in the runner no longer takes its caller down with it, which the linked Task did.
  • prepare/3 silently discarded every camera option. :percentile, :fov, :margin and :elevation were accepted and dropped on the floor.
  • The framing distance used tan where the geometry needs sin. The limiting ray is tangent to the bounding sphere, so d = r / sin(θ). The camera sat closer than the tangent line and clipped the scene — masked by the default margin, visible at margin: 0.0 or a wide field of view.
  • The derived field of view was computed and never sent to the renderer. The camera was solved for 50° and rendered at the tool's default 60°.
  • :percentile and :fov were unvalidated. A percentile at or above 0.5 returned a low above its high; a negative one returned nil and raised several frames later; fov: 0.0 divided by zero and fov: 200.0 placed the camera inside the scene.
  • Non-finite values were filtered per column, not per row. --filter-nan drops whole splats, so a row of {NaN, 1.0e9, 0.0} was absent from the converted file while still contributing its enormous y to the bounds, and the camera framed geometry that was not there.
  • The splat classifier counted property prefixes. scale_cat, scale_dog, scale_eel satisfied the scale requirement, and a file with float64 positions was admitted and then decoded as float32 pairs, producing bounds that were wrong without looking wrong. Names are now matched exactly and positions must be float32.
  • :name could escape the output directory. name: "../../elsewhere" wrote outside the directory the caller named.
  • A failed preview left the converted file behind, so the next attempt was a retry over a previous run's output.
  • extract_json/1 took the first { anywhere in the output, which found the banner's decoration rather than the payload, and returned only the first element of a top-level array. It now tries every balanced candidate and returns the largest that actually parses — decoded, rather than as a substring for the caller to parse again.
  • The reported command could not be re-run. Arguments were joined unquoted, so a path containing a space came back as two arguments.
  • A preview was rendered from the source PLY, meeting the dirty data the conversion had just cleaned. It now renders from the converted file.
  • Column extraction now asks ply for only the three position properties rather than decoding all sixty and discarding fifty-seven.

Fixed — second review pass

Three of these were created by the first fix round, which is the argument for reviewing again after fixing rather than only before.

  • A multi-part output name silently lost its format. Path.extname/1 sees only the last extension, so staging renamed out.compressed.ply to out.compressed.tmp-N.ply — which the converter reads as a plain .ply. Zero exit status, a file at the right path, and no compression. Output is now staged through a temporary directory, so the tool sees the exact name the caller asked for; that also collects the .webp sidecars a meta.json conversion writes, which a renamed sibling could never have moved.
  • A failed preview destroyed the converted file. Staging made the conversion overwrite the destination, and the preview failure path then deleted it — so a deliverable that existed before the call was replaced and removed. A preview failure no longer fails the conversion at all, which is what :preview's own documentation always said it should do; the reason arrives in asset.preview_error.
  • sh_bands reported the request rather than the file. -H n caps the bands, so asking for 3 from a file carrying 0 still yields 0 — and this is the number the docs tell callers to persist.
  • extract_json/1 was quadratic. It began a fresh balanced walk at every { and [, and an unmatched opener scanned to end of output each time: a 1.1 MB log with a stray brace on 5% of its lines took 5.6 seconds. It is now a single stack-based pass.
  • A timed-out conversion retained everything it had printed. yes under a 300 ms timeout produced a 64 MB error struct. Only the tail is ever shown, so only the tail is kept.
  • A timeout left port messages in the caller's mailbox, where they surface as unexpected handle_info callbacks in a GenServer or Oban worker; and a caller that traps exits blocked until the deadline and got a bogus timeout, because the loop had no clause for the port's EXIT.
  • Every timeout reported "exceeded 0s" — the span measured the time remaining when it fired rather than the budget that was exceeded.
  • A temporary file survived a caller killed mid-run, accumulating with nothing to collect it, and its name drew on a node-local counter that two releases in the same directory pick identically.
  • A malformed :camera raised a FunctionClauseError where every other invalid option in the module returns an error tuple.

Fixed — third review pass

Two of these were introduced by the second fix round, in the two subsystems it was rewriting. The pattern is now confirmed three times over.

  • The retained output kept the start, not the tail. The accumulator is newest-first, so flattening it without reversing gave the output in reverse block order, and taking a suffix of that kept the oldest bytes — discarding the converter's actual complaint, which is the only reason the output is attached to an error at all. The trimmed value also froze on the first retained region and was never refreshed. The comment above it claimed the opposite.
  • A failed promotion left an asset from two different runs. Files were renamed one at a time with no rollback, so a failure part way through left some of this run's output and some of the last one's at the destination — reported as an error, and indistinguishable on disk from a good result. Destinations are now checked before anything moves, and anything already moved is put back.
  • File.ls! raised out of convert/3, whose whole contract is an error tuple.
  • The mailbox fix covered only the case that cannot happen. A port links to its owner, so a caller that traps exits — a GenServer or an Oban worker, the stated motivation — receives {:EXIT, port, :normal} after an ordinary successful run. Draining happened only on the timeout path, and the test used a non-trapping process, so it passed vacuously.
  • div(timeout, 1000) reported "exceeded 0s" for any sub-second budget.
  • A malformed :camera still raised, just with a different exception than before, while the comment above it said it must not raise at all. It now returns an error tuple like every other invalid option.
  • One stray " poisoned the whole JSON scan, because string state was carried across the entire output. JSON forbids a raw newline inside a string, so a newline now ends that state.
  • A fixed cap of twenty candidates could step past the payload — thirty steps of an ASCII progress bar are thirty small candidates. Bounded by cumulative bytes instead.
  • add_chunk/2 was quadratic in chunk count (IO.iodata_length/1 per chunk); it carries a running size now.
  • timeout: :infinity raised an ArithmeticError.
  • Added sweep_staging/1, because try/after does not run when the calling process is killed — the comment claiming it did was wrong, and the leftover is a hidden directory inside the caller's own output directory.
  • Corrected the module documentation, which still described the first fix round's renamed-sibling mechanism, and now states plainly that convert/3 replaces the destination and does not remove an earlier run's other outputs.

Fixed — fourth review pass

  • A failed promotion destroyed the previous run's file. The rollback added last round moved this run's files back into the staging directory — which is deleted when the conversion ends. So a promotion that failed part way left the destination missing a file it had before the call. A locked or immutable file passes the pre-flight (lstat reports it as ordinary) and fails at the rename, which is all it takes. Existing files are now moved aside first and put back on failure. A stale asset is recoverable; one with a hole in it is not.
  • The mailbox fix still leaked about one run in a hundred. Draining with receive ... after 0 is a race — the exit status and the link signal are delivered separately, so the process can wake on the status and drain before the signal has arrived. The port is now unlinked when it is opened, so no signal is ever sent. Third attempt at this defect, and the first without a race.
  • sweep_staging/1 deleted the working directory of a running conversion. Its own documentation says to run it on a schedule; doing so broke conversions and reported that they had written no file. It now only removes directories older than :older_than (default one hour), and only directories — a plain file sharing the prefix was being deleted too.
  • extract_json/1 gave up instead of skipping when the largest candidate exceeded the whole budget, losing a payload that was present and affordable.

Changed

  • Camera.from_bounds/2 returns {:ok, camera} rather than a bare camera, and out-of-range options return {:error, %SplatTools.Error{}} rather than raising — the package now has one error contract instead of two.
  • asset.splat_count is now asset.source_splat_count, with a nan_filtered flag beside it, because it counts the rows in the source header and the delivered file usually holds fewer.
  • SplatTools.Transform takes its executable from config :splat_tools, executable:, so a project-local install is reachable.

Added

  • SplatTools.inspect_file/1 — classify a PLY as :splat, :mesh or :point_cloud from its header alone, with splat count, SH bands and record size. Cheap on any file size.
  • SplatTools.prepare/3 — validate, convert to SOG, and return everything worth persisting, including a derived camera.
  • SplatTools.measure/2 — bounds and a framing camera without converting.
  • SplatTools.Camera — percentile-based bounds and camera derivation, skipping non-finite values.
  • SplatTools.Transform — a wrapper over splat-transform with a real timeout, output verification, and errors carrying the command that failed.
  • SplatTools.Error — structured errors that name the command and quote the tool's own output.