Research: the GEOS engine
Status: reference. Facts here were verified against libgeos.org (C API programming guide, FAQ, release posts) in October 2026. This is the contract our binding must respect — every design doc traces back to something on this page.
1. What GEOS is
GEOS (Geometry Engine Open Source) is a C/C++ computational-geometry library implementing
the OGC Simple Features model. It began as a direct C++ port of the
JTS Topology Suite and remains tightly synced with it
(the two projects share their test corpus — this matters for our testing strategy).
It is the geometry engine inside PostGIS, QGIS, GDAL, Shapely, R sf, and DuckDB spatial.
Capabilities relevant to this project: the full Simple Features geometry model and predicates; overlay/boolean operations; prepared geometry (internal spatial indexes); the STR (Sort-Tile-Recursive) packed R-tree; WKT/WKB/GeoJSON readers and writers.
License: LGPL-2.1+. Compliance model: licensing.
2. The C API is the only binding surface
- The C API (
geos_c.h,libgeos_c) promises stable API and stable ABI — new GEOS binaries drop in without rebuilding dependents. - The C++ API is explicitly unstable: "may have changes in every release" (GEOS FAQ).
Every mature binding wraps the C API. We bind to libgeos_c only (D2).
3. The reentrant (thread-safe) API
Every function exists in two forms:
GEOSGeometry* GEOSIntersection(GEOSGeometry* a, GEOSGeometry* b); // global state
GEOSGeometry* GEOSIntersection_r(GEOSContextHandle_t ctx,
GEOSGeometry* a, GEOSGeometry* b); // reentrant
- The
GEOSContextHandle_tcarries thread-local state (memory arena bookkeeping, the installed message handlers). Created withGEOS_init_r(), released withGEOS_finish_r(). - Each thread that touches GEOS must have its own context. We use
_rexclusively, one handle per JS thread (D3). - Geometries belong to the context that created them; they must be destroyed by the same context. This is why Worker isolation rules exist (D11).
4. Object model and ownership rules
The two core objects are GEOSCoordSequence and GEOSGeometry.
| Rule | Detail |
|---|---|
| Explicit destruction | Everything created by a GEOS*Create*/*read*/*write* function must be destroyed by its matching destructor (GEOSGeom_destroy, GEOSWKTReader_destroy, …). |
GEOSFree, never free |
Memory GEOS allocated (e.g. WKT output strings) must be freed with GEOSFree — the allocating and freeing CRT must match, which matters most on Windows. |
| Transfer on create | GEOSGeom_createPoint/LineString/LinearRing take ownership of the coord sequence; destroying the geometry frees it. |
| Collections | GEOSGeom_createPolygon/createCollection take ownership of the contained objects but not the array holding them — the array must be freed by the caller. |
| Readers/writers are separate objects | Created, used, destroyed independently of the geometries they produce/consume. |
| STRtree items are borrowed | GEOSSTRtree_insert(tree, geom, item) stores raw pointers; the tree takes no ownership. A tree that outlives freed geometries is a use-after-free — our JS-side id registry exists because of this rule. |
5. Coordinate marshaling — the golden path
GEOSCoordSequence* GEOSCoordSeq_copyFromBuffer(const double* buf,
unsigned int size,
int hasZ, int hasM);
int GEOSCoordSeq_copyToBuffer(GEOSCoordSequence* seq, double* buf,
int hasZ, int hasM);
buf is interleaved coordinates: X Y [Z [M]] × size. This is:
- one FFI call per coordinate sequence in each direction,
- directly fed by / into a JS
Float64Array(zero-copy pointer passing), - the same layout as GeoArrow coordinate buffers (see io design).
Per-coordinate accessors (GEOSCoordSeq_setX, getOrdinate, …) exist but are the
anti-pattern at scale — one FFI crossing per number.
6. Error and interrupt protocol
- On failure, C API functions return
NULL(pointer returns) or0(int returns) and invoke the context's installed handlers:- notice handler:
void(*)(const char* fmt, ...) - error handler:
void(*)(const char* fmt, ...)
- notice handler:
- The handlers are variadic. This is the single hardest constraint on a pure-FFI
binding: variadic function pointers cannot be declared in
bun:ffi, and passing a non-variadic function where a variadic one is invoked is undefined behavior (on x86-64 SysV the caller must set%alfor variadic calls). → the C shim installs real variadic handlers that stash the formatted message in a thread-local buffer (shim). - Long operations can be cancelled cooperatively via
GEOSInterruptCallback(void(*)(void)) +GEOS_interrupt_request()— a signaturebun:ffican express; we wire it toAbortSignal(D11).
7. Prepared geometry
GEOSPrepare(geom) → const GEOSPreparedGeometry* — an indexed structure analogous to a
prepared statement. Repeated predicate calls (GEOSPreparedIntersects, …Contains,
…Covers, …) against it are dramatically faster. Destroy with
GEOSPreparedGeom_destroy — a separate lifetime from the source geometry.
Since 3.13 (RelateNG), every predicate — and the full DE-9IM matrix via
GEOSPreparedRelate / GEOSPreparedRelatePattern — is available in prepared form, with
short-circuit speedups even unprepared.
8. STRtree
GEOSSTRtree* tree = GEOSSTRtree_create(10); // 10 = node capacity (recommended default)
GEOSSTRtree_insert(tree, geom, item); // item = void* we choose (we use ids)
GEOSSTRtree_query(tree, queryGeom, callback, userdata);
GEOSSTRtree_nearest_generic(tree, item, geom, distCallback, userdata);
Bounds are computed from the inserted geometry. Query returns candidates (envelope intersection) — exact filtering is the caller's job (prepared predicates slot in here). The query callback is per-candidate → collected by the shim into a preallocated array instead of crossing into JS per hit (D4).
9. Version feature map
Everything in this table is assumed available — we ship GEOS 3.14 (D5).
| GEOS | Year | Features this project relies on |
|---|---|---|
| 3.10 | 2021 | OverlayNG + OverlayNGRobust — the standard overlay ops now self-heal near-degenerate inputs via snapping heuristics instead of failing; GEOSMakeValidWithParams (linework vs structure method, keep-collapsed) |
| 3.11 | 2022 | Concave hulls; GeoJSON reader/writer; offset-curve rework |
| 3.12 | 2023 | Coverage ops: CoverageUnion (orders-of-magnitude union for aligned polygonal coverages), CoverageIsValid, CoverageSimplifyVW; DisjointSubsetUnion; M-dimension support; GEOSSTRtree_build; Voronoi; GEOSOrientPolygons; GEOSEqualsIdentical |
| 3.13 | 2024 | RelateNG — rewritten predicate engine: all predicates + full DE-9IM available prepared, short-circuit speedups; ISO SQL/MM curve types (create/read/write) |
| 3.14 | 2025 | GEOSCoverageClean(WithParams) — remove small gaps/overlaps in polygonal coverages, with tolerance and gap-removal heuristics (longest-border / max-area / min-area); C-API clustering (GEOSClusterDBSCAN + geometry/envelope distance/intersects variants); GEOSGridIntersectionFractions; GEOSCoordSeq_hasZ/hasM/getM/setM; GEOSGeoJSONWriter_setOutputDimension; GEOSIsSimpleDetail (locates self-intersections — used in our repair diagnostics) |
Distribution context (why bundling is mandatory, D6): Ubuntu 24.04 ships GEOS 3.12, Debian 12 ships 3.11, Homebrew ships 3.14. No common distro ships 3.14.
10. Semantics gotchas (document loudly or they become issues)
- Planar, Cartesian, 2D only. No geodetic/ellipsoidal math (GEOS FAQ is explicit). Users coming from turf will buffer in degrees and get garbage. Docs must say: project to a planar CRS first.
- IEEE-754 robustness.
intersects(intersection(A, B), A)can legitimately befalse; interpolated points rarely lie exactly on a line. Not a bug — finite precision. The real tools are OverlayNGRobust (automatic since 3.10) and the precision model (GEOSGeom_setPrecision,*Precoverlay variants), which we expose first-class. - Empty geometries are typed but quirky (the Shapely 1.x saga:
Polygon()producingGEOMETRYCOLLECTION EMPTY; empty points serialized asPOINT (NaN NaN)in WKB). Shapely 2.0's fix — typed, consistent empties — is our target behavior. - Ring orientation. GEOS has
GEOSOrientPolygonsfor a reason; GeoJSON (RFC 7946) recommends right-hand-rule exterior rings. We preserve input order and expose an explicit orient helper rather than silently normalizing. - Z/M preservation is best-effort. GEOS retains Z/M through I/O and many ops but
documents no guarantee through every computation. We surface
hasZ/hasM(3.14 accessors) and document per-op behavior honestly.
11. Testing gift
GEOS and JTS share their test corpus (the xmltester XML suite: thousands of
overlay/predicate cases with expected results). We run it as a differential gate —
see testing strategy.