tg_geometry 0.3.1 → 0.3.2

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.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module TG
4
4
  module Geometry
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
data/lib/tg/geometry.rb CHANGED
@@ -43,6 +43,30 @@ module TG
43
43
  # @!method srid
44
44
  # @return [Integer, nil] SRID metadata; not used for reprojection
45
45
  #
46
+ # @!method distance_to_lnglat_meters(lng, lat)
47
+ # Approximate meters in a query-local equirectangular frame. Not geodesy.
48
+ # @return [Float]
49
+ #
50
+ # @!method boundary_distance_to_lnglat_meters(lng, lat)
51
+ # Approximate meters to nearest boundary/segment/point.
52
+ # @return [Float]
53
+ #
54
+ # @!method nearest_point_lnglat(lng, lat)
55
+ # Raw planar nearest boundary/geometry point. Longitude is not wrapped.
56
+ # @return [Array(Float, Float)]
57
+ #
58
+ # @!method distance_to_xy(x, y)
59
+ # Planar distance in input coordinate units.
60
+ # @return [Float]
61
+ #
62
+ # @!method boundary_distance_to_xy(x, y)
63
+ # Planar boundary distance in input coordinate units.
64
+ # @return [Float]
65
+ #
66
+ # @!method nearest_point_xy(x, y)
67
+ # Planar nearest boundary/geometry point in input coordinate units.
68
+ # @return [Array(Float, Float)]
69
+ #
46
70
  # @!method to_ewkb(srid: nil)
47
71
  # Writes EWKB with the SRID flag set. Uses explicit srid: when provided,
48
72
  # otherwise Geom#srid. Raises if no SRID is available. to_wkb remains plain.
@@ -68,6 +92,22 @@ module TG
68
92
  # Stored geometries for which tg_geom_contains(stored, query) is true.
69
93
  # Direction: stored contains query. Boundary points are not contained.
70
94
  # @return [Array<Object>] ids in insertion order
95
+ #
96
+ # @!method within_distance_lnglat_meters(lng, lat, radius_m, sort: false)
97
+ # Rtree bbox prefilter plus exact approximate-meter distance filter.
98
+ # @return [Array<Array(Object, Float)>]
99
+ #
100
+ # @!method within_distance_ids_lnglat_meters(lng, lat, radius_m)
101
+ # Same membership as within_distance_lnglat_meters, ids only.
102
+ # @return [Array<Object>]
103
+ #
104
+ # @!method within_distance_xy(x, y, radius, sort: false)
105
+ # Rtree bbox prefilter plus exact planar distance filter.
106
+ # @return [Array<Array(Object, Float)>]
107
+ #
108
+ # @!method within_distance_ids_xy(x, y, radius)
109
+ # Same membership as within_distance_xy, ids only.
110
+ # @return [Array<Object>]
71
111
  end
72
112
 
73
113
  class Line
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "point to geometry distance" do
6
+ R = 6_371_008.8
7
+ DEG = Math::PI / 180.0
8
+
9
+ def meters_per_lng_at(lat)
10
+ R * DEG * Math.cos(lat * DEG)
11
+ end
12
+
13
+ def meters_per_lat
14
+ R * DEG
15
+ end
16
+
17
+ def point_to_segment_distance(px, py, ax, ay, bx, by)
18
+ dx = bx - ax
19
+ dy = by - ay
20
+ len_sq = dx * dx + dy * dy
21
+ return Math.hypot(px - ax, py - ay) if len_sq.zero?
22
+
23
+ t = (((px - ax) * dx) + ((py - ay) * dy)) / len_sq
24
+ t = 0.0 if t < 0.0
25
+ t = 1.0 if t > 1.0
26
+ Math.hypot(px - (ax + t * dx), py - (ay + t * dy))
27
+ end
28
+
29
+ def lnglat_segment_distance_m(query_lng, query_lat, a, b)
30
+ sx = meters_per_lng_at(query_lat)
31
+ sy = meters_per_lat
32
+ ax = sx * (a[0] - query_lng)
33
+ ay = sy * (a[1] - query_lat)
34
+ bx = sx * (b[0] - query_lng)
35
+ by = sy * (b[1] - query_lat)
36
+ point_to_segment_distance(0.0, 0.0, ax, ay, bx, by)
37
+ end
38
+
39
+ it "implements polygon inside, boundary, outside, and hole semantics" do
40
+ polygon = TG::Geometry.polygon(
41
+ [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]],
42
+ holes: [[[2, 2], [4, 2], [4, 4], [2, 4], [2, 2]]]
43
+ )
44
+
45
+ expect(polygon.distance_to_xy(5, 5)).to eq(0.0)
46
+ expect(polygon.boundary_distance_to_xy(5, 5)).to be_within(1e-12).of(Math.sqrt(2.0))
47
+ expect(polygon.distance_to_xy(0, 5)).to eq(0.0)
48
+ expect(polygon.boundary_distance_to_xy(0, 5)).to eq(0.0)
49
+ expect(polygon.distance_to_xy(12, 5)).to be_within(1e-12).of(2.0)
50
+ expect(polygon.distance_to_xy(3, 3)).to be_within(1e-12).of(1.0)
51
+ end
52
+
53
+ it "uses approximate local meters for lng/lat outside distances" do
54
+ polygon = TG::Geometry.polygon([[0, 0], [0.01, 0], [0.01, 0.01], [0, 0.01], [0, 0]])
55
+ distance = polygon.distance_to_lnglat_meters(0.02, 0.005)
56
+ expected = meters_per_lng_at(0.005) * 0.01
57
+
58
+ expect(distance).to be_within(0.01).of(expected)
59
+ expect(polygon.distance_to_lnglat_meters(0.005, 0.005)).to eq(0.0)
60
+ expect(polygon.boundary_distance_to_lnglat_meters(0.005, 0.005)).to be > 0.0
61
+ end
62
+
63
+ it "uses minimum distance over multipolygon members" do
64
+ multipolygon = TG::Geometry.parse_wkt(
65
+ "MULTIPOLYGON (((0 0, 2 0, 2 2, 0 2, 0 0)), ((10 0, 12 0, 12 2, 10 2, 10 0)))"
66
+ )
67
+
68
+ expect(multipolygon.distance_to_xy(1, 1)).to eq(0.0)
69
+ expect(multipolygon.distance_to_xy(6, 1)).to be_within(1e-12).of(4.0)
70
+ end
71
+
72
+ it "handles lines, points, multipoints, and mixed geometry collections" do
73
+ line = TG::Geometry.line_string([[0, 0], [10, 0]])
74
+ point = TG::Geometry.point(3, 4)
75
+ multipoint = TG::Geometry.parse_wkt("MULTIPOINT ((0 0), (10 0))")
76
+ collection = TG::Geometry.parse_wkt(
77
+ "GEOMETRYCOLLECTION (POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20)), LINESTRING (0 0, 10 0))"
78
+ )
79
+
80
+ expect(line.distance_to_xy(5, 3)).to be_within(1e-12).of(line.boundary_distance_to_xy(5, 3))
81
+ expect(line.distance_to_xy(5, 3)).to be_within(1e-12).of(3.0)
82
+ expect(point.distance_to_xy(0, 0)).to be_within(1e-12).of(5.0)
83
+ expect(multipoint.distance_to_xy(7, 0)).to be_within(1e-12).of(3.0)
84
+ expect(collection.distance_to_xy(5, 2)).to be_within(1e-12).of(2.0)
85
+ expect(collection.distance_to_xy(25, 25)).to eq(0.0)
86
+ end
87
+
88
+ it "skips empty members but raises when nothing is measurable" do
89
+ mixed = TG::Geometry.parse_wkt("GEOMETRYCOLLECTION (POINT EMPTY, POINT (1 1))")
90
+ all_empty = TG::Geometry.parse_wkt("GEOMETRYCOLLECTION (POINT EMPTY, LINESTRING EMPTY)")
91
+
92
+ expect(mixed.distance_to_xy(4, 5)).to be_within(1e-12).of(5.0)
93
+ expect { all_empty.distance_to_xy(0, 0) }.to raise_error(TG::Geometry::ArgumentError)
94
+ expect { TG::Geometry.empty_polygon.boundary_distance_to_xy(0, 0) }.to raise_error(TG::Geometry::ArgumentError)
95
+ end
96
+
97
+ it "returns nearest boundary points for interior polygon queries" do
98
+ polygon = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]])
99
+ nearest = polygon.nearest_point_xy(5, 5)
100
+ boundary_distance = polygon.boundary_distance_to_xy(5, 5)
101
+
102
+ expect(polygon.distance_to_xy(5, 5)).to eq(0.0)
103
+ expect(boundary_distance).to be_within(1e-12).of(5.0)
104
+ expect(point_to_segment_distance(5, 5, nearest[0], nearest[1], nearest[0], nearest[1])).to be_within(1e-12).of(boundary_distance)
105
+ expect(nearest[0] == 0.0 || nearest[0] == 10.0 || nearest[1] == 0.0 || nearest[1] == 10.0).to be(true)
106
+ end
107
+
108
+ it "keeps nearest lng/lat raw and does not force-wrap returned longitude" do
109
+ line = TG::Geometry.line_string([[181, 0], [181, 1]])
110
+ nearest = line.nearest_point_lnglat(180, 0.5)
111
+
112
+ expect(nearest[0]).to be_within(1e-12).of(181.0)
113
+ expect(nearest[1]).to be_within(1e-12).of(0.5)
114
+ end
115
+
116
+ it "does not wrap antimeridian proximity" do
117
+ point = TG::Geometry.point(179.9, 0)
118
+
119
+ expect(point.distance_to_lnglat_meters(-179.9, 0)).to be > 30_000_000
120
+ expect(point.covers_xy?(-179.9, 0)).to be(false)
121
+ end
122
+
123
+ it "matches independent local-meter segment math and stays finite near the pole" do
124
+ line = TG::Geometry.line_string([[0, 0], [0.01, 0]])
125
+ expected = lnglat_segment_distance_m(0.005, 0.01, [0, 0], [0.01, 0])
126
+
127
+ expect(line.distance_to_lnglat_meters(0.005, 0.01)).to be_within(0.001).of(expected)
128
+ expect(TG::Geometry.point(10, 90).distance_to_lnglat_meters(0, 90)).to be_finite
129
+ expect(TG::Geometry.point(10, 90).nearest_point_lnglat(0, 90)).to eq([10.0, 90.0])
130
+ end
131
+
132
+ it "keeps Geom and Index memsize unchanged after distance calls" do
133
+ require "objspace"
134
+
135
+ geom = TG::Geometry.polygon([[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]])
136
+ index = TG::Geometry::Index.build([[:zone, geom]], via: :geom, strategy: :rtree)
137
+ geom_size = ObjectSpace.memsize_of(geom)
138
+ index_size = ObjectSpace.memsize_of(index)
139
+
140
+ 5.times do
141
+ geom.distance_to_xy(5, 5)
142
+ geom.nearest_point_lnglat(0.01, 0.01)
143
+ index.within_distance_xy(5, 5, 10)
144
+ index.within_distance_lnglat_meters(0.01, 0.01, 2_000)
145
+ end
146
+
147
+ expect(ObjectSpace.memsize_of(geom)).to eq(geom_size)
148
+ expect(ObjectSpace.memsize_of(index)).to eq(index_size)
149
+ end
150
+
151
+ it "survives GC stress and compaction" do
152
+ geom = TG::Geometry.line_string([[0, 0], [10, 0]])
153
+
154
+ GC.stress = true
155
+ expect(geom.distance_to_xy(5, 3)).to be_within(1e-12).of(3.0)
156
+ expect(geom.nearest_point_xy(5, 3)).to eq([5.0, 0.0])
157
+ ensure
158
+ GC.stress = false
159
+ GC.compact if GC.respond_to?(:compact)
160
+ end
161
+ end
162
+
163
+ RSpec.describe "Index distance radius queries" do
164
+ def build_distance_entries
165
+ [
166
+ [:zone, TG::Geometry.polygon([[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]])],
167
+ [:line, TG::Geometry.line_string([[4, 0], [4, 4]])],
168
+ [:point, TG::Geometry.point(8, 0)]
169
+ ]
170
+ end
171
+
172
+ it "matches brute force membership for xy and returns filter distances" do
173
+ entries = build_distance_entries
174
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
175
+
176
+ srand(1234)
177
+ 30.times do
178
+ x = rand * 10.0 - 1.0
179
+ y = rand * 5.0 - 1.0
180
+ radius = rand * 3.0
181
+ expected = entries.filter_map do |id, geom|
182
+ distance = geom.distance_to_xy(x, y)
183
+ [id, distance] if distance <= radius
184
+ end
185
+ actual = index.within_distance_xy(x, y, radius)
186
+
187
+ expect(actual.map(&:first)).to eq(expected.map(&:first))
188
+ actual.each do |id, distance|
189
+ geom = entries.assoc(id)[1]
190
+ expect(distance).to be_within(1e-12).of(geom.distance_to_xy(x, y))
191
+ end
192
+ expect(index.within_distance_ids_xy(x, y, radius)).to eq(expected.map(&:first))
193
+ end
194
+ end
195
+
196
+ it "matches brute force membership for lng/lat meters" do
197
+ entries = [
198
+ [:zone, TG::Geometry.polygon([[0, 0], [0.02, 0], [0.02, 0.02], [0, 0.02], [0, 0]])],
199
+ [:line, TG::Geometry.line_string([[0.04, 0], [0.04, 0.04]])],
200
+ [:point, TG::Geometry.point(0.08, 0)]
201
+ ]
202
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
203
+
204
+ srand(5678)
205
+ 30.times do
206
+ lng = rand * 0.1 - 0.01
207
+ lat = rand * 0.05 - 0.01
208
+ radius = rand * 3_000.0
209
+ expected = entries.filter_map do |id, geom|
210
+ distance = geom.distance_to_lnglat_meters(lng, lat)
211
+ [id, distance] if distance <= radius
212
+ end
213
+ actual = index.within_distance_lnglat_meters(lng, lat, radius)
214
+
215
+ expect(actual.map(&:first)).to eq(expected.map(&:first))
216
+ actual.each do |id, distance|
217
+ geom = entries.assoc(id)[1]
218
+ expect(distance).to be_within(1e-9).of(geom.distance_to_lnglat_meters(lng, lat))
219
+ end
220
+ expect(index.within_distance_ids_lnglat_meters(lng, lat, radius)).to eq(expected.map(&:first))
221
+ end
222
+ end
223
+
224
+ it "sorts filtered pairs by distance only when requested" do
225
+ entries = build_distance_entries
226
+ index = TG::Geometry::Index.build(entries, via: :geom, strategy: :rtree)
227
+ sorted = index.within_distance_xy(3, 1, 10, sort: true)
228
+
229
+ expect(sorted.map(&:last)).to eq(sorted.map(&:last).sort)
230
+ expect(index.within_distance_xy(3, 1, 10, sort: false).map(&:first)).to eq([:zone, :line, :point])
231
+ end
232
+
233
+ it "handles zero radius and near-pole/full-longitude prefilter cases" do
234
+ zone = TG::Geometry.polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]])
235
+ pole_point = TG::Geometry.point(90, 90)
236
+ index = TG::Geometry::Index.build([[:zone, zone], [:pole, pole_point]], via: :geom, strategy: :rtree)
237
+
238
+ expect(index.within_distance_ids_xy(0.5, 0.5, 0)).to eq([:zone])
239
+ expect(index.within_distance_ids_lnglat_meters(0, 90, 1)).to eq([:pole])
240
+ end
241
+
242
+ it "rejects invalid arguments and keywords" do
243
+ index = TG::Geometry::Index.build(build_distance_entries, via: :geom, strategy: :rtree)
244
+ geom = TG::Geometry.point(0, 0)
245
+
246
+ expect { geom.distance_to_lnglat_meters(Float::NAN, 0) }.to raise_error(TG::Geometry::ArgumentError)
247
+ expect { geom.distance_to_lnglat_meters(0, Float::INFINITY) }.to raise_error(TG::Geometry::ArgumentError)
248
+ expect { geom.distance_to_lnglat_meters(181, 0) }.to raise_error(TG::Geometry::ArgumentError)
249
+ expect { geom.distance_to_lnglat_meters(0, -91) }.to raise_error(TG::Geometry::ArgumentError)
250
+ expect { geom.distance_to_xy(0, Float::NAN) }.to raise_error(TG::Geometry::ArgumentError)
251
+ expect { geom.distance_to_xy(0, 0, metric: :meters) }.to raise_error(TG::Geometry::ArgumentError)
252
+ expect { index.within_distance_xy(0, 0, -1) }.to raise_error(TG::Geometry::ArgumentError)
253
+ expect { index.within_distance_lnglat_meters(0, 0, Float::INFINITY) }.to raise_error(TG::Geometry::ArgumentError)
254
+ expect { index.within_distance_xy(0, 0, 1, bogus: true) }.to raise_error(TG::Geometry::ArgumentError)
255
+ expect { index.within_distance_ids_xy(0, 0, 1, sort: true) }.to raise_error(TG::Geometry::ArgumentError)
256
+ expect { index.within_distance_ids_lnglat_meters(0, 0, 1, sort: true) }.to raise_error(TG::Geometry::ArgumentError)
257
+ end
258
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tg_geometry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-28 00:00:00.000000000 Z
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -64,9 +64,9 @@ description: Defines TG::Geometry with immutable Geom parsing and constructor wr
64
64
  wrappers, value Segment wrappers, Registry reload sugar, optional ActiveRecord source
65
65
  helpers, and an immutable geofencing-oriented Index with owned and borrowed geometry
66
66
  ingestion, flat/rtree strategies, deterministic ordered id results, exact rtree
67
- allocation accounting, and native-endian packed point batch queries, and FeatureSource
68
- GeoJSON FeatureCollection extraction/build paths over vendored C sources. Ractor
69
- support is not claimed.
67
+ allocation accounting, and native-endian packed point batch queries, explicit local
68
+ point-to-geometry distance/radius queries, and FeatureSource GeoJSON FeatureCollection
69
+ extraction/build paths over vendored C sources. Ractor support is not claimed.
70
70
  email:
71
71
  - romnhajdarov@gmail.com
72
72
  executables: []
@@ -81,6 +81,9 @@ files:
81
81
  - Rakefile
82
82
  - benchmark/_support.rb
83
83
  - benchmark/batch_packed_vs_loop.rb
84
+ - benchmark/distance_memory_accounting.rb
85
+ - benchmark/distance_point_geom.rb
86
+ - benchmark/distance_within_radius.rb
84
87
  - benchmark/ewkb_roundtrip.rb
85
88
  - benchmark/falcon_concurrency.rb
86
89
  - benchmark/feature_source.rb
@@ -135,6 +138,7 @@ files:
135
138
  - spec/batch_packed_spec.rb
136
139
  - spec/concurrency_spec.rb
137
140
  - spec/constructors_spec.rb
141
+ - spec/distance_spec.rb
138
142
  - spec/error_hardening_spec.rb
139
143
  - spec/feature_source_nogvl_spec.rb
140
144
  - spec/feature_source_spec.rb
@@ -202,5 +206,6 @@ rubygems_version: 3.3.27
202
206
  signing_key:
203
207
  specification_version: 4
204
208
  summary: Native extension for TG::Geometry parsing, predicates, immutable indexes,
205
- FeatureSource imports, registries, low-level wrappers, and packed point batches
209
+ FeatureSource imports, registries, low-level wrappers, packed point batches, and
210
+ local distance queries
206
211
  test_files: []