Skip to content

Point Clouds

terrio reads and writes lidar and laser scan point clouds in pure Rust:

Name Format Read Write Extensions
LAS ASPRS LAS 1.0 to 1.4, point formats 0 to 10, full waveforms ✓ ✓ las
LAZ LASzip-compressed LAS (chunked, layered, and LASzip 1.x's unchunked compressor) ✓ ✓ laz
COPC Cloud Optimized Point Cloud ✓ ✓ copc.laz
EPT Entwine Point Tile (LASzip, binary and Zstandard tiles), local or remote ✓ ✓ ept.json
PLY Polygon File Format, ASCII and binary (the vertices) ✓ ✓ ply
E57 ASTM E2807 laser scans (every scan, posed) ✓ ✓ e57

Every dimension is read, extra bytes included (every type, scaled, arrays, several extra-bytes records), with the VLRs and EVLRs and the CRS from the OGC WKT record or the GeoTIFF GeoKeys (compound CRSs are split into their horizontal and vertical parts). Points are held in columns, one typed array per dimension, named as laspy names them: x, y, z (with the scale and offset applied), intensity, return_number, number_of_returns, classification, scan_angle_rank or scan_angle, gps_time, red, green, blue, nir...

LASzip comes from the pure-Rust laz crate, the codec of laspy's lazrs backend; terrio reads and writes the LAS container and the COPC structures itself. E57 files are read and written with the pure-Rust e57 crate, and triangulations come from delaunator.

Dimensions that are not LAS's are named as PDAL names them, in snake case: normal_x, spherical_range, origin_id. PLY properties named like PDAL's dimensions, in any case (red, Intensity, returnnumber, nx), take terrio's names; others keep theirs.

EPT, local or remote

An EPT dataset is an octree of tiles with its hierarchy in JSON pages. terrio reads it as PDAL's readers.ept does: a read walks the hierarchy through the nodes that overlap --bounds, down to the depth --resolution needs (or --max-level), and fetches the tiles it selects in parallel. Remote datasets (https://, s3://) are read in place:

terrio info s3://usgs-lidar-public/WI_8County_Columbia_2020/ept.json   # 11.5 billion points
terrio convert https://s3-us-west-2.amazonaws.com/usgs-lidar-public/WI_8County_Columbia_2020/ept.json \
  sub.laz --bounds -9951440.5 5382600 -9951140.5 5382900 --resolution 2
# the same 347 376 points as PDAL's readers.ept, in 10 s

Writing an EPT dataset (an output named ept.json, or an ept:// path) builds the same octree as a COPC file's (a cube around the points, each node an even sample of one point per cell of a 128³ grid, down to nodes of at most 100 000 points), in bounded memory, and writes each node as a LASzip file (Entwine's default), binary records in the schema's order or Zstandard-compressed records (--ept-data-type laszip|binary|zstandard), the hierarchy as a JSON page and ept.json with PDAL's dimension names:

terrio convert madison.copc.laz madison-ept/ept.json        # 4.07 million points, 12 s
terrio convert tile.laz tile-ept/ept.json --ept-data-type binary

PDAL's readers.ept reads the result as it reads the source, every dimension of every point (binary tiles: x, y and z as PDAL 2.9 rounds them; Zstandard tiles need a PDAL built with Zstandard).

Appending to an EPT dataset

--append adds the points of a file to an existing EPT dataset, in place, as Entwine does when it builds into an existing output (terrio's datasets, Entwine's and others', in any data type):

# A dataset built from the first tile, its cube the bounds of all the tiles
terrio convert tile-1.laz tiles-ept/ept.json --ept-bounds 635577 848882 406 639004 853538 616
terrio convert tile-2.laz tiles-ept/ept.json --append
terrio convert tile-3.laz tiles-ept/ept.json --append --t-srs EPSG:2992   # reprojected first
terrio convert other-ept/ept.json tiles-ept/ept.json --append            # a merge

The dataset's cube (bounds in ept.json) does not change: points outside it are refused and the dataset is left as it was, which is why a dataset that will take more files is written with --ept-bounds covering them all (Entwine's --bounds; the cube is the cube around those bounds). The new points go down the octree as the writer's do: each node keeps the first new point to land in each free cell of its span³ grid and passes the others to its children; a leaf (a node without children) takes all the points that reach it until it holds more than 100 000, and is then split, its points placed again as a node and children of their own. Every node keeps the points it had, so reads by bounds return the same points as a dataset built from all the files at once; reads by resolution return as many points per level, but not the same sample.

The points take the dataset's schema, as when an EPT dataset is written: dimensions the dataset has and the points do not are written as zeros, and those the points have and the dataset does not are dropped, with a warning. ept.json's points and boundsConforming are updated, and the schema's statistics (count, minimum, maximum, mean, variance, stddev, counts) when it has them. Each file appended is an entry of the source list, ept-sources/manifest.json (or Entwine 1's list.json), with its own metadata file, and its points' OriginId (when the schema has one) is its position in the list. A dataset without a source list gets one, starting with an entry for the points already there (one per OriginId of theirs).

An append writes the tiles it changes or adds, the hierarchy pages it changes, the source list and ept.json in a directory of their own in the dataset's (.terrio-ept-append-N), each synced to disk, then a COMMITTED marker, then moves them into place: the tiles first, then the source metadata and list, the hierarchy pages (the root page last) and ept.json last. An append interrupted before the marker leaves the dataset as it was; one interrupted while the files are moved is completed by the next append, which moves the remaining ones in. Readers that open the dataset while the files move may see the new tiles with the previous hierarchy. A lock on the file .terrio-ept-append.lock keeps to one append at a time; on Unix it is an advisory lock (flock), which the system releases when the process ends, so an interrupted append leaves none held. The options that shape a new dataset (--ept-bounds, --ept-data-type, --point-format, --scale, --offset) are refused with --append. The points are spilled to a temporary file (in TMPDIR) and placed one node at a time, the points of each child in memory or in a temporary file of their own, so memory stays bounded whatever the number of points appended (the hierarchy is held whole). In Rust, append_points and EptAppender (chunks of points written as they come); in Python, terrio.append_points and convert_points(..., append=True).

PLY and E57

PLY files are read and written as PDAL's readers.ply and writers.ply do: x, y and z as float64, other properties with their PLY type; terrio writes PDAL's names (returnnumber, gpstime) and sized types (uint8), in ASCII by default (--ply-encoding binary_little_endian for binary). Unlike PDAL's ASCII writer, which keeps 6 significant digits by default, terrio writes as many digits as needed to read the same values back (--ply-precision N for N decimals).

E57 files are read as PDAL's readers.e57 reads them: the points of every scan with its pose applied, x, y and z from spherical coordinates when needed, colours and intensities scaled to 16 bits by 65535 / (limit max - limit min) (colours of 0 to 255 become 0 to 65535). Written E57 files have one scan, intensities and colours of 0 to 65535, normals in the nor extension and the other dimensions in terrio's own.

Larger than memory

terrio convert (to every point cloud format: LAS, LAZ, COPC, PLY, E57 and EPT), terrio info --stats, terrio grid (the statistics), terrio filter range and terrio filter expression read and write points in chunks (--chunk-points, a million by default), so their memory does not grow with the file. On a 3.05 GB LAS file of 101.7 million points:

Command Time Peak memory
terrio info big.las --stats 18.5 s 66 MB
terrio convert big.las big.laz 139 s 117 MB
terrio convert big.laz big.copc.laz 267 s 251 MB
terrio grid big.las dsm.tif --tr 5 --stat max 25 s 769 MB (the 5001 × 5001 grid)
terrio filter range big.laz ground.laz --limits "Classification[2:2]" 73 s 120 MB
PDAL writers.gdal, the same grid (identical) 52 s 6.3 GB

COPC files and EPT datasets written in chunks spill the points to a temporary file (in TMPDIR), then build the octree one node of a coarse level at a time: the octree is the one built in memory. E57 files are written packet by packet as the points come (converting the 4.07 million points of USGS 3DEP Madison to E57 takes 59 MB, against 207 MB when held whole). In Rust, PointCloudReader::chunks and PointWriter do the same; in Python, PointCloudFile.chunks() and PointCloudWriter.

Waveforms

LAS point formats 4, 5, 9 and 10 locate each point's waveform in the waveform data packets, in the file or in a .wdp file next to it. terrio reads the wave packet descriptors and a point's samples (PointCloudReader::waveform, PointCloudFile.waveform in Python), and keeps the packets when the points are written to LAS or LAZ (and the .wdp file next to a converted file).

Filters

terrio filter (and the library's pointcloud::filters, Python's and JavaScript's PointCloud methods) runs PDAL's filters, with PDAL's options and defaults:

terrio PDAL What it does
filter smrf filters.smrf ground classification (Simple Morphological Filter): last and only returns become 2 (ground) or 1
filter pmf filters.pmf ground classification (Progressive Morphological Filter)
filter csf filters.csf ground classification (Cloth Simulation Filter)
filter elm filters.elm low noise (Extended Local Minimum) becomes 7
filter outlier filters.outlier statistical or radius outliers become 7 (noise); --remove drops them
filter hag_nn filters.hag_nn heights above the nearest ground points (HeightAboveGround)
filter hag_delaunay filters.hag_delaunay heights above a triangulation of the nearest ground points
filter range filters.range the points within ranges, such as Classification[2:2],Z[0:100)
filter expression filters.expression the points for which an expression holds, such as Classification == 2 && Z > 100
filter assign filters.assign values assigned by range (Classification[1:1]=2) or expression (Z = Z * 0.3048 WHERE Classification == 2)
filter crop filters.crop the points in boxes, polygons (WKT or GeoJSON), circles or spheres, or outside them
filter decimation filters.decimation every n-th point
filter merge filters.merge several point clouds as one
filter splitter filters.splitter squares of a grid (a file each with # in the output name)
filter chipper filters.chipper squarish chips of at most --capacity points
filter sample filters.sample Poisson sampling: no two points closer than --radius
filter voxeldownsize filters.voxeldownsize the first point of each voxel (moved to its centre in center mode)
filter voxelcenternearestneighbor filters.voxelcenternearestneighbor the point nearest each voxel's centre
filter normal filters.normal normals and curvature (NormalX, NormalY, NormalZ, Curvature)
filter cluster filters.cluster Euclidean clusters (ClusterID)
filter dbscan filters.dbscan density-based clusters (ClusterID, from 0, -1 for noise), in any dimensions
filter covariancefeatures filters.covariancefeatures features of the neighbours' covariance (Linearity, Planarity, Scattering, Verticality, Omnivariance, Anisotropy, Eigenentropy, EigenvalueSum, SurfaceVariation, DemantkeVerticality, Density)
filter eigenvalues filters.eigenvalues the neighbours' covariance eigenvalues (Eigenvalue0 to Eigenvalue2)
filter approximatecoplanar filters.approximatecoplanar approximately coplanar points (Coplanar)
filter estimaterank filters.estimaterank the rank of the neighbourhood (Rank)
filter lof filters.lof local outlier factors (NNDistance, LocalReachabilityDistance, LocalOutlierFactor)
filter nndistance filters.nndistance the distance to the k-th nearest neighbour, or the mean distance (NNDistance)
filter radialdensity filters.radialdensity the density of points within a radius (RadialDensity)
filter reciprocity filters.reciprocity the share of neighbours that do not have the point as theirs (Reciprocity)
filter miniball filters.miniball the distance to the centre of the smallest ball around the neighbours (Miniball)
filter zsmooth filters.zsmooth a percentile of the neighbours' Z, in x and y, in a dimension
filter skewnessbalancing filters.skewnessbalancing ground classification by skewness balancing (the points come out sorted by Z)
filter head, filter tail filters.head, filters.tail the first or last points (or all but them)
filter sort filters.sort the points sorted by dimensions
filter locate filters.locate the point of the largest or smallest value of a dimension
filter iqr, filter mad filters.iqr, filters.mad the points within the interquartile fences, or median absolute deviations, of a dimension
filter dem filters.dem the points within a range of a raster's values, such as Z[20:20]
filter colorization filters.colorization dimensions from raster bands (Red:1:1.0, Green:2, Blue:3)
filter overlay filters.overlay a dimension from an attribute of the polygons of a vector layer the points are in
filter hexbin filters.hexbin the boundary of the points: the hexagons with at least --threshold points, as polygons with holes (a vector dataset), and those hexagons with their counts (--density)

They give PDAL's results exactly, PDAL's quirks included (filters.sample takes the first point's y as the origin's z; SMRF fills empty cells with the mean of the 8 nearest as nanoflann orders them; PMF and ELM find a point's cell from the floor of its offset before dividing by the cell size; filters.decimation keeps the points it keeps when streaming, as pdal translate runs it; filters.normal's refine changes nothing in PDAL 2.9 and is left out). Reprojection (filters.reprojection) is terrio convert --t-srs.

Reprojection transforms z too when the points' CRS and the target CRS both have heights: compound CRSs (EPSG:6588+6360, a horizontal CRS with NAVD88 height in US survey feet) and geographic 3D CRSs (EPSG:4979). Vertical units, geoid models and vertical datum transformations apply as in CRS transformations, and the vertical CRS a LAS file declares (in its WKT or its VerticalCSTypeGeoKey) counts as part of the points' CRS, unless --a-srs assigns another CRS, which replaces it. When only one of the CRSs has heights, z is kept as it is, as PDAL keeps it. If the target has heights and the points have none, terrio issues a warning. LAS files written with GeoKeys record the vertical CRS of a compound CRS in VerticalCSTypeGeoKey.

terrio convert tile.laz metres.laz --t-srs EPSG:6588+5703   # NAVD88 height (ftUS) to metres
terrio convert utm.laz itrf.laz --t-srs EPSG:7912           # EPSG:6343+5703 through GEOID18

The neighbourhood filters find neighbours as PDAL does: nanoflann's k-d tree and its order of neighbours as far as each other, radius searches sorted by distance with a stable sort (as libstdc++'s std::sort orders up to 16 points; larger results may order points at exactly the same distance differently, which on regular grids moves covariance features by about 1e-15, relative), covariances with PDAL's running means and offsets rounded to floats, and eigenvalues and eigenvectors as Eigen's SelfAdjointEigenSolver computes them, operation for operation, so that, short of such ties, their features are PDAL's to the bit. Two differ by rounding: Omnivariance takes the cube root of Rust rather than of the C library (within an ulp), and filters.miniball finds the smallest enclosing ball exactly (Welzl's algorithm), where PDAL's Seb stops within its tolerance (within 1e-9). PDAL's quirks are kept: filters.colorization makes its dimensions doubles (it asks for the bands' types before opening the raster); filters.dem keeps no point when the range has no lower or upper bound; filters.overlay takes the second attribute when no column is named; filters.sort orders by the last dimension, the others breaking its ties (and keeps the order of points as large, which PDAL's default algorithm leaves to std::sort); filters.skewnessbalancing returns the points sorted by Z. Two are not: PDAL 2.9's filters.colorization reads the n-th band for the n-th dimension, whatever band the dimension names (Infrared:4 alone gets band 1), where terrio reads the band named; and filters.overlay takes a layer without a CRS to be in the points' CRS, where PDAL refuses it. filters.dem and filters.colorization take the raster to be in the points' CRS, as PDAL does; filters.overlay reprojects the polygons to it. covariancefeatures's threads (terrio uses every core) and overlay's query and bounds are left out. Every filter takes --a-srs to assign a CRS to points that have none or a wrong one, as terrio grid and terrio convert do (PDAL's readers.las.override_srs).

filter hexbin writes a vector dataset rather than points: one multipolygon feature, the union of the hexagons (flat-topped, of edge --edge-length, PDAL's edge_length or edge_size) with at least --threshold points, with its area, density, avg_pt_spacing, point_count, hex_count, edge_length and threshold as attributes; --density writes the hexagons with their counts too. Python's PointCloud.hexbin returns PDAL's metadata as a dictionary (boundary as WKT, boundary_json, area, density, estimated_edge...) with both layers; JavaScript's returns a vector dataset of both. They are PDAL's: the grid anchored at the first point, points assigned to hexagons as hexer assigns them (in the strips where columns of hexagons interlock, below the first point, hexer counts some points in the hexagon next to the one around them), the edge length estimated as hexer estimates it when it is not given (from --sample-size points), the boundary's coordinates rounded to --precision decimals and, by default, smoothed (simplified preserving topology, with PDAL's tolerance; its small polygons and holes dropped), and the area and density those of the hexagons before smoothing. Since the first point anchors the grid, the boundary depends on the order points are read in: COPC files read by PDAL's readers.copc and by terrio come in different orders, so give --origin (the first point PDAL reads) to get PDAL's hexagons. The boundary is always a multipolygon (PDAL's smoothed boundary of one polygon is a polygon); holes run clockwise (hexer's run counter-clockwise, as its exteriors); H3 grids (h3_grid) are not implemented.

terrio filter smrf tile.laz classified.laz
terrio filter outlier tile.laz clean.laz --method statistical --mean-k 8 --multiplier 2 --remove
terrio filter voxeldownsize tile.laz thin.laz --cell 1 --mode first
terrio filter csf tile.laz classified.laz --cloth-resolution 0.5
terrio filter hag_delaunay classified.laz hag.laz
terrio filter expression tile.laz high.laz --expression "Classification == 2 && Z > 100"
terrio filter assign tile.laz fixed.laz --value "Classification = 7 WHERE Z < -10"
terrio filter crop tile.laz part.laz --polygon "POLYGON((0 0, 100 0, 50 80, 0 0))"
terrio filter splitter tile.laz "tiles/tile_#.laz" --length 500
terrio filter merge a.laz b.laz merged.laz
terrio filter merge @tiles.txt merged.laz        # the inputs listed in a file, one per line
terrio filter smrf tile.las.zip ground.laz --a-srs EPSG:2056   # a zipped LAS without a CRS
terrio filter covariancefeatures tile.laz features.laz --feature-set Dimensionality,Omnivariance --knn 16
terrio filter dbscan tile.laz clusters.laz --eps 2 --min-cluster-points 10
terrio filter colorization tile.laz rgb.laz --raster ortho.tif --dimensions "Red:1, Green:2, Blue:3"
terrio filter overlay tile.laz zoned.laz --datasource zones.gpkg --dimension Classification --column class
terrio filter dem tile.laz near.laz --raster dem.tif --limits "Z[2:2]"
terrio filter hexbin bridge.copc.laz footprint.geojson --edge-size 1.4 --threshold 3 --no-smooth --density hexagons.geojson

COPC, local or remote

A COPC file is a LAZ file whose points are organized in an octree, each node a separately compressed chunk, with the hierarchy of nodes in pages. terrio reads COPC files in place: opening one fetches its header and root hierarchy page, and a read fetches only the nodes that intersect the bounds asked for, down to the level asked for, with HTTP range requests (in as few requests as possible).

terrio info https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz
# 2 requests, 120 kB of an 81 MB file: header, CRS, dimensions, and the octree's depth and
# points per level
terrio convert https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz sub.laz \
  --bounds 637000 851000 637500 851500 --resolution 7.27
# 8 requests, 3.7 MB: the same 18901 points as PDAL's readers.copc

The levels read are chosen with --max-level (0 is the root), --resolution (the levels needed for a point spacing, as PDAL's readers.copc chooses them) or --max-points (the deepest level that keeps the count within it). Points come in file order.

Writing COPC builds the octree: each node keeps an even sample of its points (one per cell of a 128³ grid), down to nodes of at most 100 000 points, which keep all theirs. Within a node, points are in GPS time order, which LASzip compresses best.

Command line

terrio info tile.laz                      # header, CRS, dimensions, VLRs, points by return
terrio info tile.laz --stats              # and classification counts, each dimension's range
terrio convert tile.las tile.laz          # LAS <-> LAZ <-> COPC
terrio convert tile.laz tile.copc.laz
terrio convert tile.laz ground.laz --class 2 --returns last,only
terrio convert tile.laz sub.las --bounds 637000 851000 637500 851500 --z-range 0 500
terrio convert tile.laz wgs84.laz --t-srs EPSG:4326
terrio convert tile.las tile14.las --point-format 6 --las-version 1.4 --scale 0.001 0.001 0.001
terrio convert tile.laz points.gpkg --dims z,intensity,classification  # a layer of 3D points
terrio grid tile.laz dsm.tif --tr 1 --stat max
terrio grid tile.laz dtm.tif --tr 1 --stat idw --class 2 --window-size 3
terrio grid tile.laz density.tif --tr 5 --stat count --bin
terrio grid tile.laz dtm.tif --tr 1 --class 2 --method linear   # a TIN, as gdal_grid -a linear
terrio grid tile.laz nn.tif --tr 1 --method nearest             # as gdal_grid -a nearest
terrio convert scan.e57 scan.laz                                # E57, PLY, EPT to LAS and back
terrio convert tile.laz tile.ply --ply-encoding binary_little_endian

terrio grid grids points into a raster as PDAL's writers.gdal does: each point counts towards every cell whose centre is closer than --radius (by default the cell diagonal), or with --bin towards its own cell only; the statistics (--stat, one band each) are min, max, mean, idw (inverse distance weighted, power --power), count and stdev. Empty cells are nodata, or are filled from their neighbours within --window-size cells. The grid starts at the points' minimum (or --te), as PDAL's does.

--method linear interpolates linearly in the triangles of the points' Delaunay triangulation (a TIN), as gdal_grid -a linear; cells outside it take a near point's value as GDAL finds it (the nearest of the points in the smallest square around the cell that has some), within --radius, or nodata with --radius 0. --method nearest takes the nearest point's value, as gdal_grid -a nearest (within the square of half-side --radius with one).

Library

```rust,no_run use terrio::pointcloud::{self, PointFilter, PointReadOptions};

let mut f = pointcloud::open_points("lidar.copc.laz")?; let options = PointReadOptions { filter: PointFilter { classes: vec![2], ..Default::default() }, max_level: Some(4), ..Default::default() }; let ground = f.read(&options)?; let z: &[f64] = ground.coords("z").unwrap(); pointcloud::write_points(&ground, "ground.laz", &Default::default())?; let grid = pointcloud::grid::GridOptions { resolution: 1.0, ..Default::default() }; let dtm = pointcloud::grid::grid(&ground, &grid)?; // a RasterDataset terrio::write_raster_dataset(&dtm, "dtm.tif", &Default::default())?;

Ok::<(), terrio::Error>(())

`pointcloud::vector::to_layer` and `from_layer` turn points into vector layers and back, and
`from_raster` turns raster cells into points.

## Python

```python
import terrio

f = terrio.open_points("https://host/lidar.copc.laz")
f.format, f.point_count, f.crs, f.bounds, f.dimensions
points = f.read(bounds=(x0, y0, x1, y1), max_level=4, classes=[2])
points["z"]            # numpy float64; points["intensity"] is uint16...
points.to_numpy()      # a structured array
points.write("ground.copc.laz")
dem = points.grid(1.0, stat="idw")       # a Raster
tin = points.grid(1.0, method="linear")  # a TIN, as gdal_grid
gdf = points.to_layer().to_geopandas()   # points with attributes
ground = points.smrf()                   # PDAL's filters.smrf
clean = points.outlier().range("Classification![7:7]")
hag = points.csf().hag_nn()["height_above_ground"]
high = points.expression("Classification == 2 && Z > 100")
tiles = points.splitter(500.0)           # a list of PointClouds
both = terrio.merge_points([points, other])
feats = points.covariance_features(feature_set=["Linearity", "Planarity"])  # filters.covariancefeatures
rgb = points.colorization("ortho.tif")   # filters.colorization: Red, Green, Blue
hexbin = points.hexbin(1.4, threshold=3, smooth=False)  # filters.hexbin
hexbin["boundary"]                       # 'MULTIPOLYGON (((...)))', as PDAL's metadata
hexbin["hexagons"].to_geopandas()        # the hexagons with their point counts
zoned = points.overlay("zones.gpkg", "Classification", column="class")
points.write("tile-ept/ept.json")        # an EPT dataset
terrio.append_points("tile-ept/ept.json", ["tile-2.laz", "tile-3.laz"])  # added in place

# Larger than memory: chunks in, chunks out.
f = terrio.open_points("huge.laz")
with terrio.PointCloudWriter("huge.copc.laz", f.template()) as w:
    for chunk in f.chunks(1_000_000):
        w.write(chunk)
dsm = terrio.grid_points("huge.laz", 1.0, stat="max")  # in bounded memory

JavaScript

const pc = readPoints(lazBytes, "tile.laz", { classes: [2] });
pc.get("z");                     // Float64Array
pc.toBytes("tile.copc.laz");     // LAS, LAZ or COPC bytes
pc.grid(1, { stat: ["max"] });   // a raster Dataset
pc.grid(1, { method: "linear" }); // a TIN
pc.smrf().range("Classification[2:2]");
pc.lof(10).get("local_outlier_factor"); // filters.lof, dbscan, covarianceFeatures...
pc.colorization(rasterDataset, "Red:1, Green:2, Blue:3");
pc.hexbin(1.4, { threshold: 3, smooth: false }).toGeoJSON("boundary"); // filters.hexbin
readPoints(plyBytes, "scan.ply"); // PLY and E57 bytes too

Compared with laspy and PDAL

scripts/compare_pointclouds.py reads every test file (the PDAL and laspy test suites, USGS 3DEP lidar over Madison as COPC, and laspy-written files of every point format with every extra-bytes type) with terrio and compares:

Check Reference Result
Every value of every dimension, 55 files laspy 2.7 identical
Header fields, counts by return, VLR and EVLR counts laspy identical
Count, version, format, scale, offset, bounds, EPSG code, COPC pdal info --metadata identical
LAS, LAZ and COPC written by terrio, read back laspy, PDAL readers.las, readers.copc identical values, 294 checks
COPC reads by bounds, resolution and both PDAL readers.copc the same points
Gridding: min, max, mean, IDW, count, stdev; radius, power, bin mode, window filling PDAL writers.gdal identical to 1e-9
Reprojection (same datum: NAD83 to NAD83 geographic and Albers, RGF93 to WGS 84) PDAL filters.reprojection within 2e-7 degrees, 1 mm
PLY (7 files), E57 (13 files), EPT (LASzip, binary, Zstandard): every value PDAL readers.ply, readers.e57, readers.ept identical
EPT reads by bounds and resolution, local and USGS 3DEP (11.5 billion points) PDAL readers.ept the same points
PLY and E57 written by terrio PDAL readers.ply, readers.e57 identical
EPT datasets written by terrio (LASzip and binary tiles; 4.07 million points of USGS 3DEP Madison) PDAL readers.ept identical
EPT datasets appended to (autzen, 10.65 million points in three parts, LASzip, binary and Zstandard tiles; Entwine's lone-star, 1.2-with-color and ellipsoid datasets merged with themselves) PDAL readers.ept; the source; a dataset built from all the points every point once (twice when merged), reads by bounds identical
Waveform samples; packets kept through LAS and LAZ the file's bytes (LASlib's offsets) identical
SMRF, outliers (statistical, radius), range, sample, voxels, on autzen and USGS 3DEP Madison (4.07 million points) PDAL's filters identical classes and points
PMF, CSF, ELM, HAG (nearest, Delaunay), crop, decimation, expression, assign, merge, splitter, chipper, normals, clusters, on autzen and half a million points of USGS 3DEP Madison PDAL's filters identical classes, heights, tiles, cluster ids and points; normals within 1e-6
Hexbin on autzen and a 300 x 300 ft crop of it (given and estimated edge lengths, thresholds 1 to 15, smoothed and not) PDAL's filters.hexbin identical hexagons and counts; the same polygons, holes and vertices (to PDAL's 8 decimals), a symmetric difference of 0 and areas within 1.2e-10 (PDAL prints 10 digits)
DBSCAN, covariance features, eigenvalues, approximate coplanarity, rank, LOF, nearest-neighbour distances, radial density, reciprocity, miniball, z smoothing, skewness balancing, head, tail, sort, locate, IQR, MAD, on autzen PDAL's filters every value identical to the bit (PDAL's doubles read back from LAS extra bytes) but omnivariance (within 1e-15) and miniball (within 1e-9)
DEM, colorization and overlay, on autzen, with rasters and polygons made from it (and a NAIP orthophoto, a USGS 3DEP DEM) PDAL's filters the same points; identical values
TIN (linear) grids gdal_grid -a linear 99.91% of cells identical (see below)
Nearest-neighbour grids gdal_grid -a nearest identical but for ties
A grid of 101.7 million points, read in chunks PDAL writers.gdal identical

PDAL 2.9 rounds the coordinates of binary EPT tiles to integers (it writes the scaled values back into their integer fields); terrio keeps them (and matches PDAL once rounded the same way). TIN grids differ from gdal_grid's where several points share an x and y (qhull and terrio's triangulation keep different ones), and, at large coordinates, where qhull's triangulation loses precision and is not Delaunay: on 568 119 points of USGS 3DEP lidar in state plane feet, 98.8% of the cells are identical, and 99.996% once the coordinates are moved near the origin.

terrio also reads LASzip 1.2's first, unchunked compressor, which laspy's lazrs and PDAL's lazperf cannot read. PDAL does not read point formats 4, 5, 9 and 10 (waveforms); those files are compared with laspy only. terrio is faster than PDAL on these filters (SMRF on 4.07 million points: 48 s, PDAL 164 s; statistical outliers: 0.8 s, PDAL 21 s; CSF on autzen: 11.5 s, PDAL 22 s).