Server-side toolkit for 3D Gaussian splat assets.
Takes what a splat trainer produces — a large PLY — and turns it into something a browser can load, plus the metadata an application needs to store alongside it.
{:ok, asset} = SplatTools.prepare("capture.ply", "priv/static/scans")
asset.sog #=> "priv/static/scans/capture.sog"
asset.source_splat_count #=> 1_284_331
asset.camera #=> %{position: {…}, target: {…}, fov: 50.0}What runs where
Elixir owns decisions and records; the bytes are somebody else's job.
Validation, argument construction, metadata extraction, camera framing and
error reporting are here. Compression is splat-transform, the MIT-licensed
converter from PlayCanvas — a gigabyte PLY is 4.2 million rows and 260
million float extractions, and its encoder needs k-means clustering and a
spatial sort that no library on Hex or crates.io provides.
This is the same shape as every other media type: shell out for the codec, own the pipeline.
Install the converter
npm install -g @playcanvas/splat-transformSplatTools.Transform.available?/0 says whether it is there;
SplatTools.Transform.version/0 says which version.
Store the camera, not just the file
prepare/3 derives a framing camera and returns it. Persist it next to the
file. No splat format carries a viewpoint — PlayCanvas's own publish flow
stores a camera pose rather than a preview image — and without one a viewer
opens pointing at nothing.
Non-finite values are normal
Real training output contains NaN, which is why splat-transform ships a
--filter-nan flag and why it is on by default here. Bounds computation
drops the whole splat when any of its coordinates is non-finite, matching
what the converter does, so the camera frames the geometry that actually
ends up in the file.
Summary
Functions
How much smaller the converted file is, as a ratio.
Inspects a splat file from its header alone.
Computes bounds and a framing camera without converting anything.
Prepares a splat for web delivery.
Whether a file is a Gaussian-splat point cloud.
Types
@type asset() :: %{ source: Path.t(), sog: Path.t(), preview: Path.t() | nil, preview_error: SplatTools.Error.t() | nil, source_splat_count: non_neg_integer(), nan_filtered: boolean(), sh_bands: 0..3, sh_coefficients: non_neg_integer(), bytes_per_splat: pos_integer() | :variable, source_bytes: non_neg_integer(), sog_bytes: non_neg_integer(), bounds: SplatTools.Camera.bounds(), camera: SplatTools.Camera.t() }
A prepared asset and everything worth recording about it.
source_splat_count, sh_coefficients and bytes_per_splat describe the
source file, which is the only thing that can be known without decoding
the output again. With nan_filtered true — the default — the delivered
file holds no more splats than that, and usually fewer.
bounds and camera describe the source geometry too, so passing a
transforming flag through :extra_args will move the output out from under
them.
Functions
@spec compression_ratio(%{ source_bytes: non_neg_integer(), sog_bytes: non_neg_integer() }) :: float()
How much smaller the converted file is, as a ratio.
iex> SplatTools.compression_ratio(%{source_bytes: 1000, sog_bytes: 100})
10.0
@spec inspect_file(Ply.source()) :: {:ok, SplatTools.Inspect.t()} | {:error, SplatTools.Error.t()}
Inspects a splat file from its header alone.
Cheap on any file, because it reads only the header — worth doing before anything that costs minutes.
@spec measure( Ply.source(), keyword() ) :: {:ok, %{bounds: SplatTools.Camera.bounds(), camera: SplatTools.Camera.t()}} | {:error, SplatTools.Error.t()}
Computes bounds and a framing camera without converting anything.
Reads only the position columns, packed — a capture carries around sixty properties and this needs three, so the other fifty-seven are never decoded. The three that are still become a list of floats per axis, which is the cost of sorting for a percentile: budget roughly 140 bytes per splat, so a 4-million-splat capture peaks near 600 MB.
Options
Passed to SplatTools.Camera; :percentile (default 0.01) is the one
that matters, since stray splats otherwise inflate the bounds.
@spec prepare(Path.t(), Path.t(), keyword()) :: {:ok, asset()} | {:error, SplatTools.Error.t()}
Prepares a splat for web delivery.
Validates the file, converts it to SOG, computes bounds and a framing camera, and reports everything worth persisting.
Options
:name— base name for outputs, default the source's basename. A plain filename: anything with a path separator is refused, since it would place the output outsideoutput_dir.:sh_bands— seeSplatTools.Transform.convert/3:preview— also render a.webppreview, from the converted file. Off by default: it needs a GPU or a software Vulkan driver. A preview that fails does not fail the conversion — that would be the wrong trade, and it would mean discarding a finished.sogover a thumbnail. The asset comes back withpreview: niland the reason inpreview_error.:timeout— milliseconds for the conversion:percentile,:fov,:margin,:elevation— camera framing, seeSplatTools.Camera
Examples
{:ok, asset} = SplatTools.prepare("room.ply", "priv/static/scans")
{:ok, asset} = SplatTools.prepare("room.ply", "out", sh_bands: 0, preview: true)
@spec splat?(Ply.source()) :: boolean()
Whether a file is a Gaussian-splat point cloud.