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.
data/lib/geodetic/path.rb CHANGED
@@ -32,7 +32,7 @@ module Geodetic
32
32
  end
33
33
 
34
34
  def segments
35
- @coordinates.each_cons(2).to_a
35
+ @coordinates.each_cons(2).map { |a, b| Segment.new(a, b) }
36
36
  end
37
37
 
38
38
  def size
@@ -66,7 +66,7 @@ module Geodetic
66
66
  def contains?(coordinate, tolerance: DEFAULT_TOLERANCE_METERS)
67
67
  return true if include?(coordinate)
68
68
 
69
- segments.any? { |a, b| on_segment?(a, b, coordinate, tolerance) }
69
+ segments.any? { |seg| seg.contains?(coordinate, tolerance: tolerance) }
70
70
  end
71
71
 
72
72
  alias inside? contains?
@@ -146,17 +146,11 @@ module Geodetic
146
146
 
147
147
  accumulated = 0.0
148
148
 
149
- segments.each do |a, b|
150
- seg_len = a.distance_to(b).meters
149
+ segments.each do |seg|
150
+ seg_len = seg.length_meters
151
151
  if accumulated + seg_len >= target_m
152
152
  fraction = (target_m - accumulated) / seg_len
153
- a_lla = a.is_a?(Coordinate::LLA) ? a : a.to_lla
154
- b_lla = b.is_a?(Coordinate::LLA) ? b : b.to_lla
155
- return Coordinate::LLA.new(
156
- lat: a_lla.lat + (b_lla.lat - a_lla.lat) * fraction,
157
- lng: a_lla.lng + (b_lla.lng - a_lla.lng) * fraction,
158
- alt: a_lla.alt + (b_lla.alt - a_lla.alt) * fraction
159
- )
153
+ return seg.interpolate(fraction)
160
154
  end
161
155
  accumulated += seg_len
162
156
  end
@@ -170,7 +164,7 @@ module Geodetic
170
164
  lats = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lat : c.to_lla.lat }
171
165
  lngs = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lng : c.to_lla.lng }
172
166
 
173
- Areas::Rectangle.new(
167
+ Areas::BoundingBox.new(
174
168
  nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
175
169
  se: Coordinate::LLA.new(lat: lats.min, lng: lngs.max, alt: 0)
176
170
  )
@@ -179,12 +173,10 @@ module Geodetic
179
173
  def to_polygon
180
174
  raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
181
175
 
182
- # Check that closing last→first doesn't cross any interior segment
183
- closing_a = @coordinates.last
184
- closing_b = @coordinates.first
176
+ closing = Segment.new(@coordinates.last, @coordinates.first)
185
177
  interior = segments[1...-1] || []
186
178
 
187
- if interior.any? { |a, b| segments_intersect?(closing_a, closing_b, a, b) }
179
+ if interior.any? { |seg| closing.intersects?(seg) }
188
180
  raise ArgumentError, "closing segment intersects the path"
189
181
  end
190
182
 
@@ -194,9 +186,9 @@ module Geodetic
194
186
  def intersects?(other_path)
195
187
  raise ArgumentError, "expected a Path" unless other_path.is_a?(Path)
196
188
 
197
- segments.each do |a1, b1|
198
- other_path.segments.each do |a2, b2|
199
- return true if segments_intersect?(a1, b1, a2, b2)
189
+ segments.each do |seg1|
190
+ other_path.segments.each do |seg2|
191
+ return true if seg1.intersects?(seg2)
200
192
  end
201
193
  end
202
194
 
@@ -208,11 +200,11 @@ module Geodetic
208
200
  end
209
201
 
210
202
  def segment_distances
211
- segments.map { |a, b| a.distance_to(b) }
203
+ segments.map(&:length)
212
204
  end
213
205
 
214
206
  def segment_bearings
215
- segments.map { |a, b| a.bearing_to(b) }
207
+ segments.map(&:bearing)
216
208
  end
217
209
 
218
210
  # --- Non-mutating operators ---
@@ -343,7 +335,8 @@ module Geodetic
343
335
  best_dist = waypoint.distance_to(target).meters
344
336
 
345
337
  if idx > 0
346
- candidate, candidate_dist = project_onto_segment(@coordinates[idx - 1], waypoint, target)
338
+ seg = Segment.new(@coordinates[idx - 1], waypoint)
339
+ candidate, candidate_dist = seg.project(target)
347
340
  if candidate_dist < best_dist
348
341
  best = candidate
349
342
  best_dist = candidate_dist
@@ -351,7 +344,8 @@ module Geodetic
351
344
  end
352
345
 
353
346
  if idx < @coordinates.length - 1
354
- candidate, candidate_dist = project_onto_segment(waypoint, @coordinates[idx + 1], target)
347
+ seg = Segment.new(waypoint, @coordinates[idx + 1])
348
+ candidate, candidate_dist = seg.project(target)
355
349
  if candidate_dist < best_dist
356
350
  best = candidate
357
351
  best_dist = candidate_dist
@@ -362,24 +356,22 @@ module Geodetic
362
356
  end
363
357
 
364
358
  def closest_points_to_boundary(boundary_coords)
365
- boundary_segments = boundary_coords.each_cons(2).to_a
359
+ boundary_segs = boundary_coords.each_cons(2).map { |a, b| Segment.new(a, b) }
366
360
 
367
361
  best = { path_point: nil, area_point: nil, distance: Distance.new(Float::INFINITY) }
368
362
 
369
- # For each path segment, project each boundary vertex onto it
370
- # For each boundary segment, project each path waypoint onto it
371
- segments.each do |seg_a, seg_b|
363
+ segments.each do |seg|
372
364
  boundary_coords.each do |bpt|
373
- candidate, dist = project_onto_segment(seg_a, seg_b, bpt)
365
+ candidate, dist = seg.project(bpt)
374
366
  if dist < best[:distance].meters
375
367
  best = { path_point: candidate, area_point: bpt, distance: Distance.new(dist) }
376
368
  end
377
369
  end
378
370
  end
379
371
 
380
- boundary_segments.each do |bseg_a, bseg_b|
372
+ boundary_segs.each do |bseg|
381
373
  @coordinates.each do |wpt|
382
- candidate, dist = project_onto_segment(bseg_a, bseg_b, wpt)
374
+ candidate, dist = bseg.project(wpt)
383
375
  if dist < best[:distance].meters
384
376
  best = { path_point: wpt, area_point: candidate, distance: Distance.new(dist) }
385
377
  end
@@ -390,23 +382,16 @@ module Geodetic
390
382
  end
391
383
 
392
384
  def closest_points_to_circle(circle)
393
- # Find closest point on path to the circle's centroid
394
385
  path_point = closest_point_to_coordinate(circle.centroid)
395
386
  dist_to_center = path_point.distance_to(circle.centroid).meters
396
387
 
397
- # The closest point on the circle is along the line from
398
- # centroid toward the path point, offset by the radius
399
388
  if dist_to_center < 1e-6
400
- # Path passes through the center — pick any point on the circle
401
389
  area_point = circle.centroid
402
390
  dist = circle.radius
403
391
  elsif dist_to_center <= circle.radius
404
- # Path is inside the circle
405
392
  area_point = path_point
406
393
  dist = 0.0
407
394
  else
408
- # Interpolate from centroid toward path_point by radius distance
409
- bearing = circle.centroid.bearing_to(path_point).degrees
410
395
  fraction = circle.radius / dist_to_center
411
396
  c_lla = circle.centroid
412
397
  p_lla = path_point.is_a?(Coordinate::LLA) ? path_point : path_point.to_lla
@@ -426,7 +411,7 @@ module Geodetic
426
411
  case area
427
412
  when Areas::Polygon
428
413
  area.boundary
429
- when Areas::Rectangle
414
+ when Areas::BoundingBox
430
415
  [area.nw,
431
416
  Coordinate::LLA.new(lat: area.nw.lat, lng: area.se.lng, alt: 0),
432
417
  area.se,
@@ -443,10 +428,10 @@ module Geodetic
443
428
  resolve_geometry(other.geometry)
444
429
  when Path
445
430
  other.coordinates
446
- when Areas::Polygon, Areas::Rectangle
431
+ when Areas::Polygon, Areas::BoundingBox
447
432
  area_boundary(other)
448
433
  when Areas::Circle
449
- other # handled specially in distance_to/bearing_to/closest_coordinate_to
434
+ other
450
435
  else
451
436
  other.respond_to?(:centroid) ? other.centroid : other
452
437
  end
@@ -456,69 +441,13 @@ module Geodetic
456
441
  case other
457
442
  when Feature
458
443
  resolve_area(other.geometry)
459
- when Areas::Circle, Areas::Polygon, Areas::Rectangle, Path
444
+ when Areas::Circle, Areas::Polygon, Areas::BoundingBox, Path
460
445
  other
461
446
  else
462
447
  raise ArgumentError, "expected an Area or Path, got #{other.class}"
463
448
  end
464
449
  end
465
450
 
466
- # Projects target onto segment A→B using triangle geometry.
467
- # Returns [closest_coordinate, distance_to_target].
468
- #
469
- # Given triangle A-T with known:
470
- # - bearing A→B (segment heading)
471
- # - bearing A→T
472
- # - distance A→T
473
- #
474
- # The angle between A→B and A→T gives us:
475
- # - along-segment distance = dist_AT * cos(angle)
476
- # - perpendicular distance = dist_AT * sin(angle)
477
- #
478
- # If the foot of the perpendicular falls between A and B,
479
- # that foot is the closest point. Otherwise the nearer
480
- # endpoint wins.
481
- def project_onto_segment(a, b, target)
482
- seg_dist = a.distance_to(b).meters
483
- dist_a_t = a.distance_to(target).meters
484
-
485
- # Degenerate: zero-length segment or target at endpoint
486
- return [a, dist_a_t] if seg_dist < 1e-6
487
- return [a, 0.0] if dist_a_t < 1e-6
488
-
489
- bearing_ab = a.bearing_to(b).degrees
490
- bearing_at = a.bearing_to(target).degrees
491
-
492
- # Angle between the segment direction and the line to target
493
- angle = (bearing_at - bearing_ab).abs
494
- angle = 360.0 - angle if angle > 180.0
495
- angle_rad = angle * Geodetic::RAD_PER_DEG
496
-
497
- # Distance along the segment from A to the foot of the perpendicular
498
- along = dist_a_t * Math.cos(angle_rad)
499
-
500
- # If the foot falls before A or beyond B, the closest point is an endpoint
501
- if along <= 0.0
502
- return [a, dist_a_t]
503
- elsif along >= seg_dist
504
- dist_b_t = b.distance_to(target).meters
505
- return [b, dist_b_t]
506
- end
507
-
508
- # Interpolate along the segment to find the foot
509
- fraction = along / seg_dist
510
- a_lla = a.is_a?(Coordinate::LLA) ? a : a.to_lla
511
- b_lla = b.is_a?(Coordinate::LLA) ? b : b.to_lla
512
-
513
- foot = Coordinate::LLA.new(
514
- lat: a_lla.lat + (b_lla.lat - a_lla.lat) * fraction,
515
- lng: a_lla.lng + (b_lla.lng - a_lla.lng) * fraction,
516
- alt: a_lla.alt + (b_lla.alt - a_lla.alt) * fraction
517
- )
518
-
519
- [foot, foot.distance_to(target).meters]
520
- end
521
-
522
451
  def resolve_point_from(other)
523
452
  case other
524
453
  when Feature
@@ -532,62 +461,6 @@ module Geodetic
532
461
  end
533
462
  end
534
463
 
535
- def on_segment?(a, b, point, tolerance)
536
- seg_dist = a.distance_to(b).meters
537
- return a == point || b == point if seg_dist < 1e-6
538
-
539
- dist_a_p = a.distance_to(point).meters
540
- dist_p_b = point.distance_to(b).meters
541
-
542
- # Point must be closer to both endpoints than the segment length
543
- return false if dist_a_p > seg_dist || dist_p_b > seg_dist
544
-
545
- # Bearing from A->P should match bearing from P->B within a
546
- # delta derived from the tolerance and segment distance
547
- delta = Math.atan(tolerance / seg_dist) * Geodetic::DEG_PER_RAD
548
-
549
- bearing_ap = a.bearing_to(point).degrees
550
- bearing_pb = point.bearing_to(b).degrees
551
-
552
- (bearing_ap - bearing_pb).abs <= delta
553
- end
554
-
555
- # Checks if two segments (a1→b1) and (a2→b2) intersect using
556
- # cross-product orientation tests on a flat lat/lng approximation.
557
- def segments_intersect?(a1, b1, a2, b2)
558
- p1 = to_flat(a1); q1 = to_flat(b1)
559
- p2 = to_flat(a2); q2 = to_flat(b2)
560
-
561
- d1 = cross_sign(p2, q2, p1)
562
- d2 = cross_sign(p2, q2, q1)
563
- d3 = cross_sign(p1, q1, p2)
564
- d4 = cross_sign(p1, q1, q2)
565
-
566
- return true if d1 != d2 && d3 != d4
567
-
568
- if d1 == 0 && on_collinear?(p2, q2, p1) then return true end
569
- if d2 == 0 && on_collinear?(p2, q2, q1) then return true end
570
- if d3 == 0 && on_collinear?(p1, q1, p2) then return true end
571
- if d4 == 0 && on_collinear?(p1, q1, q2) then return true end
572
-
573
- false
574
- end
575
-
576
- def to_flat(coord)
577
- c = coord.is_a?(Coordinate::LLA) ? coord : coord.to_lla
578
- [c.lat, c.lng]
579
- end
580
-
581
- def cross_sign(a, b, c)
582
- val = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
583
- val > 1e-12 ? 1 : (val < -1e-12 ? -1 : 0)
584
- end
585
-
586
- def on_collinear?(a, b, p)
587
- p[0] >= [a[0], b[0]].min && p[0] <= [a[0], b[0]].max &&
588
- p[1] >= [a[1], b[1]].min && p[1] <= [a[1], b[1]].max
589
- end
590
-
591
464
  def dup_path
592
465
  self.class.new(coordinates: @coordinates.dup)
593
466
  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.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/geodetic.rb CHANGED
@@ -7,6 +7,7 @@ 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"
10
11
  require_relative "geodetic/path"
11
12
  require_relative "geodetic/feature"
12
13
 
data/mkdocs.yml CHANGED
@@ -136,6 +136,7 @@ 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
139
140
  - Path: reference/path.md
140
141
  - Feature: reference/feature.md
141
142
  - Serialization: reference/serialization.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.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -57,6 +57,7 @@ files:
57
57
  - docs/reference/geoid-height.md
58
58
  - docs/reference/map-rendering.md
59
59
  - docs/reference/path.md
60
+ - docs/reference/segment.md
60
61
  - docs/reference/serialization.md
61
62
  - examples/01_basic_conversions.rb
62
63
  - examples/02_all_coordinate_systems.rb
@@ -71,13 +72,20 @@ files:
71
72
  - examples/05_map_rendering/icons/park.png
72
73
  - examples/05_map_rendering/nyc_landmarks.png
73
74
  - examples/06_path_operations.rb
75
+ - examples/07_segments_and_shapes.rb
74
76
  - examples/README.md
75
77
  - fiddle_pointer_buffer_pool.md
76
78
  - lib/geodetic.rb
77
79
  - lib/geodetic/areas.rb
80
+ - lib/geodetic/areas/bounding_box.rb
78
81
  - lib/geodetic/areas/circle.rb
82
+ - lib/geodetic/areas/hexagon.rb
83
+ - lib/geodetic/areas/octagon.rb
84
+ - lib/geodetic/areas/pentagon.rb
79
85
  - lib/geodetic/areas/polygon.rb
80
86
  - lib/geodetic/areas/rectangle.rb
87
+ - lib/geodetic/areas/regular_polygon.rb
88
+ - lib/geodetic/areas/triangle.rb
81
89
  - lib/geodetic/bearing.rb
82
90
  - lib/geodetic/coordinate.rb
83
91
  - lib/geodetic/coordinate/bng.rb
@@ -104,6 +112,7 @@ files:
104
112
  - lib/geodetic/feature.rb
105
113
  - lib/geodetic/geoid_height.rb
106
114
  - lib/geodetic/path.rb
115
+ - lib/geodetic/segment.rb
107
116
  - lib/geodetic/version.rb
108
117
  - mkdocs.yml
109
118
  - sig/geodetic.rbs