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,538 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Demonstration of GEOS-Only Operations
|
|
4
|
+
# Shows geometry operations provided by the GEOS C library that have no
|
|
5
|
+
# pure Ruby equivalent in Geodetic: boolean operations, buffering,
|
|
6
|
+
# convex hull, simplification, validation repair, measurements, and
|
|
7
|
+
# PreparedGeometry batch queries.
|
|
8
|
+
#
|
|
9
|
+
# Requires: brew install geos
|
|
10
|
+
# Run: ruby -Ilib examples/13_geos_operations.rb
|
|
11
|
+
|
|
12
|
+
require_relative "../lib/geodetic"
|
|
13
|
+
|
|
14
|
+
include Geodetic
|
|
15
|
+
|
|
16
|
+
LLA = Coordinate::LLA
|
|
17
|
+
Polygon = Areas::Polygon
|
|
18
|
+
Geos = Geodetic::Geos
|
|
19
|
+
|
|
20
|
+
unless Geos.available?
|
|
21
|
+
if ENV.key?('GEODETIC_GEOS_DISABLE')
|
|
22
|
+
abort "GEODETIC_GEOS_DISABLE is set. Unset it to run this demo:\n unset GEODETIC_GEOS_DISABLE"
|
|
23
|
+
else
|
|
24
|
+
abort "libgeos_c not found. Install with: brew install geos"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def section(title)
|
|
29
|
+
puts
|
|
30
|
+
puts "=" * 70
|
|
31
|
+
puts title
|
|
32
|
+
puts "=" * 70
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subsection(title)
|
|
36
|
+
puts
|
|
37
|
+
puts " --- #{title} ---"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fmt_polygon(poly)
|
|
41
|
+
if poly.is_a?(Array)
|
|
42
|
+
poly.map { |p| fmt_polygon(p) }.join("\n ")
|
|
43
|
+
elsif poly.is_a?(Polygon)
|
|
44
|
+
n = poly.boundary.length - 1
|
|
45
|
+
centroid = poly.centroid
|
|
46
|
+
"Polygon(#{n} vertices, centroid: #{centroid.lat.round(4)}, #{centroid.lng.round(4)})"
|
|
47
|
+
else
|
|
48
|
+
poly.to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Build two overlapping polygons around NYC
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# Lower Manhattan polygon (roughly)
|
|
57
|
+
lower = Polygon.new(boundary: [
|
|
58
|
+
LLA.new(lat: 40.700, lng: -74.020, alt: 0),
|
|
59
|
+
LLA.new(lat: 40.700, lng: -73.970, alt: 0),
|
|
60
|
+
LLA.new(lat: 40.730, lng: -73.970, alt: 0),
|
|
61
|
+
LLA.new(lat: 40.730, lng: -74.020, alt: 0),
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
# Midtown Manhattan polygon (overlaps northern edge of lower)
|
|
65
|
+
midtown = Polygon.new(boundary: [
|
|
66
|
+
LLA.new(lat: 40.720, lng: -74.010, alt: 0),
|
|
67
|
+
LLA.new(lat: 40.720, lng: -73.960, alt: 0),
|
|
68
|
+
LLA.new(lat: 40.760, lng: -73.960, alt: 0),
|
|
69
|
+
LLA.new(lat: 40.760, lng: -74.010, alt: 0),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
puts <<~HEREDOC
|
|
73
|
+
=== GEOS-Only Operations Demo ===
|
|
74
|
+
|
|
75
|
+
These operations require the GEOS C library and have no pure Ruby
|
|
76
|
+
equivalent in Geodetic. They enable computational geometry workflows
|
|
77
|
+
like boolean overlay, buffering, simplification, and spatial indexing.
|
|
78
|
+
|
|
79
|
+
Two overlapping rectangles around Manhattan:
|
|
80
|
+
Lower: 40.700-40.730 lat, -74.020 to -73.970 lng
|
|
81
|
+
Midtown: 40.720-40.760 lat, -74.010 to -73.960 lng
|
|
82
|
+
Overlap: 40.720-40.730 lat, -74.010 to -73.970 lng
|
|
83
|
+
HEREDOC
|
|
84
|
+
|
|
85
|
+
# ── 1. Boolean Operations ────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
section "1. BOOLEAN OPERATIONS"
|
|
88
|
+
|
|
89
|
+
subsection "Intersection (area of overlap)"
|
|
90
|
+
overlap = Geos.intersection(lower, midtown)
|
|
91
|
+
puts " Geos.intersection(lower, midtown)"
|
|
92
|
+
puts " => #{fmt_polygon(overlap)}"
|
|
93
|
+
if overlap.is_a?(Polygon)
|
|
94
|
+
puts " Boundary:"
|
|
95
|
+
overlap.boundary[0...-1].each do |pt|
|
|
96
|
+
puts " (#{pt.lat.round(4)}, #{pt.lng.round(4)})"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
subsection "Difference (lower minus midtown)"
|
|
101
|
+
diff = Geos.difference(lower, midtown)
|
|
102
|
+
puts " Geos.difference(lower, midtown)"
|
|
103
|
+
puts " => #{fmt_polygon(diff)}"
|
|
104
|
+
puts " The part of Lower Manhattan not covered by Midtown."
|
|
105
|
+
|
|
106
|
+
subsection "Difference (midtown minus lower)"
|
|
107
|
+
diff2 = Geos.difference(midtown, lower)
|
|
108
|
+
puts " Geos.difference(midtown, lower)"
|
|
109
|
+
puts " => #{fmt_polygon(diff2)}"
|
|
110
|
+
puts " The part of Midtown not covered by Lower Manhattan."
|
|
111
|
+
|
|
112
|
+
subsection "Symmetric Difference (in one but not both)"
|
|
113
|
+
sym = Geos.symmetric_difference(lower, midtown)
|
|
114
|
+
puts " Geos.symmetric_difference(lower, midtown)"
|
|
115
|
+
puts " => #{fmt_polygon(sym)}"
|
|
116
|
+
puts " Everything except the overlap — the XOR of the two polygons."
|
|
117
|
+
|
|
118
|
+
subsection "Union (merge into one polygon)"
|
|
119
|
+
merged = Geos.union(lower)
|
|
120
|
+
puts " Geos.union(lower)"
|
|
121
|
+
puts " => #{fmt_polygon(merged)}"
|
|
122
|
+
puts " Dissolves internal boundaries (useful for merging multi-polygons)."
|
|
123
|
+
|
|
124
|
+
# ── 2. Buffering ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
section "2. BUFFERING"
|
|
127
|
+
|
|
128
|
+
puts <<~HEREDOC
|
|
129
|
+
|
|
130
|
+
Buffer creates a polygon at a fixed distance around any geometry.
|
|
131
|
+
Distance is in coordinate units (degrees for LLA).
|
|
132
|
+
~0.001 degrees ≈ 111 meters at the equator.
|
|
133
|
+
HEREDOC
|
|
134
|
+
|
|
135
|
+
# Buffer a point to create a circle-like polygon
|
|
136
|
+
point = LLA.new(lat: 40.7484, lng: -73.9857, alt: 0)
|
|
137
|
+
buffered = Geos.buffer(point, 0.005, quad_segs: 16)
|
|
138
|
+
|
|
139
|
+
puts " Point buffer (Empire State Building, radius ~555m):"
|
|
140
|
+
puts " Geos.buffer(point, 0.005, quad_segs: 16)"
|
|
141
|
+
if buffered.is_a?(Polygon)
|
|
142
|
+
puts " => #{fmt_polygon(buffered)}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Buffer a path to create a corridor
|
|
146
|
+
path = Path.new(coordinates: [
|
|
147
|
+
LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
|
|
148
|
+
LLA.new(lat: 40.7580, lng: -73.9855, alt: 0),
|
|
149
|
+
LLA.new(lat: 40.7614, lng: -73.9776, alt: 0),
|
|
150
|
+
])
|
|
151
|
+
corridor = Geos.buffer(path, 0.002)
|
|
152
|
+
|
|
153
|
+
puts
|
|
154
|
+
puts " Path buffer (corridor along a 3-point route, width ~222m):"
|
|
155
|
+
puts " Geos.buffer(path, 0.002)"
|
|
156
|
+
if corridor.is_a?(Polygon)
|
|
157
|
+
puts " => #{fmt_polygon(corridor)}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
subsection "Buffer with style options"
|
|
161
|
+
flat_buf = Geos.buffer_with_style(path, 0.002,
|
|
162
|
+
quad_segs: 8, end_cap_style: 2, join_style: 2)
|
|
163
|
+
|
|
164
|
+
puts " Geos.buffer_with_style(path, 0.002,"
|
|
165
|
+
puts " end_cap_style: 2, # flat caps"
|
|
166
|
+
puts " join_style: 2) # mitre joins"
|
|
167
|
+
if flat_buf.is_a?(Polygon)
|
|
168
|
+
puts " => #{fmt_polygon(flat_buf)}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ── 3. Convex Hull ───────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
section "3. CONVEX HULL"
|
|
174
|
+
|
|
175
|
+
puts <<~HEREDOC
|
|
176
|
+
|
|
177
|
+
Convex hull is the smallest convex polygon enclosing a geometry.
|
|
178
|
+
Think of stretching a rubber band around all the points.
|
|
179
|
+
HEREDOC
|
|
180
|
+
|
|
181
|
+
# Scatter some NYC landmarks
|
|
182
|
+
landmarks = [
|
|
183
|
+
LLA.new(lat: 40.6892, lng: -74.0445, alt: 0), # Statue of Liberty
|
|
184
|
+
LLA.new(lat: 40.7484, lng: -73.9857, alt: 0), # Empire State
|
|
185
|
+
LLA.new(lat: 40.7580, lng: -73.9855, alt: 0), # Rockefeller Center
|
|
186
|
+
LLA.new(lat: 40.7614, lng: -73.9776, alt: 0), # Central Park South
|
|
187
|
+
LLA.new(lat: 40.7128, lng: -74.0060, alt: 0), # One World Trade
|
|
188
|
+
LLA.new(lat: 40.6413, lng: -73.7781, alt: 0), # JFK Airport
|
|
189
|
+
]
|
|
190
|
+
landmark_names = [
|
|
191
|
+
"Statue of Liberty", "Empire State", "Rockefeller Center",
|
|
192
|
+
"Central Park South", "One World Trade", "JFK Airport",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# Build a path from the landmarks so GEOS sees all the points
|
|
196
|
+
landmark_path = Path.new(coordinates: landmarks)
|
|
197
|
+
hull = Geos.convex_hull(landmark_path)
|
|
198
|
+
|
|
199
|
+
puts " Input: 6 NYC landmarks"
|
|
200
|
+
landmarks.each_with_index do |pt, i|
|
|
201
|
+
puts " #{landmark_names[i]}: (#{pt.lat.round(4)}, #{pt.lng.round(4)})"
|
|
202
|
+
end
|
|
203
|
+
puts
|
|
204
|
+
puts " Geos.convex_hull(landmark_path)"
|
|
205
|
+
if hull.is_a?(Polygon)
|
|
206
|
+
puts " => #{fmt_polygon(hull)}"
|
|
207
|
+
puts " Hull vertices:"
|
|
208
|
+
hull.boundary[0...-1].each do |pt|
|
|
209
|
+
puts " (#{pt.lat.round(4)}, #{pt.lng.round(4)})"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ── 4. Simplification ────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
section "4. SIMPLIFICATION (Douglas-Peucker)"
|
|
216
|
+
|
|
217
|
+
puts <<~HEREDOC
|
|
218
|
+
|
|
219
|
+
Simplify reduces vertices while preserving shape within a tolerance.
|
|
220
|
+
Uses the Douglas-Peucker algorithm. Tolerance is in coordinate units.
|
|
221
|
+
HEREDOC
|
|
222
|
+
|
|
223
|
+
# Create a detailed polygon (50-vertex circle approximation)
|
|
224
|
+
step = 360.0 / 50
|
|
225
|
+
detailed = Polygon.new(boundary: 50.times.map { |i|
|
|
226
|
+
angle = i * step * Geodetic::RAD_PER_DEG
|
|
227
|
+
LLA.new(
|
|
228
|
+
lat: 40.75 + 0.02 * Math.sin(angle),
|
|
229
|
+
lng: -73.99 + 0.02 * Math.cos(angle),
|
|
230
|
+
alt: 0.0
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
puts " Original polygon: #{detailed.boundary.length - 1} vertices"
|
|
235
|
+
|
|
236
|
+
[0.001, 0.005, 0.01].each do |tol|
|
|
237
|
+
simplified = Geos.simplify(detailed, tol)
|
|
238
|
+
if simplified.is_a?(Polygon)
|
|
239
|
+
n = simplified.boundary.length - 1
|
|
240
|
+
puts " Geos.simplify(polygon, #{tol}) => #{n} vertices"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# ── 5. Validity Checking ─────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
section "5. VALIDITY CHECKING"
|
|
247
|
+
|
|
248
|
+
puts <<~HEREDOC
|
|
249
|
+
|
|
250
|
+
GEOS checks geometry against OGC validity rules and can explain
|
|
251
|
+
exactly why a geometry is invalid.
|
|
252
|
+
HEREDOC
|
|
253
|
+
|
|
254
|
+
puts " Valid polygon:"
|
|
255
|
+
puts " Geos.is_valid?(lower) => #{Geos.is_valid?(lower)}"
|
|
256
|
+
puts " Geos.is_valid_reason(lower) => \"#{Geos.is_valid_reason(lower)}\""
|
|
257
|
+
|
|
258
|
+
# Feed a bowtie (self-intersecting) polygon directly to GEOS via its
|
|
259
|
+
# WKT string, bypassing Geodetic's Polygon constructor which would reject it.
|
|
260
|
+
puts
|
|
261
|
+
puts " Self-intersecting polygon (bowtie):"
|
|
262
|
+
puts " Vertices cross at the center, forming a figure-8."
|
|
263
|
+
bowtie_wkt = "POLYGON((0 0, 2 2, 2 0, 0 2, 0 0))"
|
|
264
|
+
bowtie_geom = Geodetic::Geos::LibGEOS.wkt_to_geom(bowtie_wkt)
|
|
265
|
+
begin
|
|
266
|
+
valid = Geodetic::Geos::LibGEOS::F_IS_VALID.call(
|
|
267
|
+
Geodetic::Geos::LibGEOS.context, bowtie_geom) == 1
|
|
268
|
+
reason_ptr = Geodetic::Geos::LibGEOS::F_IS_VALID_REASON.call(
|
|
269
|
+
Geodetic::Geos::LibGEOS.context, bowtie_geom)
|
|
270
|
+
reason = reason_ptr.to_s
|
|
271
|
+
Geodetic::Geos::LibGEOS::F_GEOS_FREE.call(Geodetic::Geos::LibGEOS.context, reason_ptr)
|
|
272
|
+
puts " Geos.is_valid?(bowtie) => #{valid}"
|
|
273
|
+
puts " Geos.is_valid_reason(bowtie) => \"#{reason}\""
|
|
274
|
+
ensure
|
|
275
|
+
# keep bowtie_geom alive for make_valid below
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# ── 6. Make Valid (Geometry Repair) ───────────────────────────────────
|
|
279
|
+
|
|
280
|
+
section "6. MAKE VALID (Geometry Repair)"
|
|
281
|
+
|
|
282
|
+
puts <<~HEREDOC
|
|
283
|
+
|
|
284
|
+
make_valid repairs invalid geometries — fixing self-intersections,
|
|
285
|
+
ring ordering, and other OGC violations. The result is always valid.
|
|
286
|
+
HEREDOC
|
|
287
|
+
|
|
288
|
+
puts " Repairing the bowtie polygon:"
|
|
289
|
+
repaired_geom = Geodetic::Geos::LibGEOS::F_MAKE_VALID.call(
|
|
290
|
+
Geodetic::Geos::LibGEOS.context, bowtie_geom)
|
|
291
|
+
repaired_wkt = Geodetic::Geos::LibGEOS.geom_to_wkt(repaired_geom, precision: 6)
|
|
292
|
+
repaired_valid = Geodetic::Geos::LibGEOS::F_IS_VALID.call(
|
|
293
|
+
Geodetic::Geos::LibGEOS.context, repaired_geom) == 1
|
|
294
|
+
puts " Geos.make_valid(bowtie)"
|
|
295
|
+
puts " => #{repaired_wkt}"
|
|
296
|
+
puts " Geos.is_valid?(repaired) => #{repaired_valid}"
|
|
297
|
+
puts " GEOS splits the self-intersecting bowtie into valid geometry."
|
|
298
|
+
Geodetic::Geos::LibGEOS.destroy_geom(repaired_geom)
|
|
299
|
+
Geodetic::Geos::LibGEOS.destroy_geom(bowtie_geom)
|
|
300
|
+
|
|
301
|
+
# ── 7. Planar Measurements ───────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
section "7. PLANAR MEASUREMENTS"
|
|
304
|
+
|
|
305
|
+
puts <<~HEREDOC
|
|
306
|
+
|
|
307
|
+
GEOS computes area, length, and minimum distance in coordinate units.
|
|
308
|
+
For LLA geometries, units are square degrees (area) and degrees
|
|
309
|
+
(length/distance). These are planar approximations — use Geodetic's
|
|
310
|
+
Vincenty-based distance_to for geodesic accuracy.
|
|
311
|
+
HEREDOC
|
|
312
|
+
|
|
313
|
+
puts " Area:"
|
|
314
|
+
puts " Geos.area(lower) => #{Geos.area(lower).round(8)} sq degrees"
|
|
315
|
+
puts " Geos.area(midtown) => #{Geos.area(midtown).round(8)} sq degrees"
|
|
316
|
+
if overlap.is_a?(Polygon)
|
|
317
|
+
puts " Geos.area(overlap) => #{Geos.area(overlap).round(8)} sq degrees"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
puts
|
|
321
|
+
puts " Perimeter (length of boundary):"
|
|
322
|
+
puts " Geos.length(lower) => #{Geos.length(lower).round(6)} degrees"
|
|
323
|
+
puts " Geos.length(midtown) => #{Geos.length(midtown).round(6)} degrees"
|
|
324
|
+
|
|
325
|
+
puts
|
|
326
|
+
puts " Minimum distance between non-overlapping geometries:"
|
|
327
|
+
|
|
328
|
+
brooklyn = Polygon.new(boundary: [
|
|
329
|
+
LLA.new(lat: 40.630, lng: -73.990, alt: 0),
|
|
330
|
+
LLA.new(lat: 40.630, lng: -73.940, alt: 0),
|
|
331
|
+
LLA.new(lat: 40.660, lng: -73.940, alt: 0),
|
|
332
|
+
LLA.new(lat: 40.660, lng: -73.990, alt: 0),
|
|
333
|
+
])
|
|
334
|
+
dist = Geos.distance(lower, brooklyn)
|
|
335
|
+
puts " Geos.distance(lower_manhattan, brooklyn) => #{dist.round(6)} degrees"
|
|
336
|
+
puts " (Approx #{(dist * 111_000).round(0)} meters at this latitude)"
|
|
337
|
+
|
|
338
|
+
# ── 8. Nearest Points ────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
section "8. NEAREST POINTS"
|
|
341
|
+
|
|
342
|
+
puts <<~HEREDOC
|
|
343
|
+
|
|
344
|
+
nearest_points finds the closest pair of points between two
|
|
345
|
+
geometries — one point on each. Returns [LLA, LLA].
|
|
346
|
+
HEREDOC
|
|
347
|
+
|
|
348
|
+
pts = Geos.nearest_points(lower, brooklyn)
|
|
349
|
+
puts " Geos.nearest_points(lower_manhattan, brooklyn)"
|
|
350
|
+
puts " Point on Lower Manhattan: (#{pts[0].lat.round(6)}, #{pts[0].lng.round(6)})"
|
|
351
|
+
puts " Point on Brooklyn: (#{pts[1].lat.round(6)}, #{pts[1].lng.round(6)})"
|
|
352
|
+
|
|
353
|
+
geodesic_dist = pts[0].distance_to(pts[1])
|
|
354
|
+
puts " Geodesic distance between them: #{geodesic_dist.to_s(0)}"
|
|
355
|
+
|
|
356
|
+
# ── 9. PreparedGeometry (Batch Spatial Index) ─────────────────────────
|
|
357
|
+
|
|
358
|
+
section "9. PREPARED GEOMETRY (Batch Spatial Index)"
|
|
359
|
+
|
|
360
|
+
puts <<~HEREDOC
|
|
361
|
+
|
|
362
|
+
PreparedGeometry builds a spatial index once, then subsequent
|
|
363
|
+
contains?/intersects? queries run in O(log n). Essential for
|
|
364
|
+
testing many points against the same polygon.
|
|
365
|
+
HEREDOC
|
|
366
|
+
|
|
367
|
+
# Build a 100-vertex polygon
|
|
368
|
+
step = 360.0 / 100
|
|
369
|
+
big_poly = Polygon.new(boundary: 100.times.map { |i|
|
|
370
|
+
angle = i * step * Geodetic::RAD_PER_DEG
|
|
371
|
+
LLA.new(
|
|
372
|
+
lat: 40.75 + 0.05 * Math.sin(angle),
|
|
373
|
+
lng: -73.99 + 0.05 * Math.cos(angle),
|
|
374
|
+
alt: 0.0
|
|
375
|
+
)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
# Generate test points
|
|
379
|
+
srand(42)
|
|
380
|
+
test_points = 20.times.map do
|
|
381
|
+
LLA.new(lat: 40.69 + rand * 0.12, lng: -74.05 + rand * 0.12, alt: 0.0)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
prepared = Geos.prepare(big_poly)
|
|
385
|
+
|
|
386
|
+
inside = 0
|
|
387
|
+
outside = 0
|
|
388
|
+
test_points.each do |pt|
|
|
389
|
+
if prepared.contains?(pt)
|
|
390
|
+
inside += 1
|
|
391
|
+
else
|
|
392
|
+
outside += 1
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
puts " Polygon: 100 vertices, radius ~0.05 degrees"
|
|
397
|
+
puts " Test points: #{test_points.length}"
|
|
398
|
+
puts " Inside: #{inside}"
|
|
399
|
+
puts " Outside: #{outside}"
|
|
400
|
+
puts
|
|
401
|
+
puts " PreparedGeometry also supports intersects?:"
|
|
402
|
+
seg = Segment.new(
|
|
403
|
+
LLA.new(lat: 40.70, lng: -74.00, alt: 0),
|
|
404
|
+
LLA.new(lat: 40.80, lng: -73.98, alt: 0)
|
|
405
|
+
)
|
|
406
|
+
puts " prepared.intersects?(segment) => #{prepared.intersects?(seg)}"
|
|
407
|
+
|
|
408
|
+
prepared.release
|
|
409
|
+
puts " prepared.release # free GEOS memory"
|
|
410
|
+
|
|
411
|
+
# ── 10. Chaining Operations ──────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
section "10. CHAINING OPERATIONS"
|
|
414
|
+
|
|
415
|
+
puts <<~HEREDOC
|
|
416
|
+
|
|
417
|
+
GEOS operations return Geodetic objects, so results chain naturally
|
|
418
|
+
with other GEOS calls or with Geodetic's Ruby methods.
|
|
419
|
+
HEREDOC
|
|
420
|
+
|
|
421
|
+
puts " Workflow: intersect two polygons, buffer the result, compute hull"
|
|
422
|
+
puts
|
|
423
|
+
|
|
424
|
+
# Step 1: Intersection
|
|
425
|
+
result = Geos.intersection(lower, midtown)
|
|
426
|
+
puts " 1. overlap = Geos.intersection(lower, midtown)"
|
|
427
|
+
puts " => #{fmt_polygon(result)}"
|
|
428
|
+
|
|
429
|
+
# Step 2: Buffer the intersection
|
|
430
|
+
if result.is_a?(Polygon)
|
|
431
|
+
buffered_overlap = Geos.buffer(result, 0.005)
|
|
432
|
+
puts " 2. expanded = Geos.buffer(overlap, 0.005)"
|
|
433
|
+
puts " => #{fmt_polygon(buffered_overlap)}"
|
|
434
|
+
|
|
435
|
+
# Step 3: Convex hull of the buffered result
|
|
436
|
+
hull2 = Geos.convex_hull(buffered_overlap)
|
|
437
|
+
puts " 3. hull = Geos.convex_hull(expanded)"
|
|
438
|
+
puts " => #{fmt_polygon(hull2)}"
|
|
439
|
+
|
|
440
|
+
# Step 4: Compute area
|
|
441
|
+
area = Geos.area(hull2)
|
|
442
|
+
puts " 4. Geos.area(hull) => #{area.round(8)} sq degrees"
|
|
443
|
+
|
|
444
|
+
# Step 5: Validate
|
|
445
|
+
puts " 5. Geos.is_valid?(hull) => #{Geos.is_valid?(hull2)}"
|
|
446
|
+
|
|
447
|
+
# Step 6: Use Geodetic methods on the result
|
|
448
|
+
if hull2.is_a?(Polygon)
|
|
449
|
+
puts " 6. hull.centroid => (#{hull2.centroid.lat.round(4)}, #{hull2.centroid.lng.round(4)})"
|
|
450
|
+
puts " hull.includes?(Empire State) => #{hull2.includes?(point)}"
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# ── 11. GeoJSON Export ────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
section "11. GEOJSON EXPORT OF GEOS RESULTS"
|
|
457
|
+
|
|
458
|
+
puts <<~HEREDOC
|
|
459
|
+
|
|
460
|
+
GEOS results are standard Geodetic objects, so they work with
|
|
461
|
+
GeoJSON export, WKT, WKB, and all other serialization.
|
|
462
|
+
HEREDOC
|
|
463
|
+
|
|
464
|
+
gj = GeoJSON.new
|
|
465
|
+
gj << Feature.new(label: "Lower Manhattan", geometry: lower,
|
|
466
|
+
metadata: { role: "input" })
|
|
467
|
+
gj << Feature.new(label: "Midtown", geometry: midtown,
|
|
468
|
+
metadata: { role: "input" })
|
|
469
|
+
if result.is_a?(Polygon)
|
|
470
|
+
gj << Feature.new(label: "Overlap", geometry: result,
|
|
471
|
+
metadata: { role: "intersection" })
|
|
472
|
+
end
|
|
473
|
+
hull_result = Geos.convex_hull(landmark_path)
|
|
474
|
+
if hull_result.is_a?(Polygon)
|
|
475
|
+
gj << Feature.new(label: "Landmark Hull", geometry: hull_result,
|
|
476
|
+
metadata: { role: "convex_hull" })
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
puts " GeoJSON FeatureCollection with #{gj.size} features:"
|
|
480
|
+
gj.each do |obj|
|
|
481
|
+
if obj.is_a?(Feature)
|
|
482
|
+
puts " - #{obj.label} (#{obj.metadata[:role]})"
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
puts
|
|
486
|
+
puts " gj.to_json => #{gj.to_json.length} characters"
|
|
487
|
+
|
|
488
|
+
puts
|
|
489
|
+
puts " WKT of the intersection:"
|
|
490
|
+
if result.is_a?(Polygon)
|
|
491
|
+
wkt = result.to_wkt(precision: 4)
|
|
492
|
+
puts " #{wkt}"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# ── Summary ───────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
section "SUMMARY"
|
|
498
|
+
|
|
499
|
+
puts <<~HEREDOC
|
|
500
|
+
|
|
501
|
+
GEOS-only operations demonstrated:
|
|
502
|
+
|
|
503
|
+
Boolean operations:
|
|
504
|
+
Geos.intersection(a, b) Area of overlap
|
|
505
|
+
Geos.difference(a, b) A minus B
|
|
506
|
+
Geos.symmetric_difference(a, b) XOR of two geometries
|
|
507
|
+
Geos.union(a) Dissolve internal boundaries
|
|
508
|
+
|
|
509
|
+
Geometry construction:
|
|
510
|
+
Geos.buffer(geom, distance) Buffer zone around any geometry
|
|
511
|
+
Geos.buffer_with_style(...) Buffer with cap/join style control
|
|
512
|
+
Geos.convex_hull(geom) Smallest enclosing convex polygon
|
|
513
|
+
Geos.simplify(geom, tolerance) Douglas-Peucker vertex reduction
|
|
514
|
+
|
|
515
|
+
Validation and repair:
|
|
516
|
+
Geos.is_valid?(geom) OGC validity check
|
|
517
|
+
Geos.is_valid_reason(geom) Human-readable invalidity reason
|
|
518
|
+
Geos.make_valid(geom) Repair invalid geometries
|
|
519
|
+
|
|
520
|
+
Measurements:
|
|
521
|
+
Geos.area(geom) Planar area (sq degrees for LLA)
|
|
522
|
+
Geos.length(geom) Planar perimeter/length
|
|
523
|
+
Geos.distance(a, b) Minimum planar distance
|
|
524
|
+
Geos.nearest_points(a, b) Closest point pair [LLA, LLA]
|
|
525
|
+
|
|
526
|
+
Batch spatial indexing:
|
|
527
|
+
Geos.prepare(polygon) Build spatial index
|
|
528
|
+
prepared.contains?(point) O(log n) containment
|
|
529
|
+
prepared.intersects?(geom) O(log n) intersection
|
|
530
|
+
prepared.release Free GEOS memory
|
|
531
|
+
|
|
532
|
+
All results are standard Geodetic objects — they work with to_wkt,
|
|
533
|
+
to_wkb, to_geojson, distance_to, bearing_to, includes?, and every
|
|
534
|
+
other Geodetic method.
|
|
535
|
+
|
|
536
|
+
Install GEOS: brew install geos
|
|
537
|
+
See also: ruby -Ilib examples/12_geos_benchmark.rb
|
|
538
|
+
HEREDOC
|
data/examples/README.md
CHANGED
|
@@ -131,3 +131,85 @@ Demonstrates the `Geodetic::GeoJSON` class for building and exporting GeoJSON Fe
|
|
|
131
131
|
- **Delete and clear** removing individual objects or emptying the collection
|
|
132
132
|
- **Enumerable** iteration over collected objects
|
|
133
133
|
- **Export** via `to_h` (Ruby Hash), `to_json`/`to_json(pretty: true)` (JSON string), and `save(path, pretty:)` (file output)
|
|
134
|
+
|
|
135
|
+
## 10 - WKT Serialization
|
|
136
|
+
|
|
137
|
+
Demonstrates `Geodetic::WKT` for Well-Known Text export and import, the standard geometry format used by PostGIS, RGeo, and most GIS tools. Covers:
|
|
138
|
+
|
|
139
|
+
- **Coordinate → POINT** with `to_wkt` on any coordinate system, including altitude (Z suffix)
|
|
140
|
+
- **Segment → LINESTRING** exporting two-point directed segments
|
|
141
|
+
- **Path → LINESTRING** and optional `to_wkt(as: :polygon)` for closed paths
|
|
142
|
+
- **Areas → POLYGON** for `Polygon`, `Circle` (N-gon approximation with configurable `segments:`), and `BoundingBox`
|
|
143
|
+
- **Feature → delegates to geometry** (WKT has no properties concept)
|
|
144
|
+
- **SRID / EWKT** with `to_wkt(srid: 4326)` producing PostGIS-compatible Extended WKT
|
|
145
|
+
- **Z-dimension consistency** where any non-zero altitude triggers Z on all points in the geometry
|
|
146
|
+
- **Parsing** with `WKT.parse` (returns Geodetic objects) and `WKT.parse_with_srid` (returns object + SRID)
|
|
147
|
+
- **Roundtrip** verification showing export → parse → re-export produces identical WKT strings
|
|
148
|
+
- **File I/O** with `WKT.save` writing one WKT per line and `WKT.load` reading them back, including SRID support and full file roundtrip verification
|
|
149
|
+
|
|
150
|
+
## 11 - WKB Serialization
|
|
151
|
+
|
|
152
|
+
Demonstrates `Geodetic::WKB` for Well-Known Binary export and import, the binary counterpart to WKT used by PostGIS, GEOS, RGeo, and Shapely for efficient geometry storage. Covers:
|
|
153
|
+
|
|
154
|
+
- **Coordinate → POINT** with `to_wkb` and `to_wkb_hex` on any coordinate system, including altitude (Z type code)
|
|
155
|
+
- **Segment → LINESTRING** exporting two-point directed segments
|
|
156
|
+
- **Path → LINESTRING** and optional `to_wkb(as: :polygon)` for closed paths
|
|
157
|
+
- **Areas → POLYGON** for `Polygon`, `Circle` (N-gon approximation with configurable `segments:`), and `BoundingBox`
|
|
158
|
+
- **Feature → delegates to geometry** (WKB has no properties concept)
|
|
159
|
+
- **SRID / EWKB** with `to_wkb_hex(srid: 4326)` producing PostGIS-compatible Extended WKB
|
|
160
|
+
- **Z-dimension consistency** where any non-zero altitude triggers Z on all points in the geometry
|
|
161
|
+
- **Parsing** with `WKB.parse` (auto-detects binary vs hex) and `WKB.parse_with_srid` (returns object + SRID)
|
|
162
|
+
- **Roundtrip** verification showing export → parse → re-export produces identical hex strings
|
|
163
|
+
- **File I/O** with `WKB.save!`/`WKB.load` for binary format and `WKB.save_hex!`/`WKB.load_hex` for hex format, including fixture file loading and full roundtrip verification
|
|
164
|
+
|
|
165
|
+
## 12 - GEOS Benchmark
|
|
166
|
+
|
|
167
|
+
Benchmarks pure Ruby spatial operations against GEOS-accelerated versions. Requires `libgeos_c` installed (`brew install geos` on macOS). Uses the `GEODETIC_GEOS_DISABLE` environment variable to toggle between Ruby and GEOS within the same run. Covers:
|
|
168
|
+
|
|
169
|
+
- **Polygon validation** comparing Ruby's O(n^2) pairwise segment test against GEOS's O(n log n) spatial index at 50, 100, and 500 vertices
|
|
170
|
+
- **Point-in-polygon** comparing Ruby's winding-number algorithm against GEOS's contains test, showing the crossover threshold at 15 vertices
|
|
171
|
+
- **Path intersection** comparing Ruby's O(n*m) brute-force segment checks against GEOS's spatial indexing for non-intersecting paths with overlapping bounds
|
|
172
|
+
- **Batch containment** with `PreparedGeometry` testing 1,000 random points against a 100-vertex polygon, comparing Ruby, GEOS one-shot, and GEOS prepared (spatial index built once)
|
|
173
|
+
- **Single segment intersection** demonstrating where Ruby wins due to FFI overhead exceeding the computation cost
|
|
174
|
+
- **GEOS-only operations** benchmarking `intersection`, `difference`, `symmetric_difference`, `convex_hull`, `simplify`, `is_valid?`, `area`, and `nearest_points` — capabilities with no pure Ruby equivalent
|
|
175
|
+
- **Summary table** with side-by-side timings and speedup ratios
|
|
176
|
+
|
|
177
|
+
Prerequisites:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
brew install geos # macOS
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Run:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
ruby -Ilib examples/12_geos_benchmark.rb
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## 13 - GEOS Operations
|
|
190
|
+
|
|
191
|
+
Demonstrates GEOS-only operations — capabilities provided by the GEOS C library that have no pure Ruby equivalent in Geodetic. Uses two overlapping rectangles around Manhattan as the primary test geometries. Covers:
|
|
192
|
+
|
|
193
|
+
- **Boolean operations** with `intersection` (area of overlap), `difference` (A minus B), `symmetric_difference` (XOR), and `union` (dissolve internal boundaries)
|
|
194
|
+
- **Buffering** with `buffer` (create polygon zones around points and paths) and `buffer_with_style` (flat caps, mitre joins)
|
|
195
|
+
- **Convex hull** computing the smallest convex polygon enclosing a set of NYC landmarks
|
|
196
|
+
- **Simplification** using the Douglas-Peucker algorithm at multiple tolerance levels (50 vertices reduced to 16, 8, or 4)
|
|
197
|
+
- **Validity checking** with `is_valid?` and `is_valid_reason` on valid and self-intersecting (bowtie) polygons
|
|
198
|
+
- **Geometry repair** with `make_valid` splitting a self-intersecting bowtie into two valid triangles
|
|
199
|
+
- **Planar measurements** with `area`, `length`, `distance` in coordinate units
|
|
200
|
+
- **Nearest points** finding the closest point pair between two separated polygons
|
|
201
|
+
- **PreparedGeometry** batch spatial indexing for O(log n) `contains?` and `intersects?` queries
|
|
202
|
+
- **Chaining operations** combining GEOS results with other GEOS calls and Geodetic methods
|
|
203
|
+
- **Serialization** exporting GEOS results to GeoJSON and WKT
|
|
204
|
+
|
|
205
|
+
Prerequisites:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
brew install geos # macOS
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Run:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
ruby -Ilib examples/13_geos_operations.rb
|
|
215
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
01010000008a1f63ee5a965ec08195438b6ccf4740
|
|
2
|
+
01e90300008a1f63ee5a965ec08195438b6ccf47400000000000006740
|
|
3
|
+
0102000000020000008a1f63ee5a965ec08195438b6ccf4740cf66d5e76aab5ec01973d712f2c14640
|
|
4
|
+
010300000001000000040000000000000000a05ec09a99999999d947406666666666865ec00000000000c047409a99999999995ec06666666666a647400000000000a05ec09a99999999d94740
|
|
5
|
+
010300000001000000050000000000000000c05ec000000000000048400000000000405ec000000000000048400000000000405ec000000000000047400000000000c05ec000000000000047400000000000c05ec00000000000004840
|
|
Binary file
|