geodetic 0.3.2 → 0.5.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 +84 -2
- data/README.md +121 -8
- data/docs/coordinate-systems/gars.md +2 -2
- data/docs/coordinate-systems/georef.md +2 -2
- data/docs/coordinate-systems/gh.md +2 -2
- data/docs/coordinate-systems/gh36.md +2 -2
- data/docs/coordinate-systems/h3.md +2 -2
- data/docs/coordinate-systems/ham.md +2 -2
- data/docs/coordinate-systems/olc.md +2 -2
- data/docs/index.md +4 -2
- data/docs/reference/areas.md +140 -14
- data/docs/reference/arithmetic.md +368 -0
- data/docs/reference/feature.md +2 -2
- data/docs/reference/path.md +3 -3
- data/docs/reference/segment.md +181 -0
- data/docs/reference/vector.md +256 -0
- data/examples/02_all_coordinate_systems.rb +6 -6
- data/examples/06_path_operations.rb +2 -4
- data/examples/07_segments_and_shapes.rb +258 -0
- data/examples/08_geodetic_arithmetic.rb +393 -0
- data/examples/README.md +35 -1
- data/lib/geodetic/areas/bounding_box.rb +56 -0
- data/lib/geodetic/areas/circle.rb +8 -0
- data/lib/geodetic/areas/hexagon.rb +11 -0
- data/lib/geodetic/areas/octagon.rb +11 -0
- data/lib/geodetic/areas/pentagon.rb +11 -0
- data/lib/geodetic/areas/polygon.rb +64 -14
- data/lib/geodetic/areas/rectangle.rb +85 -35
- data/lib/geodetic/areas/regular_polygon.rb +59 -0
- data/lib/geodetic/areas/triangle.rb +180 -0
- data/lib/geodetic/areas.rb +6 -0
- data/lib/geodetic/coordinate/gh36.rb +1 -1
- data/lib/geodetic/coordinate/h3.rb +1 -1
- data/lib/geodetic/coordinate/spatial_hash.rb +2 -2
- data/lib/geodetic/coordinate.rb +26 -1
- data/lib/geodetic/distance.rb +5 -1
- data/lib/geodetic/path.rb +85 -153
- data/lib/geodetic/segment.rb +193 -0
- data/lib/geodetic/vector.rb +242 -0
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +2 -0
- data/mkdocs.yml +1 -0
- metadata +14 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Demonstration of Geodetic Arithmetic
|
|
4
|
+
# Shows how operators compose geometry from coordinates, vectors, and distances:
|
|
5
|
+
# + builds geometry (Segments, Paths, Circles)
|
|
6
|
+
# * translates geometry (Coordinates, Segments, Paths, Circles, Polygons)
|
|
7
|
+
# Also demonstrates the Vector class and Path#to_corridor.
|
|
8
|
+
|
|
9
|
+
require_relative "../lib/geodetic"
|
|
10
|
+
|
|
11
|
+
include Geodetic
|
|
12
|
+
|
|
13
|
+
LLA = Coordinate::LLA
|
|
14
|
+
Distance = Geodetic::Distance
|
|
15
|
+
Vector = Geodetic::Vector
|
|
16
|
+
Bearing = Geodetic::Bearing
|
|
17
|
+
|
|
18
|
+
# ── Notable locations ────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
seattle = LLA.new(lat: 47.6205, lng: -122.3493, alt: 0.0)
|
|
21
|
+
portland = LLA.new(lat: 45.5152, lng: -122.6784, alt: 0.0)
|
|
22
|
+
sf = LLA.new(lat: 37.7749, lng: -122.4194, alt: 0.0)
|
|
23
|
+
la = LLA.new(lat: 34.0522, lng: -118.2437, alt: 0.0)
|
|
24
|
+
nyc = LLA.new(lat: 40.7128, lng: -74.0060, alt: 0.0)
|
|
25
|
+
|
|
26
|
+
puts "=== Geodetic Arithmetic Demo ==="
|
|
27
|
+
puts
|
|
28
|
+
|
|
29
|
+
# ── 1. Building Segments with + ──────────────────────────────────
|
|
30
|
+
|
|
31
|
+
puts "--- 1. Coordinate + Coordinate → Segment ---"
|
|
32
|
+
puts
|
|
33
|
+
|
|
34
|
+
seg = seattle + portland
|
|
35
|
+
puts <<~SEG
|
|
36
|
+
seattle + portland
|
|
37
|
+
Type: #{seg.class}
|
|
38
|
+
Start: #{seg.start_point.to_s(4)}
|
|
39
|
+
End: #{seg.end_point.to_s(4)}
|
|
40
|
+
Length: #{seg.length.to_km.to_s(1)}
|
|
41
|
+
Bearing: #{seg.bearing}
|
|
42
|
+
SEG
|
|
43
|
+
|
|
44
|
+
# Cross-system: UTM + LLA works
|
|
45
|
+
utm = portland.to_utm
|
|
46
|
+
cross_seg = seattle + utm
|
|
47
|
+
puts <<~CROSS
|
|
48
|
+
Cross-system: seattle (LLA) + portland (UTM)
|
|
49
|
+
Length: #{cross_seg.length.to_km.to_s(1)}
|
|
50
|
+
CROSS
|
|
51
|
+
puts
|
|
52
|
+
|
|
53
|
+
# ── 2. Building Paths with + ────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
puts "--- 2. Chaining + → Path ---"
|
|
56
|
+
puts
|
|
57
|
+
|
|
58
|
+
path = seattle + portland + sf + la
|
|
59
|
+
puts <<~PATH
|
|
60
|
+
seattle + portland + sf + la
|
|
61
|
+
Type: #{path.class}
|
|
62
|
+
Waypoints: #{path.size}
|
|
63
|
+
First: #{path.first.to_s(4)}
|
|
64
|
+
Last: #{path.last.to_s(4)}
|
|
65
|
+
Total distance: #{path.total_distance.to_km.to_s(1)}
|
|
66
|
+
PATH
|
|
67
|
+
|
|
68
|
+
# Coordinate + Segment → Path
|
|
69
|
+
seg = portland + sf
|
|
70
|
+
path2 = seattle + seg
|
|
71
|
+
puts <<~CSEG
|
|
72
|
+
seattle + (portland + sf)
|
|
73
|
+
Waypoints: #{path2.size} (seattle → portland → sf)
|
|
74
|
+
CSEG
|
|
75
|
+
|
|
76
|
+
# Segment + Segment → Path
|
|
77
|
+
seg1 = seattle + portland
|
|
78
|
+
seg2 = sf + la
|
|
79
|
+
path3 = seg1 + seg2
|
|
80
|
+
puts <<~SSEG
|
|
81
|
+
(seattle + portland) + (sf + la)
|
|
82
|
+
Waypoints: #{path3.size} (seattle → portland → sf → la)
|
|
83
|
+
SSEG
|
|
84
|
+
puts
|
|
85
|
+
|
|
86
|
+
# ── 3. Coordinate + Distance → Circle ───────────────────────────
|
|
87
|
+
|
|
88
|
+
puts "--- 3. Coordinate + Distance → Circle ---"
|
|
89
|
+
puts
|
|
90
|
+
|
|
91
|
+
radius = Distance.km(50)
|
|
92
|
+
circle = seattle + radius
|
|
93
|
+
puts <<~CIRC
|
|
94
|
+
seattle + Distance.km(50)
|
|
95
|
+
Type: #{circle.class}
|
|
96
|
+
Centroid: #{circle.centroid.to_s(4)}
|
|
97
|
+
Radius: #{circle.radius} m
|
|
98
|
+
|
|
99
|
+
Portland inside 50km circle? #{circle.includes?(portland)}
|
|
100
|
+
SF inside 50km circle? #{circle.includes?(sf)}
|
|
101
|
+
CIRC
|
|
102
|
+
|
|
103
|
+
# Distance + Coordinate also works
|
|
104
|
+
circle2 = Distance.km(50) + seattle
|
|
105
|
+
puts <<~CIRC2
|
|
106
|
+
Distance.km(50) + seattle (commutative)
|
|
107
|
+
Same circle? #{circle.centroid == circle2.centroid && circle.radius == circle2.radius}
|
|
108
|
+
CIRC2
|
|
109
|
+
puts
|
|
110
|
+
|
|
111
|
+
# ── 4. Vector Construction ──────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
puts "--- 4. Vectors ---"
|
|
114
|
+
puts
|
|
115
|
+
|
|
116
|
+
v = Vector.new(distance: 100_000, bearing: 45.0)
|
|
117
|
+
puts <<~VEC
|
|
118
|
+
Vector(100km, 45°)
|
|
119
|
+
Distance: #{v.distance.to_km}
|
|
120
|
+
Bearing: #{v.bearing} (#{v.bearing.to_compass})
|
|
121
|
+
North: #{v.north.round(1)} m
|
|
122
|
+
East: #{v.east.round(1)} m
|
|
123
|
+
Magnitude: #{v.magnitude.round(1)} m
|
|
124
|
+
VEC
|
|
125
|
+
|
|
126
|
+
# From a Segment
|
|
127
|
+
seg = seattle + portland
|
|
128
|
+
v_from_seg = seg.to_vector
|
|
129
|
+
puts <<~VSEG
|
|
130
|
+
Vector.from_segment(seattle → portland)
|
|
131
|
+
Distance: #{v_from_seg.distance.to_km.to_s(1)}
|
|
132
|
+
Bearing: #{v_from_seg.bearing} (#{v_from_seg.bearing.to_compass})
|
|
133
|
+
VSEG
|
|
134
|
+
|
|
135
|
+
# From components
|
|
136
|
+
v2 = Vector.from_components(north: 1000, east: 1000)
|
|
137
|
+
puts <<~VCOMP
|
|
138
|
+
Vector.from_components(north: 1000, east: 1000)
|
|
139
|
+
Distance: #{v2.distance.to_s(1)}
|
|
140
|
+
Bearing: #{v2.bearing}
|
|
141
|
+
VCOMP
|
|
142
|
+
puts
|
|
143
|
+
|
|
144
|
+
# ── 5. Vector Arithmetic ────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
puts "--- 5. Vector Arithmetic ---"
|
|
147
|
+
puts
|
|
148
|
+
|
|
149
|
+
north = Vector.new(distance: 1000, bearing: 0.0)
|
|
150
|
+
east = Vector.new(distance: 1000, bearing: 90.0)
|
|
151
|
+
|
|
152
|
+
sum = north + east
|
|
153
|
+
puts <<~VADD
|
|
154
|
+
north(1km) + east(1km)
|
|
155
|
+
Result: #{sum.distance.to_s(1)} at #{sum.bearing}
|
|
156
|
+
(1km north + 1km east = #{sum.distance.meters.round(1)}m at 45°)
|
|
157
|
+
VADD
|
|
158
|
+
|
|
159
|
+
scaled = north * 5
|
|
160
|
+
puts <<~VSCALE
|
|
161
|
+
north(1km) * 5
|
|
162
|
+
Result: #{scaled.distance.to_km}
|
|
163
|
+
VSCALE
|
|
164
|
+
|
|
165
|
+
reversed = north.reverse
|
|
166
|
+
puts <<~VREV
|
|
167
|
+
north(1km).reverse
|
|
168
|
+
Bearing: #{reversed.bearing} (#{reversed.bearing.to_compass})
|
|
169
|
+
VREV
|
|
170
|
+
|
|
171
|
+
cancelled = north + north.reverse
|
|
172
|
+
puts <<~VCANCEL
|
|
173
|
+
north + north.reverse
|
|
174
|
+
Zero? #{cancelled.zero?}
|
|
175
|
+
VCANCEL
|
|
176
|
+
|
|
177
|
+
puts <<~VDOT
|
|
178
|
+
Dot & Cross products:
|
|
179
|
+
north.dot(east) = #{north.dot(east).round(1)} (perpendicular → 0)
|
|
180
|
+
north.dot(north) = #{north.dot(north).round(1)} (parallel → magnitude²)
|
|
181
|
+
north.cross(east) = #{north.cross(east).round(1)} (perpendicular → area)
|
|
182
|
+
VDOT
|
|
183
|
+
puts
|
|
184
|
+
|
|
185
|
+
# ── 6. Coordinate + Vector → Segment (Vincenty Direct) ──────────
|
|
186
|
+
|
|
187
|
+
puts "--- 6. Coordinate + Vector → Segment ---"
|
|
188
|
+
puts
|
|
189
|
+
|
|
190
|
+
v = Vector.new(distance: 100_000, bearing: 45.0)
|
|
191
|
+
seg = seattle + v
|
|
192
|
+
puts <<~CV
|
|
193
|
+
seattle + Vector(100km, 45° NE)
|
|
194
|
+
Type: #{seg.class}
|
|
195
|
+
Start: #{seg.start_point.to_s(4)}
|
|
196
|
+
End: #{seg.end_point.to_s(4)}
|
|
197
|
+
Length: #{seg.length.to_km.to_s(1)}
|
|
198
|
+
Bearing: #{seg.bearing}
|
|
199
|
+
CV
|
|
200
|
+
|
|
201
|
+
# Vector + Coordinate → Segment (prepend via reverse)
|
|
202
|
+
seg2 = v + seattle
|
|
203
|
+
puts <<~VC
|
|
204
|
+
Vector(100km, 45° NE) + seattle
|
|
205
|
+
Start: #{seg2.start_point.to_s(4)} (100km SW of seattle)
|
|
206
|
+
End: #{seg2.end_point.to_s(4)} (seattle)
|
|
207
|
+
Length: #{seg2.length.to_km.to_s(1)}
|
|
208
|
+
VC
|
|
209
|
+
puts
|
|
210
|
+
|
|
211
|
+
# ── 7. Extending with Vectors ───────────────────────────────────
|
|
212
|
+
|
|
213
|
+
puts "--- 7. Extending Geometry with Vectors ---"
|
|
214
|
+
puts
|
|
215
|
+
|
|
216
|
+
# Segment + Vector → Path
|
|
217
|
+
seg = seattle + portland
|
|
218
|
+
detour_east = Vector.new(distance: 50_000, bearing: 90.0)
|
|
219
|
+
path = seg + detour_east
|
|
220
|
+
puts <<~SV
|
|
221
|
+
(seattle + portland) + Vector(50km east)
|
|
222
|
+
Type: #{path.class}
|
|
223
|
+
Waypoints: #{path.size}
|
|
224
|
+
Last point #{path.last.lng > portland.lng ? "is east of" : "is NOT east of"} portland
|
|
225
|
+
SV
|
|
226
|
+
|
|
227
|
+
# Vector + Segment → Path (prepend)
|
|
228
|
+
approach = Vector.new(distance: 30_000, bearing: 180.0)
|
|
229
|
+
path2 = approach + seg
|
|
230
|
+
puts <<~VS
|
|
231
|
+
Vector(30km south) + (seattle + portland)
|
|
232
|
+
Type: #{path2.class}
|
|
233
|
+
Waypoints: #{path2.size}
|
|
234
|
+
First point #{path2.first.lat > seattle.lat ? "is north of" : "is NOT north of"} seattle
|
|
235
|
+
(reversed: 30km south → first point is 30km north)
|
|
236
|
+
VS
|
|
237
|
+
|
|
238
|
+
# Path + Vector → Path
|
|
239
|
+
route = seattle + portland + sf
|
|
240
|
+
extend_south = Vector.new(distance: 200_000, bearing: 180.0)
|
|
241
|
+
route2 = route + extend_south
|
|
242
|
+
puts <<~PV
|
|
243
|
+
(seattle + portland + sf) + Vector(200km south)
|
|
244
|
+
Waypoints: #{route2.size}
|
|
245
|
+
Last point latitude: #{route2.last.lat.round(4)} (south of SF's #{sf.lat})
|
|
246
|
+
PV
|
|
247
|
+
puts
|
|
248
|
+
|
|
249
|
+
# ── 8. Translation with * ───────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
puts "--- 8. Translation with * ---"
|
|
252
|
+
puts
|
|
253
|
+
|
|
254
|
+
shift_east = Vector.new(distance: 100_000, bearing: 90.0)
|
|
255
|
+
|
|
256
|
+
# Coordinate
|
|
257
|
+
p2 = seattle * shift_east
|
|
258
|
+
puts <<~TC
|
|
259
|
+
Coordinate * Vector(100km east)
|
|
260
|
+
seattle: #{seattle.to_s(4)}
|
|
261
|
+
translated: #{p2.to_s(4)}
|
|
262
|
+
seattle.translate(v) also works: #{seattle.translate(shift_east).to_s(4)}
|
|
263
|
+
TC
|
|
264
|
+
|
|
265
|
+
# Segment
|
|
266
|
+
seg = seattle + portland
|
|
267
|
+
seg2 = seg * shift_east
|
|
268
|
+
puts <<~TS
|
|
269
|
+
Segment * Vector(100km east)
|
|
270
|
+
Original length: #{seg.length.to_km.to_s(1)}
|
|
271
|
+
Shifted length: #{seg2.length.to_km.to_s(1)} (preserved)
|
|
272
|
+
Start moved east: #{seg2.start_point.lng > seg.start_point.lng}
|
|
273
|
+
TS
|
|
274
|
+
|
|
275
|
+
# Path
|
|
276
|
+
route = seattle + portland + sf
|
|
277
|
+
route2 = route * shift_east
|
|
278
|
+
puts <<~TP
|
|
279
|
+
Path * Vector(100km east)
|
|
280
|
+
Original waypoints: #{route.size}
|
|
281
|
+
Shifted waypoints: #{route2.size} (preserved)
|
|
282
|
+
All moved east: #{route.coordinates.zip(route2.coordinates).all? { |a, b| b.lng > a.lng }}
|
|
283
|
+
TP
|
|
284
|
+
|
|
285
|
+
# Circle
|
|
286
|
+
circle = seattle + Distance.km(25)
|
|
287
|
+
circle2 = circle * shift_east
|
|
288
|
+
puts <<~TCIR
|
|
289
|
+
Circle * Vector(100km east)
|
|
290
|
+
Original centroid: #{circle.centroid.to_s(4)}
|
|
291
|
+
Shifted centroid: #{circle2.centroid.to_s(4)}
|
|
292
|
+
Radius preserved: #{circle.radius == circle2.radius}
|
|
293
|
+
TCIR
|
|
294
|
+
|
|
295
|
+
# Polygon
|
|
296
|
+
a = LLA.new(lat: 47.5, lng: -122.5, alt: 0)
|
|
297
|
+
b = LLA.new(lat: 47.5, lng: -122.2, alt: 0)
|
|
298
|
+
c = LLA.new(lat: 47.7, lng: -122.35, alt: 0)
|
|
299
|
+
poly = Areas::Polygon.new(boundary: [a, b, c])
|
|
300
|
+
poly2 = poly * shift_east
|
|
301
|
+
puts <<~TPOLY
|
|
302
|
+
Polygon * Vector(100km east)
|
|
303
|
+
Original centroid: #{poly.centroid.to_s(4)}
|
|
304
|
+
Shifted centroid: #{poly2.centroid.to_s(4)}
|
|
305
|
+
TPOLY
|
|
306
|
+
puts
|
|
307
|
+
|
|
308
|
+
# ── 9. + vs * with Vector ───────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
puts "--- 9. Key Distinction: + vs * ---"
|
|
311
|
+
puts
|
|
312
|
+
|
|
313
|
+
v = Vector.new(distance: 50_000, bearing: 0.0)
|
|
314
|
+
journey = seattle + v # Segment (the journey)
|
|
315
|
+
destination = seattle * v # Coordinate (just the result)
|
|
316
|
+
|
|
317
|
+
puts <<~DIFF
|
|
318
|
+
v = Vector(50km north)
|
|
319
|
+
|
|
320
|
+
seattle + v → #{journey.class}
|
|
321
|
+
start: #{journey.start_point.to_s(4)}
|
|
322
|
+
end: #{journey.end_point.to_s(4)}
|
|
323
|
+
|
|
324
|
+
seattle * v → #{destination.class}
|
|
325
|
+
result: #{destination.to_s(4)}
|
|
326
|
+
|
|
327
|
+
The endpoint equals the translated point:
|
|
328
|
+
#{journey.end_point == destination}
|
|
329
|
+
DIFF
|
|
330
|
+
puts
|
|
331
|
+
|
|
332
|
+
# ── 10. Corridors ───────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
puts "--- 10. Path Corridors ---"
|
|
335
|
+
puts
|
|
336
|
+
|
|
337
|
+
route = seattle + portland + sf
|
|
338
|
+
corridor = route.to_corridor(width: 50_000)
|
|
339
|
+
puts <<~CORR
|
|
340
|
+
route.to_corridor(width: 50_000) (50km wide)
|
|
341
|
+
Type: #{corridor.class}
|
|
342
|
+
Vertices: #{corridor.boundary.size} (#{route.size} left + #{route.size} right + 1 closing)
|
|
343
|
+
|
|
344
|
+
Portland inside corridor? #{corridor.includes?(portland)}
|
|
345
|
+
CORR
|
|
346
|
+
|
|
347
|
+
# Translate the corridor
|
|
348
|
+
shifted_corridor = corridor * Vector.new(distance: 200_000, bearing: 90.0)
|
|
349
|
+
puts <<~SCORR
|
|
350
|
+
corridor * Vector(200km east)
|
|
351
|
+
Portland still inside shifted corridor? #{shifted_corridor.includes?(portland)}
|
|
352
|
+
SCORR
|
|
353
|
+
puts
|
|
354
|
+
|
|
355
|
+
# ── 11. Composing Operations ────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
puts "--- 11. Composing Operations ---"
|
|
358
|
+
puts
|
|
359
|
+
|
|
360
|
+
# Build a route, translate it, make a corridor
|
|
361
|
+
v = Vector.new(distance: 100_000, bearing: 90.0)
|
|
362
|
+
shifted_route = (seattle + portland + sf) * v
|
|
363
|
+
corridor = shifted_route.to_corridor(width: Distance.km(20))
|
|
364
|
+
puts <<~COMPOSE
|
|
365
|
+
((seattle + portland + sf) * Vector(100km east)).to_corridor(width: 20km)
|
|
366
|
+
Route waypoints: #{shifted_route.size}
|
|
367
|
+
Corridor vertices: #{corridor.boundary.size}
|
|
368
|
+
COMPOSE
|
|
369
|
+
|
|
370
|
+
# Vector arithmetic to build displacement
|
|
371
|
+
leg1 = Vector.new(distance: 50_000, bearing: 0.0) # 50km north
|
|
372
|
+
leg2 = Vector.new(distance: 30_000, bearing: 90.0) # 30km east
|
|
373
|
+
combined = leg1 + leg2
|
|
374
|
+
seg = seattle + combined
|
|
375
|
+
puts <<~VECMATH
|
|
376
|
+
Combined vector: north(50km) + east(30km)
|
|
377
|
+
Resultant: #{combined.distance.to_km.to_s(1)} at #{combined.bearing}
|
|
378
|
+
Segment from seattle: #{seg.length.to_km.to_s(1)} at #{seg.bearing}
|
|
379
|
+
VECMATH
|
|
380
|
+
|
|
381
|
+
# Scale a vector to create multiple equidistant points
|
|
382
|
+
base = Vector.new(distance: 100_000, bearing: 45.0)
|
|
383
|
+
points = (1..5).map { |i| seattle * (base * i) }
|
|
384
|
+
puts <<~EQUI
|
|
385
|
+
5 equidistant points at 100km intervals, bearing 45° NE:
|
|
386
|
+
EQUI
|
|
387
|
+
points.each_with_index do |pt, i|
|
|
388
|
+
d = seattle.distance_to(pt).to_km
|
|
389
|
+
puts " #{i + 1}. #{pt.to_s(4)} (#{d.to_s(0)} from seattle)"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
puts
|
|
393
|
+
puts "=== Done ==="
|
data/examples/README.md
CHANGED
|
@@ -76,10 +76,44 @@ Demonstrates the `Geodetic::Path` class with a walking route through Manhattan.
|
|
|
76
76
|
- **Equality** comparing paths by coordinates and order
|
|
77
77
|
- **Subpath extraction** with `between` and **splitting** with `split_at`
|
|
78
78
|
- **Interpolation** finding coordinates at a given distance along the path with `at_distance`
|
|
79
|
-
- **Bounding box** with `bounds` returning an `Areas::
|
|
79
|
+
- **Bounding box** with `bounds` returning an `Areas::BoundingBox`
|
|
80
80
|
- **Polygon conversion** with `to_polygon` (validates no self-intersection)
|
|
81
81
|
- **Path intersection** detection with `intersects?`
|
|
82
82
|
- **Path-to-Path closest points** finding the nearest pair between two paths
|
|
83
83
|
- **Path-to-Area closest points** for Circle and Polygon areas
|
|
84
84
|
- **Reverse** to create the return route
|
|
85
85
|
- **Feature integration** wrapping a Path with label and metadata
|
|
86
|
+
|
|
87
|
+
## 07 - Segments and Shapes
|
|
88
|
+
|
|
89
|
+
Demonstrates `Geodetic::Segment` and the polygon subclasses (`Triangle`, `Rectangle`, `Pentagon`, `Hexagon`, `Octagon`) using landmarks around the National Mall in Washington DC. Covers:
|
|
90
|
+
|
|
91
|
+
- **Segment properties** including `length`/`distance`, `bearing`, `midpoint`/`centroid`
|
|
92
|
+
- **Segment operations** with `interpolate`, `project`, `reverse`
|
|
93
|
+
- **Membership testing** with `includes?` (vertex check) and `contains?` (on-segment check)
|
|
94
|
+
- **Intersection detection** between crossing and parallel segments
|
|
95
|
+
- **Triangle construction** in four modes: isosceles, equilateral by side, equilateral by radius, arbitrary vertices
|
|
96
|
+
- **Triangle predicates** with `equilateral?`, `isosceles?`, `scalene?`, and `side_lengths`
|
|
97
|
+
- **Rectangle construction** from a `Segment` centerline (or two-point array) plus width
|
|
98
|
+
- **Rectangle properties** with `centerline`, `center`, `height`, `bearing`, `square?`
|
|
99
|
+
- **Regular polygons** (Pentagon, Hexagon, Octagon) from center and radius
|
|
100
|
+
- **Polygon segments** accessing edges as `Segment` objects
|
|
101
|
+
- **Bounding boxes** via `to_bounding_box` on any polygon subclass
|
|
102
|
+
- **Containment testing** with `includes?` across different shapes
|
|
103
|
+
- **Feature integration** wrapping Segment and area geometries with labels and metadata
|
|
104
|
+
|
|
105
|
+
## 08 - Geodetic Arithmetic
|
|
106
|
+
|
|
107
|
+
Demonstrates the operator-based geometry system and the `Geodetic::Vector` class using West Coast cities. Covers:
|
|
108
|
+
|
|
109
|
+
- **Building Segments with +** combining two coordinates, including cross-system (LLA + UTM)
|
|
110
|
+
- **Chaining + into Paths** with Coordinate + Coordinate + Coordinate, Coordinate + Segment, and Segment + Segment
|
|
111
|
+
- **Coordinate + Distance → Circle** with commutative Distance + Coordinate
|
|
112
|
+
- **Vector construction** from distance/bearing, from a Segment (`to_vector`), and from north/east components
|
|
113
|
+
- **Vector arithmetic** with addition, subtraction, scalar multiplication, reverse, zero cancellation, dot product, and cross product
|
|
114
|
+
- **Coordinate + Vector → Segment** solving the Vincenty direct problem, and Vector + Coordinate for the reverse
|
|
115
|
+
- **Extending geometry with Vectors** using Segment + Vector, Vector + Segment, and Path + Vector
|
|
116
|
+
- **Translation with \*** shifting Coordinates, Segments, Paths, Circles, and Polygons by a Vector (also available as `.translate`)
|
|
117
|
+
- **Key distinction: + vs \*** where `P + V` returns a Segment (the journey) and `P * V` returns a Coordinate (the destination)
|
|
118
|
+
- **Path corridors** with `to_corridor(width:)` converting a path into a polygon, and translating the corridor
|
|
119
|
+
- **Composing operations** chaining arithmetic, vector math, and corridors in single expressions
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../coordinate/lla'
|
|
4
|
+
|
|
5
|
+
module Geodetic
|
|
6
|
+
module Areas
|
|
7
|
+
class BoundingBox
|
|
8
|
+
attr_reader :nw, :se, :centroid
|
|
9
|
+
|
|
10
|
+
# Define an axis-aligned bounding box by its NW and SE corners.
|
|
11
|
+
# Accepts any coordinate that responds to to_lla.
|
|
12
|
+
#
|
|
13
|
+
# BoundingBox.new(
|
|
14
|
+
# nw: LLA.new(lat: 41.0, lng: -75.0),
|
|
15
|
+
# se: LLA.new(lat: 40.0, lng: -74.0)
|
|
16
|
+
# )
|
|
17
|
+
def initialize(nw:, se:)
|
|
18
|
+
@nw = nw.is_a?(Coordinate::LLA) ? nw : nw.to_lla
|
|
19
|
+
@se = se.is_a?(Coordinate::LLA) ? se : se.to_lla
|
|
20
|
+
|
|
21
|
+
raise ArgumentError, "NW corner must have higher latitude than SE corner" if @nw.lat < @se.lat
|
|
22
|
+
raise ArgumentError, "NW corner must have lower longitude than SE corner" if @nw.lng > @se.lng
|
|
23
|
+
|
|
24
|
+
@centroid = Coordinate::LLA.new(
|
|
25
|
+
lat: (@nw.lat + @se.lat) / 2.0,
|
|
26
|
+
lng: (@nw.lng + @se.lng) / 2.0,
|
|
27
|
+
alt: 0.0
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ne
|
|
32
|
+
Coordinate::LLA.new(lat: @nw.lat, lng: @se.lng, alt: 0.0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sw
|
|
36
|
+
Coordinate::LLA.new(lat: @se.lat, lng: @nw.lng, alt: 0.0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def includes?(a_point)
|
|
40
|
+
lla = a_point.respond_to?(:to_lla) ? a_point.to_lla : a_point
|
|
41
|
+
lla.lat >= @se.lat && lla.lat <= @nw.lat &&
|
|
42
|
+
lla.lng >= @nw.lng && lla.lng <= @se.lng
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def excludes?(a_point)
|
|
46
|
+
!includes?(a_point)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
alias_method :include?, :includes?
|
|
50
|
+
alias_method :exclude?, :excludes?
|
|
51
|
+
alias_method :inside?, :includes?
|
|
52
|
+
alias_method :outside?, :excludes?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -24,6 +24,14 @@ module Geodetic
|
|
|
24
24
|
alias_method :exclude?, :excludes?
|
|
25
25
|
alias_method :inside?, :includes?
|
|
26
26
|
alias_method :outside?, :excludes?
|
|
27
|
+
|
|
28
|
+
def *(other)
|
|
29
|
+
raise ArgumentError, "expected a Vector, got #{other.class}" unless other.is_a?(Vector)
|
|
30
|
+
|
|
31
|
+
self.class.new(centroid: other.destination_from(@centroid), radius: @radius)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
alias_method :translate, :*
|
|
27
35
|
end
|
|
28
36
|
end
|
|
29
37
|
end
|
|
@@ -7,29 +7,26 @@ module Geodetic
|
|
|
7
7
|
class Polygon
|
|
8
8
|
attr_reader :boundary, :centroid
|
|
9
9
|
|
|
10
|
-
def initialize(boundary:)
|
|
10
|
+
def initialize(boundary:, validate: true)
|
|
11
11
|
raise ArgumentError, "A Polygon requires more than #{boundary.length} points on its boundary" unless boundary.length > 2
|
|
12
12
|
|
|
13
13
|
@boundary = boundary.dup
|
|
14
14
|
@boundary << boundary[0] unless boundary.first == boundary.last
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
centroid_lng = 0.0
|
|
18
|
-
area = 0.0
|
|
19
|
-
|
|
20
|
-
0.upto(@boundary.length - 2) do |i|
|
|
21
|
-
cross = @boundary[i].lng * @boundary[i + 1].lat - @boundary[i + 1].lng * @boundary[i].lat
|
|
22
|
-
area += 0.5 * cross
|
|
23
|
-
centroid_lng += (@boundary[i].lng + @boundary[i + 1].lng) * cross
|
|
24
|
-
centroid_lat += (@boundary[i].lat + @boundary[i + 1].lat) * cross
|
|
25
|
-
end
|
|
16
|
+
validate_no_self_intersection! if validate
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
compute_centroid
|
|
19
|
+
end
|
|
29
20
|
|
|
30
|
-
|
|
21
|
+
# Returns Segment objects for each edge of the polygon.
|
|
22
|
+
# Returns Segment objects for each side of the polygon.
|
|
23
|
+
def segments
|
|
24
|
+
@segments ||= @boundary.each_cons(2).map { |a, b| Segment.new(a, b) }
|
|
31
25
|
end
|
|
32
26
|
|
|
27
|
+
alias edges segments
|
|
28
|
+
alias border segments
|
|
29
|
+
|
|
33
30
|
def includes?(a_point)
|
|
34
31
|
turn_angle = 0.0
|
|
35
32
|
|
|
@@ -52,6 +49,59 @@ module Geodetic
|
|
|
52
49
|
alias_method :exclude?, :excludes?
|
|
53
50
|
alias_method :inside?, :includes?
|
|
54
51
|
alias_method :outside?, :excludes?
|
|
52
|
+
|
|
53
|
+
def *(other)
|
|
54
|
+
raise ArgumentError, "expected a Vector, got #{other.class}" unless other.is_a?(Vector)
|
|
55
|
+
|
|
56
|
+
# Translate all vertices except the closing point (it gets re-added by initialize)
|
|
57
|
+
translated = @boundary[0...-1].map { |pt| other.destination_from(pt) }
|
|
58
|
+
self.class.new(boundary: translated)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
alias_method :translate, :*
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def compute_centroid
|
|
66
|
+
centroid_lat = 0.0
|
|
67
|
+
centroid_lng = 0.0
|
|
68
|
+
area = 0.0
|
|
69
|
+
|
|
70
|
+
0.upto(@boundary.length - 2) do |i|
|
|
71
|
+
cross = @boundary[i].lng * @boundary[i + 1].lat - @boundary[i + 1].lng * @boundary[i].lat
|
|
72
|
+
area += 0.5 * cross
|
|
73
|
+
centroid_lng += (@boundary[i].lng + @boundary[i + 1].lng) * cross
|
|
74
|
+
centroid_lat += (@boundary[i].lat + @boundary[i + 1].lat) * cross
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if area.abs < 1e-12
|
|
78
|
+
# Degenerate polygon (collinear or self-intersecting) — fall back to mean
|
|
79
|
+
centroid_lat = @boundary[0...-1].sum(&:lat) / (@boundary.length - 1).to_f
|
|
80
|
+
centroid_lng = @boundary[0...-1].sum(&:lng) / (@boundary.length - 1).to_f
|
|
81
|
+
else
|
|
82
|
+
centroid_lng /= (6.0 * area)
|
|
83
|
+
centroid_lat /= (6.0 * area)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@centroid = Coordinate::LLA.new(lat: centroid_lat, lng: centroid_lng, alt: 0.0)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_no_self_intersection!
|
|
90
|
+
segs = @boundary.each_cons(2).map { |a, b| Segment.new(a, b) }
|
|
91
|
+
|
|
92
|
+
segs.each_with_index do |seg_i, i|
|
|
93
|
+
segs.each_with_index do |seg_j, j|
|
|
94
|
+
# Skip same edge and adjacent edges (they share a vertex)
|
|
95
|
+
next if j <= i + 1
|
|
96
|
+
# Skip first-last pair (they share the closing vertex)
|
|
97
|
+
next if i == 0 && j == segs.length - 1
|
|
98
|
+
|
|
99
|
+
if seg_i.intersects?(seg_j)
|
|
100
|
+
raise ArgumentError, "edge #{i} intersects edge #{j} — polygon boundary must not self-intersect"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
55
105
|
end
|
|
56
106
|
end
|
|
57
107
|
end
|