tg_geometry 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +107 -14
  4. data/benchmark/ewkb_roundtrip.rb +29 -0
  5. data/benchmark/geom_query.rb +33 -0
  6. data/benchmark/nearest_segment.rb +20 -0
  7. data/docs/GEOMETRY_QUERIES.md +23 -0
  8. data/docs/LIMITATIONS.md +12 -10
  9. data/docs/NEAREST_SEGMENT.md +17 -0
  10. data/docs/SRID_AND_EWKB.md +23 -0
  11. data/ext/tg_geometry/tg_geometry_ext.c +1176 -4
  12. data/lib/tg/geometry/active_record_source.rb +16 -34
  13. data/lib/tg/geometry/active_record_type.rb +61 -0
  14. data/lib/tg/geometry/registry.rb +17 -68
  15. data/lib/tg/geometry/version.rb +1 -1
  16. data/lib/tg/geometry.rb +85 -0
  17. data/spec/active_record_type_spec.rb +45 -0
  18. data/spec/constructors_spec.rb +104 -0
  19. data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
  20. data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
  21. data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
  22. data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
  23. data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
  24. data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
  25. data/spec/fixtures/postgis/README.md +16 -0
  26. data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
  27. data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
  28. data/spec/fixtures/postgis/point_4326.ewkb +0 -0
  29. data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
  30. data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
  31. data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
  32. data/spec/index_geom_query_spec.rb +68 -0
  33. data/spec/keyword_validation_spec.rb +31 -0
  34. data/spec/nearest_segment_spec.rb +62 -0
  35. data/spec/postgis_fixtures_spec.rb +68 -0
  36. data/spec/srid_spec.rb +43 -0
  37. data/spec/to_ewkb_spec.rb +37 -0
  38. metadata +50 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3b2fd9ecb971425116e95aed147c0467eba4105b151ff15ba128ef35b10ad6a
4
- data.tar.gz: e87484d1ab62a0c21b19c79603ed7da3f0017962543c32b5cd5095cc353a044e
3
+ metadata.gz: fdc6ab78992bee6ba1708b13adfdb0e4eec26fef04c4fc8eae87df21fbfed5fa
4
+ data.tar.gz: ada3eff77de10cb101cbedbd35e2d40e119fe37c5a09003afe9df1ab266827fe
5
5
  SHA512:
6
- metadata.gz: e82e6a1729a0e076fdd16e6c378b3e363a744fddb0b41216d1371a4a7e320234772f829cb38e024b5b397bca630dcb787ae9d31e0cc114e84836ae7cb0bb904e
7
- data.tar.gz: '0529e7db75d831d0fa39877ca0ddd4f43d9138c985471f2827bbd92abefc9fec07ad9bba881ec9ba1c157f14a8c5c99be229d2c0fa9c08db73904620e043a029'
6
+ metadata.gz: 87200faec4b6d0442eab7df12f768807193aed725bb75b25502a64cc5b2e24e97f2f9d98cf9254c54e3f62d9b0481f39dc2287818f69a740e9728a33e97d9f0b
7
+ data.tar.gz: 3e14f4563facef36fc109e1c8006b707502bb384f67c1c27da05faa7dcec7b7a7970d82a22d39ac99c00595c74944c7a6467fa81a5cdd1d14129df70f4d3675a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 27.05.2026
4
+
5
+ ### Added
6
+
7
+ - `TG::Geometry.line_string`, `TG::Geometry.polygon`, and `TG::Geometry.multi_polygon` constructors from Ruby arrays.
8
+ - `TG::Geometry::Geom#srid` with automatic EWKB SRID extraction in `parse_wkb` and `parse_hex`.
9
+ - `TG::Geometry::Geom#to_ewkb` writer with explicit `srid:` override.
10
+ - `TG::Geometry::Index#intersecting_geom_ids`, `#covering_geom_ids`, and `#containing_geom_ids`.
11
+ - `TG::Geometry::Line#nearest_segment` and `TG::Geometry::Ring#nearest_segment` with `TG::Geometry::NearestSegment` result objects.
12
+ - `TG::Geometry::ActiveRecordType` read-only optional require.
13
+ - PostGIS/EWKB fixture suite in `spec/fixtures/postgis/`.
14
+
15
+ ### Fixed
16
+
17
+ - Reject unsupported/unknown keywords on v0.3.0 APIs instead of silently ignoring them, including non-goals such as `release_gvl:`, `parse_wkb(srid:)`, and `parse_wkb(ewkb:)`.
18
+ - Harden constructor cleanup paths so intermediate C `tg_ring` / `tg_poly` objects are released when Ruby exceptions occur during nested polygon construction.
19
+ - Keep geometry-index query collection in a C-only status-returning phase before Ruby result materialization, preserving the intended no-GVL-safe internal shape.
20
+
21
+ ### Clarified
22
+
23
+ - `Index.build(predicate:)` affects only legacy point query methods: `find_covering`, `covering_ids(x, y)`, and `covering_ids_batch_packed`.
24
+
25
+ ### Not included
26
+
27
+ - Geodesic / Haversine distance.
28
+ - Projection / reprojection.
29
+ - Buffer / union / difference / convex hull.
30
+ - Index nearest_ids (KNN).
31
+ - GeoBIN bbox helpers.
32
+ - Streaming FeatureSource.
33
+ - Index serialization / mmap.
34
+ - Ractor support.
35
+ - Windows / JRuby.
36
+ - Write-side ActiveRecordType / AR scopes / migrations.
37
+ - Z/M variants of constructors.
38
+ - Public `release_gvl:` option.
39
+
3
40
  ## 0.1.0 - unreleased
4
41
 
5
42
  Initial public release candidate for `tg_geometry`.
data/README.md CHANGED
@@ -58,6 +58,44 @@ TG::Geometry.parse_geobin(bytes, index: :ystripes)
58
58
 
59
59
  `TG::Geometry::Geom` objects are immutable and cannot be manually allocated or manually freed from Ruby.
60
60
 
61
+ ## Constructors
62
+
63
+ Construct simple planar geometries directly from Ruby arrays without parsing strings or bytes:
64
+
65
+ ```ruby
66
+ line = TG::Geometry.line_string([[0.0, 0.0], [10.0, 0.0]], index: :natural, srid: 4326)
67
+ poly = TG::Geometry.polygon(
68
+ [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]],
69
+ holes: [[[2, 2], [4, 2], [4, 4], [2, 4], [2, 2]]],
70
+ index: :ystripes,
71
+ srid: 4326
72
+ )
73
+ mp = TG::Geometry.multi_polygon([
74
+ { exterior: [[0, 0], [1, 0], [1, 1], [0, 0]], holes: [] },
75
+ [[10, 10], [11, 10], [11, 11], [10, 10]]
76
+ ])
77
+ ```
78
+
79
+ Constructors are strict: rings must already be closed, invalid coordinates raise `TG::Geometry::ArgumentError`, and no winding/self-intersection fixes are performed. `srid:` is metadata on the wrapper; it does not alter coordinates or perform reprojection.
80
+
81
+ ## SRID and EWKB
82
+
83
+ `parse_wkb` and `parse_hex` preserve EWKB SRID metadata when the EWKB SRID flag is present:
84
+
85
+ ```ruby
86
+ geom = TG::Geometry.parse_wkb(postgis_bytea)
87
+ geom.srid # => 4326, 3857, 0, or nil for plain WKB
88
+ ```
89
+
90
+ `to_wkb` always writes plain WKB. Use `to_ewkb` when a PostGIS-compatible SRID-bearing payload is required:
91
+
92
+ ```ruby
93
+ ewkb = geom.to_ewkb # uses geom.srid
94
+ ewkb = geom.to_ewkb(srid: 4326) # explicit override
95
+ ```
96
+
97
+ SRID is metadata only. `tg_geometry` does not check SRID compatibility, transform coordinates, or calculate geodesic distances.
98
+
61
99
  ## Rect
62
100
 
63
101
  ```ruby
@@ -127,8 +165,29 @@ Accepted predicates:
127
165
  - `:covers` — default for geofencing; boundary points are included.
128
166
  - `:contains` — stricter containment semantics.
129
167
 
168
+ The `predicate:` option affects only the legacy point-based query methods (`find_covering`, `covering_ids(x, y)`, `covering_ids_batch_packed`). The geometry-based query methods (`intersecting_geom_ids`, `covering_geom_ids`, `containing_geom_ids`) use their own predicates based on the method name.
169
+
130
170
  `strategy: :auto` is intentionally not exposed. Choose the strategy explicitly and benchmark on your own data.
131
171
 
172
+ ## Geometry-based index queries
173
+
174
+ ```ruby
175
+ query = TG::Geometry.polygon([[1, 1], [2, 1], [2, 2], [1, 1]])
176
+ index.intersecting_geom_ids(query)
177
+ index.covering_geom_ids(query)
178
+ index.containing_geom_ids(query)
179
+ ```
180
+
181
+ Predicate direction is explicit:
182
+
183
+ | Method | Predicate direction | Boundary semantics |
184
+ | --- | --- | --- |
185
+ | `intersecting_geom_ids(query)` | stored geom intersects query | any intersection |
186
+ | `covering_geom_ids(query)` | stored geom covers query | boundary included |
187
+ | `containing_geom_ids(query)` | stored geom contains query | strict interior; boundary excluded |
188
+
189
+ Results are ids only and preserve insertion order. Duplicate ids remain possible if duplicate ids were inserted.
190
+
132
191
  ## GeoJSON FeatureSource
133
192
 
134
193
  `TG::Geometry::FeatureSource` reads GeoJSON `FeatureCollection` sources without `JSON.parse` of the whole document into Ruby Hash/Array objects.
@@ -186,6 +245,20 @@ index.covering_ids_batch_packed(points)
186
245
 
187
246
  Input is a Ruby String containing native-endian doubles in `lon, lat` pairs. Length must be a multiple of 16 bytes. Empty string returns `[]`.
188
247
 
248
+ ## Nearest segment
249
+
250
+ `Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` return a frozen `TG::Geometry::NearestSegment`:
251
+
252
+ ```ruby
253
+ nearest = polygon.polygon.exterior_ring.nearest_segment(5, 5)
254
+ nearest.segment # => TG::Geometry::Segment
255
+ nearest.index # => segment index
256
+ nearest.distance # => Float
257
+ nearest.point # => [x, y] projection on the segment
258
+ ```
259
+
260
+ Distance is planar Euclidean distance in input coordinate units. It is not meters unless your input coordinates are already meters. Equal-distance tie-breaks follow tg iteration order and are not API-stable.
261
+
189
262
  ## Registry helper
190
263
 
191
264
  `Registry` is Ruby-level sugar over immutable indexes:
@@ -208,6 +281,23 @@ registry.find_covering(5, 5)
208
281
 
209
282
  Reload builds a new immutable index first and swaps the reference only after a successful build. Existing readers keep using the previous index safely.
210
283
 
284
+ ## ActiveRecord integration — read-only
285
+
286
+ `TG::Geometry::ActiveRecordType` is an optional read-only convenience type for PostGIS columns. It is not required by `tg/geometry`; load it explicitly:
287
+
288
+ ```ruby
289
+ require "tg/geometry/active_record_type"
290
+
291
+ class Zone < ApplicationRecord
292
+ attribute :geom, TG::Geometry::ActiveRecordType.new
293
+ end
294
+
295
+ zone.geom.srid
296
+ zone.geom.covers_xy?(lon, lat)
297
+ ```
298
+
299
+ It can deserialize EWKB bytes, hex EWKB, `\x`-prefixed hex EWKB, GeoJSON, and WKT. Writing `Geom` values is intentionally unsupported in v0.3.0. User applications need `activemodel >= 6.0`; `activerecord` is not a gem dependency.
300
+
211
301
  ## Memory and concurrency
212
302
 
213
303
  The implementation uses explicit allocator pairs and Ruby GC accounting for native memory. `ObjectSpace.memsize_of(index)` includes entries, owned TG geometries, and exact rtree allocation bytes. Borrowed geometries are not double-counted by the index.
@@ -229,6 +319,9 @@ bundle exec ruby benchmark/rss_stability.rb
229
319
  bundle exec ruby benchmark/gvl_threshold.rb
230
320
  bundle exec ruby benchmark/falcon_concurrency.rb
231
321
  bundle exec ruby benchmark/feature_source.rb
322
+ bundle exec ruby benchmark/geom_query.rb
323
+ bundle exec ruby benchmark/nearest_segment.rb
324
+ bundle exec ruby benchmark/ewkb_roundtrip.rb
232
325
  ```
233
326
 
234
327
  The benchmarks are engineering tools, not marketing claims.
@@ -239,20 +332,20 @@ The benchmarks are engineering tools, not marketing claims.
239
332
 
240
333
  Not included:
241
334
 
242
- - geocoding;
243
- - routing;
244
- - projections;
245
- - geodesic distance/area;
246
- - buffer / union / difference / overlay result geometry operations;
247
- - nearest POI index;
248
- - Rails dependency in the native extension;
249
- - Redis or external service dependency;
250
- - public callback/search APIs;
251
- - Ractor support claim;
252
- - no-GVL execution claim;
253
- - universal `:auto` strategy.
254
-
255
- 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.
335
+ - Geodesic / Haversine distance;
336
+ - Projection / reprojection;
337
+ - Buffer / union / difference / convex hull;
338
+ - Index nearest_ids (KNN);
339
+ - GeoBIN bbox helpers;
340
+ - Streaming FeatureSource;
341
+ - Index serialization / mmap;
342
+ - Ractor support;
343
+ - Windows / JRuby;
344
+ - Write-side ActiveRecordType / AR scopes / migrations;
345
+ - Z/M variants of array constructors;
346
+ - Public `release_gvl:` option.
347
+
348
+ TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, perimeter, and nearest-segment distances are in input coordinate units, not meters.
256
349
 
257
350
  ## Development
258
351
 
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "_support"
5
+
6
+ geom = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], srid: 4326)
7
+ ewkb = geom.to_ewkb
8
+
9
+ Benchmark.bm(32) do |x|
10
+ x.report("tg parse_wkb -> to_ewkb 100k") do
11
+ 100_000.times { TG::Geometry.parse_wkb(ewkb).to_ewkb }
12
+ end
13
+ end
14
+
15
+ if ENV["WITH_RGEO"]
16
+ begin
17
+ require "rgeo"
18
+ factory = RGeo::Cartesian.factory(srid: 4326)
19
+ rgeo_geom = factory.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")
20
+
21
+ Benchmark.bm(32) do |x|
22
+ x.report("rgeo WKT parse 100k") do
23
+ 100_000.times { factory.parse_wkt(rgeo_geom.as_text) }
24
+ end
25
+ end
26
+ rescue LoadError
27
+ warn "rgeo is not installed"
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "_support"
5
+
6
+ COUNTS = [1_000, 10_000].freeze
7
+ STRATEGIES = %i[flat rtree].freeze
8
+
9
+
10
+ def box(x, y, size = 1.0)
11
+ TG::Geometry.polygon([[x, y], [x + size, y], [x + size, y + size], [x, y + size], [x, y]])
12
+ end
13
+
14
+ COUNTS.each do |count|
15
+ entries = count.times.map do |i|
16
+ x = (i % 100).to_f * 2.0
17
+ y = (i / 100).to_f * 2.0
18
+ [i, box(x, y)]
19
+ end
20
+
21
+ small_query = box(10.5, 10.5, 2.0)
22
+ large_query = box(0.5, 0.5, 120.0)
23
+
24
+ puts "\n#{count} polygons"
25
+ STRATEGIES.each do |strategy|
26
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: strategy)
27
+
28
+ Benchmark.bm(32) do |x|
29
+ x.report("#{strategy} small intersecting_geom_ids") { 10_000.times { index.intersecting_geom_ids(small_query) } }
30
+ x.report("#{strategy} large intersecting_geom_ids") { 1_000.times { index.intersecting_geom_ids(large_query) } }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require_relative "_support"
5
+
6
+ [100, 1_000, 10_000].each do |count|
7
+ points = count.times.map do |i|
8
+ angle = 2.0 * Math::PI * i / count
9
+ [Math.cos(angle), Math.sin(angle)]
10
+ end
11
+ points << points.first
12
+ ring = TG::Geometry.polygon(points).polygon.exterior_ring
13
+
14
+ puts "\nring segments: #{ring.num_segments}"
15
+ Benchmark.bm(28) do |x|
16
+ x.report("1M nearest_segment calls") do
17
+ 1_000_000.times { ring.nearest_segment(0.25, 0.33) }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # Geometry-based index queries
2
+
3
+ v0.3.0 adds geometry query methods to `TG::Geometry::Index`:
4
+
5
+ | Method | Predicate direction | Boundary semantics |
6
+ | --- | --- | --- |
7
+ | `intersecting_geom_ids(query)` | `stored_geom` intersects `query` | any intersection |
8
+ | `covering_geom_ids(query)` | `stored_geom` covers `query` | boundary included |
9
+ | `containing_geom_ids(query)` | `stored_geom` contains `query` | strict interior; boundary excluded |
10
+
11
+ The direction is always from the stored geometry to the query geometry. For example, `covering_geom_ids(query)` asks which indexed geometries cover the query.
12
+
13
+ The `predicate:` option on `Index.build` affects only the legacy point-based methods:
14
+
15
+ - `find_covering(x, y)`
16
+ - `covering_ids(x, y)`
17
+ - `covering_ids_batch_packed(packed_doubles)`
18
+
19
+ It does not affect `intersecting_geom_ids`, `covering_geom_ids`, or `containing_geom_ids`.
20
+
21
+ Results are arrays of ids in insertion order. Duplicate ids are preserved if duplicate ids were inserted.
22
+
23
+ In v0.3.0 these operations run under the GVL. The heavy C phase is structured without Ruby API calls so it can be made no-GVL-safe later after benchmarking, but there is no public `release_gvl:` knob.
data/docs/LIMITATIONS.md CHANGED
@@ -4,16 +4,18 @@
4
4
 
5
5
  Not included:
6
6
 
7
- - geocoding;
8
- - routing;
9
- - projections;
10
- - geodesic distance/area;
11
- - buffer / union / difference / overlay result geometry operations;
12
- - nearest POI indexing;
13
- - public callback/search APIs;
14
- - Ractor support claim;
15
- - no-GVL execution claim;
16
- - automatic strategy selection.
7
+ - Geodesic / Haversine distance;
8
+ - Projection / reprojection;
9
+ - Buffer / union / difference / convex hull;
10
+ - Index nearest_ids (KNN);
11
+ - GeoBIN bbox helpers;
12
+ - Streaming FeatureSource;
13
+ - Index serialization / mmap;
14
+ - Ractor support;
15
+ - Windows / JRuby;
16
+ - Write-side ActiveRecordType / AR scopes / migrations;
17
+ - Z/M variants of array constructors;
18
+ - Public `release_gvl:` option.
17
19
 
18
20
  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.
19
21
 
@@ -0,0 +1,17 @@
1
+ # Nearest segment
2
+
3
+ `Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` compute the nearest segment to a point in planar XY space.
4
+
5
+ ```ruby
6
+ nearest = ring.nearest_segment(x, y)
7
+ nearest.segment # TG::Geometry::Segment
8
+ nearest.index # Integer segment index in the parent line/ring
9
+ nearest.distance # Float
10
+ nearest.point # [x, y] projection onto the segment
11
+ ```
12
+
13
+ Distance is Euclidean distance in input coordinate units. It is not meters unless the input coordinate system is already meters.
14
+
15
+ Degenerate segments (`a == b`) are handled as point distance. The projection point for a degenerate segment is the segment endpoint.
16
+
17
+ When several segments have the same distance, tie-break behaviour follows tg iteration order. Code should not rely on a specific equal-distance segment.
@@ -0,0 +1,23 @@
1
+ # SRID and EWKB
2
+
3
+ EWKB extends WKB with extra metadata bits. PostGIS sets the SRID flag (`0x20000000`) in the geometry type word and inserts a 32-bit SRID after the type header.
4
+
5
+ `tg` already understands EWKB enough to parse the geometry payload correctly, but it does not expose SRID metadata. `tg_geometry` therefore reads the EWKB header at wrapper level before passing the original bytes to `tg_parse_wkb_ix` / `tg_parse_hexn_ix`.
6
+
7
+ The original bytes are not modified. The native `tg_geom` remains a plain planar geometry. The Ruby `TG::Geometry::Geom` wrapper stores SRID metadata in parallel:
8
+
9
+ ```ruby
10
+ geom = TG::Geometry.parse_wkb(ewkb)
11
+ geom.srid # => Integer or nil
12
+ ```
13
+
14
+ `parse_wkb` and `parse_hex` preserve SRID metadata. GeoJSON, WKT, GeoBIN, and `parse(format: :auto)` do not guarantee SRID preservation in v0.3.0.
15
+
16
+ `to_wkb` intentionally stays plain WKB. Use `to_ewkb` for PostGIS-compatible SRID-bearing output:
17
+
18
+ ```ruby
19
+ geom.to_ewkb
20
+ geom.to_ewkb(srid: 4326)
21
+ ```
22
+
23
+ SRID is metadata only. No reprojection, SRID compatibility check, meter conversion, or geodesic calculation is performed.