tg_geometry
tg_geometry is a Ruby C extension around the vendored tidwall/tg geometry
library and tidwall/rtree.c.
It exposes the public Ruby namespace TG::Geometry and the canonical require
path:
require "tg/geometry"
The gem targets fast in-process planar geometry parsing, predicates, format conversion, and geofencing-oriented immutable indexes. It does not try to be a full GIS system.
Status
This repository is prepared as a first public release candidate with an expanded API surface:
- release-core
Geom,Rect, and immutableIndexAPIs; - expanded format coverage for Hex and GeoBIN;
- read-only borrowed wrappers for lower-level TG geometry components;
Registryreload/swap sugar;- optional ActiveRecord-style source helpers that do not add a Rails runtime dependency.
strategy: :auto, Ractor support, callback/search APIs, no-allocation point
query optimization, geodesic helpers, projections, and no-GVL execution are not
claimed in this release.
Installation
Add this line to your application's Gemfile:
gem "tg_geometry"
Then run:
bundle install
The extension is built from vendored C sources. There is no GEOS, PostGIS, PROJ, GDAL, system TG, or system rtree dependency.
Supported first-release platforms are Linux and macOS on x86_64/aarch64. Windows is not supported in this release.
Basic parsing and predicates
require "tg/geometry"
zone = TG::Geometry.parse_geojson(<<~JSON)
{
"type": "Polygon",
"coordinates": [[[0,0], [10,0], [10,10], [0,10], [0,0]]]
}
JSON
zone.frozen? # => true
zone.type # => :polygon
zone.covers_xy?(5, 5) # => true
zone.covers_xy?(0, 0) # => true, boundary is covered
zone.bbox # => #<TG::Geometry::Rect ...>
wkt = zone.to_wkt
wkb = zone.to_wkb
TG::Geometry::Geom objects are immutable. They cannot be manually allocated or
manually freed from Ruby. Native memory is released by Ruby GC through the typed
data wrapper.
Parse API
TG::Geometry.parse(str, format: :auto, index: :ystripes)
TG::Geometry.parse_geojson(str, index: :ystripes)
TG::Geometry.parse_wkt(str, index: :ystripes)
TG::Geometry.parse_wkb(bytes, index: :ystripes)
TG::Geometry.parse_hex(str, index: :ystripes)
TG::Geometry.parse_geobin(bytes, index: :ystripes)
Accepted format: values for parse are:
:auto:geojson:wkt:wkb:hex:geobin
Accepted TG internal polygon index values are:
:default:none:natural:ystripes
Parse failures raise TG::Geometry::ParseError. Invalid options raise
TG::Geometry::ArgumentError, which inherits from Ruby's ::ArgumentError.
Geom API
Release-core methods:
geom.type
geom.bbox
geom.covers_xy?(x, y)
geom.contains?(other_geom)
geom.intersects?(other_geom)
geom.to_geojson
geom.to_wkt
geom.to_wkb
Expanded methods include additional predicates, format writers, metadata accessors, and read-only borrowed child wrappers. See:
docs/FORMAT_COVERAGE.mddocs/LOW_LEVEL_GEOMETRY.mddocs/FULL_TG_API_COVERAGE.md
For point predicates, this release prioritizes exact covers / contains
semantics over the fastest possible no-allocation path. Query methods construct
a temporary TG point geometry and free it before returning. A future optimized
point path requires boundary and hole-boundary equivalence tests plus benchmark
proof.
Rect API
rect = TG::Geometry::Rect.new(0, 0, 10, 10)
rect.min_x
rect.min_y
rect.max_x
rect.max_y
rect.center # => [5.0, 5.0]
rect.contains_point?(5, 5) # => true
rect.intersects?(other_rect)
rect.(other_rect)
rect.(x, y)
Rect rejects non-finite coordinates and invalid coordinate order. It is frozen
after construction.
There is intentionally no first-release Rect#contains? method because the name
is ambiguous. Use contains_point?.
Immutable Index
TG::Geometry::Index is built once and then read-only forever.
entries = [
[:zone_a, '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}'],
[:zone_b, '{"type":"Polygon","coordinates":[[[20,20],[30,20],[30,30],[20,30],[20,20]]]}']
]
index = TG::Geometry::Index.build(
entries,
via: :geojson,
strategy: :rtree,
predicate: :covers,
geometry_index: :ystripes
)
index.frozen? # => true
index.size # => 2
index.strategy # => :rtree
index.predicate # => :covers
index.find_covering(5, 5) # => :zone_a
index.covering_ids(5, 5) # => [:zone_a]
index.intersecting_rect(0, 0, 25, 25)
Accepted input format:
[[id1, object1], [id2, object2], ...]
Rules:
entriesmust be an Array.- Every entry must be a two-element Array.
idmay be any Ruby object exceptnil.falseids are accepted, but discouraged becausefind_coveringusesnilfor no match.- Duplicate ids are allowed.
- Returned ids are the same Ruby objects stored in the index; they are not copied, frozen, stringified, or duplicated.
- Result order is insertion order for both
:flatand:rtree.
Accepted via: modes:
:geom— borrow an existingTG::Geometry::Geom; the index marks the owner wrapper so the borrowed native pointer remains valid.:geojson— parse and own native TG geometries inside the index.:wkb— parse and own native TG geometries inside the index.
Accepted strategies:
:flat:rtree
strategy: :auto is not exposed in this release. The benchmark output does not
support a single universal threshold: flat scan may win for early insertion-order
hits or heavily overlapping datasets, while rtree may win for misses, later hits,
or selective rectangle queries. Choose the strategy explicitly and benchmark on
your own data.
Accepted predicates:
:covers— default for geofencing; boundary points are included.:contains— stricter OGC-style containment semantics.
Packed batch point queries
For high-throughput same-process point lookups, the index supports a packed native-endian double input format:
points = [5.0, 5.0, 25.0, 25.0].pack("d*")
index.covering_ids_batch_packed(points)
# => [:zone_a, :zone_b]
Input format:
- Ruby String treated as raw bytes.
- Native-endian doubles.
- Pairs of
lon, lat. - Length must be a multiple of 16 bytes.
- Empty string returns
[].
This format is intentionally native-endian for same-process speed and simplicity. Do not use it as a cross-platform serialized file format.
Registry reload pattern
Registry is Ruby-level sugar over immutable indexes:
class DeliveryZones < TG::Geometry::Registry
source do
[
[:zone_a, '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}']
]
end
via: :geojson, strategy: :rtree, predicate: :covers
end
registry = DeliveryZones.new
registry.reload!
registry.find_covering(5, 5)
Reload builds a new full immutable index first and swaps the reference only after successful build:
new_index = TG::Geometry::Index.build(entries, via: :geojson, strategy: :rtree)
@index = new_index
Old indexes remain alive while existing readers hold references to them. There
is no in-place mutation, no public add, delete, clear, or rebuild! API on
Index.
See docs/REGISTRY.md and docs/ACTIVE_RECORD.md for the expanded helpers.
Memory ownership model
The implementation uses explicit allocator pairs and GC accounting:
| Resource | Allocator | Deallocator | Owner |
|---|---|---|---|
tg_geom_wrapper_t |
TypedData_Make_Struct / Ruby allocator |
ruby_xfree |
Ruby Geom object |
TG geometry in Geom |
TG parser/constructor | tg_geom_free |
Geom wrapper |
tg_index_t |
TypedData_Make_Struct / Ruby allocator |
ruby_xfree |
Ruby Index object |
| Index entries array | calloc |
free |
Index |
TG geometry via :geojson / :wkb |
TG parser | tg_geom_free |
Index |
TG geometry via :geom |
Existing Geom wrapper |
Existing Geom wrapper |
Borrowed by Index through geom_owner |
| rtree internals | custom tg_rtree_malloc with header |
custom tg_rtree_free |
rtree / Index accounting |
| Ruby ids | Ruby VM | Ruby GC | Marked and compacted by Index |
ObjectSpace.memsize_of(index) includes entries, owned TG geometries, and exact
rtree allocation bytes. Borrowed geometries are not double-counted by the index.
See docs/MEMORY_OWNERSHIP.md for the full table and cleanup rules.
Concurrency model
Index and Geom are immutable after construction. Concurrent read-only use
from normal Ruby threads is supported by design and covered by tests.
The first release keeps GVL for parse, write, query, batch, and rtree build/free paths. This is intentional: the rtree allocator calls Ruby GC accounting APIs, and no-GVL execution would require separate input-copying and allocator-accounting design.
No Ractor support is claimed.
See docs/CONCURRENCY.md and docs/RACTOR.md.
Benchmarks
Benchmark scripts live in benchmark/:
bundle exec ruby benchmark/parse_throughput.rb
bundle exec ruby benchmark/flat_vs_rtree.rb
bundle exec ruby benchmark/batch_packed_vs_loop.rb
bundle exec ruby benchmark/objectspace_memsize.rb
bundle exec ruby benchmark/rss_stability.rb
bundle exec ruby benchmark/gvl_threshold.rb
bundle exec ruby benchmark/falcon_concurrency.rb
By default, benchmarks use a fast local matrix. Set TGEOMETRY_BENCH_FULL=1 for
the larger matrix where supported.
The repository benchmarks are engineering tools, not universal marketing claims. Do not copy upstream TG C benchmark numbers as Ruby gem performance claims.
Limitations
tg_geometry is not a full GIS system.
Not included in this release:
- geocoding;
- routing;
- projections;
- geodesic distance/area;
- buffer / union / difference / overlay result geometry operations;
- nearest POI index;
- Rails dependency in the core extension;
- Redis or external service dependency;
- public callback/search APIs;
- Ractor support claim;
- no-GVL execution claim;
- universal
:autostrategy.
TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, and perimeter-style values are in input coordinate units, not meters. Use PostGIS, GEOS, PROJ, or other GIS tooling when full GIS functionality is needed.
Development
bundle install
bundle exec rake compile
bundle exec rake spec
Useful targeted checks:
bundle exec rspec spec/block_12_batch_packed_spec.rb
bundle exec rspec spec/block_14_memory_gc_hardening_spec.rb
bundle exec rspec spec/block_20_concurrency_spec.rb
bundle exec rspec spec/block_20_fuzz_spec.rb
Memory-tool CI jobs for ASAN and Valgrind are intentionally left as OPEN QUESTION placeholders until the exact setup is approved. Do not replace them with guessed configuration.
License
MIT. Vendored upstream license files for tidwall/tg and tidwall/rtree.c are
included under ext/tg_geometry/vendor/.