geodetic 0.5.2 → 0.7.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.
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of WKB (Well-Known Binary) Serialization
4
+ # Shows export, import, roundtrip, SRID/EWKB, Z-dimension handling,
5
+ # hex encoding, and file I/O in both binary and hex formats.
6
+
7
+ require_relative "../lib/geodetic"
8
+
9
+ include Geodetic
10
+
11
+ LLA = Coordinate::LLA
12
+
13
+ puts "=== WKB Serialization Demo ==="
14
+ puts
15
+ puts " WKB is the binary counterpart to WKT — the format PostGIS, GEOS,"
16
+ puts " RGeo, and Shapely use for efficient geometry storage and transfer."
17
+ puts " Output is always little-endian (NDR), matching modern GIS conventions."
18
+ puts
19
+
20
+ # ── 1. Coordinate → POINT ──────────────────────────────────────────
21
+
22
+ puts "--- 1. Coordinate → POINT ---"
23
+ puts
24
+
25
+ seattle = LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
26
+ wkb = seattle.to_wkb
27
+ puts " seattle.to_wkb"
28
+ puts " => #{wkb.bytesize} bytes (binary)"
29
+ puts
30
+
31
+ hex = seattle.to_wkb_hex
32
+ puts " seattle.to_wkb_hex"
33
+ puts " => #{hex}"
34
+ puts
35
+
36
+ # Cross-system
37
+ utm = seattle.to_utm
38
+ puts " seattle.to_utm.to_wkb_hex"
39
+ puts " => #{utm.to_wkb_hex}"
40
+ puts
41
+
42
+ # Altitude triggers Z type code (Point Z = 1001)
43
+ space_needle = LLA.new(lat: 47.6205, lng: -122.3493, alt: 184.0)
44
+ puts " With altitude (Point Z):"
45
+ puts " => #{space_needle.to_wkb_hex}"
46
+ puts
47
+
48
+ # ── 2. Segment → LINESTRING ────────────────────────────────────────
49
+
50
+ puts "--- 2. Segment → LINESTRING ---"
51
+ puts
52
+
53
+ portland = LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
54
+ seg = Geodetic::Segment.new(seattle, portland)
55
+ puts " #{seg.to_wkb.bytesize} bytes: #{seg.to_wkb_hex}"
56
+ puts
57
+
58
+ # ── 3. Path → LINESTRING / POLYGON ────────────────────────────────
59
+
60
+ puts "--- 3. Path → LINESTRING / POLYGON ---"
61
+ puts
62
+
63
+ sf = LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
64
+ route = Path.new(coordinates: [seattle, portland, sf])
65
+ puts " Path (3 cities): #{route.to_wkb.bytesize} bytes"
66
+ puts " hex: #{route.to_wkb_hex[0..60]}..."
67
+ puts
68
+
69
+ triangle = Path.new(coordinates: [
70
+ LLA.new(lat: 47.0, lng: -122.5, alt: 0),
71
+ LLA.new(lat: 46.0, lng: -121.0, alt: 0),
72
+ LLA.new(lat: 46.0, lng: -123.0, alt: 0)
73
+ ])
74
+ poly_wkb = triangle.to_wkb(as: :polygon)
75
+ puts " Path as polygon: #{poly_wkb.bytesize} bytes"
76
+ puts
77
+
78
+ # ── 4. Areas → POLYGON ─────────────────────────────────────────────
79
+
80
+ puts "--- 4. Areas → POLYGON ---"
81
+ puts
82
+
83
+ a = LLA.new(lat: 47.7, lng: -122.5, alt: 0)
84
+ b = LLA.new(lat: 47.5, lng: -122.1, alt: 0)
85
+ c = LLA.new(lat: 47.3, lng: -122.4, alt: 0)
86
+ poly = Areas::Polygon.new(boundary: [a, b, c])
87
+ puts " Polygon: #{poly.to_wkb.bytesize} bytes"
88
+
89
+ circle = Areas::Circle.new(centroid: seattle, radius: 10_000)
90
+ puts " Circle (32-gon): #{circle.to_wkb.bytesize} bytes"
91
+ puts " Circle (8-gon): #{circle.to_wkb(segments: 8).bytesize} bytes"
92
+
93
+ bbox = Areas::BoundingBox.new(
94
+ nw: LLA.new(lat: 48.0, lng: -123.0, alt: 0),
95
+ se: LLA.new(lat: 46.0, lng: -121.0, alt: 0)
96
+ )
97
+ puts " BoundingBox: #{bbox.to_wkb.bytesize} bytes"
98
+ puts
99
+
100
+ # ── 5. Feature delegates ───────────────────────────────────────────
101
+
102
+ puts "--- 5. Feature → delegates to geometry ---"
103
+ puts
104
+
105
+ city = Feature.new(label: "Seattle", geometry: seattle, metadata: { pop: 750_000 })
106
+ puts " Feature('Seattle').to_wkb_hex"
107
+ puts " => #{city.to_wkb_hex}"
108
+ puts " (WKB has no properties; label/metadata are lost)"
109
+ puts
110
+
111
+ # ── 6. SRID / EWKB ─────────────────────────────────────────────────
112
+
113
+ puts "--- 6. SRID / EWKB ---"
114
+ puts
115
+
116
+ puts " POINT with SRID=4326:"
117
+ puts " => #{seattle.to_wkb_hex(srid: 4326)}"
118
+
119
+ puts " LINESTRING with SRID=4326:"
120
+ puts " => #{seg.to_wkb_hex(srid: 4326)}"
121
+ puts
122
+
123
+ # ── 7. Z-dimension consistency ──────────────────────────────────────
124
+
125
+ puts "--- 7. Z-dimension consistency ---"
126
+ puts
127
+
128
+ sf_alt = LLA.new(lat: 37.7749, lng: -122.4194, alt: 100.0)
129
+ mixed = Geodetic::Segment.new(seattle, sf_alt)
130
+ puts " seattle (alt=0) + sf (alt=100):"
131
+ puts " Type code: #{mixed.to_wkb[1, 4].unpack1("V")} (1002 = LineString Z)"
132
+ puts " Both points get Z even though seattle has alt=0"
133
+ puts
134
+
135
+ no_alt = Geodetic::Segment.new(seattle, portland)
136
+ puts " seattle + portland (both alt=0):"
137
+ puts " Type code: #{no_alt.to_wkb[1, 4].unpack1("V")} (2 = LineString)"
138
+ puts
139
+
140
+ # ── 8. Parsing WKB ─────────────────────────────────────────────────
141
+
142
+ puts "--- 8. Parsing WKB ---"
143
+ puts
144
+
145
+ hex_samples = {
146
+ "POINT(1 2)" => "0101000000000000000000f03f0000000000000040",
147
+ "LINESTRING(1..4)" => "010200000002000000000000000000f03f000000000000004000000000000008400000000000001040",
148
+ "POLYGON" => "01030000000100000004000000000000000000f03f00000000000000400000000000000840000000000000104000000000000014400000000000001840000000000000f03f0000000000000040",
149
+ "MULTIPOINT" => "0104000000020000000101000000000000000000f03f0000000000000040010100000000000000000008400000000000001040",
150
+ "GEOM_COLLECTION" => "0107000000020000000101000000000000000000f03f0000000000000040010200000002000000000000000000f03f000000000000004000000000000008400000000000001040"
151
+ }
152
+
153
+ hex_samples.each do |label, hex|
154
+ result = WKB.parse(hex)
155
+ if result.is_a?(Array)
156
+ puts " #{label.ljust(18)} → [#{result.map { |r| r.class.name.split('::').last }.join(', ')}]"
157
+ else
158
+ puts " #{label.ljust(18)} → #{result.class.name.split('::').last}"
159
+ end
160
+ end
161
+ puts
162
+
163
+ # Parse EWKB
164
+ obj, srid = WKB.parse_with_srid("0101000020e61000008a1f63ee5a965ec08195438b6ccf4740")
165
+ puts " EWKB → #{obj.class.name.split('::').last}(#{obj.to_s(4)}), SRID=#{srid}"
166
+ puts
167
+
168
+ # Parse binary directly
169
+ binary = seattle.to_wkb
170
+ parsed = WKB.parse(binary)
171
+ puts " Binary parse → #{parsed.class.name.split('::').last}(#{parsed.to_s(4)})"
172
+ puts
173
+
174
+ # ── 9. Roundtrip ───────────────────────────────────────────────────
175
+
176
+ puts "--- 9. Roundtrip (encode → parse → re-encode) ---"
177
+ puts
178
+
179
+ objects = {
180
+ "POINT" => seattle,
181
+ "POINT Z" => space_needle,
182
+ "LINESTRING" => Geodetic::Segment.new(seattle, portland),
183
+ "PATH" => Path.new(coordinates: [seattle, portland, sf]),
184
+ "POLYGON" => poly,
185
+ "BBOX" => bbox,
186
+ "SRID" => seattle
187
+ }
188
+
189
+ objects.each do |label, obj|
190
+ srid_val = label == "SRID" ? 4326 : nil
191
+ original = obj.to_wkb_hex(srid: srid_val)
192
+
193
+ if srid_val
194
+ parsed, parsed_srid = WKB.parse_with_srid(original)
195
+ roundtrip = parsed.to_wkb_hex(srid: parsed_srid)
196
+ else
197
+ parsed = WKB.parse(original)
198
+ roundtrip = parsed.to_wkb_hex
199
+ end
200
+
201
+ match = original == roundtrip ? "MATCH" : "DIFF"
202
+ puts " #{label.ljust(12)} #{match} #{original.length} hex chars"
203
+ end
204
+ puts
205
+
206
+ # ── 10. File I/O ───────────────────────────────────────────────────
207
+
208
+ puts "--- 10. File I/O ---"
209
+ puts
210
+
211
+ # Binary format
212
+ bin_path = File.join(__dir__, "geodetic_demo.wkb")
213
+ save_objects = [seattle, space_needle, seg, poly, bbox]
214
+ WKB.save!(bin_path, save_objects)
215
+ puts " Binary: saved #{save_objects.length} objects to #{bin_path}"
216
+ puts " File size: #{File.size(bin_path)} bytes"
217
+
218
+ loaded = WKB.load(bin_path)
219
+ puts " Loaded #{loaded.length} objects:"
220
+ loaded.each do |obj|
221
+ puts " #{obj.class.name.split('::').last} (#{obj.to_wkb.bytesize} bytes)"
222
+ end
223
+ puts
224
+
225
+ # Hex format
226
+ hex_path = File.join(__dir__, "geodetic_demo_output.wkb.hex")
227
+ WKB.save_hex!(hex_path, save_objects)
228
+ puts " Hex: saved #{save_objects.length} objects to #{hex_path}"
229
+ puts " File contents:"
230
+ File.readlines(hex_path, chomp: true).each_with_index do |line, i|
231
+ puts " #{i + 1}: #{line[0..60]}#{"..." if line.length > 60}"
232
+ end
233
+ puts
234
+
235
+ hex_loaded = WKB.load_hex(hex_path)
236
+ puts " Loaded #{hex_loaded.length} objects from hex file"
237
+ puts
238
+
239
+ # Roundtrip verification
240
+ puts " File roundtrip check:"
241
+ save_objects.zip(loaded).each_with_index do |(orig, back), i|
242
+ match = orig.to_wkb_hex == back.to_wkb_hex ? "MATCH" : "DIFF"
243
+ puts " object #{i + 1}: #{match}"
244
+ end
245
+ puts
246
+
247
+ # Load the fixture files
248
+ fixture_bin = File.join(__dir__, "sample_geometries.wkb")
249
+ if File.exist?(fixture_bin)
250
+ fixture_objects = WKB.load(fixture_bin)
251
+ puts " Fixture binary: loaded #{fixture_objects.length} geometries from sample_geometries.wkb"
252
+ end
253
+
254
+ fixture_hex = File.join(__dir__, "sample_geometries.wkb.hex")
255
+ if File.exist?(fixture_hex)
256
+ hex_objects = WKB.load_hex(fixture_hex)
257
+ puts " Fixture hex: loaded #{hex_objects.length} geometries from sample_geometries.wkb.hex"
258
+ end
259
+ puts
260
+
261
+ puts "=== Done ==="
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # GEOS Performance Benchmark
5
+ #
6
+ # Compares pure Ruby spatial operations against GEOS-accelerated versions.
7
+ # Uses GEODETIC_GEOS_DISABLE env var to force Ruby-only execution, then
8
+ # runs the same operations with GEOS enabled.
9
+ #
10
+ # Requires: brew install geos
11
+ # Run: ruby -Ilib examples/12_geos_benchmark.rb
12
+
13
+ require_relative '../lib/geodetic'
14
+ require 'benchmark'
15
+
16
+ LLA = Geodetic::Coordinate::LLA
17
+ Polygon = Geodetic::Areas::Polygon
18
+ Path = Geodetic::Path
19
+ Segment = Geodetic::Segment
20
+ Geos = Geodetic::Geos
21
+
22
+ unless Geos::LibGEOS.available?
23
+ abort "libgeos_c not found. Install with: brew install geos"
24
+ end
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def make_polygon(n_vertices, center_lat: 40.0, center_lng: -74.0, radius_deg: 1.0)
31
+ step = 360.0 / n_vertices
32
+ boundary = n_vertices.times.map do |i|
33
+ angle = i * step * Geodetic::RAD_PER_DEG
34
+ LLA.new(
35
+ lat: center_lat + radius_deg * Math.sin(angle),
36
+ lng: center_lng + radius_deg * Math.cos(angle),
37
+ alt: 0.0
38
+ )
39
+ end
40
+ Polygon.new(boundary: boundary)
41
+ end
42
+
43
+ def make_path(n_points, start_lat: 40.0, start_lng: -74.0, step_deg: 0.01, zigzag: 0.001)
44
+ coords = n_points.times.map do |i|
45
+ LLA.new(
46
+ lat: start_lat + (i.even? ? zigzag : -zigzag),
47
+ lng: start_lng + i * step_deg,
48
+ alt: 0.0
49
+ )
50
+ end
51
+ Path.new(coordinates: coords)
52
+ end
53
+
54
+ def section(title)
55
+ puts
56
+ puts "=" * 70
57
+ puts title
58
+ puts "=" * 70
59
+ end
60
+
61
+ def speedup(ruby_time, geos_time)
62
+ return "N/A" if geos_time == 0
63
+ ratio = ruby_time / geos_time
64
+ format("%.1fx", ratio)
65
+ end
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Collect timings for Ruby-only vs GEOS
69
+ # ---------------------------------------------------------------------------
70
+
71
+ results = []
72
+
73
+ # --- 1. Polygon validation ------------------------------------------------
74
+
75
+ section "1. POLYGON VALIDATION"
76
+ puts " Ruby uses O(n^2) pairwise segment tests."
77
+ puts " GEOS uses an O(n log n) spatial index."
78
+
79
+ [50, 100, 500].each do |n|
80
+ boundary = n.times.map do |i|
81
+ angle = i * (360.0 / n) * Geodetic::RAD_PER_DEG
82
+ LLA.new(lat: 40.0 + Math.sin(angle), lng: -74.0 + Math.cos(angle), alt: 0.0)
83
+ end
84
+ iterations = n <= 100 ? 500 : 50
85
+
86
+ puts "\n #{n} vertices, #{iterations} iterations:"
87
+
88
+ # Ruby-only (disable GEOS)
89
+ ENV['GEODETIC_GEOS_DISABLE'] = '1'
90
+ ruby_time = Benchmark.realtime do
91
+ iterations.times { Polygon.new(boundary: boundary.dup) }
92
+ end
93
+
94
+ # GEOS-enabled
95
+ ENV.delete('GEODETIC_GEOS_DISABLE')
96
+ geos_time = Benchmark.realtime do
97
+ iterations.times { Polygon.new(boundary: boundary.dup) }
98
+ end
99
+
100
+ puts format(" Ruby: %8.4fs", ruby_time)
101
+ puts format(" GEOS: %8.4fs (%s faster)", geos_time, speedup(ruby_time, geos_time))
102
+ results << { test: "Validation #{n}v", ruby: ruby_time, geos: geos_time }
103
+ end
104
+
105
+ # --- 2. Point-in-polygon --------------------------------------------------
106
+
107
+ section "2. POINT-IN-POLYGON"
108
+ puts " Ruby uses winding-number with bearing calculations."
109
+ puts " GEOS uses computational geometry contains test."
110
+ puts " Threshold: polygons with >= #{Polygon::GEOS_INCLUDES_THRESHOLD} vertices use GEOS."
111
+
112
+ test_point = LLA.new(lat: 40.0, lng: -74.0, alt: 0.0)
113
+
114
+ [8, 30, 100, 500].each do |n|
115
+ polygon = make_polygon(n)
116
+ iterations = n <= 100 ? 5_000 : 1_000
117
+
118
+ above = n >= Polygon::GEOS_INCLUDES_THRESHOLD
119
+ label = above ? "GEOS" : "Ruby (below threshold)"
120
+ puts "\n #{n} vertices -> #{label}, #{iterations} iterations:"
121
+
122
+ ENV['GEODETIC_GEOS_DISABLE'] = '1'
123
+ ruby_time = Benchmark.realtime do
124
+ iterations.times { polygon.includes?(test_point) }
125
+ end
126
+
127
+ ENV.delete('GEODETIC_GEOS_DISABLE')
128
+ geos_time = Benchmark.realtime do
129
+ iterations.times { polygon.includes?(test_point) }
130
+ end
131
+
132
+ puts format(" Ruby: %8.4fs", ruby_time)
133
+ puts format(" GEOS: %8.4fs (%s faster)", geos_time, speedup(ruby_time, geos_time))
134
+ results << { test: "Includes #{n}v", ruby: ruby_time, geos: geos_time }
135
+ end
136
+
137
+ # --- 3. Path intersection -------------------------------------------------
138
+
139
+ section "3. PATH INTERSECTION"
140
+ puts " Ruby uses O(n*m) pairwise segment tests."
141
+ puts " GEOS uses spatial indexing for fast intersection."
142
+ puts " Non-intersecting paths with overlapping bounds (worst case for Ruby)."
143
+
144
+ [100, 500, 1000].each do |n|
145
+ # Two zigzag paths: bounds overlap but segments don't cross.
146
+ # Ruby must check all O(n*m) segment pairs before returning false.
147
+ path1 = make_path(n, start_lat: 40.0, zigzag: 0.05)
148
+ path2 = make_path(n, start_lat: 40.08, zigzag: 0.05)
149
+ iterations = [5000 / n, 5].max
150
+
151
+ puts "\n #{n} points per path, #{iterations} iterations:"
152
+
153
+ ENV['GEODETIC_GEOS_DISABLE'] = '1'
154
+ ruby_time = Benchmark.realtime do
155
+ iterations.times { path1.intersects?(path2) }
156
+ end
157
+
158
+ ENV.delete('GEODETIC_GEOS_DISABLE')
159
+ geos_time = Benchmark.realtime do
160
+ iterations.times { path1.intersects?(path2) }
161
+ end
162
+
163
+ puts format(" Ruby: %8.4fs", ruby_time)
164
+ puts format(" GEOS: %8.4fs (%s faster)", geos_time, speedup(ruby_time, geos_time))
165
+ results << { test: "Path intersect #{n}pt", ruby: ruby_time, geos: geos_time }
166
+ end
167
+
168
+ # --- 4. Batch containment with PreparedGeometry ----------------------------
169
+
170
+ section "4. BATCH CONTAINMENT (PreparedGeometry)"
171
+ puts " Testing 1,000 random points against a 100-vertex polygon."
172
+ puts " PreparedGeometry builds a spatial index once, then queries are O(log n)."
173
+
174
+ srand(42) # reproducible random points
175
+ polygon = make_polygon(100)
176
+ test_points = 1_000.times.map do
177
+ LLA.new(lat: 39.0 + rand * 2.0, lng: -75.0 + rand * 2.0, alt: 0.0)
178
+ end
179
+
180
+ ENV['GEODETIC_GEOS_DISABLE'] = '1'
181
+ ruby_time = Benchmark.realtime do
182
+ test_points.each { |pt| polygon.includes?(pt) }
183
+ end
184
+
185
+ ENV.delete('GEODETIC_GEOS_DISABLE')
186
+ geos_one_shot_time = Benchmark.realtime do
187
+ test_points.each { |pt| polygon.includes?(pt) }
188
+ end
189
+
190
+ prepared_time = Benchmark.realtime do
191
+ prepared = Geos.prepare(polygon)
192
+ test_points.each { |pt| prepared.contains?(pt) }
193
+ prepared.release
194
+ end
195
+
196
+ puts format("\n Ruby: %8.4fs", ruby_time)
197
+ puts format(" GEOS one-shot: %8.4fs (%s faster)", geos_one_shot_time, speedup(ruby_time, geos_one_shot_time))
198
+ puts format(" GEOS prepared: %8.4fs (%s faster)", prepared_time, speedup(ruby_time, prepared_time))
199
+ results << { test: "Batch 1k points", ruby: ruby_time, geos: prepared_time }
200
+
201
+ # --- 5. Segment intersection (Ruby wins) ----------------------------------
202
+
203
+ section "5. SINGLE SEGMENT INTERSECTION (Ruby wins here)"
204
+ puts " For trivial operations, FFI overhead exceeds the computation cost."
205
+ puts " Geodetic correctly keeps Ruby for this case."
206
+
207
+ seg1 = Segment.new(
208
+ LLA.new(lat: 0.0, lng: 0.0, alt: 0.0),
209
+ LLA.new(lat: 2.0, lng: 2.0, alt: 0.0)
210
+ )
211
+ seg2 = Segment.new(
212
+ LLA.new(lat: 0.0, lng: 2.0, alt: 0.0),
213
+ LLA.new(lat: 2.0, lng: 0.0, alt: 0.0)
214
+ )
215
+ iterations = 50_000
216
+
217
+ ruby_time = Benchmark.realtime { iterations.times { seg1.intersects?(seg2) } }
218
+ geos_time = Benchmark.realtime { iterations.times { Geos.intersects?(seg1, seg2) } }
219
+
220
+ puts format("\n Ruby: %8.4fs", ruby_time)
221
+ puts format(" GEOS: %8.4fs (Ruby is %s faster)", geos_time, speedup(geos_time, ruby_time))
222
+
223
+ # --- 6. GEOS-only operations ----------------------------------------------
224
+
225
+ section "6. GEOS-ONLY OPERATIONS (no Ruby equivalent)"
226
+ puts " These capabilities are only available when GEOS is installed."
227
+
228
+ poly_a = make_polygon(100, center_lat: 40.0, center_lng: -74.0, radius_deg: 1.0)
229
+ poly_b = make_polygon(100, center_lat: 40.5, center_lng: -73.5, radius_deg: 1.0)
230
+ iterations = 500
231
+
232
+ puts "\n Two 100-vertex polygons, #{iterations} iterations each:"
233
+ Benchmark.bm(22) do |x|
234
+ x.report(" intersection") { iterations.times { Geos.intersection(poly_a, poly_b) } }
235
+ x.report(" difference") { iterations.times { Geos.difference(poly_a, poly_b) } }
236
+ x.report(" symmetric_diff") { iterations.times { Geos.symmetric_difference(poly_a, poly_b) } }
237
+ x.report(" convex_hull") { iterations.times { Geos.convex_hull(poly_a) } }
238
+ x.report(" simplify(0.01)") { iterations.times { Geos.simplify(poly_a, 0.01) } }
239
+ x.report(" is_valid?") { iterations.times { Geos.is_valid?(poly_a) } }
240
+ x.report(" area") { iterations.times { Geos.area(poly_a) } }
241
+ x.report(" nearest_points") { iterations.times { Geos.nearest_points(poly_a, poly_b) } }
242
+ end
243
+
244
+ # --- Summary ---------------------------------------------------------------
245
+
246
+ section "SUMMARY"
247
+ puts
248
+ puts format(" %-25s %10s %10s %10s", "Test", "Ruby", "GEOS", "Speedup")
249
+ puts " " + "-" * 57
250
+
251
+ results.each do |r|
252
+ puts format(" %-25s %9.4fs %9.4fs %10s",
253
+ r[:test], r[:ruby], r[:geos], speedup(r[:ruby], r[:geos]))
254
+ end
255
+
256
+ puts
257
+ puts " Note: GEOS acceleration is automatic when libgeos_c is installed."
258
+ puts " Set GEODETIC_GEOS_DISABLE=1 to force pure Ruby for all operations."