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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +94 -0
- data/docs/index.md +4 -0
- data/docs/reference/geos-acceleration.md +170 -0
- data/docs/reference/wkb.md +385 -0
- data/docs/reference/wkt.md +354 -0
- data/examples/10_wkt_serialization.rb +248 -0
- data/examples/11_wkb_serialization.rb +261 -0
- data/examples/12_geos_benchmark.rb +258 -0
- data/examples/13_geos_operations.rb +538 -0
- data/examples/README.md +82 -0
- data/examples/geodetic_demo.wkb +0 -0
- data/examples/geodetic_demo.wkt +5 -0
- data/examples/geodetic_demo_output.wkb.hex +5 -0
- data/examples/sample_geometries.wkb +0 -0
- data/examples/sample_geometries.wkb.hex +60 -0
- data/lib/geodetic/areas/polygon.rb +66 -10
- data/lib/geodetic/geos.rb +547 -0
- data/lib/geodetic/path.rb +20 -8
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic/wkb.rb +360 -0
- data/lib/geodetic/wkt.rb +313 -0
- data/lib/geodetic.rb +3 -0
- data/mkdocs.yml +2 -0
- metadata +16 -1
|
@@ -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."
|