geodetic 0.3.1 → 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.
@@ -0,0 +1,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geodetic
4
+ class Path
5
+ include Enumerable
6
+
7
+ attr_reader :coordinates
8
+
9
+ def initialize(coordinates: [])
10
+ @coordinates = []
11
+ coordinates.each { |c| append!(c) }
12
+ end
13
+
14
+ # --- Navigation ---
15
+
16
+ def first
17
+ @coordinates.first
18
+ end
19
+
20
+ def last
21
+ @coordinates.last
22
+ end
23
+
24
+ def next(coordinate)
25
+ idx = index_of!(coordinate)
26
+ idx == @coordinates.length - 1 ? nil : @coordinates[idx + 1]
27
+ end
28
+
29
+ def prev(coordinate)
30
+ idx = index_of!(coordinate)
31
+ idx == 0 ? nil : @coordinates[idx - 1]
32
+ end
33
+
34
+ def segments
35
+ @coordinates.each_cons(2).map { |a, b| Segment.new(a, b) }
36
+ end
37
+
38
+ def size
39
+ @coordinates.size
40
+ end
41
+
42
+ def each(&block)
43
+ @coordinates.each(&block)
44
+ end
45
+
46
+ def empty?
47
+ @coordinates.empty?
48
+ end
49
+
50
+ def include?(coordinate)
51
+ @coordinates.any? { |c| c == coordinate }
52
+ end
53
+
54
+ alias includes? include?
55
+
56
+ def ==(other)
57
+ other.is_a?(Path) &&
58
+ size == other.size &&
59
+ @coordinates.zip(other.coordinates).all? { |a, b| a == b }
60
+ end
61
+
62
+ # --- Containment (on any segment within tolerance) ---
63
+
64
+ DEFAULT_TOLERANCE_METERS = 10.0
65
+
66
+ def contains?(coordinate, tolerance: DEFAULT_TOLERANCE_METERS)
67
+ return true if include?(coordinate)
68
+
69
+ segments.any? { |seg| seg.contains?(coordinate, tolerance: tolerance) }
70
+ end
71
+
72
+ alias inside? contains?
73
+
74
+ def excludes?(coordinate, tolerance: DEFAULT_TOLERANCE_METERS)
75
+ !contains?(coordinate, tolerance: tolerance)
76
+ end
77
+
78
+ alias exclude? excludes?
79
+ alias outside? excludes?
80
+
81
+ # --- Spatial ---
82
+
83
+ def nearest_waypoint(target)
84
+ raise ArgumentError, "path is empty" if empty?
85
+
86
+ @coordinates.min_by { |c| c.distance_to(target).meters }
87
+ end
88
+
89
+ def closest_coordinate_to(other)
90
+ raise ArgumentError, "path is empty" if empty?
91
+
92
+ result = resolve_and_compute(other)
93
+ result[:path_point]
94
+ end
95
+
96
+ def closest_points_to(other)
97
+ area = resolve_area(other)
98
+ raise ArgumentError, "path is empty" if empty?
99
+
100
+ if area.is_a?(Areas::Circle)
101
+ closest_points_to_circle(area)
102
+ elsif area.is_a?(Path)
103
+ closest_points_to_boundary(area.coordinates)
104
+ else
105
+ closest_points_to_boundary(area_boundary(area))
106
+ end
107
+ end
108
+
109
+ def distance_to(other)
110
+ resolve_and_compute(other)[:distance]
111
+ end
112
+
113
+ def bearing_to(other)
114
+ result = resolve_and_compute(other)
115
+ result[:path_point].bearing_to(result[:area_point] || result[:target])
116
+ end
117
+
118
+ # --- Computed ---
119
+
120
+ def reverse
121
+ self.class.new(coordinates: @coordinates.reverse)
122
+ end
123
+
124
+ def between(from, to)
125
+ i = index_of!(from)
126
+ j = index_of!(to)
127
+ raise ArgumentError, "from must precede to in path" if j < i
128
+
129
+ self.class.new(coordinates: @coordinates[i..j])
130
+ end
131
+
132
+ def split_at(coordinate)
133
+ idx = index_of!(coordinate)
134
+ left = self.class.new(coordinates: @coordinates[0..idx])
135
+ right = self.class.new(coordinates: @coordinates[idx..])
136
+ [left, right]
137
+ end
138
+
139
+ def at_distance(target_distance)
140
+ raise ArgumentError, "path is empty" if empty?
141
+
142
+ target_m = target_distance.is_a?(Distance) ? target_distance.meters : target_distance.to_f
143
+ raise ArgumentError, "distance must be non-negative" if target_m < 0
144
+
145
+ return @coordinates.first if target_m == 0 || size == 1
146
+
147
+ accumulated = 0.0
148
+
149
+ segments.each do |seg|
150
+ seg_len = seg.length_meters
151
+ if accumulated + seg_len >= target_m
152
+ fraction = (target_m - accumulated) / seg_len
153
+ return seg.interpolate(fraction)
154
+ end
155
+ accumulated += seg_len
156
+ end
157
+
158
+ @coordinates.last
159
+ end
160
+
161
+ def bounds
162
+ raise ArgumentError, "path is empty" if empty?
163
+
164
+ lats = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lat : c.to_lla.lat }
165
+ lngs = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lng : c.to_lla.lng }
166
+
167
+ Areas::BoundingBox.new(
168
+ nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
169
+ se: Coordinate::LLA.new(lat: lats.min, lng: lngs.max, alt: 0)
170
+ )
171
+ end
172
+
173
+ def to_polygon
174
+ raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
175
+
176
+ closing = Segment.new(@coordinates.last, @coordinates.first)
177
+ interior = segments[1...-1] || []
178
+
179
+ if interior.any? { |seg| closing.intersects?(seg) }
180
+ raise ArgumentError, "closing segment intersects the path"
181
+ end
182
+
183
+ Areas::Polygon.new(boundary: @coordinates.dup)
184
+ end
185
+
186
+ def intersects?(other_path)
187
+ raise ArgumentError, "expected a Path" unless other_path.is_a?(Path)
188
+
189
+ segments.each do |seg1|
190
+ other_path.segments.each do |seg2|
191
+ return true if seg1.intersects?(seg2)
192
+ end
193
+ end
194
+
195
+ false
196
+ end
197
+
198
+ def total_distance
199
+ segment_distances.reduce(Distance.new(0)) { |sum, d| sum + d }
200
+ end
201
+
202
+ def segment_distances
203
+ segments.map(&:length)
204
+ end
205
+
206
+ def segment_bearings
207
+ segments.map(&:bearing)
208
+ end
209
+
210
+ # --- Non-mutating operators ---
211
+
212
+ def +(coordinate)
213
+ dup_path.tap { |p| p.send(:append!, coordinate) }
214
+ end
215
+
216
+ def -(other)
217
+ if other.is_a?(Path)
218
+ other.coordinates.each { |c| index_of!(c) }
219
+ self.class.new(coordinates: @coordinates.reject { |c| other.include?(c) })
220
+ else
221
+ index_of!(other)
222
+ dup_path_without(other)
223
+ end
224
+ end
225
+
226
+ # --- Mutating operators ---
227
+
228
+ def <<(coordinate)
229
+ append!(coordinate)
230
+ self
231
+ end
232
+
233
+ def >>(coordinate)
234
+ prepend!(coordinate)
235
+ self
236
+ end
237
+
238
+ def prepend(coordinate)
239
+ prepend!(coordinate)
240
+ self
241
+ end
242
+
243
+ def insert(coordinate, after: nil, before: nil)
244
+ raise ArgumentError, "provide either after: or before:, not both" if after && before
245
+ raise ArgumentError, "provide after: or before:" unless after || before
246
+
247
+ check_duplicate!(coordinate)
248
+
249
+ if after
250
+ idx = index_of!(after)
251
+ @coordinates.insert(idx + 1, coordinate)
252
+ else
253
+ idx = index_of!(before)
254
+ @coordinates.insert(idx, coordinate)
255
+ end
256
+ self
257
+ end
258
+
259
+ def delete(coordinate)
260
+ index_of!(coordinate)
261
+ @coordinates.delete_if { |c| c == coordinate }
262
+ self
263
+ end
264
+
265
+ alias remove delete
266
+
267
+ # --- Display ---
268
+
269
+ def to_s
270
+ points = @coordinates.map(&:to_s).join(" -> ")
271
+ "Path(#{size}): #{points}"
272
+ end
273
+
274
+ def inspect
275
+ "#<Geodetic::Path size=#{size} first=#{first&.inspect} last=#{last&.inspect}>"
276
+ end
277
+
278
+ private
279
+
280
+ def append!(other)
281
+ if other.is_a?(Path)
282
+ other.coordinates.each { |c| append!(c) }
283
+ else
284
+ check_duplicate!(other)
285
+ @coordinates << other
286
+ end
287
+ end
288
+
289
+ def prepend!(other)
290
+ if other.is_a?(Path)
291
+ other.coordinates.reverse_each { |c| prepend!(c) }
292
+ else
293
+ check_duplicate!(other)
294
+ @coordinates.unshift(other)
295
+ end
296
+ end
297
+
298
+ def check_duplicate!(coordinate)
299
+ return unless include?(coordinate)
300
+
301
+ raise ArgumentError, "duplicate coordinate: #{coordinate}"
302
+ end
303
+
304
+ def index_of!(coordinate)
305
+ idx = @coordinates.index { |c| c == coordinate }
306
+ raise ArgumentError, "coordinate not in path: #{coordinate}" unless idx
307
+
308
+ idx
309
+ end
310
+
311
+ def resolve_and_compute(other)
312
+ raise ArgumentError, "path is empty" if empty?
313
+
314
+ geom = resolve_geometry(other)
315
+
316
+ case geom
317
+ when Areas::Circle
318
+ closest_points_to_circle(geom)
319
+ when Array
320
+ closest_points_to_boundary(geom)
321
+ else
322
+ point = closest_point_to_coordinate(geom)
323
+ dist = point.distance_to(geom)
324
+ { path_point: point, area_point: nil, target: geom, distance: dist }
325
+ end
326
+ end
327
+
328
+ def closest_point_to_coordinate(target)
329
+ return @coordinates.first if size == 1
330
+
331
+ waypoint = nearest_waypoint(target)
332
+ idx = @coordinates.index { |c| c == waypoint }
333
+
334
+ best = waypoint
335
+ best_dist = waypoint.distance_to(target).meters
336
+
337
+ if idx > 0
338
+ seg = Segment.new(@coordinates[idx - 1], waypoint)
339
+ candidate, candidate_dist = seg.project(target)
340
+ if candidate_dist < best_dist
341
+ best = candidate
342
+ best_dist = candidate_dist
343
+ end
344
+ end
345
+
346
+ if idx < @coordinates.length - 1
347
+ seg = Segment.new(waypoint, @coordinates[idx + 1])
348
+ candidate, candidate_dist = seg.project(target)
349
+ if candidate_dist < best_dist
350
+ best = candidate
351
+ best_dist = candidate_dist
352
+ end
353
+ end
354
+
355
+ best
356
+ end
357
+
358
+ def closest_points_to_boundary(boundary_coords)
359
+ boundary_segs = boundary_coords.each_cons(2).map { |a, b| Segment.new(a, b) }
360
+
361
+ best = { path_point: nil, area_point: nil, distance: Distance.new(Float::INFINITY) }
362
+
363
+ segments.each do |seg|
364
+ boundary_coords.each do |bpt|
365
+ candidate, dist = seg.project(bpt)
366
+ if dist < best[:distance].meters
367
+ best = { path_point: candidate, area_point: bpt, distance: Distance.new(dist) }
368
+ end
369
+ end
370
+ end
371
+
372
+ boundary_segs.each do |bseg|
373
+ @coordinates.each do |wpt|
374
+ candidate, dist = bseg.project(wpt)
375
+ if dist < best[:distance].meters
376
+ best = { path_point: wpt, area_point: candidate, distance: Distance.new(dist) }
377
+ end
378
+ end
379
+ end
380
+
381
+ best
382
+ end
383
+
384
+ def closest_points_to_circle(circle)
385
+ path_point = closest_point_to_coordinate(circle.centroid)
386
+ dist_to_center = path_point.distance_to(circle.centroid).meters
387
+
388
+ if dist_to_center < 1e-6
389
+ area_point = circle.centroid
390
+ dist = circle.radius
391
+ elsif dist_to_center <= circle.radius
392
+ area_point = path_point
393
+ dist = 0.0
394
+ else
395
+ fraction = circle.radius / dist_to_center
396
+ c_lla = circle.centroid
397
+ p_lla = path_point.is_a?(Coordinate::LLA) ? path_point : path_point.to_lla
398
+
399
+ area_point = Coordinate::LLA.new(
400
+ lat: c_lla.lat + (p_lla.lat - c_lla.lat) * fraction,
401
+ lng: c_lla.lng + (p_lla.lng - c_lla.lng) * fraction,
402
+ alt: c_lla.alt + (p_lla.alt - c_lla.alt) * fraction
403
+ )
404
+ dist = dist_to_center - circle.radius
405
+ end
406
+
407
+ { path_point: path_point, area_point: area_point, distance: Distance.new([dist, 0.0].max) }
408
+ end
409
+
410
+ def area_boundary(area)
411
+ case area
412
+ when Areas::Polygon
413
+ area.boundary
414
+ when Areas::BoundingBox
415
+ [area.nw,
416
+ Coordinate::LLA.new(lat: area.nw.lat, lng: area.se.lng, alt: 0),
417
+ area.se,
418
+ Coordinate::LLA.new(lat: area.se.lat, lng: area.nw.lng, alt: 0),
419
+ area.nw]
420
+ else
421
+ raise ArgumentError, "unsupported area type: #{area.class}"
422
+ end
423
+ end
424
+
425
+ def resolve_geometry(other)
426
+ case other
427
+ when Feature
428
+ resolve_geometry(other.geometry)
429
+ when Path
430
+ other.coordinates
431
+ when Areas::Polygon, Areas::BoundingBox
432
+ area_boundary(other)
433
+ when Areas::Circle
434
+ other
435
+ else
436
+ other.respond_to?(:centroid) ? other.centroid : other
437
+ end
438
+ end
439
+
440
+ def resolve_area(other)
441
+ case other
442
+ when Feature
443
+ resolve_area(other.geometry)
444
+ when Areas::Circle, Areas::Polygon, Areas::BoundingBox, Path
445
+ other
446
+ else
447
+ raise ArgumentError, "expected an Area or Path, got #{other.class}"
448
+ end
449
+ end
450
+
451
+ def resolve_point_from(other)
452
+ case other
453
+ when Feature
454
+ geo = other.geometry
455
+ geo.respond_to?(:centroid) ? geo.centroid : geo
456
+ when Path
457
+ raise ArgumentError, "path is empty" if other.empty?
458
+ other.first
459
+ else
460
+ other.respond_to?(:centroid) ? other.centroid : other
461
+ end
462
+ end
463
+
464
+ def dup_path
465
+ self.class.new(coordinates: @coordinates.dup)
466
+ end
467
+
468
+ def dup_path_without(coordinate)
469
+ self.class.new(coordinates: @coordinates.reject { |c| c == coordinate })
470
+ end
471
+ end
472
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geodetic
4
+ class Segment
5
+ attr_reader :start_point, :end_point
6
+
7
+ def initialize(start_point, end_point)
8
+ @start_point = start_point.is_a?(Coordinate::LLA) ? start_point : start_point.to_lla
9
+ @end_point = end_point.is_a?(Coordinate::LLA) ? end_point : end_point.to_lla
10
+ end
11
+
12
+ # --- Properties ---
13
+
14
+ def length
15
+ @length ||= @start_point.distance_to(@end_point)
16
+ end
17
+
18
+ alias distance length
19
+
20
+ def length_meters
21
+ length.meters
22
+ end
23
+
24
+ def bearing
25
+ @bearing ||= @start_point.bearing_to(@end_point)
26
+ end
27
+
28
+ def midpoint
29
+ @midpoint ||= interpolate(0.5)
30
+ end
31
+
32
+ alias centroid midpoint
33
+
34
+ # --- Geometry ---
35
+
36
+ def reverse
37
+ self.class.new(@end_point, @start_point)
38
+ end
39
+
40
+ def interpolate(fraction)
41
+ Coordinate::LLA.new(
42
+ lat: @start_point.lat + (@end_point.lat - @start_point.lat) * fraction,
43
+ lng: @start_point.lng + (@end_point.lng - @start_point.lng) * fraction,
44
+ alt: @start_point.alt + (@end_point.alt - @start_point.alt) * fraction
45
+ )
46
+ end
47
+
48
+ # Projects a point onto this segment.
49
+ # Returns [closest_point_on_segment, distance_in_meters].
50
+ def project(point)
51
+ target = point.is_a?(Coordinate::LLA) ? point : point.to_lla
52
+ seg_len = length_meters
53
+ dist_a_t = @start_point.distance_to(target).meters
54
+
55
+ return [@start_point, dist_a_t] if seg_len < 1e-6
56
+ return [@start_point, 0.0] if dist_a_t < 1e-6
57
+
58
+ bearing_ab = bearing.degrees
59
+ bearing_at = @start_point.bearing_to(target).degrees
60
+
61
+ angle = (bearing_at - bearing_ab).abs
62
+ angle = 360.0 - angle if angle > 180.0
63
+ angle_rad = angle * Geodetic::RAD_PER_DEG
64
+
65
+ along = dist_a_t * Math.cos(angle_rad)
66
+
67
+ if along <= 0.0
68
+ return [@start_point, dist_a_t]
69
+ elsif along >= seg_len
70
+ dist_b_t = @end_point.distance_to(target).meters
71
+ return [@end_point, dist_b_t]
72
+ end
73
+
74
+ foot = interpolate(along / seg_len)
75
+ [foot, foot.distance_to(target).meters]
76
+ end
77
+
78
+ # Tests if a point lies on this segment within a tolerance (meters).
79
+ def contains?(point, tolerance: 10.0)
80
+ target = point.is_a?(Coordinate::LLA) ? point : point.to_lla
81
+ seg_len = length_meters
82
+
83
+ return @start_point == target || @end_point == target if seg_len < 1e-6
84
+
85
+ dist_a_p = @start_point.distance_to(target).meters
86
+ dist_p_b = target.distance_to(@end_point).meters
87
+
88
+ # Exact endpoint match
89
+ return true if dist_a_p < 1e-6 || dist_p_b < 1e-6
90
+
91
+ return false if dist_a_p > seg_len || dist_p_b > seg_len
92
+
93
+ delta = Math.atan(tolerance / seg_len) * Geodetic::DEG_PER_RAD
94
+ bearing_ap = @start_point.bearing_to(target).degrees
95
+ bearing_pb = target.bearing_to(@end_point).degrees
96
+
97
+ (bearing_ap - bearing_pb).abs <= delta
98
+ end
99
+
100
+ # True if the point is a vertex (start or end point).
101
+ def includes?(point)
102
+ target = point.is_a?(Coordinate::LLA) ? point : point.to_lla
103
+ @start_point == target || @end_point == target
104
+ end
105
+
106
+ def excludes?(point, tolerance: 10.0)
107
+ !contains?(point, tolerance: tolerance)
108
+ end
109
+
110
+ # Tests if this segment intersects another segment.
111
+ def intersects?(other)
112
+ p1 = flat(@start_point); q1 = flat(@end_point)
113
+ p2 = flat(other.start_point); q2 = flat(other.end_point)
114
+
115
+ d1 = cross_sign(p2, q2, p1)
116
+ d2 = cross_sign(p2, q2, q1)
117
+ d3 = cross_sign(p1, q1, p2)
118
+ d4 = cross_sign(p1, q1, q2)
119
+
120
+ return true if d1 != d2 && d3 != d4
121
+
122
+ return true if d1 == 0 && on_collinear?(p2, q2, p1)
123
+ return true if d2 == 0 && on_collinear?(p2, q2, q1)
124
+ return true if d3 == 0 && on_collinear?(p1, q1, p2)
125
+ return true if d4 == 0 && on_collinear?(p1, q1, q2)
126
+
127
+ false
128
+ end
129
+
130
+ # --- Conversion ---
131
+
132
+ def to_path
133
+ Path.new(coordinates: [@start_point, @end_point])
134
+ end
135
+
136
+ def to_a
137
+ [@start_point, @end_point]
138
+ end
139
+
140
+ # --- Equality / Display ---
141
+
142
+ def ==(other)
143
+ other.is_a?(Segment) &&
144
+ @start_point == other.start_point &&
145
+ @end_point == other.end_point
146
+ end
147
+
148
+ def to_s
149
+ "Segment(#{@start_point} -> #{@end_point})"
150
+ end
151
+
152
+ def inspect
153
+ "#<Geodetic::Segment start=#{@start_point.inspect} end=#{@end_point.inspect} length=#{length}>"
154
+ end
155
+
156
+ private
157
+
158
+ def flat(coord)
159
+ [coord.lat, coord.lng]
160
+ end
161
+
162
+ def cross_sign(a, b, c)
163
+ val = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
164
+ val > 1e-12 ? 1 : (val < -1e-12 ? -1 : 0)
165
+ end
166
+
167
+ def on_collinear?(a, b, p)
168
+ p[0] >= [a[0], b[0]].min && p[0] <= [a[0], b[0]].max &&
169
+ p[1] >= [a[1], b[1]].min && p[1] <= [a[1], b[1]].max
170
+ end
171
+ end
172
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Geodetic
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/geodetic.rb CHANGED
@@ -7,6 +7,8 @@ require_relative "geodetic/bearing"
7
7
  require_relative "geodetic/geoid_height"
8
8
  require_relative "geodetic/coordinate"
9
9
  require_relative "geodetic/areas"
10
+ require_relative "geodetic/segment"
11
+ require_relative "geodetic/path"
10
12
  require_relative "geodetic/feature"
11
13
 
12
14
  module Geodetic
data/mkdocs.yml CHANGED
@@ -136,6 +136,8 @@ nav:
136
136
  - Datums: reference/datums.md
137
137
  - Geoid Height: reference/geoid-height.md
138
138
  - Areas: reference/areas.md
139
+ - Segment: reference/segment.md
140
+ - Path: reference/path.md
139
141
  - Feature: reference/feature.md
140
142
  - Serialization: reference/serialization.md
141
143
  - Conversions: reference/conversions.md
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: geodetic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -56,6 +56,8 @@ files:
56
56
  - docs/reference/feature.md
57
57
  - docs/reference/geoid-height.md
58
58
  - docs/reference/map-rendering.md
59
+ - docs/reference/path.md
60
+ - docs/reference/segment.md
59
61
  - docs/reference/serialization.md
60
62
  - examples/01_basic_conversions.rb
61
63
  - examples/02_all_coordinate_systems.rb
@@ -69,13 +71,21 @@ files:
69
71
  - examples/05_map_rendering/icons/monument.png
70
72
  - examples/05_map_rendering/icons/park.png
71
73
  - examples/05_map_rendering/nyc_landmarks.png
74
+ - examples/06_path_operations.rb
75
+ - examples/07_segments_and_shapes.rb
72
76
  - examples/README.md
73
77
  - fiddle_pointer_buffer_pool.md
74
78
  - lib/geodetic.rb
75
79
  - lib/geodetic/areas.rb
80
+ - lib/geodetic/areas/bounding_box.rb
76
81
  - lib/geodetic/areas/circle.rb
82
+ - lib/geodetic/areas/hexagon.rb
83
+ - lib/geodetic/areas/octagon.rb
84
+ - lib/geodetic/areas/pentagon.rb
77
85
  - lib/geodetic/areas/polygon.rb
78
86
  - lib/geodetic/areas/rectangle.rb
87
+ - lib/geodetic/areas/regular_polygon.rb
88
+ - lib/geodetic/areas/triangle.rb
79
89
  - lib/geodetic/bearing.rb
80
90
  - lib/geodetic/coordinate.rb
81
91
  - lib/geodetic/coordinate/bng.rb
@@ -101,6 +111,8 @@ files:
101
111
  - lib/geodetic/distance.rb
102
112
  - lib/geodetic/feature.rb
103
113
  - lib/geodetic/geoid_height.rb
114
+ - lib/geodetic/path.rb
115
+ - lib/geodetic/segment.rb
104
116
  - lib/geodetic/version.rb
105
117
  - mkdocs.yml
106
118
  - sig/geodetic.rbs