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
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 ---
@@ -272,6 +264,46 @@ module Geodetic
272
264
 
273
265
  alias remove delete
274
266
 
267
+ # --- Translation ---
268
+
269
+ def *(other)
270
+ raise ArgumentError, "expected a Vector, got #{other.class}" unless other.is_a?(Vector)
271
+
272
+ self.class.new(coordinates: @coordinates.map { |c| other.destination_from(c) })
273
+ end
274
+
275
+ alias translate *
276
+
277
+ # --- Geometric conversions ---
278
+
279
+ def to_corridor(width:)
280
+ raise ArgumentError, "need at least 2 coordinates for a corridor" if size < 2
281
+
282
+ half = (width.is_a?(Distance) ? width.meters : width.to_f) / 2.0
283
+ segs = segments
284
+ bearings = segs.map { |s| s.bearing.degrees }
285
+
286
+ left_side = []
287
+ right_side = []
288
+
289
+ @coordinates.each_with_index do |coord, i|
290
+ lla = coord.is_a?(Coordinate::LLA) ? coord : coord.to_lla
291
+
292
+ if i == 0
293
+ perp = bearings[0]
294
+ elsif i == @coordinates.length - 1
295
+ perp = bearings[-1]
296
+ else
297
+ perp = mean_bearing(bearings[i - 1], bearings[i])
298
+ end
299
+
300
+ left_side << offset_point(lla, perp - 90.0, half)
301
+ right_side << offset_point(lla, perp + 90.0, half)
302
+ end
303
+
304
+ Areas::Polygon.new(boundary: left_side + right_side.reverse)
305
+ end
306
+
275
307
  # --- Display ---
276
308
 
277
309
  def to_s
@@ -288,6 +320,12 @@ module Geodetic
288
320
  def append!(other)
289
321
  if other.is_a?(Path)
290
322
  other.coordinates.each { |c| append!(c) }
323
+ elsif other.is_a?(Segment)
324
+ [other.start_point, other.end_point].each { |c| append!(c) }
325
+ elsif other.is_a?(Vector)
326
+ dest = other.destination_from(@coordinates.last)
327
+ check_duplicate!(dest)
328
+ @coordinates << dest
291
329
  else
292
330
  check_duplicate!(other)
293
331
  @coordinates << other
@@ -343,7 +381,8 @@ module Geodetic
343
381
  best_dist = waypoint.distance_to(target).meters
344
382
 
345
383
  if idx > 0
346
- candidate, candidate_dist = project_onto_segment(@coordinates[idx - 1], waypoint, target)
384
+ seg = Segment.new(@coordinates[idx - 1], waypoint)
385
+ candidate, candidate_dist = seg.project(target)
347
386
  if candidate_dist < best_dist
348
387
  best = candidate
349
388
  best_dist = candidate_dist
@@ -351,7 +390,8 @@ module Geodetic
351
390
  end
352
391
 
353
392
  if idx < @coordinates.length - 1
354
- candidate, candidate_dist = project_onto_segment(waypoint, @coordinates[idx + 1], target)
393
+ seg = Segment.new(waypoint, @coordinates[idx + 1])
394
+ candidate, candidate_dist = seg.project(target)
355
395
  if candidate_dist < best_dist
356
396
  best = candidate
357
397
  best_dist = candidate_dist
@@ -362,24 +402,22 @@ module Geodetic
362
402
  end
363
403
 
364
404
  def closest_points_to_boundary(boundary_coords)
365
- boundary_segments = boundary_coords.each_cons(2).to_a
405
+ boundary_segs = boundary_coords.each_cons(2).map { |a, b| Segment.new(a, b) }
366
406
 
367
407
  best = { path_point: nil, area_point: nil, distance: Distance.new(Float::INFINITY) }
368
408
 
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|
409
+ segments.each do |seg|
372
410
  boundary_coords.each do |bpt|
373
- candidate, dist = project_onto_segment(seg_a, seg_b, bpt)
411
+ candidate, dist = seg.project(bpt)
374
412
  if dist < best[:distance].meters
375
413
  best = { path_point: candidate, area_point: bpt, distance: Distance.new(dist) }
376
414
  end
377
415
  end
378
416
  end
379
417
 
380
- boundary_segments.each do |bseg_a, bseg_b|
418
+ boundary_segs.each do |bseg|
381
419
  @coordinates.each do |wpt|
382
- candidate, dist = project_onto_segment(bseg_a, bseg_b, wpt)
420
+ candidate, dist = bseg.project(wpt)
383
421
  if dist < best[:distance].meters
384
422
  best = { path_point: wpt, area_point: candidate, distance: Distance.new(dist) }
385
423
  end
@@ -390,23 +428,16 @@ module Geodetic
390
428
  end
391
429
 
392
430
  def closest_points_to_circle(circle)
393
- # Find closest point on path to the circle's centroid
394
431
  path_point = closest_point_to_coordinate(circle.centroid)
395
432
  dist_to_center = path_point.distance_to(circle.centroid).meters
396
433
 
397
- # The closest point on the circle is along the line from
398
- # centroid toward the path point, offset by the radius
399
434
  if dist_to_center < 1e-6
400
- # Path passes through the center — pick any point on the circle
401
435
  area_point = circle.centroid
402
436
  dist = circle.radius
403
437
  elsif dist_to_center <= circle.radius
404
- # Path is inside the circle
405
438
  area_point = path_point
406
439
  dist = 0.0
407
440
  else
408
- # Interpolate from centroid toward path_point by radius distance
409
- bearing = circle.centroid.bearing_to(path_point).degrees
410
441
  fraction = circle.radius / dist_to_center
411
442
  c_lla = circle.centroid
412
443
  p_lla = path_point.is_a?(Coordinate::LLA) ? path_point : path_point.to_lla
@@ -426,7 +457,7 @@ module Geodetic
426
457
  case area
427
458
  when Areas::Polygon
428
459
  area.boundary
429
- when Areas::Rectangle
460
+ when Areas::BoundingBox
430
461
  [area.nw,
431
462
  Coordinate::LLA.new(lat: area.nw.lat, lng: area.se.lng, alt: 0),
432
463
  area.se,
@@ -443,10 +474,10 @@ module Geodetic
443
474
  resolve_geometry(other.geometry)
444
475
  when Path
445
476
  other.coordinates
446
- when Areas::Polygon, Areas::Rectangle
477
+ when Areas::Polygon, Areas::BoundingBox
447
478
  area_boundary(other)
448
479
  when Areas::Circle
449
- other # handled specially in distance_to/bearing_to/closest_coordinate_to
480
+ other
450
481
  else
451
482
  other.respond_to?(:centroid) ? other.centroid : other
452
483
  end
@@ -456,69 +487,13 @@ module Geodetic
456
487
  case other
457
488
  when Feature
458
489
  resolve_area(other.geometry)
459
- when Areas::Circle, Areas::Polygon, Areas::Rectangle, Path
490
+ when Areas::Circle, Areas::Polygon, Areas::BoundingBox, Path
460
491
  other
461
492
  else
462
493
  raise ArgumentError, "expected an Area or Path, got #{other.class}"
463
494
  end
464
495
  end
465
496
 
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
497
  def resolve_point_from(other)
523
498
  case other
524
499
  when Feature
@@ -532,62 +507,6 @@ module Geodetic
532
507
  end
533
508
  end
534
509
 
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
510
  def dup_path
592
511
  self.class.new(coordinates: @coordinates.dup)
593
512
  end
@@ -595,5 +514,18 @@ module Geodetic
595
514
  def dup_path_without(coordinate)
596
515
  self.class.new(coordinates: @coordinates.reject { |c| c == coordinate })
597
516
  end
517
+
518
+ def offset_point(lla, bearing_deg, distance_m)
519
+ Vector.new(distance: distance_m, bearing: bearing_deg).destination_from(lla)
520
+ end
521
+
522
+ def mean_bearing(b1, b2)
523
+ r1 = b1 * RAD_PER_DEG
524
+ r2 = b2 * RAD_PER_DEG
525
+ Math.atan2(
526
+ Math.sin(r1) + Math.sin(r2),
527
+ Math.cos(r1) + Math.cos(r2)
528
+ ) * DEG_PER_RAD
529
+ end
598
530
  end
599
531
  end
@@ -0,0 +1,193 @@
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
+ # --- Arithmetic ---
131
+
132
+ def +(other)
133
+ to_path + other
134
+ end
135
+
136
+ def *(other)
137
+ raise ArgumentError, "expected a Vector, got #{other.class}" unless other.is_a?(Vector)
138
+
139
+ self.class.new(
140
+ other.destination_from(@start_point),
141
+ other.destination_from(@end_point)
142
+ )
143
+ end
144
+
145
+ alias translate *
146
+
147
+ # --- Conversion ---
148
+
149
+ def to_path
150
+ Path.new(coordinates: [@start_point, @end_point])
151
+ end
152
+
153
+ def to_vector
154
+ Vector.from_segment(self)
155
+ end
156
+
157
+ def to_a
158
+ [@start_point, @end_point]
159
+ end
160
+
161
+ # --- Equality / Display ---
162
+
163
+ def ==(other)
164
+ other.is_a?(Segment) &&
165
+ @start_point == other.start_point &&
166
+ @end_point == other.end_point
167
+ end
168
+
169
+ def to_s
170
+ "Segment(#{@start_point} -> #{@end_point})"
171
+ end
172
+
173
+ def inspect
174
+ "#<Geodetic::Segment start=#{@start_point.inspect} end=#{@end_point.inspect} length=#{length}>"
175
+ end
176
+
177
+ private
178
+
179
+ def flat(coord)
180
+ [coord.lat, coord.lng]
181
+ end
182
+
183
+ def cross_sign(a, b, c)
184
+ val = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
185
+ val > 1e-12 ? 1 : (val < -1e-12 ? -1 : 0)
186
+ end
187
+
188
+ def on_collinear?(a, b, p)
189
+ p[0] >= [a[0], b[0]].min && p[0] <= [a[0], b[0]].max &&
190
+ p[1] >= [a[1], b[1]].min && p[1] <= [a[1], b[1]].max
191
+ end
192
+ end
193
+ end