geodetic 0.3.2 → 0.4.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.
@@ -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,49 @@ module Geodetic
52
49
  alias_method :exclude?, :excludes?
53
50
  alias_method :inside?, :includes?
54
51
  alias_method :outside?, :excludes?
52
+
53
+ private
54
+
55
+ def compute_centroid
56
+ centroid_lat = 0.0
57
+ centroid_lng = 0.0
58
+ area = 0.0
59
+
60
+ 0.upto(@boundary.length - 2) do |i|
61
+ cross = @boundary[i].lng * @boundary[i + 1].lat - @boundary[i + 1].lng * @boundary[i].lat
62
+ area += 0.5 * cross
63
+ centroid_lng += (@boundary[i].lng + @boundary[i + 1].lng) * cross
64
+ centroid_lat += (@boundary[i].lat + @boundary[i + 1].lat) * cross
65
+ end
66
+
67
+ if area.abs < 1e-12
68
+ # Degenerate polygon (collinear or self-intersecting) — fall back to mean
69
+ centroid_lat = @boundary[0...-1].sum(&:lat) / (@boundary.length - 1).to_f
70
+ centroid_lng = @boundary[0...-1].sum(&:lng) / (@boundary.length - 1).to_f
71
+ else
72
+ centroid_lng /= (6.0 * area)
73
+ centroid_lat /= (6.0 * area)
74
+ end
75
+
76
+ @centroid = Coordinate::LLA.new(lat: centroid_lat, lng: centroid_lng, alt: 0.0)
77
+ end
78
+
79
+ def validate_no_self_intersection!
80
+ segs = @boundary.each_cons(2).map { |a, b| Segment.new(a, b) }
81
+
82
+ segs.each_with_index do |seg_i, i|
83
+ segs.each_with_index do |seg_j, j|
84
+ # Skip same edge and adjacent edges (they share a vertex)
85
+ next if j <= i + 1
86
+ # Skip first-last pair (they share the closing vertex)
87
+ next if i == 0 && j == segs.length - 1
88
+
89
+ if seg_i.intersects?(seg_j)
90
+ raise ArgumentError, "edge #{i} intersects edge #{j} — polygon boundary must not self-intersect"
91
+ end
92
+ end
93
+ end
94
+ end
55
95
  end
56
96
  end
57
97
  end
@@ -1,55 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../coordinate/lla'
3
+ require_relative "polygon"
4
4
 
5
5
  module Geodetic
6
6
  module Areas
7
- class Rectangle
8
- attr_reader :nw, :se, :centroid
7
+ class Rectangle < Polygon
8
+ attr_reader :centerline, :width
9
9
 
10
- # Define an axis-aligned rectangle by its NW and SE corners.
11
- # Accepts any coordinate that responds to to_lla.
10
+ # Construct a rectangle from a centerline Segment and a width (meters).
12
11
  #
13
- # Rectangle.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
- )
12
+ # Rectangle.new(segment: a_segment, width: 200)
13
+ # Rectangle.new(segment: [point_a, point_b], width: 200)
14
+ #
15
+ # The segment defines the centerline — height = segment.length,
16
+ # bearing = segment.bearing, center = segment.midpoint.
17
+ # Width is the perpendicular extent on each side of the centerline.
18
+ #
19
+ def initialize(segment:, width:)
20
+ @centerline = segment.is_a?(Segment) ? segment : Segment.new(*segment)
21
+ @width = width.is_a?(Distance) ? width.meters : width.to_f
22
+
23
+ raise ArgumentError, "width must be positive" unless @width > 0
24
+
25
+ super(boundary: generate_vertices, validate: false)
26
+ end
27
+
28
+ def sides
29
+ 4
30
+ end
31
+
32
+ # Center is the midpoint of the centerline.
33
+ def center
34
+ @centerline.midpoint
35
+ end
36
+
37
+ # Height is the length of the centerline in meters.
38
+ def height
39
+ @centerline.length_meters
29
40
  end
30
41
 
31
- def ne
32
- Coordinate::LLA.new(lat: @nw.lat, lng: @se.lng, alt: 0.0)
42
+ # Bearing is the direction of the centerline in degrees.
43
+ def bearing
44
+ @centerline.bearing.degrees
33
45
  end
34
46
 
35
- def sw
36
- Coordinate::LLA.new(lat: @se.lat, lng: @nw.lng, alt: 0.0)
47
+ # Returns the 4 corner coordinates [front-left, front-right, back-right, back-left]
48
+ # relative to the bearing direction.
49
+ def corners
50
+ boundary[0..3]
37
51
  end
38
52
 
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
53
+ # True when width equals height (within tolerance).
54
+ def square?
55
+ (@width - height).abs < 1e-6
43
56
  end
44
57
 
45
- def excludes?(a_point)
46
- !includes?(a_point)
58
+ # Returns the axis-aligned bounding box that encloses this rectangle.
59
+ def to_bounding_box
60
+ lats = corners.map(&:lat)
61
+ lngs = corners.map(&:lng)
62
+
63
+ BoundingBox.new(
64
+ nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
65
+ se: Coordinate::LLA.new(lat: lats.min, lng: lngs.max, alt: 0)
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def generate_vertices
72
+ half_w = @width / 2.0
73
+ bearing_deg = @centerline.bearing.degrees
74
+ front = @centerline.end_point
75
+ back = @centerline.start_point
76
+
77
+ # Perpendicular bearing: +90 for right, -90 for left
78
+ [
79
+ offset_point(front, -half_w, bearing_deg + 90), # front-left
80
+ offset_point(front, half_w, bearing_deg + 90), # front-right
81
+ offset_point(back, half_w, bearing_deg + 90), # back-right
82
+ offset_point(back, -half_w, bearing_deg + 90) # back-left
83
+ ]
47
84
  end
48
85
 
49
- alias_method :include?, :includes?
50
- alias_method :exclude?, :excludes?
51
- alias_method :inside?, :includes?
52
- alias_method :outside?, :excludes?
86
+ # Offset a point by a signed distance along a bearing using flat-earth projection.
87
+ def offset_point(origin, distance_m, bearing_deg)
88
+ bearing_rad = bearing_deg * Geodetic::RAD_PER_DEG
89
+ lat_rad = origin.lat * Geodetic::RAD_PER_DEG
90
+
91
+ m_per_deg_lat = 111_320.0
92
+ m_per_deg_lng = 111_320.0 * Math.cos(lat_rad)
93
+
94
+ north = distance_m * Math.cos(bearing_rad) / m_per_deg_lat
95
+ east = distance_m * Math.sin(bearing_rad) / m_per_deg_lng
96
+
97
+ Coordinate::LLA.new(
98
+ lat: origin.lat + north,
99
+ lng: origin.lng + east,
100
+ alt: origin.alt
101
+ )
102
+ end
53
103
  end
54
104
  end
55
105
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "polygon"
4
+
5
+ module Geodetic
6
+ module Areas
7
+ # Base class for regular polygons (equal sides, equal angles).
8
+ # Subclasses set SIDES and inherit vertex generation.
9
+ class RegularPolygon < Polygon
10
+ attr_reader :center, :radius, :bearing
11
+
12
+ def initialize(center:, radius:, bearing: 0)
13
+ @center = center.is_a?(Coordinate::LLA) ? center : center.to_lla
14
+ @radius = radius.to_f
15
+ @bearing = bearing.to_f
16
+
17
+ raise ArgumentError, "radius must be positive" unless @radius > 0
18
+
19
+ super(boundary: generate_vertices, validate: false)
20
+ end
21
+
22
+ def sides
23
+ self.class::SIDES
24
+ end
25
+
26
+ private
27
+
28
+ def generate_vertices
29
+ n = self.class::SIDES
30
+ step = 360.0 / n
31
+
32
+ n.times.map do |i|
33
+ angle = @bearing + step * i
34
+ destination(@center, @radius, angle)
35
+ end
36
+ end
37
+
38
+ # Compute a destination point given start, distance (meters), and bearing (degrees).
39
+ # Uses a simplified flat-earth projection adequate for polygon-scale distances.
40
+ def destination(origin, distance_m, bearing_deg)
41
+ bearing_rad = bearing_deg * Geodetic::RAD_PER_DEG
42
+ lat_rad = origin.lat * Geodetic::RAD_PER_DEG
43
+
44
+ # Approximate meters per degree at this latitude
45
+ m_per_deg_lat = 111_320.0
46
+ m_per_deg_lng = 111_320.0 * Math.cos(lat_rad)
47
+
48
+ dlat = distance_m * Math.cos(bearing_rad) / m_per_deg_lat
49
+ dlng = distance_m * Math.sin(bearing_rad) / m_per_deg_lng
50
+
51
+ Coordinate::LLA.new(
52
+ lat: origin.lat + dlat,
53
+ lng: origin.lng + dlng,
54
+ alt: origin.alt
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "polygon"
4
+
5
+ module Geodetic
6
+ module Areas
7
+ class Triangle < Polygon
8
+ attr_reader :center, :width, :height, :bearing
9
+
10
+ SIDE_TOLERANCE = 5.0 # meters — accounts for flat-earth projection error
11
+
12
+ # Four construction modes:
13
+ #
14
+ # Isosceles: Triangle.new(center:, width:, height:, bearing:)
15
+ # Equilateral by radius: Triangle.new(center:, radius:, bearing:)
16
+ # Equilateral by side: Triangle.new(center:, side:, bearing:)
17
+ # Arbitrary 3 vertices: Triangle.new(vertices: [p1, p2, p3])
18
+ #
19
+ def initialize(center: nil, width: nil, height: nil, radius: nil, side: nil, bearing: 0, vertices: nil)
20
+ if vertices
21
+ raise ArgumentError, "vertices: cannot be combined with other shape arguments" if center || width || height || radius || side
22
+
23
+ verts = validate_vertices(vertices)
24
+ @center = compute_center(verts)
25
+ @bearing = 0.0
26
+ @width = 0.0
27
+ @height = 0.0
28
+ super(boundary: verts)
29
+ else
30
+ raise ArgumentError, "center is required" unless center
31
+
32
+ @center = center.is_a?(Coordinate::LLA) ? center : center.to_lla
33
+ @bearing = bearing.to_f
34
+ resolve_dimensions(width, height, radius, side)
35
+
36
+ raise ArgumentError, "width must be positive" unless @width > 0
37
+ raise ArgumentError, "height must be positive" unless @height > 0
38
+
39
+ super(boundary: generate_vertices, validate: false)
40
+ end
41
+ end
42
+
43
+ def sides
44
+ 3
45
+ end
46
+
47
+ # Returns the three vertices.
48
+ def vertices
49
+ boundary[0..2]
50
+ end
51
+
52
+ # Base length (same as width). Returns nil for arbitrary vertex triangles.
53
+ def base
54
+ @width > 0 ? @width : nil
55
+ end
56
+
57
+ # True when all three side lengths are equal (within tolerance).
58
+ def equilateral?
59
+ a, b, c = side_lengths
60
+ (a - b).abs < SIDE_TOLERANCE &&
61
+ (b - c).abs < SIDE_TOLERANCE
62
+ end
63
+
64
+ # True when exactly two side lengths are equal (within tolerance).
65
+ def isosceles?
66
+ a, b, c = side_lengths
67
+ pairs = [
68
+ (a - b).abs < SIDE_TOLERANCE,
69
+ (b - c).abs < SIDE_TOLERANCE,
70
+ (a - c).abs < SIDE_TOLERANCE
71
+ ]
72
+ equal_count = pairs.count(true)
73
+ equal_count == 1
74
+ end
75
+
76
+ # True when no two side lengths are equal (within tolerance).
77
+ def scalene?
78
+ !equilateral? && !isosceles?
79
+ end
80
+
81
+ # Returns the three side lengths in meters as [ab, bc, ca].
82
+ def side_lengths
83
+ v = vertices
84
+ [
85
+ v[0].distance_to(v[1]).meters,
86
+ v[1].distance_to(v[2]).meters,
87
+ v[2].distance_to(v[0]).meters
88
+ ]
89
+ end
90
+
91
+ # Returns the axis-aligned bounding box that encloses this triangle.
92
+ def to_bounding_box
93
+ lats = vertices.map(&:lat)
94
+ lngs = vertices.map(&:lng)
95
+
96
+ BoundingBox.new(
97
+ nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
98
+ se: Coordinate::LLA.new(lat: lats.min, lng: lngs.max, alt: 0)
99
+ )
100
+ end
101
+
102
+ private
103
+
104
+ def validate_vertices(verts)
105
+ raise ArgumentError, "exactly 3 vertices required" unless verts.size == 3
106
+
107
+ verts.map { |v| v.is_a?(Coordinate::LLA) ? v : v.to_lla }
108
+ end
109
+
110
+ def compute_center(verts)
111
+ Coordinate::LLA.new(
112
+ lat: verts.sum(&:lat) / 3.0,
113
+ lng: verts.sum(&:lng) / 3.0,
114
+ alt: verts.sum(&:alt) / 3.0
115
+ )
116
+ end
117
+
118
+ def resolve_dimensions(width, height, radius, side)
119
+ given = { width: width, height: height, radius: radius, side: side }
120
+ .compact
121
+
122
+ if given.key?(:radius)
123
+ raise ArgumentError, "radius cannot be combined with width, height, or side" if given.size > 1
124
+
125
+ r = radius.to_f
126
+ raise ArgumentError, "radius must be positive" unless r > 0
127
+
128
+ @width = r * Math.sqrt(3)
129
+ @height = r * 1.5
130
+
131
+ elsif given.key?(:side)
132
+ raise ArgumentError, "side cannot be combined with width or height" if given.size > 1
133
+
134
+ s = side.to_f
135
+ raise ArgumentError, "side must be positive" unless s > 0
136
+
137
+ @width = s
138
+ @height = s * Math.sqrt(3) / 2.0
139
+
140
+ elsif given.key?(:width) && given.key?(:height)
141
+ raise ArgumentError, "width/height cannot be combined with radius or side" if given.size > 2
142
+
143
+ @width = width.to_f
144
+ @height = height.to_f
145
+
146
+ else
147
+ raise ArgumentError, "provide width: + height:, radius:, side:, or vertices:"
148
+ end
149
+ end
150
+
151
+ def generate_vertices
152
+ half_w = @width / 2.0
153
+ half_h = @height / 2.0
154
+
155
+ [
156
+ vertex(-half_w, -half_h), # base-left
157
+ vertex( half_w, -half_h), # base-right
158
+ vertex( 0.0, half_h) # apex
159
+ ]
160
+ end
161
+
162
+ def vertex(x_offset, y_offset)
163
+ bearing_rad = @bearing * Geodetic::RAD_PER_DEG
164
+ lat_rad = @center.lat * Geodetic::RAD_PER_DEG
165
+
166
+ m_per_deg_lat = 111_320.0
167
+ m_per_deg_lng = 111_320.0 * Math.cos(lat_rad)
168
+
169
+ north = y_offset * Math.cos(bearing_rad) - x_offset * Math.sin(bearing_rad)
170
+ east = y_offset * Math.sin(bearing_rad) + x_offset * Math.cos(bearing_rad)
171
+
172
+ Coordinate::LLA.new(
173
+ lat: @center.lat + north / m_per_deg_lat,
174
+ lng: @center.lng + east / m_per_deg_lng,
175
+ alt: @center.alt
176
+ )
177
+ end
178
+ end
179
+ end
180
+ end
@@ -2,4 +2,10 @@
2
2
 
3
3
  require_relative "areas/circle"
4
4
  require_relative "areas/polygon"
5
+ require_relative "areas/bounding_box"
6
+ require_relative "areas/regular_polygon"
7
+ require_relative "areas/triangle"
5
8
  require_relative "areas/rectangle"
9
+ require_relative "areas/pentagon"
10
+ require_relative "areas/hexagon"
11
+ require_relative "areas/octagon"
@@ -89,7 +89,7 @@ module Geodetic
89
89
  bb = self.class.send(:decode_bounds, @geohash)
90
90
  nw = LLA.new(lat: bb[:max_lat], lng: bb[:min_lng], alt: 0.0)
91
91
  se = LLA.new(lat: bb[:min_lat], lng: bb[:max_lng], alt: 0.0)
92
- Areas::Rectangle.new(nw: nw, se: se)
92
+ Areas::BoundingBox.new(nw: nw, se: se)
93
93
  end
94
94
 
95
95
  # Uses formula-based precision instead of bounds-based
@@ -11,7 +11,7 @@
11
11
  #
12
12
  # Key differences from other spatial hashes:
13
13
  # - Cells are hexagons (6 vertices), not rectangles
14
- # - to_area returns Areas::Polygon, not Areas::Rectangle
14
+ # - to_area returns Areas::Polygon, not Areas::BoundingBox
15
15
  # - neighbors returns an Array (6 cells), not a directional Hash
16
16
  # - "precision" maps to H3 resolution (0-15), not string length
17
17
  #
@@ -309,12 +309,12 @@ module Geodetic
309
309
  end
310
310
  end
311
311
 
312
- # Returns the cell as an Areas::Rectangle
312
+ # Returns the cell as an Areas::BoundingBox
313
313
  def to_area
314
314
  bb = decode_bounds(code_value)
315
315
  nw = LLA.new(lat: bb[:max_lat], lng: bb[:min_lng], alt: 0.0)
316
316
  se = LLA.new(lat: bb[:min_lat], lng: bb[:max_lng], alt: 0.0)
317
- Areas::Rectangle.new(nw: nw, se: se)
317
+ Areas::BoundingBox.new(nw: nw, se: se)
318
318
  end
319
319
 
320
320
  # Returns precision in meters as {lat:, lng:}