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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -2
  3. data/README.md +121 -8
  4. data/docs/coordinate-systems/gars.md +2 -2
  5. data/docs/coordinate-systems/georef.md +2 -2
  6. data/docs/coordinate-systems/gh.md +2 -2
  7. data/docs/coordinate-systems/gh36.md +2 -2
  8. data/docs/coordinate-systems/h3.md +2 -2
  9. data/docs/coordinate-systems/ham.md +2 -2
  10. data/docs/coordinate-systems/olc.md +2 -2
  11. data/docs/index.md +4 -2
  12. data/docs/reference/areas.md +140 -14
  13. data/docs/reference/arithmetic.md +368 -0
  14. data/docs/reference/feature.md +2 -2
  15. data/docs/reference/path.md +3 -3
  16. data/docs/reference/segment.md +181 -0
  17. data/docs/reference/vector.md +256 -0
  18. data/examples/02_all_coordinate_systems.rb +6 -6
  19. data/examples/06_path_operations.rb +2 -4
  20. data/examples/07_segments_and_shapes.rb +258 -0
  21. data/examples/08_geodetic_arithmetic.rb +393 -0
  22. data/examples/README.md +35 -1
  23. data/lib/geodetic/areas/bounding_box.rb +56 -0
  24. data/lib/geodetic/areas/circle.rb +8 -0
  25. data/lib/geodetic/areas/hexagon.rb +11 -0
  26. data/lib/geodetic/areas/octagon.rb +11 -0
  27. data/lib/geodetic/areas/pentagon.rb +11 -0
  28. data/lib/geodetic/areas/polygon.rb +64 -14
  29. data/lib/geodetic/areas/rectangle.rb +85 -35
  30. data/lib/geodetic/areas/regular_polygon.rb +59 -0
  31. data/lib/geodetic/areas/triangle.rb +180 -0
  32. data/lib/geodetic/areas.rb +6 -0
  33. data/lib/geodetic/coordinate/gh36.rb +1 -1
  34. data/lib/geodetic/coordinate/h3.rb +1 -1
  35. data/lib/geodetic/coordinate/spatial_hash.rb +2 -2
  36. data/lib/geodetic/coordinate.rb +26 -1
  37. data/lib/geodetic/distance.rb +5 -1
  38. data/lib/geodetic/path.rb +85 -153
  39. data/lib/geodetic/segment.rb +193 -0
  40. data/lib/geodetic/vector.rb +242 -0
  41. data/lib/geodetic/version.rb +1 -1
  42. data/lib/geodetic.rb +2 -0
  43. data/mkdocs.yml +1 -0
  44. 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::Rectangle`
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "regular_polygon"
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Hexagon < RegularPolygon
8
+ SIDES = 6
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "regular_polygon"
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Octagon < RegularPolygon
8
+ SIDES = 8
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "regular_polygon"
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Pentagon < RegularPolygon
8
+ SIDES = 5
9
+ end
10
+ end
11
+ 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
- centroid_lat = 0.0
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
- centroid_lng /= (6.0 * area)
28
- centroid_lat /= (6.0 * area)
18
+ compute_centroid
19
+ end
29
20
 
30
- @centroid = Coordinate::LLA.new(lat: centroid_lat, lng: centroid_lng, alt: 0.0)
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