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-transformrefuses 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/2only kills the process doing the waiting; the child kept its CPU and kept writing. The command now runs under aPort, 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 linkedTaskdid. prepare/3silently discarded every camera option.:percentile,:fov,:marginand:elevationwere accepted and dropped on the floor.- The framing distance used
tanwhere the geometry needssin. The limiting ray is tangent to the bounding sphere, sod = r / sin(θ). The camera sat closer than the tangent line and clipped the scene — masked by the default margin, visible atmargin: 0.0or 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°.
:percentileand:fovwere unvalidated. A percentile at or above 0.5 returned a low above its high; a negative one returnedniland raised several frames later;fov: 0.0divided by zero andfov: 200.0placed the camera inside the scene.- Non-finite values were filtered per column, not per row.
--filter-nandrops whole splats, so a row of{NaN, 1.0e9, 0.0}was absent from the converted file while still contributing its enormousyto the bounds, and the camera framed geometry that was not there. - The splat classifier counted property prefixes.
scale_cat,scale_dog,scale_eelsatisfied 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. :namecould 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/1took 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
plyfor 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/1sees only the last extension, so staging renamedout.compressed.plytoout.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.webpsidecars ameta.jsonconversion 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 inasset.preview_error. sh_bandsreported the request rather than the file.-H ncaps 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/1was 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.
yesunder 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_infocallbacks 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'sEXIT. - 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
:cameraraised aFunctionClauseErrorwhere 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 ofconvert/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
:camerastill 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/2was quadratic in chunk count (IO.iodata_length/1per chunk); it carries a running size now.timeout: :infinityraised anArithmeticError.- Added
sweep_staging/1, becausetry/afterdoes 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/3replaces 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 (
lstatreports 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 0is 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/1deleted 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/1gave up instead of skipping when the largest candidate exceeded the whole budget, losing a payload that was present and affordable.
Changed
Camera.from_bounds/2returns{: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_countis nowasset.source_splat_count, with anan_filteredflag beside it, because it counts the rows in the source header and the delivered file usually holds fewer.SplatTools.Transformtakes its executable fromconfig :splat_tools, executable:, so a project-local install is reachable.
Added
SplatTools.inspect_file/1— classify a PLY as:splat,:meshor:point_cloudfrom 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 oversplat-transformwith 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.