# 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.1] - 2026-08-21

### Fixed

- **A configured executable that is not executable was reported as
  available.** `File.regular?/1` says yes to a file whose executable bit is
  gone — which happens to anything that has been through a zip, or copied by
  a tool that does not preserve modes. `available?/0` then said the tool was
  ready and `run/2` and `version/0` raised `:eacces` out of functions whose
  specs promise an error tuple. The PATH branch already applied this test via
  `System.find_executable/1`; the configured branch now matches it — the same
  `type=regular` and `band 8#111` test OTP itself uses in `os.erl`.
- The error for a file that exists but cannot be executed said to reinstall
  with npm. The fix is `chmod +x`, and it now says so.
- `config :splat_tools, executable: nil` raised a `FunctionClauseError` out of
  `available?/0` rather than reporting the tool as missing.

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