diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@
 The serialization format carries its own version tag, independent of the package
 version; any change to it is recorded here explicitly.
 
+## 1.0.1.0
+
+* Add the geometry-only `delaunayGeometry` entrance and re-export
+  `delaunayFromCoordinates` with its duplicate-payload policy from the main
+  facade.
+* Add one versioned C ABI over immutable geometry meshes, with typed
+  obstruction witnesses and thin Python, TypeScript, and Rust bindings.
+
 ## 1.0.0.0
 
 * Specialize the public geometry surface to binary64 and remove the ornamental
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -34,6 +34,8 @@
 
 | What you need | Use | Do not substitute |
 | --- | --- | --- |
+| Build a geometry-only mesh from coordinates | `delaunayGeometry` | Do not manufacture unit annotations and erase them afterward. |
+| Build from separate coordinates and annotations | `delaunayFromCoordinates` | Do not fabricate a `HasPosition` instance for an annotation that does not own geometry. |
 | Combine two unconstrained meshes | `union` | Do not concatenate vertices and rebuild manually; `union` owns overlap annotations and the measured schedule. |
 | Combine many unconstrained meshes | `unions` | Do not left-fold `union`; `unions` owns balanced association. |
 | Classify coordinate support without constructing a mesh | `siteRelation` | Do not compare vertex counts or resident `Eq`; neither answers support order. |
@@ -52,6 +54,15 @@
 | Refine a proved face section without changing protected faces | `refineWithinDomain` | Supply its exact permitted faces and interface edges; a guessed boundary is a typed refusal, not a hint. |
 | Require construction-independent numbering | `canonicalize` at the observation boundary | Do not canonicalize every intermediate value; it is intentionally global work. |
 
+## Foreign bindings
+
+The `moonlight-triangulation-c` foreign-library component exposes opaque
+immutable geometry meshes, batch insertion, finite-set operations, and dense
+vertex and triangle projections through one versioned C ABI. Python,
+TypeScript, and Rust bindings live beneath [`bindings/`](bindings/README.md)
+and descend through that same ABI; none restates the triangulation engine or
+introduces a second mesh representation.
+
 `union a a` is `a`; commutativity and associativity hold after explicit
 `canonicalize`; and a join adds no sites: the result carries
 `|A| + |B| − |A ∩ B|` of them. Structural `Eq` remains exact resident equality
@@ -162,10 +173,9 @@
 
 main :: IO ()
 main = do
-  let build :: [(Double, Double)] -> IO (DelaunayTriangulation Double ())
-      build coords = case delaunay unitElementDefaults (fromList [Point x y | (x, y) <- coords]) of
-        Left err -> fail (show err)
-        Right result -> pure (mapVertices (const ()) (buildTriangulation result))
+  let build :: [(Double, Double)] -> IO (DelaunayTriangulation ())
+      build coords =
+        either (fail . show) pure (delaunayGeometry (fromList [Point x y | (x, y) <- coords]))
   a <- build [(x, y) | x <- [0 .. 9], y <- [0 .. 9]]
   b <- build [(x + 6, y) | x <- [0 .. 9], y <- [0 .. 9]]
 
@@ -188,10 +198,10 @@
 `refinementComplete` states that sufficiency. Where operands leave concavities,
 the same composition places Steiner sites exactly at the slivers it dissolves.
 
-Payloads annotate geometry through `mapVertices`; the input `Point`s arrive as
-their own vertex payload. The example deliberately erases them with
-`mapVertices (const ())`, while annotation-preserving restriction is available
-through `intersectionWith`, `difference`, and `symmetricDifference`. Constraints enter through
+`delaunayGeometry` constructs the unit-annotated carrier directly. Payloads can
+instead enter with their coordinates through `delaunay`, or independently
+through `delaunayFromCoordinates`; annotation-preserving restriction is
+available through `intersectionWith`, `difference`, and `symmetricDifference`. Constraints enter through
 `constrainedDelaunay`; bulk incremental work names the machine-room module
 directly (`Moonlight.Triangulation.BulkLoad` for `insertMany`,
 `Moonlight.Triangulation.Session` for the owned editing transaction —
@@ -281,9 +291,11 @@
 main :: IO ()
 main = do
   let pts = ring 4 32 <> ring 2 16 <> ring 3 24
-  mesh <- case delaunay unitElementDefaults (fromList [Point x y | (x, y) <- pts]) of
-    Left err -> fail (show err)
-    Right result -> pure (mapVertices (const () :: Point -> ()) (buildTriangulation result))
+  mesh <-
+    either
+      (fail . show)
+      pure
+      (delaunayGeometry (fromList [Point x y | (x, y) <- pts]))
   let corners f =
         [(x, y) | v <- faceVertices mesh f, let Point x y = vertexPoint mesh v]
       circumradius (ax, ay) (bx, by) (cx, cy) =
diff --git a/bench/join/Moonlight/Triangulation/JoinBench.hs b/bench/join/Moonlight/Triangulation/JoinBench.hs
--- a/bench/join/Moonlight/Triangulation/JoinBench.hs
+++ b/bench/join/Moonlight/Triangulation/JoinBench.hs
@@ -428,7 +428,7 @@
 dropAdjacentDuplicates rest = rest
 
 geometryMesh :: [Point] -> IO Mesh
-geometryMesh points = mapVertices (const ()) <$> siteMesh points
+geometryMesh points = requireRight (delaunayGeometry (V.fromList points))
 
 siteMesh :: [Point] -> IO SiteMesh
 siteMesh points =
diff --git a/bindings/README.md b/bindings/README.md
new file mode 100644
--- /dev/null
+++ b/bindings/README.md
@@ -0,0 +1,55 @@
+# Foreign bindings
+
+`moonlight-triangulation.cabal` owns a private Haskell `ffi` sublibrary and the
+`moonlight-triangulation-c` foreign library. Python, TypeScript, and Rust are
+leaf bindings over that sole C ABI; none restates the engine or mesh.
+
+From `compiler`, build and locate the shared library:
+
+```bash
+cabal build moonlight-triangulation:flib:moonlight-triangulation-c --enable-shared --project-file=cabal.project.triangulation-dev
+cabal list-bin moonlight-triangulation:flib:moonlight-triangulation-c --enable-shared --project-file=cabal.project.triangulation-dev
+```
+
+Set `MOONLIGHT_TRIANGULATION_LIBRARY` to that `.dylib`, `.so`, or `.dll`; Rust
+linking also reads its directory from `MOONLIGHT_TRIANGULATION_LIB_DIR`.
+
+The ABI accepts interleaved binary64 coordinates and publishes opaque immutable
+mesh handles. Set operations and batch insertion return new handles, leaving
+inputs valid until freed. Dense vertices and triangles follow vertex-handle and
+bounded-face-handle order. Every status-returning call accepts an optional
+`ml_obstruction` distinguishing API misuse, capacity refusal, geometric
+obstruction, and runtime failure, with retained index, value, coordinate, and
+message witnesses. `UINT64_MAX` means no input index applies.
+
+Batch related edits into one `insert_many` call; each call publishes a new mesh,
+so repeated singleton calls pay repeated publication costs.
+
+`ml_runtime_initialize` is idempotent and process-lifetime. There is no shutdown
+call because GHC cannot reliably restart after its outermost `hs_exit`.
+
+## Python
+
+```python
+from moonlight_triangulation import Moonlight
+mesh = Moonlight().delaunay([(0, 0), (1, 0), (0, 1)])
+print(mesh.vertices, mesh.triangles)
+mesh.close()
+```
+
+## TypeScript
+
+```typescript
+import { Moonlight } from "@moonlight/triangulation";
+const mesh = new Moonlight().delaunay([[0, 0], [1, 0], [0, 1]]);
+console.log(mesh.vertices(), mesh.triangles());
+mesh.close();
+```
+
+## Rust
+
+```rust
+use moonlight_triangulation::Moonlight;
+let mesh = Moonlight::initialize()?.delaunay(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]])?;
+println!("{:?} {:?}", mesh.vertices()?, mesh.triangles()?);
+```
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
new file mode 100644
--- /dev/null
+++ b/bindings/python/pyproject.toml
@@ -0,0 +1,13 @@
+[build-system]
+requires = ["hatchling>=1.27,<2"]
+build-backend = "hatchling.build"
+
+[project]
+name = "moonlight-triangulation"
+version = "1.0.0"
+description = "Python bindings for Moonlight's immutable Delaunay mesh algebra."
+requires-python = ">=3.11"
+license = { text = "MIT" }
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/moonlight_triangulation"]
diff --git a/bindings/python/src/moonlight_triangulation/__init__.py b/bindings/python/src/moonlight_triangulation/__init__.py
new file mode 100644
--- /dev/null
+++ b/bindings/python/src/moonlight_triangulation/__init__.py
@@ -0,0 +1,247 @@
+from __future__ import annotations
+
+import ctypes
+import os
+import weakref
+from collections.abc import Callable, Sequence
+from pathlib import Path
+from typing import Final
+
+
+class _Obstruction(ctypes.Structure):
+    _fields_ = [
+        ("code", ctypes.c_uint32),
+        ("coordinate_error", ctypes.c_uint32),
+        ("input_index", ctypes.c_uint64),
+        ("first_index", ctypes.c_uint64),
+        ("second_index", ctypes.c_uint64),
+        ("first_value", ctypes.c_double),
+        ("second_value", ctypes.c_double),
+        ("point_x", ctypes.c_double),
+        ("point_y", ctypes.c_double),
+        ("message", ctypes.c_char * 256),
+    ]
+
+
+class MoonlightError(RuntimeError):
+    def __init__(self, status: int, obstruction: _Obstruction) -> None:
+        self.status = status
+        self.code = obstruction.code
+        self.coordinate_error = obstruction.coordinate_error
+        self.input_index = obstruction.input_index
+        self.first_index = obstruction.first_index
+        self.second_index = obstruction.second_index
+        self.first_value = obstruction.first_value
+        self.second_value = obstruction.second_value
+        self.point = (obstruction.point_x, obstruction.point_y)
+        message = bytes(obstruction.message).split(b"\0", 1)[0].decode("utf-8", errors="replace")
+        super().__init__(message or f"Moonlight ABI failure {status}:{self.code}")
+
+
+class _NativeApi:
+    _OK: Final = 0
+
+    def __init__(self, library_path: Path) -> None:
+        library = ctypes.CDLL(str(library_path))
+        mesh = ctypes.c_void_p
+        mesh_output = ctypes.POINTER(mesh)
+        obstruction = ctypes.POINTER(_Obstruction)
+        count_output = ctypes.POINTER(ctypes.c_size_t)
+        _configure(library, "ml_abi_version", ())
+        _configure(library, "ml_runtime_initialize", ())
+        _configure(library, "ml_delaunay_f64", (ctypes.POINTER(ctypes.c_double), ctypes.c_size_t, mesh_output, obstruction))
+        _configure(library, "ml_mesh_insert_many_f64", (mesh, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t, mesh_output, obstruction))
+        _configure(library, "ml_mesh_union", (mesh, mesh, mesh_output, obstruction))
+        _configure(library, "ml_mesh_intersection", (mesh, mesh, mesh_output, obstruction))
+        _configure(library, "ml_mesh_difference", (mesh, mesh, mesh_output, obstruction))
+        _configure(library, "ml_mesh_symmetric_difference", (mesh, mesh, mesh_output, obstruction))
+        _configure(library, "ml_mesh_vertex_count", (mesh, count_output, obstruction))
+        _configure(library, "ml_mesh_triangle_count", (mesh, count_output, obstruction))
+        _configure(library, "ml_mesh_copy_vertices_f64", (mesh, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t, count_output, obstruction))
+        _configure(library, "ml_mesh_copy_triangles_u32", (mesh, ctypes.POINTER(ctypes.c_uint32), ctypes.c_size_t, count_output, obstruction))
+        _configure(library, "ml_mesh_free", (mesh,), None)
+
+        status = int(library.ml_runtime_initialize())
+        if status != self._OK:
+            raise RuntimeError(f"Moonlight runtime initialization failed with status {status}")
+        abi_version = int(library.ml_abi_version())
+        if abi_version != 1:
+            raise RuntimeError(f"unsupported Moonlight ABI version {abi_version}")
+        self.library = library
+
+    def check(self, status: int, obstruction: _Obstruction) -> None:
+        if status != self._OK:
+            raise MoonlightError(status, obstruction)
+
+
+Point = tuple[float, float]
+Triangle = tuple[int, int, int]
+_BinaryNativeOperation = Callable[[ctypes.c_void_p, ctypes.c_void_p, object, object], int]
+
+
+class Moonlight:
+    def __init__(self, library_path: str | os.PathLike[str] | None = None) -> None:
+        configured_path = library_path or os.environ.get("MOONLIGHT_TRIANGULATION_LIBRARY")
+        if configured_path is None:
+            raise ValueError("set MOONLIGHT_TRIANGULATION_LIBRARY or pass library_path")
+        self._native = _NativeApi(Path(configured_path))
+
+    def delaunay(self, points: Sequence[Point]) -> Mesh:
+        coordinates, pointer = _coordinate_buffer(points)
+        output = ctypes.c_void_p()
+        obstruction = _Obstruction()
+        status = int(
+            self._native.library.ml_delaunay_f64(
+                pointer,
+                len(points),
+                ctypes.byref(output),
+                ctypes.byref(obstruction),
+            )
+        )
+        self._native.check(status, obstruction)
+        return Mesh(self._native, _required_handle(output))
+
+
+class Mesh:
+    __slots__ = ("_native", "_handle", "_finalizer", "__weakref__")
+
+    def __init__(self, native: _NativeApi, handle: ctypes.c_void_p) -> None:
+        self._native = native
+        self._handle = handle
+        self._finalizer = weakref.finalize(self, native.library.ml_mesh_free, handle)
+
+    def close(self) -> None:
+        self._finalizer()
+        self._handle = ctypes.c_void_p()
+
+    def __enter__(self) -> Mesh:
+        return self
+
+    def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
+        self.close()
+
+    @property
+    def vertex_count(self) -> int:
+        return self._count(self._native.library.ml_mesh_vertex_count)
+
+    @property
+    def triangle_count(self) -> int:
+        return self._count(self._native.library.ml_mesh_triangle_count)
+
+    @property
+    def vertices(self) -> tuple[Point, ...]:
+        count = self.vertex_count
+        output = (ctypes.c_double * (count * 2))()
+        written = ctypes.c_size_t()
+        obstruction = _Obstruction()
+        status = int(
+            self._native.library.ml_mesh_copy_vertices_f64(
+                self._live_handle(),
+                output,
+                count,
+                ctypes.byref(written),
+                ctypes.byref(obstruction),
+            )
+        )
+        self._native.check(status, obstruction)
+        return tuple((float(output[index * 2]), float(output[index * 2 + 1])) for index in range(written.value))
+
+    @property
+    def triangles(self) -> tuple[Triangle, ...]:
+        count = self.triangle_count
+        output = (ctypes.c_uint32 * (count * 3))()
+        written = ctypes.c_size_t()
+        obstruction = _Obstruction()
+        status = int(
+            self._native.library.ml_mesh_copy_triangles_u32(
+                self._live_handle(),
+                output,
+                count,
+                ctypes.byref(written),
+                ctypes.byref(obstruction),
+            )
+        )
+        self._native.check(status, obstruction)
+        return tuple(
+            (int(output[index * 3]), int(output[index * 3 + 1]), int(output[index * 3 + 2]))
+            for index in range(written.value)
+        )
+
+    def insert_many(self, points: Sequence[Point]) -> Mesh:
+        coordinates, pointer = _coordinate_buffer(points)
+        output = ctypes.c_void_p()
+        obstruction = _Obstruction()
+        status = int(
+            self._native.library.ml_mesh_insert_many_f64(
+                self._live_handle(),
+                pointer,
+                len(points),
+                ctypes.byref(output),
+                ctypes.byref(obstruction),
+            )
+        )
+        self._native.check(status, obstruction)
+        return Mesh(self._native, _required_handle(output))
+
+    def union(self, other: Mesh) -> Mesh:
+        return self._binary(other, self._native.library.ml_mesh_union)
+
+    def intersection(self, other: Mesh) -> Mesh:
+        return self._binary(other, self._native.library.ml_mesh_intersection)
+
+    def difference(self, other: Mesh) -> Mesh:
+        return self._binary(other, self._native.library.ml_mesh_difference)
+
+    def symmetric_difference(self, other: Mesh) -> Mesh:
+        return self._binary(other, self._native.library.ml_mesh_symmetric_difference)
+
+    def _binary(self, other: Mesh, operation: _BinaryNativeOperation) -> Mesh:
+        if self._native is not other._native:
+            raise ValueError("both meshes must belong to the same Moonlight runtime")
+        output = ctypes.c_void_p()
+        obstruction = _Obstruction()
+        status = int(
+            operation(
+                self._live_handle(),
+                other._live_handle(),
+                ctypes.byref(output),
+                ctypes.byref(obstruction),
+            )
+        )
+        self._native.check(status, obstruction)
+        return Mesh(self._native, _required_handle(output))
+
+    def _count(self, operation: Callable[[ctypes.c_void_p, object, object], int]) -> int:
+        output = ctypes.c_size_t()
+        obstruction = _Obstruction()
+        status = int(operation(self._live_handle(), ctypes.byref(output), ctypes.byref(obstruction)))
+        self._native.check(status, obstruction)
+        return int(output.value)
+
+    def _live_handle(self) -> ctypes.c_void_p:
+        if not self._finalizer.alive:
+            raise RuntimeError("mesh is closed")
+        return self._handle
+
+
+def _coordinate_buffer(points: Sequence[Point]) -> tuple[object, object]:
+    values = tuple(component for x, y in points for component in (x, y))
+    if not values:
+        return (), None
+    coordinates = (ctypes.c_double * len(values))(*values)
+    return coordinates, coordinates
+
+
+def _required_handle(handle: ctypes.c_void_p) -> ctypes.c_void_p:
+    if not handle.value:
+        raise RuntimeError("Moonlight returned success without a mesh handle")
+    return handle
+
+
+def _configure(library: ctypes.CDLL, name: str, parameters: Sequence[object], result: object = ctypes.c_uint32) -> None:
+    function = getattr(library, name)
+    setattr(function, "argtypes", list(parameters))
+    setattr(function, "restype", result)
+
+
+__all__ = ["Mesh", "Moonlight", "MoonlightError", "Point", "Triangle"]
diff --git a/bindings/python/tests/test_binding.py b/bindings/python/tests/test_binding.py
new file mode 100644
--- /dev/null
+++ b/bindings/python/tests/test_binding.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import math
+import os
+import unittest
+from typing import ClassVar
+
+from moonlight_triangulation import Moonlight, MoonlightError
+
+
+class MoonlightBindingTest(unittest.TestCase):
+    engine: ClassVar[Moonlight]
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.engine = Moonlight(os.environ["MOONLIGHT_TRIANGULATION_LIBRARY"])
+
+    def test_immutable_mesh_algebra_and_dense_projection(self) -> None:
+        left = self.engine.delaunay([(0, 0), (2, 0), (0, 2), (2, 2)])
+        right = self.engine.delaunay([(2, 0), (4, 0), (2, 2), (4, 2)])
+        union = left.union(right)
+        intersection = left.intersection(right)
+        difference = left.difference(right)
+        symmetric = left.symmetric_difference(right)
+        extended = left.insert_many([(1, 1), (3, 1)])
+        self.addCleanup(left.close)
+        self.addCleanup(right.close)
+        self.addCleanup(union.close)
+        self.addCleanup(intersection.close)
+        self.addCleanup(difference.close)
+        self.addCleanup(symmetric.close)
+        self.addCleanup(extended.close)
+
+        self.assertEqual(left.vertex_count, 4)
+        self.assertEqual(union.vertex_count, 6)
+        self.assertEqual(intersection.vertex_count, 2)
+        self.assertEqual(difference.vertex_count, 2)
+        self.assertEqual(symmetric.vertex_count, 4)
+        self.assertEqual(extended.vertex_count, 6)
+        self.assertEqual(len(left.vertices), left.vertex_count)
+        self.assertEqual(len(left.triangles), left.triangle_count)
+        self.assertTrue(all(max(triangle) < left.vertex_count for triangle in left.triangles))
+
+    def test_invalid_coordinate_preserves_typed_witness(self) -> None:
+        with self.assertRaises(MoonlightError) as raised:
+            self.engine.delaunay([(0, 0), (math.nan, 1), (1, 0)])
+        self.assertEqual(raised.exception.status, 4)
+        self.assertEqual(raised.exception.code, 1)
+        self.assertEqual(raised.exception.coordinate_error, 1)
+        self.assertEqual(raised.exception.input_index, 1)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/bindings/rust/Cargo.toml b/bindings/rust/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/bindings/rust/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "moonlight-triangulation"
+version = "1.0.0"
+edition = "2024"
+license = "MIT"
+description = "Rust bindings for Moonlight's immutable Delaunay mesh algebra."
+repository = "https://github.com/PaleRoses/moonlight.git"
+build = "build.rs"
+links = "moonlight-triangulation-c"
+
+[lib]
+path = "src/lib.rs"
+
+[workspace]
diff --git a/bindings/rust/build.rs b/bindings/rust/build.rs
new file mode 100644
--- /dev/null
+++ b/bindings/rust/build.rs
@@ -0,0 +1,11 @@
+use std::env;
+
+fn main() {
+    println!("cargo:rerun-if-env-changed=MOONLIGHT_TRIANGULATION_LIB_DIR");
+    if let Some(directory) = env::var_os("MOONLIGHT_TRIANGULATION_LIB_DIR") {
+        println!(
+            "cargo:rustc-link-search=native={}",
+            directory.to_string_lossy()
+        );
+    }
+}
diff --git a/bindings/rust/src/lib.rs b/bindings/rust/src/lib.rs
new file mode 100644
--- /dev/null
+++ b/bindings/rust/src/lib.rs
@@ -0,0 +1,373 @@
+use std::error::Error;
+use std::ffi::{c_char, c_double, c_uint};
+use std::fmt::{Display, Formatter};
+use std::marker::PhantomData;
+use std::ptr::NonNull;
+use std::rc::Rc;
+
+const STATUS_OK: u32 = 0;
+const ABI_VERSION: u32 = 1;
+
+#[repr(C)]
+struct NativeMesh {
+    _private: [u8; 0],
+}
+
+#[repr(C)]
+struct NativeObstruction {
+    code: u32,
+    coordinate_error: u32,
+    input_index: u64,
+    first_index: u64,
+    second_index: u64,
+    first_value: f64,
+    second_value: f64,
+    point_x: f64,
+    point_y: f64,
+    message: [c_char; 256],
+}
+
+impl Default for NativeObstruction {
+    fn default() -> Self {
+        Self {
+            code: 0,
+            coordinate_error: 0,
+            input_index: u64::MAX,
+            first_index: 0,
+            second_index: 0,
+            first_value: 0.0,
+            second_value: 0.0,
+            point_x: 0.0,
+            point_y: 0.0,
+            message: [0; 256],
+        }
+    }
+}
+
+#[link(name = "moonlight-triangulation-c")]
+unsafe extern "C" {
+    fn ml_abi_version() -> c_uint;
+    fn ml_runtime_initialize() -> c_uint;
+    fn ml_delaunay_f64(
+        coordinates: *const c_double,
+        point_count: usize,
+        result: *mut *mut NativeMesh,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_insert_many_f64(
+        mesh: *const NativeMesh,
+        coordinates: *const c_double,
+        point_count: usize,
+        result: *mut *mut NativeMesh,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_union(
+        left: *const NativeMesh,
+        right: *const NativeMesh,
+        result: *mut *mut NativeMesh,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_intersection(
+        left: *const NativeMesh,
+        right: *const NativeMesh,
+        result: *mut *mut NativeMesh,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_difference(
+        left: *const NativeMesh,
+        right: *const NativeMesh,
+        result: *mut *mut NativeMesh,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_symmetric_difference(
+        left: *const NativeMesh,
+        right: *const NativeMesh,
+        result: *mut *mut NativeMesh,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_vertex_count(
+        mesh: *const NativeMesh,
+        count: *mut usize,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_triangle_count(
+        mesh: *const NativeMesh,
+        count: *mut usize,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_copy_vertices_f64(
+        mesh: *const NativeMesh,
+        coordinates: *mut c_double,
+        point_capacity: usize,
+        points_written: *mut usize,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_copy_triangles_u32(
+        mesh: *const NativeMesh,
+        triangles: *mut u32,
+        triangle_capacity: usize,
+        triangles_written: *mut usize,
+        obstruction: *mut NativeObstruction,
+    ) -> c_uint;
+    fn ml_mesh_free(mesh: *mut NativeMesh);
+}
+
+type BinaryMeshOperation = unsafe extern "C" fn(
+    *const NativeMesh,
+    *const NativeMesh,
+    *mut *mut NativeMesh,
+    *mut NativeObstruction,
+) -> c_uint;
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct MoonlightError {
+    pub status: u32,
+    pub code: u32,
+    pub coordinate_error: u32,
+    pub input_index: u64,
+    pub first_index: u64,
+    pub second_index: u64,
+    pub first_value: f64,
+    pub second_value: f64,
+    pub point: [f64; 2],
+    pub message: String,
+}
+
+impl Display for MoonlightError {
+    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(formatter, "{}", self.message)
+    }
+}
+
+impl Error for MoonlightError {}
+
+pub struct Moonlight;
+
+impl Moonlight {
+    pub fn initialize() -> Result<Self, MoonlightError> {
+        let status = unsafe { ml_runtime_initialize() };
+        if status != STATUS_OK {
+            return Err(synthetic_error(
+                status,
+                "Moonlight runtime initialization failed",
+                0,
+                0,
+            ));
+        }
+        let version = unsafe { ml_abi_version() };
+        if version != ABI_VERSION {
+            return Err(synthetic_error(
+                5,
+                format!("unsupported Moonlight ABI version {version}"),
+                u64::from(version),
+                u64::from(ABI_VERSION),
+            ));
+        }
+        Ok(Self)
+    }
+
+    pub fn delaunay(&self, points: &[[f64; 2]]) -> Result<Mesh, MoonlightError> {
+        let coordinates = flatten_points(points);
+        create_mesh(|output, obstruction| unsafe {
+            ml_delaunay_f64(coordinates.as_ptr(), points.len(), output, obstruction)
+        })
+    }
+}
+
+pub struct Mesh {
+    handle: NonNull<NativeMesh>,
+    _thread_affinity: PhantomData<Rc<()>>,
+}
+
+impl Mesh {
+    pub fn vertex_count(&self) -> Result<usize, MoonlightError> {
+        self.count(ml_mesh_vertex_count)
+    }
+
+    pub fn triangle_count(&self) -> Result<usize, MoonlightError> {
+        self.count(ml_mesh_triangle_count)
+    }
+
+    pub fn vertices(&self) -> Result<Vec<[f64; 2]>, MoonlightError> {
+        let count = self.vertex_count()?;
+        let mut coordinates = vec![0.0; count * 2];
+        let mut written = 0;
+        let mut obstruction = NativeObstruction::default();
+        let status = unsafe {
+            ml_mesh_copy_vertices_f64(
+                self.handle.as_ptr(),
+                coordinates.as_mut_ptr(),
+                count,
+                &mut written,
+                &mut obstruction,
+            )
+        };
+        status_result(status, obstruction)?;
+        coordinates.truncate(written * 2);
+        Ok(coordinates
+            .chunks_exact(2)
+            .map(|point| [point[0], point[1]])
+            .collect())
+    }
+
+    pub fn triangles(&self) -> Result<Vec<[u32; 3]>, MoonlightError> {
+        let count = self.triangle_count()?;
+        let mut triangles = vec![0; count * 3];
+        let mut written = 0;
+        let mut obstruction = NativeObstruction::default();
+        let status = unsafe {
+            ml_mesh_copy_triangles_u32(
+                self.handle.as_ptr(),
+                triangles.as_mut_ptr(),
+                count,
+                &mut written,
+                &mut obstruction,
+            )
+        };
+        status_result(status, obstruction)?;
+        triangles.truncate(written * 3);
+        Ok(triangles
+            .chunks_exact(3)
+            .map(|triangle| [triangle[0], triangle[1], triangle[2]])
+            .collect())
+    }
+
+    pub fn insert_many(&self, points: &[[f64; 2]]) -> Result<Self, MoonlightError> {
+        let coordinates = flatten_points(points);
+        create_mesh(|output, obstruction| unsafe {
+            ml_mesh_insert_many_f64(
+                self.handle.as_ptr(),
+                coordinates.as_ptr(),
+                points.len(),
+                output,
+                obstruction,
+            )
+        })
+    }
+
+    pub fn union(&self, other: &Self) -> Result<Self, MoonlightError> {
+        self.binary(other, ml_mesh_union)
+    }
+
+    pub fn intersection(&self, other: &Self) -> Result<Self, MoonlightError> {
+        self.binary(other, ml_mesh_intersection)
+    }
+
+    pub fn difference(&self, other: &Self) -> Result<Self, MoonlightError> {
+        self.binary(other, ml_mesh_difference)
+    }
+
+    pub fn symmetric_difference(&self, other: &Self) -> Result<Self, MoonlightError> {
+        self.binary(other, ml_mesh_symmetric_difference)
+    }
+
+    fn binary(&self, other: &Self, operation: BinaryMeshOperation) -> Result<Self, MoonlightError> {
+        create_mesh(|output, obstruction| unsafe {
+            operation(
+                self.handle.as_ptr(),
+                other.handle.as_ptr(),
+                output,
+                obstruction,
+            )
+        })
+    }
+
+    fn count(
+        &self,
+        operation: unsafe extern "C" fn(
+            *const NativeMesh,
+            *mut usize,
+            *mut NativeObstruction,
+        ) -> c_uint,
+    ) -> Result<usize, MoonlightError> {
+        let mut count = 0;
+        let mut obstruction = NativeObstruction::default();
+        let status = unsafe { operation(self.handle.as_ptr(), &mut count, &mut obstruction) };
+        status_result(status, obstruction)?;
+        Ok(count)
+    }
+}
+
+impl Drop for Mesh {
+    fn drop(&mut self) {
+        unsafe { ml_mesh_free(self.handle.as_ptr()) }
+    }
+}
+
+fn flatten_points(points: &[[f64; 2]]) -> Vec<f64> {
+    points.iter().flat_map(|[x, y]| [*x, *y]).collect()
+}
+
+#[inline]
+fn create_mesh(
+    operation: impl FnOnce(*mut *mut NativeMesh, *mut NativeObstruction) -> u32,
+) -> Result<Mesh, MoonlightError> {
+    let mut output = std::ptr::null_mut();
+    let mut obstruction = NativeObstruction::default();
+    let status = operation(&mut output, &mut obstruction);
+    status_result(status, obstruction)?;
+    NonNull::new(output)
+        .map(|handle| Mesh {
+            handle,
+            _thread_affinity: PhantomData,
+        })
+        .ok_or_else(success_without_handle_error)
+}
+
+fn status_result(status: u32, obstruction: NativeObstruction) -> Result<(), MoonlightError> {
+    if status == STATUS_OK {
+        Ok(())
+    } else {
+        Err(MoonlightError::from_native(status, obstruction))
+    }
+}
+
+impl MoonlightError {
+    fn from_native(status: u32, obstruction: NativeObstruction) -> Self {
+        let message_end = obstruction
+            .message
+            .iter()
+            .position(|character| *character == 0)
+            .unwrap_or(obstruction.message.len());
+        let message_bytes = obstruction.message[..message_end]
+            .iter()
+            .map(|character| *character as u8)
+            .collect::<Vec<_>>();
+        Self {
+            status,
+            code: obstruction.code,
+            coordinate_error: obstruction.coordinate_error,
+            input_index: obstruction.input_index,
+            first_index: obstruction.first_index,
+            second_index: obstruction.second_index,
+            first_value: obstruction.first_value,
+            second_value: obstruction.second_value,
+            point: [obstruction.point_x, obstruction.point_y],
+            message: String::from_utf8_lossy(&message_bytes).into_owned(),
+        }
+    }
+}
+
+fn success_without_handle_error() -> MoonlightError {
+    synthetic_error(5, "Moonlight returned success without a mesh handle", 0, 0)
+}
+
+fn synthetic_error(
+    status: u32,
+    message: impl Into<String>,
+    first_index: u64,
+    second_index: u64,
+) -> MoonlightError {
+    MoonlightError {
+        status,
+        code: 103,
+        coordinate_error: 0,
+        input_index: u64::MAX,
+        first_index,
+        second_index,
+        first_value: 0.0,
+        second_value: 0.0,
+        point: [0.0, 0.0],
+        message: message.into(),
+    }
+}
diff --git a/bindings/rust/tests/binding.rs b/bindings/rust/tests/binding.rs
new file mode 100644
--- /dev/null
+++ b/bindings/rust/tests/binding.rs
@@ -0,0 +1,49 @@
+use moonlight_triangulation::Moonlight;
+
+#[test]
+fn immutable_mesh_algebra_and_dense_projection() {
+    let engine = Moonlight::initialize().expect("runtime");
+    let left = engine
+        .delaunay(&[[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0]])
+        .expect("left mesh");
+    let right = engine
+        .delaunay(&[[2.0, 0.0], [4.0, 0.0], [2.0, 2.0], [4.0, 2.0]])
+        .expect("right mesh");
+    let union = left.union(&right).expect("union");
+    let intersection = left.intersection(&right).expect("intersection");
+    let difference = left.difference(&right).expect("difference");
+    let symmetric = left
+        .symmetric_difference(&right)
+        .expect("symmetric difference");
+    let extended = left
+        .insert_many(&[[1.0, 1.0], [3.0, 1.0]])
+        .expect("batch insertion");
+
+    assert_eq!(left.vertex_count().expect("left count"), 4);
+    assert_eq!(union.vertex_count().expect("union count"), 6);
+    assert_eq!(intersection.vertex_count().expect("intersection count"), 2);
+    assert_eq!(difference.vertex_count().expect("difference count"), 2);
+    assert_eq!(symmetric.vertex_count().expect("symmetric count"), 4);
+    assert_eq!(extended.vertex_count().expect("extended count"), 6);
+    assert_eq!(
+        left.vertices().expect("vertices").len(),
+        left.vertex_count().expect("vertex count")
+    );
+    assert_eq!(
+        left.triangles().expect("triangles").len(),
+        left.triangle_count().expect("triangle count")
+    );
+}
+
+#[test]
+fn invalid_coordinate_preserves_typed_witness() {
+    let engine = Moonlight::initialize().expect("runtime");
+    let failure = engine
+        .delaunay(&[[0.0, 0.0], [f64::NAN, 1.0], [1.0, 0.0]])
+        .err()
+        .expect("invalid coordinate refusal");
+    assert_eq!(failure.status, 4);
+    assert_eq!(failure.code, 1);
+    assert_eq!(failure.coordinate_error, 1);
+    assert_eq!(failure.input_index, 1);
+}
diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json
new file mode 100644
--- /dev/null
+++ b/bindings/typescript/package.json
@@ -0,0 +1,33 @@
+{
+  "name": "@moonlight/triangulation",
+  "version": "1.0.0",
+  "description": "TypeScript bindings for Moonlight's immutable Delaunay mesh algebra.",
+  "type": "module",
+  "main": "./dist/src/index.js",
+  "types": "./dist/src/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/src/index.d.ts",
+      "import": "./dist/src/index.js"
+    }
+  },
+  "files": [
+    "dist/src"
+  ],
+  "scripts": {
+    "build": "tsc -p tsconfig.json",
+    "test": "node --test dist/test/binding.test.js"
+  },
+  "engines": {
+    "node": ">=20"
+  },
+  "dependencies": {
+    "koffi": "3.1.4"
+  },
+  "devDependencies": {
+    "@types/node": "26.2.0",
+    "typescript": "7.0.2"
+  },
+  "license": "MIT",
+  "packageManager": "pnpm@11.21.0"
+}
diff --git a/bindings/typescript/pnpm-workspace.yaml b/bindings/typescript/pnpm-workspace.yaml
new file mode 100644
--- /dev/null
+++ b/bindings/typescript/pnpm-workspace.yaml
@@ -0,0 +1,5 @@
+packages:
+  - .
+
+allowBuilds:
+  koffi: true
diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts
new file mode 100644
--- /dev/null
+++ b/bindings/typescript/src/index.ts
@@ -0,0 +1,280 @@
+import koffi from "koffi";
+
+const STATUS_OK = 0;
+const ABI_VERSION = 1;
+
+export type Point = readonly [x: number, y: number];
+export type Triangle = readonly [first: number, second: number, third: number];
+
+type MeshHandle = object;
+type NativeInteger = number | bigint;
+
+interface NativeObstruction {
+  code?: number;
+  coordinate_error?: number;
+  input_index?: NativeInteger;
+  first_index?: NativeInteger;
+  second_index?: NativeInteger;
+  first_value?: number;
+  second_value?: number;
+  point_x?: number;
+  point_y?: number;
+  message?: string | readonly number[];
+}
+
+type BuildOperation = (coordinates: Float64Array, pointCount: number, output: Array<MeshHandle | null>, obstruction: NativeObstruction) => number;
+type InsertOperation = (mesh: MeshHandle, coordinates: Float64Array, pointCount: number, output: Array<MeshHandle | null>, obstruction: NativeObstruction) => number;
+type BinaryOperation = (left: MeshHandle, right: MeshHandle, output: Array<MeshHandle | null>, obstruction: NativeObstruction) => number;
+type CountOperation = (mesh: MeshHandle, output: number[], obstruction: NativeObstruction) => number;
+
+interface NativeApi {
+  readonly abiVersion: () => number;
+  readonly runtimeInitialize: () => number;
+  readonly delaunay: BuildOperation;
+  readonly insertMany: InsertOperation;
+  readonly union: BinaryOperation;
+  readonly intersection: BinaryOperation;
+  readonly difference: BinaryOperation;
+  readonly symmetricDifference: BinaryOperation;
+  readonly vertexCount: CountOperation;
+  readonly triangleCount: CountOperation;
+  readonly copyVertices: (mesh: MeshHandle, coordinates: Float64Array, capacity: number, written: number[], obstruction: NativeObstruction) => number;
+  readonly copyTriangles: (mesh: MeshHandle, triangles: Uint32Array, capacity: number, written: number[], obstruction: NativeObstruction) => number;
+  readonly free: (mesh: MeshHandle) => void;
+}
+
+interface FinalizerState {
+  readonly free: (mesh: MeshHandle) => void;
+  readonly handle: MeshHandle;
+}
+
+const meshFinalizer = new FinalizationRegistry<FinalizerState>(({ free, handle }) => free(handle));
+
+export class MoonlightError extends Error {
+  readonly status: number;
+  readonly code: number;
+  readonly coordinateError: number;
+  readonly inputIndex: bigint;
+  readonly firstIndex: bigint;
+  readonly secondIndex: bigint;
+  readonly firstValue: number;
+  readonly secondValue: number;
+  readonly point: Point;
+
+  constructor(status: number, obstruction: NativeObstruction) {
+    super(obstructionMessage(obstruction));
+    this.name = "MoonlightError";
+    this.status = status;
+    this.code = obstruction.code ?? 0;
+    this.coordinateError = obstruction.coordinate_error ?? 0;
+    this.inputIndex = toBigInt(obstruction.input_index);
+    this.firstIndex = toBigInt(obstruction.first_index);
+    this.secondIndex = toBigInt(obstruction.second_index);
+    this.firstValue = obstruction.first_value ?? 0;
+    this.secondValue = obstruction.second_value ?? 0;
+    this.point = [obstruction.point_x ?? 0, obstruction.point_y ?? 0];
+  }
+}
+
+export class Moonlight {
+  readonly #native: NativeApi;
+
+  constructor(libraryPath = process.env.MOONLIGHT_TRIANGULATION_LIBRARY) {
+    if (libraryPath === undefined || libraryPath.length === 0) {
+      throw new Error("set MOONLIGHT_TRIANGULATION_LIBRARY or pass libraryPath");
+    }
+    const native = createNativeApi(libraryPath);
+    const initializationStatus = native.runtimeInitialize();
+    if (initializationStatus !== STATUS_OK) {
+      throw new Error(`Moonlight runtime initialization failed with status ${initializationStatus}`);
+    }
+    const abiVersion = native.abiVersion();
+    if (abiVersion !== ABI_VERSION) {
+      throw new Error(`unsupported Moonlight ABI version ${abiVersion}`);
+    }
+    this.#native = native;
+  }
+
+  delaunay(points: readonly Point[]): Mesh {
+    const output: Array<MeshHandle | null> = [null];
+    const obstruction: NativeObstruction = {};
+    const status = this.#native.delaunay(flattenPoints(points), points.length, output, obstruction);
+    checkStatus(status, obstruction);
+    return new Mesh(this.#native, requiredHandle(output[0]));
+  }
+}
+
+export class Mesh {
+  readonly #native: NativeApi;
+  #handle: MeshHandle | null;
+
+  constructor(native: NativeApi, handle: MeshHandle) {
+    this.#native = native;
+    this.#handle = handle;
+    meshFinalizer.register(this, { free: native.free, handle }, this);
+  }
+
+  close(): void {
+    const handle = this.#handle;
+    if (handle !== null) {
+      meshFinalizer.unregister(this);
+      this.#native.free(handle);
+      this.#handle = null;
+    }
+  }
+
+  vertexCount(): number {
+    return this.#count(this.#native.vertexCount);
+  }
+
+  triangleCount(): number {
+    return this.#count(this.#native.triangleCount);
+  }
+
+  vertices(): readonly Point[] {
+    const count = this.vertexCount();
+    const coordinates = new Float64Array(count * 2);
+    const written = [0];
+    const obstruction: NativeObstruction = {};
+    const status = this.#native.copyVertices(this.#liveHandle(), coordinates, count, written, obstruction);
+    checkStatus(status, obstruction);
+    return Array.from({ length: written[0] ?? 0 }, (_unused, index): Point => [
+      coordinates[index * 2] ?? 0,
+      coordinates[index * 2 + 1] ?? 0,
+    ]);
+  }
+
+  triangles(): readonly Triangle[] {
+    const count = this.triangleCount();
+    const triangles = new Uint32Array(count * 3);
+    const written = [0];
+    const obstruction: NativeObstruction = {};
+    const status = this.#native.copyTriangles(this.#liveHandle(), triangles, count, written, obstruction);
+    checkStatus(status, obstruction);
+    return Array.from({ length: written[0] ?? 0 }, (_unused, index): Triangle => [
+      triangles[index * 3] ?? 0,
+      triangles[index * 3 + 1] ?? 0,
+      triangles[index * 3 + 2] ?? 0,
+    ]);
+  }
+
+  insertMany(points: readonly Point[]): Mesh {
+    const output: Array<MeshHandle | null> = [null];
+    const obstruction: NativeObstruction = {};
+    const status = this.#native.insertMany(this.#liveHandle(), flattenPoints(points), points.length, output, obstruction);
+    checkStatus(status, obstruction);
+    return new Mesh(this.#native, requiredHandle(output[0]));
+  }
+
+  union(other: Mesh): Mesh {
+    return this.#binary(other, this.#native.union);
+  }
+
+  intersection(other: Mesh): Mesh {
+    return this.#binary(other, this.#native.intersection);
+  }
+
+  difference(other: Mesh): Mesh {
+    return this.#binary(other, this.#native.difference);
+  }
+
+  symmetricDifference(other: Mesh): Mesh {
+    return this.#binary(other, this.#native.symmetricDifference);
+  }
+
+  #binary(other: Mesh, operation: BinaryOperation): Mesh {
+    if (this.#native !== other.#native) {
+      throw new Error("both meshes must belong to the same Moonlight runtime");
+    }
+    const output: Array<MeshHandle | null> = [null];
+    const obstruction: NativeObstruction = {};
+    const status = operation(this.#liveHandle(), other.#liveHandle(), output, obstruction);
+    checkStatus(status, obstruction);
+    return new Mesh(this.#native, requiredHandle(output[0]));
+  }
+
+  #count(operation: CountOperation): number {
+    const output = [0];
+    const obstruction: NativeObstruction = {};
+    const status = operation(this.#liveHandle(), output, obstruction);
+    checkStatus(status, obstruction);
+    return output[0] ?? 0;
+  }
+
+  #liveHandle(): MeshHandle {
+    if (this.#handle === null) {
+      throw new Error("mesh is closed");
+    }
+    return this.#handle;
+  }
+}
+
+function createNativeApi(libraryPath: string): NativeApi {
+  const library = koffi.load(libraryPath);
+  const mesh = koffi.opaque();
+  const meshPointer = koffi.pointer(mesh);
+  const meshOutput = koffi.out(koffi.pointer(mesh, 2));
+  const obstruction = koffi.struct({
+    code: "uint32_t",
+    coordinate_error: "uint32_t",
+    input_index: "uint64_t",
+    first_index: "uint64_t",
+    second_index: "uint64_t",
+    first_value: "double",
+    second_value: "double",
+    point_x: "double",
+    point_y: "double",
+    message: koffi.array("char", 256),
+  });
+  const obstructionOutput = koffi.out(koffi.pointer(obstruction));
+  const meshCountOutput = koffi.out(koffi.pointer("size_t"));
+  return {
+    abiVersion: library.func("ml_abi_version", "uint32_t", []),
+    runtimeInitialize: library.func("ml_runtime_initialize", "uint32_t", []),
+    delaunay: library.func("ml_delaunay_f64", "uint32_t", [koffi.pointer("double"), "size_t", meshOutput, obstructionOutput]),
+    insertMany: library.func("ml_mesh_insert_many_f64", "uint32_t", [meshPointer, koffi.pointer("double"), "size_t", meshOutput, obstructionOutput]),
+    union: library.func("ml_mesh_union", "uint32_t", [meshPointer, meshPointer, meshOutput, obstructionOutput]),
+    intersection: library.func("ml_mesh_intersection", "uint32_t", [meshPointer, meshPointer, meshOutput, obstructionOutput]),
+    difference: library.func("ml_mesh_difference", "uint32_t", [meshPointer, meshPointer, meshOutput, obstructionOutput]),
+    symmetricDifference: library.func("ml_mesh_symmetric_difference", "uint32_t", [meshPointer, meshPointer, meshOutput, obstructionOutput]),
+    vertexCount: library.func("ml_mesh_vertex_count", "uint32_t", [meshPointer, meshCountOutput, obstructionOutput]),
+    triangleCount: library.func("ml_mesh_triangle_count", "uint32_t", [meshPointer, meshCountOutput, obstructionOutput]),
+    copyVertices: library.func("ml_mesh_copy_vertices_f64", "uint32_t", [meshPointer, koffi.out(koffi.pointer("double")), "size_t", meshCountOutput, obstructionOutput]),
+    copyTriangles: library.func("ml_mesh_copy_triangles_u32", "uint32_t", [meshPointer, koffi.out(koffi.pointer("uint32_t")), "size_t", meshCountOutput, obstructionOutput]),
+    free: library.func("ml_mesh_free", "void", [meshPointer]),
+  };
+}
+
+function flattenPoints(points: readonly Point[]): Float64Array {
+  return Float64Array.from(points.flatMap(([x, y]) => [x, y]));
+}
+
+function checkStatus(status: number, obstruction: NativeObstruction): void {
+  if (status !== STATUS_OK) {
+    throw new MoonlightError(status, obstruction);
+  }
+}
+
+function requiredHandle(handle: MeshHandle | null | undefined): MeshHandle {
+  if (handle === null || handle === undefined) {
+    throw new Error("Moonlight returned success without a mesh handle");
+  }
+  return handle;
+}
+
+function toBigInt(value: NativeInteger | undefined): bigint {
+  return value === undefined ? 0n : BigInt(value);
+}
+
+function obstructionMessage(obstruction: NativeObstruction): string {
+  const message = obstruction.message;
+  if (message === undefined) {
+    return `Moonlight ABI failure ${obstruction.code ?? 0}`;
+  }
+  if (typeof message === "string") {
+    return message.split("\0", 1)[0] ?? "";
+  }
+  const terminator = message.indexOf(0);
+  const length = terminator === -1 ? message.length : terminator;
+  return new TextDecoder().decode(Uint8Array.from(message.slice(0, length)));
+}
diff --git a/bindings/typescript/test/binding.test.ts b/bindings/typescript/test/binding.test.ts
new file mode 100644
--- /dev/null
+++ b/bindings/typescript/test/binding.test.ts
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { Moonlight, MoonlightError } from "../src/index.js";
+
+test("immutable mesh algebra and dense projection", () => {
+  const engine = new Moonlight();
+  const left = engine.delaunay([[0, 0], [2, 0], [0, 2], [2, 2]]);
+  const right = engine.delaunay([[2, 0], [4, 0], [2, 2], [4, 2]]);
+  const meshes = [
+    left,
+    right,
+    left.union(right),
+    left.intersection(right),
+    left.difference(right),
+    left.symmetricDifference(right),
+    left.insertMany([[1, 1], [3, 1]]),
+  ];
+  const [original, _right, union, intersection, difference, symmetric, extended] = meshes;
+  assert.equal(original?.vertexCount(), 4);
+  assert.equal(union?.vertexCount(), 6);
+  assert.equal(intersection?.vertexCount(), 2);
+  assert.equal(difference?.vertexCount(), 2);
+  assert.equal(symmetric?.vertexCount(), 4);
+  assert.equal(extended?.vertexCount(), 6);
+  assert.equal(original?.vertices().length, original?.vertexCount());
+  assert.equal(original?.triangles().length, original?.triangleCount());
+  meshes.forEach((mesh) => mesh.close());
+});
+
+test("invalid coordinate preserves typed witness", () => {
+  const engine = new Moonlight();
+  assert.throws(
+    () => engine.delaunay([[0, 0], [Number.NaN, 1], [1, 0]]),
+    (failure: unknown) =>
+      failure instanceof MoonlightError &&
+      failure.status === 4 &&
+      failure.code === 1 &&
+      failure.coordinateError === 1 &&
+      failure.inputIndex === 1n,
+  );
+});
diff --git a/bindings/typescript/tsconfig.json b/bindings/typescript/tsconfig.json
new file mode 100644
--- /dev/null
+++ b/bindings/typescript/tsconfig.json
@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ES2023",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "strict": true,
+    "noImplicitAny": true,
+    "strictNullChecks": true,
+    "noUncheckedIndexedAccess": true,
+    "exactOptionalPropertyTypes": true,
+    "types": [
+      "node"
+    ],
+    "declaration": true,
+    "rootDir": ".",
+    "outDir": "dist",
+    "skipLibCheck": false
+  },
+  "include": [
+    "src/**/*.ts",
+    "test/**/*.ts"
+  ]
+}
diff --git a/cbits/moonlight-triangulation.def b/cbits/moonlight-triangulation.def
new file mode 100644
--- /dev/null
+++ b/cbits/moonlight-triangulation.def
@@ -0,0 +1,15 @@
+LIBRARY moonlight-triangulation-c
+EXPORTS
+  ml_abi_version
+  ml_runtime_initialize
+  ml_delaunay_f64
+  ml_mesh_insert_many_f64
+  ml_mesh_union
+  ml_mesh_intersection
+  ml_mesh_difference
+  ml_mesh_symmetric_difference
+  ml_mesh_vertex_count
+  ml_mesh_triangle_count
+  ml_mesh_copy_vertices_f64
+  ml_mesh_copy_triangles_u32
+  ml_mesh_free
diff --git a/cbits/moonlight_runtime.c b/cbits/moonlight_runtime.c
new file mode 100644
--- /dev/null
+++ b/cbits/moonlight_runtime.c
@@ -0,0 +1,48 @@
+#include "moonlight_triangulation.h"
+#include "HsFFI.h"
+
+#if defined(_WIN32)
+#include <windows.h>
+
+static INIT_ONCE moonlight_runtime_once = INIT_ONCE_STATIC_INIT;
+
+static BOOL CALLBACK moonlight_initialize_runtime(PINIT_ONCE once, PVOID parameter, PVOID *context) {
+  int argc = 1;
+  char program_name[] = "moonlight-triangulation";
+  char *argv[] = {program_name, NULL};
+  char **argv_pointer = argv;
+  (void)once;
+  (void)parameter;
+  (void)context;
+  hs_init(&argc, &argv_pointer);
+  return TRUE;
+}
+
+ML_API ml_status ml_runtime_initialize(void) {
+  return InitOnceExecuteOnce(&moonlight_runtime_once, moonlight_initialize_runtime, NULL, NULL)
+    ? ML_STATUS_OK
+    : ML_STATUS_RUNTIME_FAILURE;
+}
+#else
+#include <pthread.h>
+
+static pthread_once_t moonlight_runtime_once = PTHREAD_ONCE_INIT;
+
+static void moonlight_initialize_runtime(void) {
+  int argc = 1;
+  char program_name[] = "moonlight-triangulation";
+  char *argv[] = {program_name, NULL};
+  char **argv_pointer = argv;
+  hs_init(&argc, &argv_pointer);
+}
+
+ML_API ml_status ml_runtime_initialize(void) {
+  return pthread_once(&moonlight_runtime_once, moonlight_initialize_runtime) == 0
+    ? ML_STATUS_OK
+    : ML_STATUS_RUNTIME_FAILURE;
+}
+#endif
+
+ML_API uint32_t ml_abi_version(void) {
+  return 1;
+}
diff --git a/include/moonlight_triangulation.h b/include/moonlight_triangulation.h
new file mode 100644
--- /dev/null
+++ b/include/moonlight_triangulation.h
@@ -0,0 +1,132 @@
+#ifndef MOONLIGHT_TRIANGULATION_H
+#define MOONLIGHT_TRIANGULATION_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#if defined(_WIN32)
+#define ML_API __declspec(dllexport)
+#else
+#define ML_API __attribute__((visibility("default")))
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct ml_mesh ml_mesh;
+typedef uint32_t ml_status;
+typedef uint32_t ml_obstruction_code;
+typedef uint32_t ml_coordinate_error;
+
+enum {
+  ML_STATUS_OK = 0,
+  ML_STATUS_NULL_POINTER = 1,
+  ML_STATUS_COUNT_OVERFLOW = 2,
+  ML_STATUS_BUFFER_TOO_SMALL = 3,
+  ML_STATUS_BUILD_OBSTRUCTION = 4,
+  ML_STATUS_RUNTIME_FAILURE = 5
+};
+
+enum {
+  ML_OBSTRUCTION_NONE = 0,
+  ML_OBSTRUCTION_INVALID_COORDINATE = 1,
+  ML_OBSTRUCTION_POINT_LOCATION_FAILED = 2,
+  ML_OBSTRUCTION_LOCATION_WALK_EXHAUSTED = 3,
+  ML_OBSTRUCTION_REFINEMENT_INPUT_TOPOLOGY_INVALID = 4,
+  ML_OBSTRUCTION_FRESH_INSERTION_MATCHED_EXISTING_VERTEX = 5,
+  ML_OBSTRUCTION_DEGENERATE_LINE_ENDPOINT_MISSING_OUTGOING = 6,
+  ML_OBSTRUCTION_DEGENERATE_LINE_ENDPOINT_TURN_MISSING = 7,
+  ML_OBSTRUCTION_DEGENERATE_LINE_CONNECTED_VERTEX_MISSING = 8,
+  ML_OBSTRUCTION_HULL_START_NOT_VISIBLE = 9,
+  ML_OBSTRUCTION_OUTER_RANGE_DID_NOT_TERMINATE = 10,
+  ML_OBSTRUCTION_OUTER_RANGE_CONTAINS_INNER_EDGE = 11,
+  ML_OBSTRUCTION_CONSTRAINED_EDGE_FLIP_REFUSED = 12,
+  ML_OBSTRUCTION_REMOVAL_VERTEX_OUT_OF_RANGE = 13,
+  ML_OBSTRUCTION_REMOVAL_EDGE_OUT_OF_RANGE = 14,
+  ML_OBSTRUCTION_REMOVAL_FACE_OUT_OF_RANGE = 15,
+  ML_OBSTRUCTION_REMOVAL_FACE_CYCLE_DID_NOT_TERMINATE = 16,
+  ML_OBSTRUCTION_REMOVAL_EMPTY_TRIANGULATION = 17,
+  ML_OBSTRUCTION_REMOVAL_TWO_POINT_DEGREE_MISMATCH = 18,
+  ML_OBSTRUCTION_REMOVAL_COLLINEAR_DEGREE_MISMATCH = 19,
+  ML_OBSTRUCTION_REMOVAL_BORDER_TOO_SHORT = 20,
+  ML_OBSTRUCTION_REMOVAL_BORDER_ARITY_MISMATCH = 21,
+  ML_OBSTRUCTION_REMOVAL_OUTGOING_CYCLE_DID_NOT_TERMINATE = 22,
+  ML_OBSTRUCTION_CIRCLE_SWEEP_HULL_EMPTY = 23,
+  ML_OBSTRUCTION_OUTER_CYCLE_DID_NOT_TERMINATE = 24,
+  ML_OBSTRUCTION_HIERARCHY_LEVEL_POPULATION_MISMATCH = 25,
+  ML_OBSTRUCTION_HIERARCHY_INSERTION_HANDLE_MISMATCH = 26,
+  ML_OBSTRUCTION_POINT_INDEX_CAPACITY_EXHAUSTED = 27,
+  ML_OBSTRUCTION_REFINEMENT_MINIMUM_ANGLE_NOT_FINITE = 28,
+  ML_OBSTRUCTION_REFINEMENT_MINIMUM_ANGLE_OUT_OF_RANGE = 29,
+  ML_OBSTRUCTION_REFINEMENT_MINIMUM_ANGLE_DERIVED_RATIO_NOT_FINITE = 30,
+  ML_OBSTRUCTION_REFINEMENT_MAXIMUM_ADDITIONAL_VERTICES_NEGATIVE = 31,
+  ML_OBSTRUCTION_REFINEMENT_MINIMUM_AREA_NOT_FINITE = 32,
+  ML_OBSTRUCTION_REFINEMENT_MINIMUM_AREA_NEGATIVE = 33,
+  ML_OBSTRUCTION_REFINEMENT_MAXIMUM_AREA_NOT_FINITE = 34,
+  ML_OBSTRUCTION_REFINEMENT_MAXIMUM_AREA_NOT_POSITIVE = 35,
+  ML_OBSTRUCTION_REFINEMENT_MAXIMUM_RADIUS_EDGE_RATIO_NOT_FINITE = 36,
+  ML_OBSTRUCTION_REFINEMENT_MAXIMUM_RADIUS_EDGE_RATIO_NOT_POSITIVE = 37,
+  ML_OBSTRUCTION_REFINEMENT_MINIMUM_AREA_EXCEEDS_MAXIMUM = 38,
+  ML_OBSTRUCTION_REFINEMENT_SEED_FACE_NOT_ACTIVE = 39,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_INTERFACE_EDGE_NOT_ACTIVE = 40,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_INTERFACE_MISSING = 41,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_INTERFACE_EXTRANEOUS = 42,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_TOPOLOGY_CHANGED = 43,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_REQUIRES_CONVEX_HULL_PRESERVATION = 44,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_REQUIRES_CONSTRAINT_PRESERVATION = 45,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_FORBIDS_OUTER_FACE_EXCLUSION = 46,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_WOULD_CROSS_INTERFACE = 47,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_WOULD_REWRITE_PROTECTED_FACE = 48,
+  ML_OBSTRUCTION_REFINEMENT_DOMAIN_PROTECTED_FACE_CHANGED = 49,
+  ML_OBSTRUCTION_CAPACITY_EXCEEDED = 50,
+  ML_OBSTRUCTION_HALF_EDGE_CAPACITY_EXCEEDED = 51,
+  ML_OBSTRUCTION_FACE_CAPACITY_EXCEEDED = 52,
+  ML_OBSTRUCTION_PAYLOAD_STORAGE_FAILURE = 53,
+  ML_OBSTRUCTION_COORDINATE_PAYLOAD_COUNT_MISMATCH = 54,
+  ML_OBSTRUCTION_NULL_POINTER = 100,
+  ML_OBSTRUCTION_COUNT_OVERFLOW = 101,
+  ML_OBSTRUCTION_BUFFER_TOO_SMALL = 102,
+  ML_OBSTRUCTION_RUNTIME_FAILURE = 103
+};
+
+enum {
+  ML_COORDINATE_ERROR_NONE = 0,
+  ML_COORDINATE_ERROR_NAN = 1,
+  ML_COORDINATE_ERROR_INFINITE = 2,
+  ML_COORDINATE_ERROR_TOO_SMALL = 3,
+  ML_COORDINATE_ERROR_TOO_LARGE = 4
+};
+
+typedef struct ml_obstruction {
+  uint32_t code;
+  uint32_t coordinate_error;
+  uint64_t input_index;
+  uint64_t first_index;
+  uint64_t second_index;
+  double first_value;
+  double second_value;
+  double point_x;
+  double point_y;
+  char message[256];
+} ml_obstruction;
+
+ML_API uint32_t ml_abi_version(void);
+ML_API ml_status ml_runtime_initialize(void);
+ML_API ml_status ml_delaunay_f64(const double *coordinates, size_t point_count, ml_mesh **result, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_insert_many_f64(const ml_mesh *mesh, const double *coordinates, size_t point_count, ml_mesh **result, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_union(const ml_mesh *left, const ml_mesh *right, ml_mesh **result, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_intersection(const ml_mesh *left, const ml_mesh *right, ml_mesh **result, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_difference(const ml_mesh *left, const ml_mesh *right, ml_mesh **result, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_symmetric_difference(const ml_mesh *left, const ml_mesh *right, ml_mesh **result, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_vertex_count(const ml_mesh *mesh, size_t *count, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_triangle_count(const ml_mesh *mesh, size_t *count, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_copy_vertices_f64(const ml_mesh *mesh, double *coordinates, size_t point_capacity, size_t *points_written, ml_obstruction *obstruction);
+ML_API ml_status ml_mesh_copy_triangles_u32(const ml_mesh *mesh, uint32_t *triangles, size_t triangle_capacity, size_t *triangles_written, ml_obstruction *obstruction);
+ML_API void ml_mesh_free(ml_mesh *mesh);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/moonlight-triangulation.cabal b/moonlight-triangulation.cabal
--- a/moonlight-triangulation.cabal
+++ b/moonlight-triangulation.cabal
@@ -1,6 +1,6 @@
 cabal-version:       3.4
 name:                moonlight-triangulation
-version:             1.0.0.0
+version:             1.0.1.0
 synopsis:            Delaunay triangulations as a lawful finite-set algebra.
 description:         Delaunay and constrained Delaunay triangulation as a lawful
                      finite-set algebra: a mesh is a value of its site set, so
@@ -34,6 +34,20 @@
   CHANGELOG.md
 extra-source-files:
   weeder.toml
+  include/moonlight_triangulation.h
+  bindings/README.md
+  bindings/python/pyproject.toml
+  bindings/python/src/moonlight_triangulation/__init__.py
+  bindings/python/tests/test_binding.py
+  bindings/rust/Cargo.toml
+  bindings/rust/build.rs
+  bindings/rust/src/lib.rs
+  bindings/rust/tests/binding.rs
+  bindings/typescript/package.json
+  bindings/typescript/pnpm-workspace.yaml
+  bindings/typescript/tsconfig.json
+  bindings/typescript/src/index.ts
+  bindings/typescript/test/binding.test.ts
 
 source-repository head
   type:     git
@@ -260,6 +274,43 @@
     , moonlight-triangulation:dual
   ghc-options: -fexpose-all-unfoldings
 
+library ffi
+  import: shared-properties
+  visibility: private
+  hs-source-dirs: src-ffi
+  exposed-modules:
+    Moonlight.Triangulation.Foreign.ABI
+  build-depends:
+    base >= 4.20 && < 5
+    , vector >= 0.13 && < 0.14
+    , moonlight-triangulation
+    , moonlight-triangulation:build
+    , moonlight-triangulation:dcel
+
+foreign-library moonlight-triangulation-c
+  import: shared-properties
+  type: native-shared
+  hs-source-dirs: src-capi
+  other-modules:
+    Moonlight.Triangulation.Foreign.Exports
+  c-sources:
+    cbits/moonlight_runtime.c
+  include-dirs:
+    include
+  install-includes:
+    moonlight_triangulation.h
+  build-depends:
+    base >= 4.20 && < 5
+    , moonlight-triangulation:ffi >= 1.0 && < 1.1
+  ghc-options: -threaded
+  if os(windows)
+    options: standalone
+    mod-def-file: cbits/moonlight-triangulation.def
+  else
+    extra-libraries: pthread
+  if os(linux)
+    lib-version-info: 1:0:0
+
 -- ── test slices ──────────────────────────────────────────────────────────────
 
 -- Each slice is an ATOM: a @common@ stanza binding a spec module to the
@@ -390,6 +441,15 @@
     test/parallel
     test/algebra
     test/support
+
+test-suite moonlight-triangulation-ffi-test
+  import: shared-properties
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  hs-source-dirs: test/ffi
+  build-depends:
+    base >= 4.20 && < 5
+    , moonlight-triangulation:ffi
 
 -- This component owns only cross-slice compile coherence.  Behavioral ownership
 -- remains in the four focused suites.  The shared test-properties stanza keeps
diff --git a/src-build/Moonlight/Triangulation/BulkLoad.hs b/src-build/Moonlight/Triangulation/BulkLoad.hs
--- a/src-build/Moonlight/Triangulation/BulkLoad.hs
+++ b/src-build/Moonlight/Triangulation/BulkLoad.hs
@@ -10,6 +10,7 @@
   ( empty
   , clear
   , delaunay
+  , delaunayGeometry
   , DuplicatePayloadPolicy (..)
   , delaunayFromCoordinates
   , insert
@@ -116,6 +117,23 @@
     (position . (input V.!))
     (input V.!)
     KeepFirstPayload
+
+-- | Build the geometry-only Delaunay triangulation of a coordinate vector.
+-- Exact duplicate positions collapse to one site. Use 'delaunay' or
+-- 'delaunayFromCoordinates' when vertex annotations or the input-to-vertex
+-- mapping are part of the result.
+delaunayGeometry
+  :: V.Vector Point
+  -> Either BuildError (DelaunayTriangulation ())
+delaunayGeometry coordinates = do
+  V.iforM_ coordinates (\index point -> validatePoint (Just index) point)
+  buildTriangulation
+    <$> buildDelaunayFromSource
+          unitElementDefaults
+          (V.length coordinates)
+          (coordinates V.!)
+          (const ())
+          KeepFirstPayload
 
 -- | Canonical construction from separate geometry and annotation sources.
 -- The coordinate vector remains the only geometry in ingress; payloads never
diff --git a/src-build/Moonlight/Triangulation/SetAlgebra.hs b/src-build/Moonlight/Triangulation/SetAlgebra.hs
--- a/src-build/Moonlight/Triangulation/SetAlgebra.hs
+++ b/src-build/Moonlight/Triangulation/SetAlgebra.hs
@@ -17,7 +17,7 @@
 import Data.Maybe (isJust)
 import qualified Data.Vector as V
 import Moonlight.Triangulation.BulkLoad (empty)
-import Moonlight.Triangulation.Dcel (numVertices, vertexData, vertexPoint)
+import Moonlight.Triangulation.Dcel (numVertices, vertexData, vertexPoint, vertexPoints)
 import Moonlight.Triangulation.Handles.Iterators.FixedIterators (vertices)
 import Moonlight.Triangulation.Internal.Join (joinBalanced, joinNormalForm)
 import Moonlight.Triangulation.Internal.Join.Rebuild (rebuildCanonicalSiteSet)
@@ -137,7 +137,7 @@
   | numVertices left == 0 || numVertices right == 0 = Right left
   | numVertices right < numVertices left
   , removalDeltaIsEligible (numVertices right) (numVertices left - numVertices right) =
-      removeAvailableFrom left (V.fromList (fmap (vertexPoint right) (vertices right)))
+      removeAvailableFrom left (vertexPoints right)
   | otherwise =
       rebuildCanonicalSiteSet
         ( siteSetDifference
diff --git a/src-capi/Moonlight/Triangulation/Foreign/Exports.hs b/src-capi/Moonlight/Triangulation/Foreign/Exports.hs
new file mode 100644
--- /dev/null
+++ b/src-capi/Moonlight/Triangulation/Foreign/Exports.hs
@@ -0,0 +1,34 @@
+{-# LANGUAGE ForeignFunctionInterface #-}
+{-# OPTIONS_GHC -Wno-missing-signatures #-}
+
+module Moonlight.Triangulation.Foreign.Exports where
+
+import Data.Word (Word32)
+import Foreign.C.Types (CDouble, CSize (..), CUInt (..))
+import Foreign.Ptr (Ptr)
+import Moonlight.Triangulation.Foreign.ABI (CObstruction)
+import qualified Moonlight.Triangulation.Foreign.ABI as ABI
+
+delaunayF64 = ABI.delaunayF64
+meshInsertManyF64 = ABI.meshInsertManyF64
+meshUnion = ABI.meshUnion
+meshIntersection = ABI.meshIntersection
+meshDifference = ABI.meshDifference
+meshSymmetricDifference = ABI.meshSymmetricDifference
+meshVertexCount = ABI.meshVertexCount
+meshTriangleCount = ABI.meshTriangleCount
+meshCopyVerticesF64 = ABI.meshCopyVerticesF64
+meshCopyTrianglesU32 = ABI.meshCopyTrianglesU32
+meshFree = ABI.meshFree
+
+foreign export ccall "ml_delaunay_f64" delaunayF64 :: Ptr CDouble -> CSize -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_insert_many_f64" meshInsertManyF64 :: Ptr () -> Ptr CDouble -> CSize -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_union" meshUnion :: Ptr () -> Ptr () -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_intersection" meshIntersection :: Ptr () -> Ptr () -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_difference" meshDifference :: Ptr () -> Ptr () -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_symmetric_difference" meshSymmetricDifference :: Ptr () -> Ptr () -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_vertex_count" meshVertexCount :: Ptr () -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_triangle_count" meshTriangleCount :: Ptr () -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_copy_vertices_f64" meshCopyVerticesF64 :: Ptr () -> Ptr CDouble -> CSize -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_copy_triangles_u32" meshCopyTrianglesU32 :: Ptr () -> Ptr Word32 -> CSize -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+foreign export ccall "ml_mesh_free" meshFree :: Ptr () -> IO ()
diff --git a/src-ffi/Moonlight/Triangulation/Foreign/ABI.hs b/src-ffi/Moonlight/Triangulation/Foreign/ABI.hs
new file mode 100644
--- /dev/null
+++ b/src-ffi/Moonlight/Triangulation/Foreign/ABI.hs
@@ -0,0 +1,445 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Moonlight.Triangulation.Foreign.ABI
+  ( CObstruction (..)
+  , delaunayF64
+  , meshInsertManyF64
+  , meshUnion
+  , meshIntersection
+  , meshDifference
+  , meshSymmetricDifference
+  , meshVertexCount
+  , meshTriangleCount
+  , meshCopyVerticesF64
+  , meshCopyTrianglesU32
+  , meshFree
+  ) where
+
+import Control.Exception (SomeException, displayException, try)
+import Control.Monad (void)
+import Data.Foldable (traverse_)
+import Data.Word (Word32, Word64)
+import Foreign.C.String (peekCString, withCStringLen)
+import Foreign.C.Types (CDouble (..), CSize (..), CUInt (..))
+import Foreign.Marshal.Utils (copyBytes, fillBytes)
+import Foreign.Ptr (Ptr, castPtr, nullPtr, plusPtr)
+import Foreign.StablePtr
+  ( StablePtr
+  , castPtrToStablePtr
+  , castStablePtrToPtr
+  , deRefStablePtr
+  , freeStablePtr
+  , newStablePtr
+  )
+import Foreign.Storable (Storable (..))
+import qualified Data.Vector as V
+import qualified Moonlight.Triangulation as T
+import Moonlight.Triangulation.Math (validatePoint)
+import qualified Moonlight.Triangulation.Session as Session
+
+type GeometryMesh = T.DelaunayTriangulation ()
+
+data CObstruction = CObstruction
+  { obstructionCode :: !Word32
+  , obstructionCoordinateError :: !Word32
+  , obstructionInputIndex :: !Word64
+  , obstructionFirstIndex :: !Word64
+  , obstructionSecondIndex :: !Word64
+  , obstructionFirstValue :: !Double
+  , obstructionSecondValue :: !Double
+  , obstructionPointX :: !Double
+  , obstructionPointY :: !Double
+  , obstructionMessage :: !String
+  }
+  deriving stock (Eq, Show)
+
+instance Storable CObstruction where
+  sizeOf _ = 320
+  alignment _ = alignment (undefined :: Word64)
+  peek pointer = do
+    obstructionCode <- peekByteOff pointer 0
+    obstructionCoordinateError <- peekByteOff pointer 4
+    obstructionInputIndex <- peekByteOff pointer 8
+    obstructionFirstIndex <- peekByteOff pointer 16
+    obstructionSecondIndex <- peekByteOff pointer 24
+    obstructionFirstValue <- peekByteOff pointer 32
+    obstructionSecondValue <- peekByteOff pointer 40
+    obstructionPointX <- peekByteOff pointer 48
+    obstructionPointY <- peekByteOff pointer 56
+    obstructionMessage <- peekCString (castPtr pointer `plusPtr` 64)
+    pure CObstruction {..}
+  poke pointer CObstruction {..} = do
+    pokeByteOff pointer 0 obstructionCode
+    pokeByteOff pointer 4 obstructionCoordinateError
+    pokeByteOff pointer 8 obstructionInputIndex
+    pokeByteOff pointer 16 obstructionFirstIndex
+    pokeByteOff pointer 24 obstructionSecondIndex
+    pokeByteOff pointer 32 obstructionFirstValue
+    pokeByteOff pointer 40 obstructionSecondValue
+    pokeByteOff pointer 48 obstructionPointX
+    pokeByteOff pointer 56 obstructionPointY
+    let messagePointer = castPtr pointer `plusPtr` 64
+    fillBytes messagePointer 0 256
+    withCStringLen obstructionMessage $ \(source, lengthInBytes) ->
+      copyBytes messagePointer source (min 255 lengthInBytes)
+
+data AbiFailure = AbiFailure !CUInt !CObstruction
+
+statusOk, statusNullPointer, statusCountOverflow, statusBufferTooSmall, statusBuildObstruction, statusRuntimeFailure :: CUInt
+statusOk = 0
+statusNullPointer = 1
+statusCountOverflow = 2
+statusBufferTooSmall = 3
+statusBuildObstruction = 4
+statusRuntimeFailure = 5
+
+emptyObstruction :: CObstruction
+emptyObstruction =
+  CObstruction
+    { obstructionCode = 0
+    , obstructionCoordinateError = 0
+    , obstructionInputIndex = maxBound
+    , obstructionFirstIndex = 0
+    , obstructionSecondIndex = 0
+    , obstructionFirstValue = 0
+    , obstructionSecondValue = 0
+    , obstructionPointX = 0
+    , obstructionPointY = 0
+    , obstructionMessage = ""
+    }
+
+apiFailure :: CUInt -> Word32 -> String -> AbiFailure
+apiFailure status code message =
+  AbiFailure status emptyObstruction {obstructionCode = code, obstructionMessage = message}
+
+nullPointerFailure :: String -> AbiFailure
+nullPointerFailure label = apiFailure statusNullPointer 100 (label <> " must not be null")
+
+countOverflowFailure :: Word64 -> AbiFailure
+countOverflowFailure count =
+  AbiFailure statusCountOverflow emptyObstruction
+    { obstructionCode = 101
+    , obstructionFirstIndex = count
+    , obstructionMessage = "count exceeds the host Int range"
+    }
+
+bufferTooSmallFailure :: Int -> Int -> AbiFailure
+bufferTooSmallFailure required capacity =
+  AbiFailure statusBufferTooSmall emptyObstruction
+    { obstructionCode = 102
+    , obstructionFirstIndex = fromIntegral required
+    , obstructionSecondIndex = fromIntegral capacity
+    , obstructionMessage = "output buffer is smaller than the required element count"
+    }
+
+runtimeFailure :: SomeException -> AbiFailure
+runtimeFailure = apiFailure statusRuntimeFailure 103 . displayException
+
+runBoundary :: Ptr CObstruction -> IO (Either AbiFailure ()) -> IO CUInt
+runBoundary obstructionPointer action = do
+  writeObstruction obstructionPointer emptyObstruction
+  outcome <- try action :: IO (Either SomeException (Either AbiFailure ()))
+  case outcome of
+    Left exception -> finishFailure obstructionPointer (runtimeFailure exception)
+    Right (Left failure) -> finishFailure obstructionPointer failure
+    Right (Right ()) -> pure statusOk
+
+finishFailure :: Ptr CObstruction -> AbiFailure -> IO CUInt
+finishFailure obstructionPointer (AbiFailure status obstruction) = do
+  writeObstruction obstructionPointer obstruction
+  pure status
+
+writeObstruction :: Ptr CObstruction -> CObstruction -> IO ()
+writeObstruction pointer obstruction
+  | pointer == nullPtr = pure ()
+  | otherwise = poke pointer obstruction
+
+requirePointer :: String -> Ptr value -> Either AbiFailure ()
+requirePointer label pointer
+  | pointer == nullPtr = Left (nullPointerFailure label)
+  | otherwise = Right ()
+
+checkedCount :: Int -> CSize -> Either AbiFailure Int
+checkedCount elementsPerItem rawCount
+  | toInteger rawCount * toInteger elementsPerItem > toInteger (maxBound :: Int) =
+      Left (countOverflowFailure (fromIntegral rawCount))
+  | otherwise = Right (fromIntegral rawCount)
+
+readPoints :: Ptr CDouble -> Int -> IO (Either AbiFailure (V.Vector T.Point))
+readPoints pointer count
+  | count == 0 = pure (Right V.empty)
+  | pointer == nullPtr = pure (Left (nullPointerFailure "coordinates"))
+  | otherwise =
+      Right
+        <$> V.generateM
+              count
+              ( \index -> do
+                  CDouble x <- peekElemOff pointer (index * 2)
+                  CDouble y <- peekElemOff pointer (index * 2 + 1)
+                  pure (T.Point x y)
+              )
+
+prepareMeshOutput :: Ptr (Ptr ()) -> IO (Either AbiFailure ())
+prepareMeshOutput pointer =
+  case requirePointer "result" pointer of
+    Left failure -> pure (Left failure)
+    Right () -> poke pointer nullPtr >> pure (Right ())
+
+publishMesh :: Ptr (Ptr ()) -> GeometryMesh -> IO ()
+publishMesh output mesh = do
+  stable <- newStablePtr mesh
+  poke output (castStablePtrToPtr stable)
+
+produceMesh :: Ptr (Ptr ()) -> IO (Either AbiFailure (Either T.BuildError GeometryMesh)) -> IO (Either AbiFailure ())
+produceMesh output obtain = do
+  prepared <- prepareMeshOutput output
+  case prepared of
+    Left failure -> pure (Left failure)
+    Right () -> do
+      outcome <- obtain
+      case outcome of
+        Left failure -> pure (Left failure)
+        Right (Left obstruction) ->
+          pure (Left (AbiFailure statusBuildObstruction (buildErrorObstruction obstruction)))
+        Right (Right mesh) -> publishMesh output mesh >> pure (Right ())
+
+delaunayF64 :: Ptr CDouble -> CSize -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+delaunayF64 coordinates rawCount output obstructionPointer =
+  runBoundary obstructionPointer $ produceMesh output $ do
+    case checkedCount 2 rawCount of
+      Left failure -> pure (Left failure)
+      Right count -> fmap (fmap T.delaunayGeometry) (readPoints coordinates count)
+
+meshInsertManyF64 :: Ptr () -> Ptr CDouble -> CSize -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+meshInsertManyF64 meshPointer coordinates rawCount output obstructionPointer =
+  runBoundary obstructionPointer $ produceMesh output $ do
+    case (requirePointer "mesh" meshPointer, checkedCount 2 rawCount) of
+      (Left failure, _) -> pure (Left failure)
+      (_, Left failure) -> pure (Left failure)
+      (Right (), Right count) -> do
+        pointsOutcome <- readPoints coordinates count
+        case pointsOutcome of
+          Left failure -> pure (Left failure)
+          Right points -> do
+            mesh <- dereferenceMesh meshPointer
+            pure (Right (insertGeometryBatch mesh points))
+
+insertGeometryBatch :: GeometryMesh -> V.Vector T.Point -> Either T.BuildError GeometryMesh
+insertGeometryBatch mesh points = do
+  normalized <-
+    V.imapM
+      (\index point -> T.queryPointValue <$> validatePoint (Just index) point)
+      points
+  (_, revised, _) <-
+    Session.withSession
+      mesh
+      (V.length normalized)
+      (traverse_ (\point -> void (Session.insertVertexAt point ())) normalized)
+  pure revised
+
+meshUnion, meshIntersection, meshDifference, meshSymmetricDifference :: Ptr () -> Ptr () -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+meshUnion = binaryMeshOperation T.union
+meshIntersection = binaryMeshOperation T.intersection
+meshDifference = binaryMeshOperation T.difference
+meshSymmetricDifference = binaryMeshOperation T.symmetricDifference
+
+binaryMeshOperation :: (GeometryMesh -> GeometryMesh -> Either T.BuildError GeometryMesh) -> Ptr () -> Ptr () -> Ptr (Ptr ()) -> Ptr CObstruction -> IO CUInt
+binaryMeshOperation operation leftPointer rightPointer output obstructionPointer =
+  runBoundary obstructionPointer $ produceMesh output $ do
+    case (requirePointer "left mesh" leftPointer, requirePointer "right mesh" rightPointer) of
+      (Left failure, _) -> pure (Left failure)
+      (_, Left failure) -> pure (Left failure)
+      (Right (), Right ()) -> do
+        left <- dereferenceMesh leftPointer
+        right <- dereferenceMesh rightPointer
+        pure (Right (operation left right))
+
+meshVertexCount, meshTriangleCount :: Ptr () -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+meshVertexCount = meshCount T.numVertices
+meshTriangleCount = meshCount (V.length . T.innerFaceVertexTriples)
+
+meshCount :: (GeometryMesh -> Int) -> Ptr () -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+meshCount observe meshPointer output obstructionPointer =
+  runBoundary obstructionPointer $
+    case (requirePointer "mesh" meshPointer, requirePointer "count" output) of
+      (Left failure, _) -> pure (Left failure)
+      (_, Left failure) -> pure (Left failure)
+      (Right (), Right ()) -> do
+        mesh <- dereferenceMesh meshPointer
+        poke output (fromIntegral (observe mesh))
+        pure (Right ())
+
+meshCopyVerticesF64 :: Ptr () -> Ptr CDouble -> CSize -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+meshCopyVerticesF64 =
+  copyMeshProjection
+    "points_written"
+    "coordinates"
+    T.vertexPoints
+    ( \output index (T.Point x y) -> do
+        pokeElemOff output (index * 2) (CDouble x)
+        pokeElemOff output (index * 2 + 1) (CDouble y)
+    )
+
+meshCopyTrianglesU32 :: Ptr () -> Ptr Word32 -> CSize -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+meshCopyTrianglesU32 =
+  copyMeshProjection
+    "triangles_written"
+    "triangles"
+    T.innerFaceVertexTriples
+    ( \output index (first, second, third) -> do
+        pokeElemOff output (index * 3) (T.unVertexId first)
+        pokeElemOff output (index * 3 + 1) (T.unVertexId second)
+        pokeElemOff output (index * 3 + 2) (T.unVertexId third)
+    )
+
+copyMeshProjection
+  :: String -> String -> (GeometryMesh -> V.Vector item) -> (Ptr element -> Int -> item -> IO ())
+  -> Ptr () -> Ptr element -> CSize -> Ptr CSize -> Ptr CObstruction -> IO CUInt
+{-# INLINE copyMeshProjection #-}
+copyMeshProjection writtenLabel outputLabel project writeItem meshPointer output rawCapacity written obstructionPointer =
+  runBoundary obstructionPointer $
+    case (requirePointer "mesh" meshPointer, requirePointer writtenLabel written, checkedCount 1 rawCapacity) of
+      (Left failure, _, _) -> pure (Left failure)
+      (_, Left failure, _) -> pure (Left failure)
+      (_, _, Left failure) -> pure (Left failure)
+      (Right (), Right (), Right capacity) -> do
+        mesh <- dereferenceMesh meshPointer
+        let items = project mesh
+            required = V.length items
+        poke written (fromIntegral required)
+        case requireOutputCapacity outputLabel output required capacity of
+          Left failure -> pure (Left failure)
+          Right () -> V.imapM_ (writeItem output) items >> pure (Right ())
+
+requireOutputCapacity :: String -> Ptr value -> Int -> Int -> Either AbiFailure ()
+requireOutputCapacity label output required capacity
+  | capacity < required = Left (bufferTooSmallFailure required capacity)
+  | required > 0 = requirePointer label output
+  | otherwise = Right ()
+
+dereferenceMesh :: Ptr () -> IO GeometryMesh
+dereferenceMesh = deRefStablePtr . (castPtrToStablePtr :: Ptr () -> StablePtr GeometryMesh)
+
+meshFree :: Ptr () -> IO ()
+meshFree pointer
+  | pointer == nullPtr = pure ()
+  | otherwise = freeStablePtr ((castPtrToStablePtr pointer) :: StablePtr GeometryMesh)
+
+buildErrorObstruction :: T.BuildError -> CObstruction
+buildErrorObstruction failure =
+  (case failure of
+    T.InvalidCoordinate inputIndex value reason ->
+      emptyObstruction
+        { obstructionCode = 1
+        , obstructionCoordinateError = coordinateErrorCode reason
+        , obstructionInputIndex = maybe maxBound fromIntegral inputIndex
+        , obstructionFirstValue = value
+        }
+    T.PointLocationFailed (T.Point x y) -> pointObstruction 2 x y
+    T.LocationWalkExhausted (T.Point x y) steps ->
+      (pointObstruction 3 x y) {obstructionFirstIndex = fromIntegral steps}
+    T.RefinementInputTopologyInvalid _ -> codeOnly 4
+    T.FreshInsertionMatchedExistingVertex first second -> indices 5 (T.unVertexId first) (T.unVertexId second)
+    T.DegenerateLineEndpointMissingOutgoing vertex -> firstIndex 6 (T.unVertexId vertex)
+    T.DegenerateLineEndpointTurnMissing index -> firstIndex 7 index
+    T.DegenerateLineConnectedVertexMissing index -> firstIndex 8 index
+    T.HullStartNotVisible edge -> firstIndex 9 (T.unDirectedEdgeId edge)
+    T.OuterRangeDidNotTerminate first second steps ->
+      (indices 10 (T.unDirectedEdgeId first) (T.unDirectedEdgeId second))
+        {obstructionFirstValue = fromIntegral steps}
+    T.OuterRangeContainsInnerEdge edge face -> indices 11 (T.unDirectedEdgeId edge) (T.unFaceId face)
+    T.ConstrainedEdgeFlipRefused edge -> firstIndex 12 (T.unUndirectedEdgeId edge)
+    T.RemovalVertexOutOfRange vertex count -> indices 13 (T.unVertexId vertex) count
+    T.RemovalEdgeOutOfRange edge count -> indices 14 (T.unUndirectedEdgeId edge) count
+    T.RemovalFaceOutOfRange face count -> indices 15 (T.unFaceId face) count
+    T.RemovalFaceCycleDidNotTerminate face edge steps ->
+      (indices 16 (T.unFaceId face) (T.unDirectedEdgeId edge))
+        {obstructionFirstValue = fromIntegral steps}
+    T.RemovalEmptyTriangulation vertex -> firstIndex 17 (T.unVertexId vertex)
+    T.RemovalTwoPointDegreeMismatch vertex degree -> indices 18 (T.unVertexId vertex) degree
+    T.RemovalCollinearDegreeMismatch vertex degree -> indices 19 (T.unVertexId vertex) degree
+    T.RemovalBorderTooShort count -> firstIndex 20 count
+    T.RemovalBorderArityMismatch count -> firstIndex 21 count
+    T.RemovalOutgoingCycleDidNotTerminate vertex edge steps ->
+      (indices 22 (T.unVertexId vertex) (T.unDirectedEdgeId edge))
+        {obstructionFirstValue = fromIntegral steps}
+    T.CircleSweepHullEmpty -> codeOnly 23
+    T.OuterCycleDidNotTerminate first second steps ->
+      (indices 24 (T.unDirectedEdgeId first) (T.unDirectedEdgeId second))
+        {obstructionFirstValue = fromIntegral steps}
+    T.HierarchyLevelPopulationMismatch level expected observed ->
+      (indices 25 expected observed) {obstructionFirstValue = fromIntegral level}
+    T.HierarchyInsertionHandleMismatch expected observed -> indices 26 (T.unVertexId expected) (T.unVertexId observed)
+    T.PointIndexCapacityExhausted count -> firstIndex 27 count
+    T.RefinementMinimumAngleNotFinite value -> nonFinite 28 value
+    T.RefinementMinimumAngleOutOfRange value -> firstValue 29 value
+    T.RefinementMinimumAngleDerivedRatioNotFinite value -> nonFinite 30 value
+    T.RefinementMaximumAdditionalVerticesNegative value -> firstValue 31 (fromIntegral value)
+    T.RefinementMinimumAreaNotFinite value -> nonFinite 32 value
+    T.RefinementMinimumAreaNegative value -> firstValue 33 value
+    T.RefinementMaximumAreaNotFinite value -> nonFinite 34 value
+    T.RefinementMaximumAreaNotPositive value -> firstValue 35 value
+    T.RefinementMaximumRadiusEdgeRatioNotFinite value -> nonFinite 36 value
+    T.RefinementMaximumRadiusEdgeRatioNotPositive value -> firstValue 37 value
+    T.RefinementMinimumAreaExceedsMaximum minimumArea maximumArea -> values 38 minimumArea maximumArea
+    T.RefinementSeedFaceNotActive face count -> indices 39 (T.unFaceId face) count
+    T.RefinementDomainInterfaceEdgeNotActive edge count -> indices 40 (T.unUndirectedEdgeId edge) count
+    T.RefinementDomainInterfaceMissing edge -> firstIndex 41 (T.unUndirectedEdgeId edge)
+    T.RefinementDomainInterfaceExtraneous edge -> firstIndex 42 (T.unUndirectedEdgeId edge)
+    T.RefinementDomainTopologyChanged -> codeOnly 43
+    T.RefinementDomainRequiresConvexHullPreservation -> codeOnly 44
+    T.RefinementDomainRequiresConstraintPreservation -> codeOnly 45
+    T.RefinementDomainForbidsOuterFaceExclusion -> codeOnly 46
+    T.RefinementDomainWouldCrossInterface edge face -> indices 47 (T.unUndirectedEdgeId edge) (T.unFaceId face)
+    T.RefinementDomainWouldRewriteProtectedFace face -> firstIndex 48 (T.unFaceId face)
+    T.RefinementDomainProtectedFaceChanged face -> firstIndex 49 (T.unFaceId face)
+    T.CapacityExceeded count -> firstIndex 50 count
+    T.HalfEdgeCapacityExceeded requested capacity -> indices 51 requested capacity
+    T.FaceCapacityExceeded requested capacity -> indices 52 requested capacity
+    T.PayloadStorageFailure _ -> codeOnly 53
+    T.CoordinatePayloadCountMismatch coordinates payloads -> indices 54 coordinates payloads
+  )
+    {obstructionMessage = show failure}
+ where
+  codeOnly :: Word32 -> CObstruction
+  codeOnly code = emptyObstruction {obstructionCode = code}
+  firstIndex :: Integral index => Word32 -> index -> CObstruction
+  firstIndex code index = (codeOnly code) {obstructionFirstIndex = fromIntegral index}
+  indices :: (Integral first, Integral second) => Word32 -> first -> second -> CObstruction
+  indices code first second =
+    (codeOnly code)
+      { obstructionFirstIndex = fromIntegral first
+      , obstructionSecondIndex = fromIntegral second
+      }
+  firstValue :: Word32 -> Double -> CObstruction
+  firstValue code value = (codeOnly code) {obstructionFirstValue = value}
+  values :: Word32 -> Double -> Double -> CObstruction
+  values code first second =
+    (codeOnly code)
+      { obstructionFirstValue = first
+      , obstructionSecondValue = second
+      }
+  pointObstruction :: Word32 -> Double -> Double -> CObstruction
+  pointObstruction code x y =
+    (codeOnly code)
+      { obstructionPointX = x
+      , obstructionPointY = y
+      }
+  nonFinite :: Word32 -> T.NonFiniteValue -> CObstruction
+  nonFinite code value = firstIndex code (nonFiniteCode value)
+
+coordinateErrorCode :: T.CoordinateError -> Word32
+coordinateErrorCode reason =
+  case reason of
+    T.CoordinateNaN -> 1
+    T.CoordinateInfinite -> 2
+    T.CoordinateTooSmall -> 3
+    T.CoordinateTooLarge -> 4
+
+nonFiniteCode :: T.NonFiniteValue -> Word32
+nonFiniteCode value =
+  case value of
+    T.ValueNaN -> 1
+    T.ValuePositiveInfinity -> 2
+    T.ValueNegativeInfinity -> 3
diff --git a/src-public/Moonlight/Triangulation.hs b/src-public/Moonlight/Triangulation.hs
--- a/src-public/Moonlight/Triangulation.hs
+++ b/src-public/Moonlight/Triangulation.hs
@@ -53,6 +53,9 @@
 
     -- * Generation — @delaunay@; canonical observation factors through the site set
   , delaunay
+  , delaunayGeometry
+  , DuplicatePayloadPolicy (..)
+  , delaunayFromCoordinates
   , BuildResult
   , buildTriangulation
   , buildInputVertices
@@ -185,7 +188,12 @@
   , InvariantViolation (..)
   ) where
 
-import Moonlight.Triangulation.BulkLoad (delaunay)
+import Moonlight.Triangulation.BulkLoad
+  ( DuplicatePayloadPolicy (..)
+  , delaunay
+  , delaunayFromCoordinates
+  , delaunayGeometry
+  )
 import Moonlight.Triangulation.Dcel
   ( destination
   , faceDirectedEdges
diff --git a/test/algebra/Moonlight/Triangulation/AlgebraFixtures.hs b/test/algebra/Moonlight/Triangulation/AlgebraFixtures.hs
--- a/test/algebra/Moonlight/Triangulation/AlgebraFixtures.hs
+++ b/test/algebra/Moonlight/Triangulation/AlgebraFixtures.hs
@@ -36,7 +36,7 @@
   , buildTriangulation
   , canonicalize
   , delaunay
-  , mapVertices
+  , delaunayGeometry
   , numFaces
   , numUndirectedEdges
   , numVertices
@@ -150,7 +150,8 @@
 -- ── construction ─────────────────────────────────────────────────────────────
 
 meshOf :: String -> [Point] -> IO Mesh
-meshOf label points = mapVertices (const ()) <$> pointMeshOf label points
+meshOf label points =
+  requireRight ("build geometry " <> label) (delaunayGeometry (V.fromList points))
 
 pointMeshOf :: String -> [Point] -> IO PointMesh
 pointMeshOf label points =
diff --git a/test/algebra/Moonlight/Triangulation/AlgebraSpec.hs b/test/algebra/Moonlight/Triangulation/AlgebraSpec.hs
--- a/test/algebra/Moonlight/Triangulation/AlgebraSpec.hs
+++ b/test/algebra/Moonlight/Triangulation/AlgebraSpec.hs
@@ -20,12 +20,15 @@
   ( BuildError
   , ConstraintMode (..)
   , DelaunayTriangulation
+  , DuplicatePayloadPolicy (..)
   , HasPosition (..)
   , JoinSemilattice (..)
   , Point (Point)
   , Triangulation
   , buildTriangulation
   , delaunay
+  , delaunayFromCoordinates
+  , delaunayGeometry
   , unitElementDefaults
   , vertexData
   , vertexPoint
@@ -69,6 +72,7 @@
 
 tests :: IO ()
 tests = do
+  testConstructionEntrances
   testJoinIdentity
   testJoinCommutative
   testJoinAssociative
@@ -94,6 +98,38 @@
   testAnnotationPreservation
   testOldEdgeAccounting
   putStrLn "algebra: ok"
+
+testConstructionEntrances :: IO ()
+testConstructionEntrances = do
+  let coordinates = V.fromList [Point 0 0, Point 3 0, Point 0 4, Point 0 0]
+      payloads = V.fromList [2 :: Int, 3, 5, 7]
+  geometry <- requireRight "geometry-only construction" (delaunayGeometry coordinates)
+  legacy <-
+    mapVertices (const ()) . buildTriangulation
+      <$> requireRight "annotated point construction" (delaunay unitElementDefaults coordinates)
+  unless (geometry == legacy) $
+    fail "delaunayGeometry disagreed with the existing point construction"
+  annotated <-
+    buildTriangulation
+      <$> requireRight
+            "coordinate/payload construction"
+            ( delaunayFromCoordinates
+                unitElementDefaults
+                coordinates
+                payloads
+                (CombineDuplicatePayload (+))
+            )
+  unless (validateTriangulation geometry == [] && validateTriangulation annotated == []) $
+    fail "public construction entrance produced an invalid triangulation"
+  let observed =
+        Map.fromList
+          [ ((x, y), vertexData annotated vertex)
+          | vertex <- vertices annotated
+          , let Point x y = vertexPoint annotated vertex
+          ]
+      expected = Map.fromList [((0, 0), 9), ((3, 0), 3), ((0, 4), 5)]
+  unless (observed == expected) $
+    fail "delaunayFromCoordinates did not apply its duplicate payload policy"
 
 -- ── laws ─────────────────────────────────────────────────────────────────────
 
diff --git a/test/ffi/Main.hs b/test/ffi/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/ffi/Main.hs
@@ -0,0 +1,149 @@
+module Main (main) where
+
+import Control.Exception (bracket)
+import Control.Monad (unless)
+import Data.Word (Word32)
+import Foreign.C.Types (CDouble (..), CSize (..), CUInt)
+import Foreign.Marshal.Alloc (alloca)
+import Foreign.Marshal.Array (allocaArray, peekArray, withArray)
+import Foreign.Ptr (Ptr, nullPtr)
+import Foreign.Storable (Storable (sizeOf), peek)
+import Moonlight.Triangulation.Foreign.ABI
+
+type MeshPointer = Ptr ()
+
+main :: IO ()
+main = do
+  unless (sizeOf (undefined :: CObstruction) == 320) $
+    fail "C obstruction layout changed"
+  bracket (buildMesh [(0, 0), (2, 0), (0, 2), (2, 2)]) meshFree $ \left ->
+    bracket (buildMesh [(2, 0), (4, 0), (2, 2), (4, 2)]) meshFree $ \right -> do
+      requireMeshCount "left vertex count" meshVertexCount left 4
+      requireMeshCount "left triangle count" meshTriangleCount left 2
+      testDenseCopies left
+      testImmutableBatch left
+      testBinaryAlgebra left right
+  testTypedCoordinateRefusal
+  testCoordinateCountOverflow
+  testNullPointerRefusal
+  putStrLn "ffi: ok"
+
+buildMesh :: [(Double, Double)] -> IO MeshPointer
+buildMesh points =
+  withPointArray points $ \coordinates ->
+    alloca $ \output ->
+      alloca $ \obstruction -> do
+        status <- delaunayF64 coordinates (fromIntegral (length points)) output obstruction
+        requireStatus "delaunay" 0 status obstruction
+        handle <- peek output
+        unless (handle /= nullPtr) (fail "delaunay returned a null handle")
+        pure handle
+
+testDenseCopies :: MeshPointer -> IO ()
+testDenseCopies mesh = do
+  alloca $ \written ->
+    alloca $ \obstruction -> do
+      status <- meshCopyVerticesF64 mesh nullPtr 0 written obstruction
+      requireStatus "undersized vertex copy" 3 status obstruction
+      required <- peek written
+      refusal <- peek obstruction
+      unless (required == 4 && obstructionCode refusal == 102) $
+        fail "undersized vertex copy lost its required-capacity witness"
+  allocaArray 8 $ \coordinates ->
+    alloca $ \written ->
+      alloca $ \obstruction -> do
+        status <- meshCopyVerticesF64 mesh coordinates 4 written obstruction
+        requireStatus "vertex copy" 0 status obstruction
+        values <- peekArray 8 coordinates
+        unless (length values == 8) (fail "vertex copy wrote the wrong coordinate extent")
+  allocaArray 6 $ \triangles ->
+    alloca $ \written ->
+      alloca $ \obstruction -> do
+        status <- meshCopyTrianglesU32 mesh triangles 2 written obstruction
+        requireStatus "triangle copy" 0 status obstruction
+        indices <- peekArray 6 triangles :: IO [Word32]
+        unless (all (< 4) indices) (fail "triangle copy produced an out-of-range vertex")
+
+testImmutableBatch :: MeshPointer -> IO ()
+testImmutableBatch original =
+  withPointArray [(1, 1), (3, 1)] $ \coordinates ->
+    bracket
+      (produceMesh "batch insert" (meshInsertManyF64 original coordinates 2))
+      meshFree
+      (\revised -> do
+        requireMeshCount "original after batch" meshVertexCount original 4
+        requireMeshCount "revised after batch" meshVertexCount revised 6
+      )
+
+testBinaryAlgebra :: MeshPointer -> MeshPointer -> IO ()
+testBinaryAlgebra left right = do
+  test "union" meshUnion 6
+  test "intersection" meshIntersection 2
+  test "difference" meshDifference 2
+  test "symmetric difference" meshSymmetricDifference 4
+ where
+  test label operation expected =
+    bracket (produceMesh label (operation left right)) meshFree $ \result ->
+      requireMeshCount label meshVertexCount result expected
+
+testTypedCoordinateRefusal :: IO ()
+testTypedCoordinateRefusal =
+  withPointArray [(0, 0), (0 / 0, 1), (1, 0)] $ \coordinates ->
+    alloca $ \output ->
+      alloca $ \obstruction -> do
+        status <- delaunayF64 coordinates 3 output obstruction
+        requireStatus "invalid coordinate" 4 status obstruction
+        refusal <- peek obstruction
+        unless (obstructionCode refusal == 1 && obstructionCoordinateError refusal == 1 && obstructionInputIndex refusal == 1) $
+          fail "invalid coordinate lost its typed witness"
+
+testNullPointerRefusal :: IO ()
+testNullPointerRefusal =
+  alloca $ \count ->
+    alloca $ \obstruction -> do
+      status <- meshVertexCount nullPtr count obstruction
+      requireStatus "null mesh" 1 status obstruction
+      refusal <- peek obstruction
+      unless (obstructionCode refusal == 100) $
+        fail "null pointer refusal lost its typed witness"
+
+testCoordinateCountOverflow :: IO ()
+testCoordinateCountOverflow =
+  alloca $ \output ->
+    alloca $ \obstruction -> do
+      let overflowingCount = fromIntegral (maxBound `div` 2 + 1 :: Int)
+      status <- delaunayF64 nullPtr overflowingCount output obstruction
+      requireStatus "coordinate count overflow" 2 status obstruction
+      refusal <- peek obstruction
+      unless (obstructionCode refusal == 101) $
+        fail "coordinate count overflow lost its typed witness"
+
+requireMeshCount :: String -> (MeshPointer -> Ptr CSize -> Ptr CObstruction -> IO CUInt) -> MeshPointer -> Int -> IO ()
+requireMeshCount label operation mesh expected =
+  alloca $ \count ->
+    alloca $ \obstruction -> do
+      status <- operation mesh count obstruction
+      requireStatus label 0 status obstruction
+      observed <- peek count
+      unless (observed == fromIntegral expected) $
+        fail (label <> " produced " <> show observed <> ", expected " <> show expected)
+
+produceMesh :: String -> (Ptr MeshPointer -> Ptr CObstruction -> IO CUInt) -> IO MeshPointer
+produceMesh label operation =
+  alloca $ \output ->
+    alloca $ \obstruction -> do
+      status <- operation output obstruction
+      requireStatus label 0 status obstruction
+      handle <- peek output
+      unless (handle /= nullPtr) (fail (label <> " returned a null handle"))
+      pure handle
+
+requireStatus :: String -> CUInt -> CUInt -> Ptr CObstruction -> IO ()
+requireStatus label expected observed obstruction
+  | observed == expected = pure ()
+  | otherwise = do
+      refusal <- peek obstruction
+      fail (label <> " returned status " <> show observed <> ": " <> obstructionMessage refusal)
+
+withPointArray :: [(Double, Double)] -> (Ptr CDouble -> IO result) -> IO result
+withPointArray points = withArray (concatMap (\(x, y) -> [CDouble x, CDouble y]) points)
diff --git a/weeder.toml b/weeder.toml
--- a/weeder.toml
+++ b/weeder.toml
@@ -39,6 +39,7 @@
   '^Moonlight\.Triangulation\.Voronoi\.Handles$',
   '^Moonlight\.Triangulation\.Interpolation$',
   '^Moonlight\.Triangulation\.HintGenerator$',
+  '^Moonlight\.Triangulation\.Foreign\.Exports$',
   '^Moonlight\.Triangulation$'
 ]
 
