geodetic 0.3.1 → 0.3.2

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,599 @@
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).to_a
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? { |a, b| on_segment?(a, b, coordinate, 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 |a, b|
150
+ seg_len = a.distance_to(b).meters
151
+ if accumulated + seg_len >= target_m
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
+ )
160
+ end
161
+ accumulated += seg_len
162
+ end
163
+
164
+ @coordinates.last
165
+ end
166
+
167
+ def bounds
168
+ raise ArgumentError, "path is empty" if empty?
169
+
170
+ lats = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lat : c.to_lla.lat }
171
+ lngs = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c.lng : c.to_lla.lng }
172
+
173
+ Areas::Rectangle.new(
174
+ nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
175
+ se: Coordinate::LLA.new(lat: lats.min, lng: lngs.max, alt: 0)
176
+ )
177
+ end
178
+
179
+ def to_polygon
180
+ raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
181
+
182
+ # Check that closing last→first doesn't cross any interior segment
183
+ closing_a = @coordinates.last
184
+ closing_b = @coordinates.first
185
+ interior = segments[1...-1] || []
186
+
187
+ if interior.any? { |a, b| segments_intersect?(closing_a, closing_b, a, b) }
188
+ raise ArgumentError, "closing segment intersects the path"
189
+ end
190
+
191
+ Areas::Polygon.new(boundary: @coordinates.dup)
192
+ end
193
+
194
+ def intersects?(other_path)
195
+ raise ArgumentError, "expected a Path" unless other_path.is_a?(Path)
196
+
197
+ segments.each do |a1, b1|
198
+ other_path.segments.each do |a2, b2|
199
+ return true if segments_intersect?(a1, b1, a2, b2)
200
+ end
201
+ end
202
+
203
+ false
204
+ end
205
+
206
+ def total_distance
207
+ segment_distances.reduce(Distance.new(0)) { |sum, d| sum + d }
208
+ end
209
+
210
+ def segment_distances
211
+ segments.map { |a, b| a.distance_to(b) }
212
+ end
213
+
214
+ def segment_bearings
215
+ segments.map { |a, b| a.bearing_to(b) }
216
+ end
217
+
218
+ # --- Non-mutating operators ---
219
+
220
+ def +(coordinate)
221
+ dup_path.tap { |p| p.send(:append!, coordinate) }
222
+ end
223
+
224
+ def -(other)
225
+ if other.is_a?(Path)
226
+ other.coordinates.each { |c| index_of!(c) }
227
+ self.class.new(coordinates: @coordinates.reject { |c| other.include?(c) })
228
+ else
229
+ index_of!(other)
230
+ dup_path_without(other)
231
+ end
232
+ end
233
+
234
+ # --- Mutating operators ---
235
+
236
+ def <<(coordinate)
237
+ append!(coordinate)
238
+ self
239
+ end
240
+
241
+ def >>(coordinate)
242
+ prepend!(coordinate)
243
+ self
244
+ end
245
+
246
+ def prepend(coordinate)
247
+ prepend!(coordinate)
248
+ self
249
+ end
250
+
251
+ def insert(coordinate, after: nil, before: nil)
252
+ raise ArgumentError, "provide either after: or before:, not both" if after && before
253
+ raise ArgumentError, "provide after: or before:" unless after || before
254
+
255
+ check_duplicate!(coordinate)
256
+
257
+ if after
258
+ idx = index_of!(after)
259
+ @coordinates.insert(idx + 1, coordinate)
260
+ else
261
+ idx = index_of!(before)
262
+ @coordinates.insert(idx, coordinate)
263
+ end
264
+ self
265
+ end
266
+
267
+ def delete(coordinate)
268
+ index_of!(coordinate)
269
+ @coordinates.delete_if { |c| c == coordinate }
270
+ self
271
+ end
272
+
273
+ alias remove delete
274
+
275
+ # --- Display ---
276
+
277
+ def to_s
278
+ points = @coordinates.map(&:to_s).join(" -> ")
279
+ "Path(#{size}): #{points}"
280
+ end
281
+
282
+ def inspect
283
+ "#<Geodetic::Path size=#{size} first=#{first&.inspect} last=#{last&.inspect}>"
284
+ end
285
+
286
+ private
287
+
288
+ def append!(other)
289
+ if other.is_a?(Path)
290
+ other.coordinates.each { |c| append!(c) }
291
+ else
292
+ check_duplicate!(other)
293
+ @coordinates << other
294
+ end
295
+ end
296
+
297
+ def prepend!(other)
298
+ if other.is_a?(Path)
299
+ other.coordinates.reverse_each { |c| prepend!(c) }
300
+ else
301
+ check_duplicate!(other)
302
+ @coordinates.unshift(other)
303
+ end
304
+ end
305
+
306
+ def check_duplicate!(coordinate)
307
+ return unless include?(coordinate)
308
+
309
+ raise ArgumentError, "duplicate coordinate: #{coordinate}"
310
+ end
311
+
312
+ def index_of!(coordinate)
313
+ idx = @coordinates.index { |c| c == coordinate }
314
+ raise ArgumentError, "coordinate not in path: #{coordinate}" unless idx
315
+
316
+ idx
317
+ end
318
+
319
+ def resolve_and_compute(other)
320
+ raise ArgumentError, "path is empty" if empty?
321
+
322
+ geom = resolve_geometry(other)
323
+
324
+ case geom
325
+ when Areas::Circle
326
+ closest_points_to_circle(geom)
327
+ when Array
328
+ closest_points_to_boundary(geom)
329
+ else
330
+ point = closest_point_to_coordinate(geom)
331
+ dist = point.distance_to(geom)
332
+ { path_point: point, area_point: nil, target: geom, distance: dist }
333
+ end
334
+ end
335
+
336
+ def closest_point_to_coordinate(target)
337
+ return @coordinates.first if size == 1
338
+
339
+ waypoint = nearest_waypoint(target)
340
+ idx = @coordinates.index { |c| c == waypoint }
341
+
342
+ best = waypoint
343
+ best_dist = waypoint.distance_to(target).meters
344
+
345
+ if idx > 0
346
+ candidate, candidate_dist = project_onto_segment(@coordinates[idx - 1], waypoint, target)
347
+ if candidate_dist < best_dist
348
+ best = candidate
349
+ best_dist = candidate_dist
350
+ end
351
+ end
352
+
353
+ if idx < @coordinates.length - 1
354
+ candidate, candidate_dist = project_onto_segment(waypoint, @coordinates[idx + 1], target)
355
+ if candidate_dist < best_dist
356
+ best = candidate
357
+ best_dist = candidate_dist
358
+ end
359
+ end
360
+
361
+ best
362
+ end
363
+
364
+ def closest_points_to_boundary(boundary_coords)
365
+ boundary_segments = boundary_coords.each_cons(2).to_a
366
+
367
+ best = { path_point: nil, area_point: nil, distance: Distance.new(Float::INFINITY) }
368
+
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|
372
+ boundary_coords.each do |bpt|
373
+ candidate, dist = project_onto_segment(seg_a, seg_b, bpt)
374
+ if dist < best[:distance].meters
375
+ best = { path_point: candidate, area_point: bpt, distance: Distance.new(dist) }
376
+ end
377
+ end
378
+ end
379
+
380
+ boundary_segments.each do |bseg_a, bseg_b|
381
+ @coordinates.each do |wpt|
382
+ candidate, dist = project_onto_segment(bseg_a, bseg_b, wpt)
383
+ if dist < best[:distance].meters
384
+ best = { path_point: wpt, area_point: candidate, distance: Distance.new(dist) }
385
+ end
386
+ end
387
+ end
388
+
389
+ best
390
+ end
391
+
392
+ def closest_points_to_circle(circle)
393
+ # Find closest point on path to the circle's centroid
394
+ path_point = closest_point_to_coordinate(circle.centroid)
395
+ dist_to_center = path_point.distance_to(circle.centroid).meters
396
+
397
+ # The closest point on the circle is along the line from
398
+ # centroid toward the path point, offset by the radius
399
+ if dist_to_center < 1e-6
400
+ # Path passes through the center — pick any point on the circle
401
+ area_point = circle.centroid
402
+ dist = circle.radius
403
+ elsif dist_to_center <= circle.radius
404
+ # Path is inside the circle
405
+ area_point = path_point
406
+ dist = 0.0
407
+ else
408
+ # Interpolate from centroid toward path_point by radius distance
409
+ bearing = circle.centroid.bearing_to(path_point).degrees
410
+ fraction = circle.radius / dist_to_center
411
+ c_lla = circle.centroid
412
+ p_lla = path_point.is_a?(Coordinate::LLA) ? path_point : path_point.to_lla
413
+
414
+ area_point = Coordinate::LLA.new(
415
+ lat: c_lla.lat + (p_lla.lat - c_lla.lat) * fraction,
416
+ lng: c_lla.lng + (p_lla.lng - c_lla.lng) * fraction,
417
+ alt: c_lla.alt + (p_lla.alt - c_lla.alt) * fraction
418
+ )
419
+ dist = dist_to_center - circle.radius
420
+ end
421
+
422
+ { path_point: path_point, area_point: area_point, distance: Distance.new([dist, 0.0].max) }
423
+ end
424
+
425
+ def area_boundary(area)
426
+ case area
427
+ when Areas::Polygon
428
+ area.boundary
429
+ when Areas::Rectangle
430
+ [area.nw,
431
+ Coordinate::LLA.new(lat: area.nw.lat, lng: area.se.lng, alt: 0),
432
+ area.se,
433
+ Coordinate::LLA.new(lat: area.se.lat, lng: area.nw.lng, alt: 0),
434
+ area.nw]
435
+ else
436
+ raise ArgumentError, "unsupported area type: #{area.class}"
437
+ end
438
+ end
439
+
440
+ def resolve_geometry(other)
441
+ case other
442
+ when Feature
443
+ resolve_geometry(other.geometry)
444
+ when Path
445
+ other.coordinates
446
+ when Areas::Polygon, Areas::Rectangle
447
+ area_boundary(other)
448
+ when Areas::Circle
449
+ other # handled specially in distance_to/bearing_to/closest_coordinate_to
450
+ else
451
+ other.respond_to?(:centroid) ? other.centroid : other
452
+ end
453
+ end
454
+
455
+ def resolve_area(other)
456
+ case other
457
+ when Feature
458
+ resolve_area(other.geometry)
459
+ when Areas::Circle, Areas::Polygon, Areas::Rectangle, Path
460
+ other
461
+ else
462
+ raise ArgumentError, "expected an Area or Path, got #{other.class}"
463
+ end
464
+ end
465
+
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
+ def resolve_point_from(other)
523
+ case other
524
+ when Feature
525
+ geo = other.geometry
526
+ geo.respond_to?(:centroid) ? geo.centroid : geo
527
+ when Path
528
+ raise ArgumentError, "path is empty" if other.empty?
529
+ other.first
530
+ else
531
+ other.respond_to?(:centroid) ? other.centroid : other
532
+ end
533
+ end
534
+
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
+ def dup_path
592
+ self.class.new(coordinates: @coordinates.dup)
593
+ end
594
+
595
+ def dup_path_without(coordinate)
596
+ self.class.new(coordinates: @coordinates.reject { |c| c == coordinate })
597
+ end
598
+ end
599
+ 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.3.2"
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/path"
10
11
  require_relative "geodetic/feature"
11
12
 
12
13
  module Geodetic
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
+ - Path: reference/path.md
139
140
  - Feature: reference/feature.md
140
141
  - Serialization: reference/serialization.md
141
142
  - 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.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -56,6 +56,7 @@ 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
59
60
  - docs/reference/serialization.md
60
61
  - examples/01_basic_conversions.rb
61
62
  - examples/02_all_coordinate_systems.rb
@@ -69,6 +70,7 @@ files:
69
70
  - examples/05_map_rendering/icons/monument.png
70
71
  - examples/05_map_rendering/icons/park.png
71
72
  - examples/05_map_rendering/nyc_landmarks.png
73
+ - examples/06_path_operations.rb
72
74
  - examples/README.md
73
75
  - fiddle_pointer_buffer_pool.md
74
76
  - lib/geodetic.rb
@@ -101,6 +103,7 @@ files:
101
103
  - lib/geodetic/distance.rb
102
104
  - lib/geodetic/feature.rb
103
105
  - lib/geodetic/geoid_height.rb
106
+ - lib/geodetic/path.rb
104
107
  - lib/geodetic/version.rb
105
108
  - mkdocs.yml
106
109
  - sig/geodetic.rbs