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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +81 -251
- data/benchmark/_support.rb +11 -4
- data/benchmark/distance_memory_accounting.rb +46 -0
- data/benchmark/distance_point_geom.rb +49 -0
- data/benchmark/distance_within_radius.rb +75 -0
- data/docs/BENCHMARKING.md +28 -1
- data/docs/CONCURRENCY.md +8 -0
- data/docs/GEOMETRY_QUERIES.md +42 -0
- data/docs/LIMITATIONS.md +12 -0
- data/ext/tg_geometry/tg_geometry_ext.c +843 -4
- data/ext/tg_geometry/vendor/.vendored +8 -8
- data/ext/tg_geometry/vendor/json/VERSION +1 -1
- data/ext/tg_geometry/vendor/json/json.c +20 -20
- data/ext/tg_geometry/vendor/json/json.h +20 -20
- data/ext/tg_geometry/vendor/rtree/VERSION +2 -2
- data/ext/tg_geometry/vendor/rtree/rtree.c +121 -148
- data/ext/tg_geometry/vendor/rtree/rtree.h +14 -11
- data/ext/tg_geometry/vendor/tg/VERSION +1 -1
- data/ext/tg_geometry/vendor/tg/tg.c +1854 -1339
- data/ext/tg_geometry/vendor/tg/tg.h +18 -17
- data/lib/tg/geometry/version.rb +1 -1
- data/lib/tg/geometry.rb +40 -0
- data/script/vendor_libs.rb +11 -7
- data/spec/distance_spec.rb +258 -0
- metadata +12 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27136bafb4a0114a2921af0413c047391bfffb38a6ff41f6c4f011f7cf4ab2ad
|
|
4
|
+
data.tar.gz: 7b2148e42f78fa74b3d5ac41e492e78673dd2617aa887c0023affcfa8fe555bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
See [GET_STARTED.md#2-parse-a-geometry](GET_STARTED.md#2-parse-a-geometry).
|
|
144
58
|
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
### SRID and EWKB
|
|
159
66
|
|
|
160
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
- `:contains` — stricter containment semantics.
|
|
71
|
+
### Rect
|
|
167
72
|
|
|
168
|
-
|
|
73
|
+
`TG::Geometry::Rect` is an immutable helper for bounding boxes, containment checks, intersections, and expansion.
|
|
169
74
|
|
|
170
|
-
|
|
75
|
+
See [GET_STARTED.md#4-use-rect-helpers](GET_STARTED.md#4-use-rect-helpers).
|
|
171
76
|
|
|
172
|
-
|
|
77
|
+
### Immutable Index
|
|
173
78
|
|
|
174
|
-
|
|
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
|
-
|
|
81
|
+
See [GET_STARTED.md#5-build-an-immutable-index](GET_STARTED.md#5-build-an-immutable-index).
|
|
182
82
|
|
|
183
|
-
|
|
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
|
-
|
|
85
|
+
Index methods cover point geofencing, packed batch point queries, rectangle intersection, and geometry-vs-geometry predicates.
|
|
190
86
|
|
|
191
|
-
|
|
87
|
+
See:
|
|
192
88
|
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
Distance APIs are explicit about units:
|
|
211
96
|
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
### GeoJSON FeatureSource
|
|
237
105
|
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
### Nearest segment
|
|
247
111
|
|
|
248
|
-
|
|
112
|
+
`Line#nearest_segment(x, y)` and `Ring#nearest_segment(x, y)` expose low-level planar nearest-segment helpers.
|
|
249
113
|
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
`TG::Geometry::Registry` is Ruby-level sugar over immutable indexes with safe reload semantics.
|
|
261
119
|
|
|
262
|
-
|
|
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
|
-
|
|
122
|
+
### Optional ActiveRecord integration
|
|
265
123
|
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
See [docs/MEMORY_OWNERSHIP.md](docs/MEMORY_OWNERSHIP.md) and [docs/CONCURRENCY.md](docs/CONCURRENCY.md).
|
|
310
137
|
|
|
311
|
-
|
|
138
|
+
## Benchmarks
|
|
312
139
|
|
|
313
|
-
|
|
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
|
-
|
|
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
|
data/benchmark/_support.rb
CHANGED
|
@@ -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
|