shull
S-hull: a fast sweep-hull routine for Delaunay triangulation by David Sinclair (see http://www.s-hull.org/) implemented in Rust with Python bindings.
The 2D case implements S-hull proper: a seed triangle with the smallest circumcircle, a radial sweep over points sorted by distance from its circumcenter, hull attachment via a linked ring with a pseudo-angle hash, and in-circle edge flipping to restore the Delaunay condition.
The 3D case generalizes Sinclair's Newton Apple Wrapper sweep-hull algorithm
(arXiv 1602.04707) one dimension up: points
are lifted onto a 4D paraboloid (w = x² + y² + z²), the 4D convex hull is
computed by incremental insertion along a Morton (Z-order) space-filling curve
— every lifted point is extreme on the paraboloid, so any insertion order is
valid, and the spatially local one keeps the hull walk cache-hot — and the
downward-facing facets are exactly the Delaunay tetrahedra.
In both cases all combinatorial decisions (visibility, in-circle/in-sphere
tests) fall back to Shewchuk's exact adaptive predicates (the
robust crate) — so the triangulation stays
valid even on adversarial inputs (cospherical grids, tight clusters far apart)
where plain floating point corrupts the result.
Install
We provide prebuilt wheels for Linux, macOS, and Windows on PyPI:
pip install shull
Usage
2D
>>> import shull
>>> import numpy as np
>>> pts = np.random.default_rng(12345).random((10000, 2))
>>> d = shull.Delaunay(pts)
>>> d.triangles # alias: d.simplices
array([[5790, 4665, 8764],
[4665, 9599, 8764],
[7711, 64, 4665],
...,
[2821, 8418, 1189],
[1500, 9364, 8681],
[5462, 1500, 8681]], dtype=int32)
3D
>>> pts = np.random.default_rng(12345).random((10000, 3))
>>> d = shull.Delaunay(pts) # dispatches on the number of columns;
>>> d.simplices # Delaunay3d is kept as an explicit alias
array([[3661, 6693, 7492, 1937],
...], dtype=int32)
scipy compatibility
shull.Delaunay aims to be a drop-in replacement for
scipy.spatial.Delaunay. Beyond points and simplices (int32, like
scipy) it provides the derived structures. neighbors comes straight out
of the triangulation (the hull construction maintains facet adjacency
anyway, so exporting it is essentially free, like qhull) and
vertex_neighbor_vertices is built in Rust on first access (~2x faster
than scipy's); the rest are computed lazily in numpy and cached:
neighbors— neighboring simplex opposite each vertex, -1 at the boundaryconvex_hull— facets of the convex hullvertex_to_simplex— a simplex containing each vertexvertex_neighbor_vertices— CSR(indptr, indices)vertex adjacencycoplanar— points not in the triangulation (dropped exact duplicates), mapped to their kept representative; recorded by the Rust core during its dedup, not reconstructed after the facttransform— barycentric transforms, same layout as scipyfind_simplex(xi, bruteforce=False, tol=None)— point location via a vectorized visibility walk (brute force as option/fallback)npoints,nsimplex,ndim,min_bound,max_bound,furthest_site,close()
Not implemented: equations (and paraboloid_scale/paraboloid_shift,
plane_distance, lift_points), incremental mode (add_points),
furthest_site=True and qhull_options — the constructor accepts scipy's
keyword arguments but raises NotImplementedError for non-default values.
Unlike scipy, float32 points are kept as float32 (see below).
Notes (both dimensions):
- Output simplices are positively oriented (counterclockwise triangles in 2D,
positive-volume tetrahedra in 3D) and index into
points. - float32 input is supported natively: no upcast copy is made (
d.pointskeeps the float32 dtype). Coordinates widen to float64 exactly internally, so the result is identical to passingpoints.astype(np.float64). Other dtypes are converted to float64. - Exact duplicate points are dropped, keeping the first occurrence as the
representative. scipy/Qhull likewise never includes duplicate indices in
simplices(though which copy Qhull keeps is arbitrary, while shull's choice is deterministic). Dropped points are reported incoplanar(scipy's convention); the rawcalculate_shull_*functions return the(dropped, kept)index pairs as a third array. - Degenerate input (too few distinct points, all points collinear in 2D, all
points coplanar/cospherical in 3D) raises
ValueError— a full-dimensional triangulation does not exist in those cases. - Points are triangulated after centering on their centroid (a ≤1-ulp perturbation of the coordinates), which makes the result robust to clouds positioned far from the origin.
Alpha shapes (concave hull)
An alpha shape is the subcomplex of the Delaunay triangulation that keeps every
simplex whose circumscribing ball has radius ≤ alpha. Small alpha gives a
tight, detail-hugging boundary; as alpha → ∞ the shape fills in to the convex
hull. It is the principled way to get a concave hull / surface — something
scipy.spatial does not provide at all.
Because the shape is just a threshold on per-simplex circumradii, it rides on shull's fast Delaunay build: the triangulation is computed once and the filtration is a cheap boolean pass. Requesting an alpha shape adds nothing to the Delaunay build's time or memory — the circumradii are computed in Rust lazily, only when asked for, from the finished triangulation.
>>> import shull, numpy as np
>>> pts = np.random.default_rng(0).random((100_000, 2))
>>> a = shull.AlphaShape(pts, alpha=0.05)
>>> a.boundary # (nfacet, ndim) int32 boundary facets: the concave hull
>>> a.simplices # the simplices filling the shape
>>> a.measure # total area (2D) / volume (3D)
>>> a.at(0.2).boundary # a looser shape, reusing the same triangulation
AlphaShape(pts) with no alpha uses optimal_alpha() — the smallest alpha
that leaves no point isolated. Everything is also available straight off a
Delaunay (so an existing triangulation is reused for free):
>>> d = shull.Delaunay(pts)
>>> d.circumradii # (nsimplex,) circumradius per simplex, cached
>>> d.alpha_complex(0.05) # simplex indices with circumradius ≤ alpha
>>> d.alpha_shape(0.05) # boundary facets (== convex_hull at alpha=inf)
Benchmark (Apple-silicon laptop, random uniform points, python bench.py --alpha) against the pure-scipy alternative (scipy.spatial.Delaunay +
circumradius filter) and the alphashape
package:
2D (shull.AlphaShape vs scipy-DIY vs alphashape)
| n | shull | scipy DIY | speedup | alphashape pkg | speedup |
|---|---|---|---|---|---|
| 1 000 | 0.0002 s | 0.002 s | 12× | 0.064 s | 400× |
| 20 000 | 0.005 s | 0.046 s | 10× | 1.37 s | 290× |
| 100 000 | 0.028 s | 0.30 s | 11× | — | — |
| 1 000 000 | 0.42 s | 4.4 s | 10× | — | — |
The scipy-DIY speedup tracks the Delaunay speedup (shull only swaps in the fast
build and native circumradii); the alphashape package builds a shapely union
per triangle and does not scale past ~10⁴ points. 3D shows the same pattern
(~4–5× over scipy-DIY, ~50–75× over the package).
Use from Rust
The crate is also usable as a plain Rust library: the Python bindings sit
behind an off-by-default python cargo feature, so depending on shull
pulls in only ndarray and robust — no pyo3, no Python at build time, no
libpython in your binary.
[dependencies]
shull = { git = "https://github.com/schlegelp/shull" }
ndarray = "0.17"
use ndarray::Array2;
use shull::{delaunay2d, delaunay4d, csr_adjacency};
let pts: Array2<f64> = /* (n, 2) array */;
// triangles (ccw), neighbor triangle opposite each vertex (-1 on the hull),
// and (dropped, kept) index pairs for exact duplicate points
let (triangles, neighbors, duplicates) = delaunay2d(pts.view())?;
// 3D points (n, 3) -> tetrahedra, same return layout:
let (tetrahedra, neighbors, duplicates) = delaunay4d(pts3.view())?;
// alpha-shape filtration: circumradius of each simplex (2D or 3D), from the
// already-built triangulation — no re-triangulation, build path untouched.
let radii = shull::circumradii(pts.view(), triangles.view())?;
csr_adjacency builds the scipy-style (indptr, indices) vertex adjacency
from a simplex array. circumradii takes points plus an (m, 3)/(m, 4)
int32 simplex array and returns one circumradius per row (infinite for a
degenerate simplex). Degenerate or oversized input is reported as a
DelaunayError rather than a panic.
Parallel builds
For large point clouds the triangulation can be built on multiple threads:
d = shull.Delaunay(pts, parallel=True) # opt-in; default is False
The cloud is partitioned into spatially compact blocks that are triangulated concurrently — each by the unchanged sequential kernel, on its points plus a one-cell halo of neighbors. A per-point certificate (all incident circumballs covered by the gathered region, with a rigorously conservative float margin) decides which local results are provably part of the global triangulation; the uncertain remainder (block borders, the convex hull, outliers) is re-triangulated in a single "crust" pass whose output is verified against the whole cloud with exact predicates. The merge then cross-checks every seam (exact local-Delaunay tests across block boundaries, face counts, boundary closure, no missing points) and, if anything is off — typically exactly cocircular/cospherical inputs split across blocks — the build transparently falls back to the plain sequential kernel. The parallel path never returns a wrong mesh: for inputs in general position the simplex set is identical to the sequential build (only the row order differs and is unspecified); degenerate ties are either resolved identically-to-fallback or consistently within one block (a valid Delaunay triangulation with different tie-breaks).
Details worth knowing:
- Only worthwhile for large clouds: inputs below ~100k points run
sequentially even with
parallel=True. progress=Truerenders a self-overwriting progress line on stderr; passing a callable instead receives(stage, done, total)events ("blocks"with a running count, then"crust","merge","done", or"fallback"right before the sequential kernel takes over) — easy to hook up to tqdm. From Rust:delaunay2d_par_with_progress/delaunay4d_par_with_progresstake aFn(ParProgress) + Synccallback. The sequential build itself reports no progress (it is untouched by the parallel feature).- Threads come from rayon's global pool (
RAYON_NUM_THREADSto control); the result is bit-identical for any thread count. - Measured on an Apple M3 Max (10 performance + 4 efficiency cores), uniform random points, wall-clock speedup over the sequential build: ~2.8× (3D, 1M points), ~3.2× (3D, 5–10M), ~2.5× (2D, 1M), ~3.4× (2D, 5M) with all 14 threads; ~2.5× (3D, 10M) with 8 threads. The block stage scales with cores until memory bandwidth saturates; the merge adds a fixed ~O(n) overhead, so speedups grow with cloud size.
- From Rust: enable the off-by-default
parallelcargo feature and calldelaunay2d_par/delaunay4d_par(same signatures and return values as the sequential functions).delaunay2d_par_with_stats/delaunay4d_par_with_statsadditionally return aParStatswith build diagnostics (block count, crust size, per-stage times, and whether — and why — the build fell back to the sequential kernel). The published wheels always enable the feature; pure-Rust consumers who skip it don't pull in rayon.
Build
cdinto directory- Activate virtual environment:
source .venv/bin/activate - Run
maturin develop(usematurin build --releaseto build wheel)
Test / benchmark
cargo test --features parallel # Rust unit tests (incl. hull invariant checks)
python -m pytest tests/ # property tests + comparison against scipy
python bench.py # benchmark against scipy.spatial.Delaunay
python bench.py --sweep # thread scaling of the parallel build
python bench.py --alpha # alpha shapes vs scipy-DIY / the alphashape pkg
Benchmark on an Apple-silicon laptop (random uniform points, release build):
2D (Delaunay vs scipy.spatial.Delaunay)
| n | shull | scipy (Qhull) | speedup |
|---|---|---|---|
| 10 000 | 0.002 s | 0.018 s | 9.8× |
| 100 000 | 0.029 s | 0.30 s | 10.7× |
| 1 000 000 | 0.46 s | 5.0 s | 10.9× |
3D (Delaunay3d vs scipy.spatial.Delaunay)
| n | shull | scipy (Qhull) | speedup |
|---|---|---|---|
| 10 000 | 0.031 s | 0.098 s | 3.2× |
| 100 000 | 0.32 s | 1.69 s | 5.3× |
| 1 000 000 | 3.8 s | 20.8 s | 5.5× |
Release files for shull 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| shull-0.4.0.tar.gz | 89.3 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| shull-0.4.0-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| shull-0.4.0-cp310-abi3-musllinux_1_2_x86_64.whl | CPython 3.10 | abi3 | Linux musl 1.2+ x86-64 | Details |
| shull-0.4.0-cp310-abi3-musllinux_1_2_aarch64.whl | CPython 3.10 | abi3 | Linux musl 1.2+ ARM64 | Details |
| shull-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| shull-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| shull-0.4.0-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
| shull-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl | CPython 3.10 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 4.9 MB
Release files / shull-0.4.0.tar.gz
| Download URL | shull-0.4.0.tar.gz |
|---|---|
| Size | 89.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d3557110d54778e1f2b04b8dd111ea833e487b0a0f63482fb2b9d673a02c926f
|
|
BLAKE2b-256 checksum How to use checksums |
6d66ca36be7048259fbbe354837c948c0faf17145205efacf9bf680646e4f450
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-win_amd64.whl
| Download URL | shull-0.4.0-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 493.9 kB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
cc7ce1c6cf4ac300f92189c02e1dd300f151b06644b2fa70457677a50a3575d0
|
|
BLAKE2b-256 checksum How to use checksums |
cb0d4bd37b9777f63680b2eacbb8599970da9c15a2a589ba1e5d92c7e53e8d12
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-musllinux_1_2_x86_64.whl
| Download URL | shull-0.4.0-cp310-abi3-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 890.6 kB |
| Tags | CPython 3.10 Linux musl 1.2+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
dea334d21d8b57c53f9801d136cd7c0d1333a264066ebe492f48b60a60fb37b9
|
|
BLAKE2b-256 checksum How to use checksums |
0d3cecdaf87b227aa72ce7f7a8cc497d6f1a7f00d10cb1be27c24793d630c820
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-musllinux_1_2_aarch64.whl
| Download URL | shull-0.4.0-cp310-abi3-musllinux_1_2_aarch64.whl |
|---|---|
| Size | 837.1 kB |
| Tags | CPython 3.10 Linux musl 1.2+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
57d4503ee3c9f08ff9478efe1ba7add285ad19b73c102fac8485c868f383371e
|
|
BLAKE2b-256 checksum How to use checksums |
cc862d3f978097f68068dddbcdf31210e86ebedb88cdb011e550bba26bd724d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | shull-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 669.4 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
9cdaa9d8fe842c42b322f63ffcd3a3f14389d7f96d1013bb81919dba8b74cdd5
|
|
BLAKE2b-256 checksum How to use checksums |
8560ce38b8cd361d76d2049c65765130d948dd362a2947cf736e5f5c9999d865
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | shull-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 660.5 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
674df304788436828312ec9ddf800fd89540ebbfd6318dfea530645907ffa54e
|
|
BLAKE2b-256 checksum How to use checksums |
a857fd7f9d42547bc6b75a03d4066a1b5f24fb233770098c48cf964c73932761
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | shull-0.4.0-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 592.9 kB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
6b0179122260391a1fc33efeadb6492b8b9741525d1c1109a1b088a3b1ebc746
|
|
BLAKE2b-256 checksum How to use checksums |
f6a0c55ba9a2739652fc94c05a72637117db38b1a7e5cd9cf8bf03ee7147f391
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / shull-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl
| Download URL | shull-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 617.9 kB |
| Tags | CPython 3.10 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
8b49c351f51cdb4d0a5c3006199143e971dd62b86714eca529f415b703dcc8c3
|
|
BLAKE2b-256 checksum How to use checksums |
efd8f93fbb2a0a5515a84801895fa3436479fc87651019b862f5289ff7f52e90
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|