@voxelkloud/format-single
v0.5.1
Published
The single-file tier for voxelkloud: a LAS, LAZ, E57, PLY, PCD or XYZ with no index, downloaded whole and turned into a streamable octree in the browser.
Maintainers
Readme
@voxelkloud/format-single
The single-file tier for voxelkloud: a .las, .laz,
.e57, .ply, .pcd or .xyz with no index at all.
npm install @voxelkloud/format-singleimport { singleFileFormat } from "@voxelkloud/format-single";
import { formats, loadPointCloud } from "@voxelkloud/loader";
// AFTER the COPC driver — see the tie-break below.
formats.register(copcFormat).register(singleFileFormat);
const { source, tree, openPoints } = await loadPointCloud("/scan.laz");
view.addCloud(source, tree, openPoints);Or from a file the user dropped, with no request at all:
const source = await loadSingleFileSource(file.name, {
bytes: new Uint8Array(await file.arrayBuffer()),
onProgress: (points) => setProgress(points),
});The build runs in a worker
By default, wherever Worker exists. It is not an optimisation to opt into: a
20M-point build is 31 synchronous seconds with no paint, no spinner and no
cancel, and the tab is simply frozen for half a minute.
What crosses back is every node's records, transferred rather than copied, so the size of the cloud costs nothing at the boundary. The worker is created per build and terminated the moment its result lands — which is the other half of the point, because the build peaks at roughly twice the records and a terminated worker's heap goes back whole. Building on the main thread leaves that peak in the page for as long as the cloud is open.
loadSingleFileSource(url, {
onProgress: (points) => …, // points decoded so far, at most every 250 ms
signal: controller.signal, // terminates the worker; the only way to stop it
worker: false, // build inline instead — Node, or a bundler that
}); // cannot emit the worker chunkonProgress is pushed from inside the build, not sampled beside it. A build
is one synchronous call, so a timer in the worker would not fire once before it
returned; the numbers come from a callback the wasm invokes mid-loop.
source.builtInWorker says which path ran. The result is identical either way,
and there is a test that builds the same file both ways and compares every byte
of every node.
The worker is dist/worker.js, loaded as
new Worker(new URL("./worker.js", import.meta.url), { type: "module" }) —
the form Vite, webpack 5 and Rollup all understand.
What it is for
Every other driver reads a file somebody already indexed — PotreeConverter wrote a directory, untwine wrote a COPC, Entwine wrote an EPT. This one is for the file as it came off the scanner, with nobody having run anything over it.
The bargain, stated plainly: the whole file is downloaded and the whole cloud is held in memory, because an unindexed file offers nothing to range-request and no tree to descend. That is exactly what COPC and EPT exist to avoid. This tier is the answer when nobody produced one — not a replacement for producing one.
The ceiling is 20 million points, measured: see
@voxelkloud/wasm-build for the 31 seconds and 1.7 GB behind
that number.
Past LAS it also reads E57, PLY, PCD and XYZ, which have no index either and never will — there is nowhere in any of them to put one.
E57 is the one with real structure behind it: the terrestrial-scanner interchange format, an XML section at the end of the file over bitpacked binary sections. Every scan's pose is applied and spherical records are converted, so a multi-scan file arrives as one cloud in the file's own frame rather than as several clouds on top of each other. Records that carry no position — the no-returns of a scan grid, 58% of libE57's own pump sample — are dropped rather than written at the origin, and which scan a point came from survives as its LAS point source id.
The tie-break with COPC
A COPC file is a valid LAZ. Both drivers can read one, and only one of them should:
| | COPC driver | this driver |
| --- | --- | --- |
| a .copc.laz | 3 | 0 — refuses outright |
| a .laz carrying a copc VLR | 3 | 1 |
| a plain .laz | 0 | 1 |
Getting this wrong is expensive in both directions. Letting this driver win
downloads two gigabytes to rebuild an octree the file already contains; letting
COPC win a plain LAZ used to stop the search and fail, because a decisive
sniff ends it — which is why the COPC driver now looks for the copc VLR in the
probe's raw bytes rather than claiming everything that starts with LASF.
What is actually here
Very little, and that is the point:
- the octree comes from
@voxelkloud/wasm-build, the converter's own partitioner; - the per-node decode from
@voxelkloud/format-las, the same one COPC and EPT use, because the builder emits raw LAS records; - the tree from
@voxelkloud/core's paged octree, resolving its one page out of the heap instead of off a socket.
Testing
The differential test is the one worth knowing about. lion_takanawa.copc.laz
is both a COPC and an ordinary LAZ, so the same file can be read two
completely different ways: streamed from the tree untwine built, or downloaded
and rebuilt here. All 341,989 points come out identical, compared as sets
because the two trees legitimately partition differently.
The worker has its own. Node has no Worker, so the tests supply one: a
stand-in that loads the real worker.ts with a shimmed self and passes
messages through structuredClone with the transfer list, which Node
honours — so the test that says a caller's buffer survives is checking a real
detach, not a mock.
MIT.
