tg_geometry 0.3.1 → 0.3.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cde617c534a8a3d9702b3fe212d46f966da46224499bb4fcb97bf40865590be8
4
- data.tar.gz: 7e1767d31814249ef695ceae1965d6c5aaf9065ff7595309e95698abc053abbc
3
+ metadata.gz: 27136bafb4a0114a2921af0413c047391bfffb38a6ff41f6c4f011f7cf4ab2ad
4
+ data.tar.gz: 7b2148e42f78fa74b3d5ac41e492e78673dd2617aa887c0023affcfa8fe555bf
5
5
  SHA512:
6
- metadata.gz: 2cf6070d21ca5342425600e436523aae7215a2792d2d8392662a6c5257ba4e178301ff2a93c5ebb847fc7606d7b1aad60b9ab1fcd8e21b67f06d5f72f8c3cfa1
7
- data.tar.gz: 99a9f9c086c6a2da9add012f57a99de800f67846007503ddf6c5d63ec25ac2a6d62cfe5b854fe1d44976ff0bddcbce04b8831db631dd8d174602c64914190e30
6
+ metadata.gz: e91a5dd12b3240540e9f6171c9c8a3c0f959d9e24779e392ae6c02c2e478712d2585bff5e37b3524bdf7d3b8581ebb30e14029b4aa038eb549229f92f0eee388
7
+ data.tar.gz: 806918f8eaaa7025b0693ef07e6d244e37cdb4998ff4cc5845a752c4b200f2cb341391a43f37afbdb696525e18d3415f55833c8d09712c31161eb2e879e179f5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 - 28.10.2026
4
+
5
+ ### Changed
6
+
7
+ - Updated vendored `tidwall/tg`, `rtree.c` to v0.6.1, and `json.c` to pinned upstream revisions.
8
+ - Vendor sync now removes trailing whitespace from C sources and headers.
9
+
10
+ ### Fixed
11
+
12
+ - Avoided querying TG parse errors when geometry allocation fails in FeatureSource.
13
+ - Corrected JSON UTF-16 surrogate boundary handling through the updated `json.c` vendor.
14
+
15
+ ## 0.3.2 - unreleased
16
+
17
+ ### Added
18
+
19
+ - Added explicit point-to-geometry distance APIs on `TG::Geometry::Geom`:
20
+ `distance_to_lnglat_meters`, `boundary_distance_to_lnglat_meters`,
21
+ `nearest_point_lnglat`, `distance_to_xy`, `boundary_distance_to_xy`, and
22
+ `nearest_point_xy`.
23
+ - Added `TG::Geometry::Index#within_distance_lnglat_meters`,
24
+ `#within_distance_ids_lnglat_meters`, `#within_distance_xy`, and
25
+ `#within_distance_ids_xy` using bbox prefilter plus exact distance filtering.
26
+ - Added distance specs and benchmarks for point-to-geometry and radius queries, including
27
+ tiny-index/full-extent radius cases and distance receiver memory-accounting checks.
28
+
29
+ ### Clarified
30
+
31
+ - lng/lat distance is approximate local equirectangular meters for local geofencing,
32
+ not geodesic distance; GeoJSON segments remain straight coordinate segments.
33
+ - The lng/lat metric is raw planar and does not wrap or split at the antimeridian.
34
+ - Distance query methods keep the GVL and do not claim Falcon/Async/Ractor behavior.
35
+
3
36
  ## 0.3.1 - 28.05.2026
4
37
 
5
38
  ### Changed
data/README.md CHANGED
@@ -8,7 +8,25 @@ It exposes the public Ruby namespace `TG::Geometry`:
8
8
  require "tg/geometry"
9
9
  ```
10
10
 
11
- The gem is focused on fast in-process planar geometry parsing, predicates, format conversion, GeoJSON FeatureCollection imports, and immutable geofencing indexes. It is not a full GIS system.
11
+ The gem is focused on fast in-process planar geometry parsing, predicates, format conversion, GeoJSON FeatureCollection imports, point-to-geometry distance queries, and immutable geofencing indexes. It is not a full GIS system.
12
+
13
+ ## Start here
14
+
15
+ For runnable examples, use **[GET_STARTED.md](GET_STARTED.md)**.
16
+
17
+ That guide shows the normal path from install to real queries:
18
+
19
+ - parsing GeoJSON/WKT/WKB/Hex/GeoBIN;
20
+ - direct Ruby constructors;
21
+ - immutable `Index` builds;
22
+ - point geofencing queries;
23
+ - packed batch point queries;
24
+ - point-to-geometry distance in planar XY and approximate lon/lat meters;
25
+ - GeoJSON FeatureCollection imports;
26
+ - SRID/EWKB conversion;
27
+ - nearest segment helpers;
28
+ - Registry and optional ActiveRecord integration;
29
+ - safe immutable-index reload pattern.
12
30
 
13
31
  ## Installation
14
32
 
@@ -26,277 +44,86 @@ The extension builds from vendored C sources. It does not require GEOS, PostGIS,
26
44
 
27
45
  Supported platforms: Linux and macOS on x86_64/aarch64. Windows is not supported for the first release.
28
46
 
29
- ## Parsing and predicates
30
-
31
- ```ruby
32
- zone = TG::Geometry.parse_geojson(<<~JSON)
33
- {
34
- "type": "Polygon",
35
- "coordinates": [[[0,0], [10,0], [10,10], [0,10], [0,0]]]
36
- }
37
- JSON
38
-
39
- zone.frozen? # => true
40
- zone.type # => :polygon
41
- zone.covers_xy?(5, 5) # => true
42
- zone.covers_xy?(0, 0) # => true, boundary is covered
43
- zone.bbox # => #<TG::Geometry::Rect ...>
44
- zone.to_wkt
45
- zone.to_wkb
46
- ```
47
-
48
- Parse shortcuts:
49
-
50
- ```ruby
51
- TG::Geometry.parse(str, format: :auto, index: :ystripes)
52
- TG::Geometry.parse_geojson(str, index: :ystripes)
53
- TG::Geometry.parse_wkt(str, index: :ystripes)
54
- TG::Geometry.parse_wkb(bytes, index: :ystripes)
55
- TG::Geometry.parse_hex(str, index: :ystripes)
56
- TG::Geometry.parse_geobin(bytes, index: :ystripes)
57
- ```
58
-
59
- `TG::Geometry::Geom` objects are immutable and cannot be manually allocated or manually freed from Ruby.
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
-
99
- ## Rect
100
-
101
- ```ruby
102
- rect = TG::Geometry::Rect.new(0, 0, 10, 10)
103
- rect.center # => [5.0, 5.0]
104
- rect.contains_point?(5, 5) # => true
105
- rect.intersects?(other_rect)
106
- rect.expand_to_include(other_rect)
107
- rect.expand_to_include_point(x, y)
108
- ```
109
-
110
- `Rect` rejects non-finite coordinates and invalid coordinate order. It is frozen after construction.
47
+ ## API map
111
48
 
112
- ## Immutable Index
49
+ This README intentionally stays compact. The runnable examples live in [GET_STARTED.md](GET_STARTED.md); deeper design notes live in `docs/`.
113
50
 
114
- ```ruby
115
- entries = [
116
- [:zone_a, '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}'],
117
- [:zone_b, '{"type":"Polygon","coordinates":[[[20,20],[30,20],[30,30],[20,30],[20,20]]]}']
118
- ]
119
-
120
- index = TG::Geometry::Index.build(
121
- entries,
122
- via: :geojson,
123
- strategy: :rtree,
124
- predicate: :covers,
125
- geometry_index: :ystripes
126
- )
127
-
128
- index.frozen? # => true
129
- index.size # => 2
130
- index.strategy # => :rtree
131
- index.predicate # => :covers
132
- index.find_covering(5, 5) # => :zone_a
133
- index.covering_ids(5, 5) # => [:zone_a]
134
- index.intersecting_rect(0, 0, 25, 25)
135
- ```
51
+ ### Parsing and format conversion
136
52
 
137
- Accepted input shape:
53
+ `TG::Geometry.parse` auto-detects GeoJSON, WKT, WKB, Hex, and GeoBIN. Explicit shortcuts are also available: `parse_geojson`, `parse_wkt`, `parse_wkb`, `parse_hex`, and `parse_geobin`.
138
54
 
139
- ```ruby
140
- [[id1, object1], [id2, object2], ...]
141
- ```
55
+ `TG::Geometry::Geom` objects are immutable and can be converted back to WKT/WKB/GeoJSON where supported.
142
56
 
143
- Rules:
57
+ See [GET_STARTED.md#2-parse-a-geometry](GET_STARTED.md#2-parse-a-geometry).
144
58
 
145
- - `entries` must be an Array.
146
- - Every entry must be a two-element Array.
147
- - `id` may be any Ruby object except `nil`.
148
- - Duplicate ids are allowed.
149
- - Returned ids are the same Ruby objects stored in the index.
150
- - Result order is insertion order for both `:flat` and `:rtree`.
59
+ ### Constructors
151
60
 
152
- Accepted `via:` modes:
61
+ Planar geometries can be constructed directly from Ruby arrays without parsing strings or bytes: point, multipoint, line string, polygon, multiline, multipolygon, and geometry collection helpers are available.
153
62
 
154
- - `:geom` — borrow an existing `TG::Geometry::Geom` and keep its owner alive.
155
- - `:geojson` — parse and own native TG geometries inside the index.
156
- - `:wkb` — parse and own native TG geometries inside the index.
63
+ See [GET_STARTED.md#3-construct-geometries-directly](GET_STARTED.md#3-construct-geometries-directly).
157
64
 
158
- Accepted strategies:
65
+ ### SRID and EWKB
159
66
 
160
- - `:flat`
161
- - `:rtree`
67
+ EWKB SRID metadata is preserved on parse when present. `to_wkb` writes plain WKB; `to_ewkb` writes a PostGIS-compatible SRID-bearing payload. SRID is metadata only: no coordinate transformation is performed.
162
68
 
163
- Accepted predicates:
69
+ See [GET_STARTED.md#8-preserve-srid-and-write-ewkb](GET_STARTED.md#8-preserve-srid-and-write-ewkb) and [docs/SRID_AND_EWKB.md](docs/SRID_AND_EWKB.md).
164
70
 
165
- - `:covers` — default for geofencing; boundary points are included.
166
- - `:contains` — stricter containment semantics.
71
+ ### Rect
167
72
 
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.
73
+ `TG::Geometry::Rect` is an immutable helper for bounding boxes, containment checks, intersections, and expansion.
169
74
 
170
- `strategy: :auto` is intentionally not exposed. Choose the strategy explicitly and benchmark on your own data.
75
+ See [GET_STARTED.md#4-use-rect-helpers](GET_STARTED.md#4-use-rect-helpers).
171
76
 
172
- ## Geometry-based index queries
77
+ ### Immutable Index
173
78
 
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
- ```
79
+ `TG::Geometry::Index` stores immutable entries with explicit `via:`, `strategy:`, and `predicate:` options. `:rtree` builds a spatial index; `:flat` does a linear scan with bbox prefiltering. There is no `:auto` strategy by design.
180
80
 
181
- Predicate direction is explicit:
81
+ See [GET_STARTED.md#5-build-an-immutable-index](GET_STARTED.md#5-build-an-immutable-index).
182
82
 
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 |
83
+ ### Point and geometry queries
188
84
 
189
- Results are ids only and preserve insertion order. Duplicate ids remain possible if duplicate ids were inserted.
85
+ Index methods cover point geofencing, packed batch point queries, rectangle intersection, and geometry-vs-geometry predicates.
190
86
 
191
- ## GeoJSON FeatureSource
87
+ See:
192
88
 
193
- `TG::Geometry::FeatureSource` reads GeoJSON `FeatureCollection` sources without `JSON.parse` of the whole document into Ruby Hash/Array objects.
89
+ - [GET_STARTED.md#5-build-an-immutable-index](GET_STARTED.md#5-build-an-immutable-index)
90
+ - [GET_STARTED.md#6-batch-point-queries](GET_STARTED.md#6-batch-point-queries)
91
+ - [docs/GEOMETRY_QUERIES.md](docs/GEOMETRY_QUERIES.md)
194
92
 
195
- ```ruby
196
- entries = TG::Geometry::FeatureSource.read_entries_file(
197
- "zones.geojson",
198
- id: ["properties", "@id"],
199
- only: [:polygon, :multipolygon]
200
- )
201
-
202
- index = TG::Geometry::Index.build(
203
- entries,
204
- via: :geojson,
205
- strategy: :rtree,
206
- predicate: :covers
207
- )
208
- ```
93
+ ### Point-to-geometry distance
209
94
 
210
- For imports that also need raw properties JSON:
95
+ Distance APIs are explicit about units:
211
96
 
212
- ```ruby
213
- features = TG::Geometry::FeatureSource.read_features_file(
214
- "zones.geojson",
215
- id: ["properties", "@id"],
216
- report: true,
217
- on_invalid: :skip
218
- )
219
-
220
- features[:features].each do |id, geometry_json, properties_json|
221
- # Store geometry_json and parse properties_json in application code if needed.
222
- end
223
- ```
97
+ - `*_xy` returns input coordinate units;
98
+ - `*_lnglat_meters` returns approximate local meters for lon/lat geofencing.
224
99
 
225
- For direct file-to-index loading:
100
+ There is no `metric:` option, no automatic lon/lat-vs-XY detection, no kNN index, and no geodesic/great-circle segment distance.
226
101
 
227
- ```ruby
228
- index = TG::Geometry::FeatureSource.build_index_file(
229
- "zones.geojson",
230
- id: ["properties", "@id"],
231
- strategy: :rtree,
232
- predicate: :covers
233
- )
234
- ```
102
+ See [GET_STARTED.md#7-measure-distance-to-a-zone](GET_STARTED.md#7-measure-distance-to-a-zone) and [docs/GEOMETRY_QUERIES.md](docs/GEOMETRY_QUERIES.md).
235
103
 
236
- FeatureSource methods are explicit: use `_file` for paths, `_json` for raw content strings, and `_io` for IO objects. There is no path/content auto-detection.
104
+ ### GeoJSON FeatureSource
237
105
 
238
- ## Packed batch point queries
106
+ `TG::Geometry::FeatureSource` reads GeoJSON `FeatureCollection` sources without `JSON.parse` of the whole document into Ruby Hash/Array objects. It can return index entries, feature triples with raw properties JSON, reports, or build an index directly.
239
107
 
240
- ```ruby
241
- points = [5.0, 5.0, 25.0, 25.0].pack("d*")
242
- index.covering_ids_batch_packed(points)
243
- # => [:zone_a, :zone_b]
244
- ```
108
+ See [GET_STARTED.md#9-import-a-geojson-featurecollection](GET_STARTED.md#9-import-a-geojson-featurecollection) and [docs/FEATURE_SOURCE.md](docs/FEATURE_SOURCE.md).
245
109
 
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 `[]`.
110
+ ### Nearest segment
247
111
 
248
- ## Nearest segment
112
+ `Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` expose low-level planar nearest-segment helpers.
249
113
 
250
- `Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` return a frozen `TG::Geometry::NearestSegment`:
114
+ See [GET_STARTED.md#10-use-nearest-segment-helpers](GET_STARTED.md#10-use-nearest-segment-helpers) and [docs/NEAREST_SEGMENT.md](docs/NEAREST_SEGMENT.md).
251
115
 
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
- ```
116
+ ### Registry helper
259
117
 
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.
118
+ `TG::Geometry::Registry` is Ruby-level sugar over immutable indexes with safe reload semantics.
261
119
 
262
- ## Registry helper
120
+ See [GET_STARTED.md#11-wrap-an-index-in-a-registry](GET_STARTED.md#11-wrap-an-index-in-a-registry).
263
121
 
264
- `Registry` is Ruby-level sugar over immutable indexes:
122
+ ### Optional ActiveRecord integration
265
123
 
266
- ```ruby
267
- class DeliveryZones < TG::Geometry::Registry
268
- source do
269
- [
270
- [:zone_a, '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}']
271
- ]
272
- end
273
-
274
- index_options via: :geojson, strategy: :rtree, predicate: :covers
275
- end
276
-
277
- registry = DeliveryZones.new
278
- registry.reload!
279
- registry.find_covering(5, 5)
280
- ```
281
-
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.
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
- ```
124
+ `TG::Geometry::ActiveRecordType` is an optional read-only convenience type for PostGIS-like geometry columns. It is not required by `tg/geometry`; load it explicitly.
298
125
 
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.
126
+ See [GET_STARTED.md#12-optional-activerecord-read-only-type](GET_STARTED.md#12-optional-activerecord-read-only-type).
300
127
 
301
128
  ## Memory and concurrency
302
129
 
@@ -306,25 +133,13 @@ The implementation uses explicit allocator pairs and Ruby GC accounting for nati
306
133
 
307
134
  No Ractor support and no universal performance claim are advertised.
308
135
 
309
- ## Benchmarks
136
+ See [docs/MEMORY_OWNERSHIP.md](docs/MEMORY_OWNERSHIP.md) and [docs/CONCURRENCY.md](docs/CONCURRENCY.md).
310
137
 
311
- Benchmark scripts live in `benchmark/`:
138
+ ## Benchmarks
312
139
 
313
- ```bash
314
- bundle exec ruby benchmark/parse_throughput.rb
315
- bundle exec ruby benchmark/flat_vs_rtree.rb
316
- bundle exec ruby benchmark/batch_packed_vs_loop.rb
317
- bundle exec ruby benchmark/objectspace_memsize.rb
318
- bundle exec ruby benchmark/rss_stability.rb
319
- bundle exec ruby benchmark/gvl_threshold.rb
320
- bundle exec ruby benchmark/falcon_concurrency.rb
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
325
- ```
140
+ Benchmark scripts live in `benchmark/`. They are engineering tools, not marketing claims.
326
141
 
327
- The benchmarks are engineering tools, not marketing claims.
142
+ Start with [docs/BENCHMARKING.md](docs/BENCHMARKING.md).
328
143
 
329
144
  ## Limitations
330
145
 
@@ -345,7 +160,21 @@ Not included:
345
160
  - Z/M variants of array constructors;
346
161
  - Public `release_gvl:` option.
347
162
 
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.
163
+ TG works in planar XY coordinates. If lon/lat coordinates are passed in, length, area, perimeter, and low-level nearest-segment distances are in input coordinate units, not meters. The explicit `*_lnglat_meters` point-to-geometry APIs are the exception: they return approximate local meters using a query-local equirectangular frame, not geodesic meters.
164
+
165
+ See [docs/LIMITATIONS.md](docs/LIMITATIONS.md) for the full list.
166
+
167
+ ## Documentation
168
+
169
+ - [GET_STARTED.md](GET_STARTED.md) — runnable examples
170
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the C extension is wired
171
+ - [docs/MEMORY_OWNERSHIP.md](docs/MEMORY_OWNERSHIP.md) — allocator pair table
172
+ - [docs/CONCURRENCY.md](docs/CONCURRENCY.md) — threading rules
173
+ - [docs/ERROR_HANDLING.md](docs/ERROR_HANDLING.md) — exception hierarchy
174
+ - [docs/BENCHMARKING.md](docs/BENCHMARKING.md) — running the benchmark scripts
175
+ - [docs/FEATURE_SOURCE.md](docs/FEATURE_SOURCE.md) — GeoJSON FeatureCollection imports
176
+ - [docs/GEOMETRY_QUERIES.md](docs/GEOMETRY_QUERIES.md) — geometry query details
177
+ - [docs/LIMITATIONS.md](docs/LIMITATIONS.md) — non-goals and accuracy boundaries
349
178
 
350
179
  ## Development
351
180
 
@@ -362,6 +191,7 @@ bundle exec rspec spec/batch_packed_spec.rb
362
191
  bundle exec rspec spec/memory_gc_spec.rb
363
192
  bundle exec rspec spec/concurrency_spec.rb
364
193
  bundle exec rspec spec/fuzz_spec.rb
194
+ bundle exec rspec spec/distance_spec.rb
365
195
  ```
366
196
 
367
197
  ## License
@@ -292,7 +292,7 @@ module TGGeometryBench
292
292
  :rect_index, :rect,
293
293
  :segments, :target_bytes, :payload_bytes,
294
294
  :points_per_batch, :threads,
295
- :entries, :rebuilds, :cycle
295
+ :entries, :rebuilds, :cycle, :receiver
296
296
  ].freeze
297
297
 
298
298
  TABLE_METRIC_COLUMNS = [
@@ -302,7 +302,8 @@ module TGGeometryBench
302
302
  :iterations, :operations,
303
303
  :geom_memsize, :flat_memsize, :rtree_memsize, :rtree_over_flat,
304
304
  :start_rss_kb, :peak_rss_kb, :finish_rss_kb, :drift_kb, :max_drift_kb,
305
- :elapsed_sec, :queries, :sample_count, :rss_kb
305
+ :elapsed_sec, :queries, :sample_count, :rss_kb,
306
+ :before_memsize, :after_memsize, :delta_memsize, :allocated_objects
306
307
  ].freeze
307
308
 
308
309
  TABLE_INTERNAL_COLUMNS = [
@@ -348,6 +349,7 @@ module TGGeometryBench
348
349
  rebuilds: "rebuilds",
349
350
  queries: "queries",
350
351
  cycle: "cycle",
352
+ receiver: "receiver",
351
353
  rss_kb: "rss KB",
352
354
  geom_memsize: "geom B",
353
355
  flat_memsize: "flat B",
@@ -359,7 +361,11 @@ module TGGeometryBench
359
361
  drift_kb: "drift KB",
360
362
  max_drift_kb: "max drift KB",
361
363
  elapsed_sec: "elapsed s",
362
- sample_count: "samples"
364
+ sample_count: "samples",
365
+ before_memsize: "before B",
366
+ after_memsize: "after B",
367
+ delta_memsize: "delta B",
368
+ allocated_objects: "alloc objs"
363
369
  }.freeze
364
370
 
365
371
  def human_int(value)
@@ -407,7 +413,8 @@ module TGGeometryBench
407
413
  :rss_kb, :geom_memsize, :flat_memsize, :rtree_memsize, :rtree_over_flat,
408
414
  :start_rss_kb, :peak_rss_kb, :finish_rss_kb, :drift_kb, :max_drift_kb,
409
415
  :target_bytes, :payload_bytes, :segments, :points_per_batch, :threads,
410
- :sample_count, :median_minor_gc, :median_major_gc
416
+ :sample_count, :median_minor_gc, :median_major_gc,
417
+ :before_memsize, :after_memsize, :delta_memsize, :allocated_objects
411
418
  human_int(value)
412
419
  when :full, :adaptive, :gc_disabled
413
420
  value ? "yes" : "no"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+ require "objspace"
5
+
6
+ TGGeometryBench.say_header("distance_memory_accounting")
7
+
8
+ geom = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]])
9
+ index = TG::Geometry::Index.build([[:zone, geom]], via: :geom, strategy: :rtree)
10
+
11
+ CASES = [
12
+ ["geom_distance_to_xy", "Geom", geom, -> { geom.distance_to_xy(5, 5) }],
13
+ ["geom_boundary_distance_to_xy", "Geom", geom, -> { geom.boundary_distance_to_xy(5, 5) }],
14
+ ["geom_nearest_point_xy", "Geom", geom, -> { geom.nearest_point_xy(5, 5) }],
15
+ ["geom_distance_to_lnglat_meters", "Geom", geom, -> { geom.distance_to_lnglat_meters(0.01, 0.01) }],
16
+ ["index_within_distance_xy", "Index", index, -> { index.within_distance_xy(5, 5, 10) }],
17
+ ["index_within_distance_lnglat_meters", "Index", index, -> { index.within_distance_lnglat_meters(0.01, 0.01, 2_000) }]
18
+ ].freeze
19
+
20
+ ITERATIONS = TGGeometryBench.env_integer("TGEOMETRY_DISTANCE_MEMORY_ITERATIONS", 10_000, min: 1)
21
+
22
+ CASES.each do |name, receiver_name, receiver, callable|
23
+ TGGeometryBench.gc_start
24
+ before_memsize = ObjectSpace.memsize_of(receiver)
25
+ before_allocated = GC.stat[:total_allocated_objects]
26
+
27
+ ITERATIONS.times { callable.call }
28
+
29
+ TGGeometryBench.gc_start
30
+ after_memsize = ObjectSpace.memsize_of(receiver)
31
+ after_allocated = GC.stat[:total_allocated_objects]
32
+
33
+ TGGeometryBench.report(
34
+ "distance_memory_accounting",
35
+ {
36
+ case: name,
37
+ receiver: receiver_name,
38
+ operations: ITERATIONS,
39
+ before_memsize: before_memsize,
40
+ after_memsize: after_memsize,
41
+ delta_memsize: after_memsize - before_memsize,
42
+ allocated_objects: after_allocated - before_allocated,
43
+ note: "delta B must stay 0; Ruby result allocations are expected for arrays/pairs"
44
+ }
45
+ )
46
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+
5
+ TGGeometryBench.say_header("distance_point_geom")
6
+
7
+ VERTEX_COUNTS = (TGGeometryBench.full? ? [16, 128, 1_024, 8_192] : [16, 128, 1_024]).freeze
8
+
9
+ def regular_polygon(vertex_count, radius: 1.0)
10
+ points = vertex_count.times.map do |i|
11
+ angle = (2.0 * Math::PI * i) / vertex_count
12
+ [Math.cos(angle) * radius, Math.sin(angle) * radius]
13
+ end
14
+ points << points.first
15
+ TG::Geometry.polygon(points)
16
+ end
17
+
18
+ def zigzag_line(vertex_count, scale: 1.0)
19
+ points = vertex_count.times.map { |i| [i.to_f * scale, (i.even? ? 0.0 : 1.0) * scale] }
20
+ TG::Geometry.line_string(points)
21
+ end
22
+
23
+ VERTEX_COUNTS.each do |vertices|
24
+ geometries = {
25
+ polygon_xy: regular_polygon(vertices, radius: 10.0),
26
+ line_xy: zigzag_line(vertices),
27
+ polygon_lnglat_meters: regular_polygon(vertices, radius: 0.01),
28
+ line_lnglat_meters: zigzag_line(vertices, scale: 0.0001)
29
+ }
30
+
31
+ geometries.each do |kind, geom|
32
+ method, args = case kind
33
+ when :polygon_xy then [:distance_to_xy, [20.0, 0.0]]
34
+ when :line_xy then [:distance_to_xy, [vertices / 2.0, 5.0]]
35
+ when :polygon_lnglat_meters then [:distance_to_lnglat_meters, [0.02, 0.0]]
36
+ when :line_lnglat_meters then [:distance_to_lnglat_meters, [vertices / 20_000.0, 0.002]]
37
+ end
38
+
39
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(1_000)) do |iter|
40
+ iter.times { geom.public_send(method, *args) }
41
+ end
42
+
43
+ TGGeometryBench.report(
44
+ "distance_point_geom",
45
+ { kind: kind, n: vertices, method: method, note: "distance methods keep the GVL" },
46
+ stats: stats
47
+ )
48
+ end
49
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_support"
4
+
5
+ TGGeometryBench.say_header("distance_within_radius")
6
+
7
+ COUNTS = (TGGeometryBench.full? ? [1_000, 10_000, 50_000] : [1_000, 10_000]).freeze
8
+ # Contract-required honesty case: tiny index + radius covering the whole data extent.
9
+ # This shows the crossover zone where paying for the rtree prefilter may not help
10
+ # compared to a direct full scan. The result is empirical: on some Ruby/CPU builds
11
+ # rtree may still win, but the benchmark must expose the scenario instead of
12
+ # only showing selective-radius wins.
13
+ TINY_COUNTS = (TGGeometryBench.full? ? [50, 100, 250] : [50, 100]).freeze
14
+
15
+ def box(x, y, size = 0.8)
16
+ TG::Geometry.polygon([[x, y], [x + size, y], [x + size, y + size], [x, y + size], [x, y]])
17
+ end
18
+
19
+ def entries(count)
20
+ count.times.map do |i|
21
+ x = (i % 250).to_f * 2.0
22
+ y = (i / 250).to_f * 2.0
23
+ [i, box(x, y)]
24
+ end
25
+ end
26
+
27
+ def brute_force(entries, x, y, radius)
28
+ entries.filter_map do |id, geom|
29
+ distance = geom.distance_to_xy(x, y)
30
+ [id, distance] if distance <= radius
31
+ end
32
+ end
33
+
34
+ def measure_radius_case(data, index, count, query_name, x, y, radius, initial:)
35
+ [
36
+ [:rtree_prefilter_exact_filter, -> { index.within_distance_xy(x, y, radius) }],
37
+ [:brute_force_full_scan, -> { brute_force(data, x, y, radius) }]
38
+ ].each do |method, callable|
39
+ stats = TGGeometryBench.measure_counted(initial_iterations: TGGeometryBench.initial_iterations(initial)) do |iterations|
40
+ iterations.times { callable.call }
41
+ end
42
+
43
+ TGGeometryBench.report(
44
+ "distance_within_radius",
45
+ { n: count, query: query_name, method: method, note: "prefilter vs full scan; distance radius search keeps the GVL" },
46
+ stats: stats
47
+ )
48
+ end
49
+ end
50
+
51
+ COUNTS.each do |count|
52
+ data = entries(count)
53
+ index = TG::Geometry::Index.build(data, via: :geom, strategy: :rtree)
54
+
55
+ {
56
+ selective: [10.5, 10.5, 1.0, 250],
57
+ broad: [150.0, 20.0, 180.0, 25]
58
+ }.each do |query_name, (x, y, radius, initial)|
59
+ measure_radius_case(data, index, count, query_name, x, y, radius, initial: initial)
60
+ end
61
+ end
62
+
63
+ TINY_COUNTS.each do |count|
64
+ data = entries(count)
65
+ index = TG::Geometry::Index.build(data, via: :geom, strategy: :rtree)
66
+
67
+ # Query at the tiny grid center with a radius that covers the whole generated extent.
68
+ max_x = ((count - 1) % 250).to_f * 2.0 + 0.8
69
+ max_y = ((count - 1) / 250).to_f * 2.0 + 0.8
70
+ x = max_x / 2.0
71
+ y = max_y / 2.0
72
+ radius = Math.hypot(max_x, max_y) + 1.0
73
+
74
+ measure_radius_case(data, index, count, :tiny_full_extent, x, y, radius, initial: 100)
75
+ end