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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -2
- data/README.md +121 -8
- data/docs/coordinate-systems/gars.md +2 -2
- data/docs/coordinate-systems/georef.md +2 -2
- data/docs/coordinate-systems/gh.md +2 -2
- data/docs/coordinate-systems/gh36.md +2 -2
- data/docs/coordinate-systems/h3.md +2 -2
- data/docs/coordinate-systems/ham.md +2 -2
- data/docs/coordinate-systems/olc.md +2 -2
- data/docs/index.md +4 -2
- data/docs/reference/areas.md +140 -14
- data/docs/reference/arithmetic.md +368 -0
- data/docs/reference/feature.md +2 -2
- data/docs/reference/path.md +3 -3
- data/docs/reference/segment.md +181 -0
- data/docs/reference/vector.md +256 -0
- data/examples/02_all_coordinate_systems.rb +6 -6
- data/examples/06_path_operations.rb +2 -4
- data/examples/07_segments_and_shapes.rb +258 -0
- data/examples/08_geodetic_arithmetic.rb +393 -0
- data/examples/README.md +35 -1
- data/lib/geodetic/areas/bounding_box.rb +56 -0
- data/lib/geodetic/areas/circle.rb +8 -0
- data/lib/geodetic/areas/hexagon.rb +11 -0
- data/lib/geodetic/areas/octagon.rb +11 -0
- data/lib/geodetic/areas/pentagon.rb +11 -0
- data/lib/geodetic/areas/polygon.rb +64 -14
- data/lib/geodetic/areas/rectangle.rb +85 -35
- data/lib/geodetic/areas/regular_polygon.rb +59 -0
- data/lib/geodetic/areas/triangle.rb +180 -0
- data/lib/geodetic/areas.rb +6 -0
- data/lib/geodetic/coordinate/gh36.rb +1 -1
- data/lib/geodetic/coordinate/h3.rb +1 -1
- data/lib/geodetic/coordinate/spatial_hash.rb +2 -2
- data/lib/geodetic/coordinate.rb +26 -1
- data/lib/geodetic/distance.rb +5 -1
- data/lib/geodetic/path.rb +85 -153
- data/lib/geodetic/segment.rb +193 -0
- data/lib/geodetic/vector.rb +242 -0
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +2 -0
- data/mkdocs.yml +1 -0
- 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).
|
|
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? { |
|
|
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 |
|
|
150
|
-
seg_len =
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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? { |
|
|
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 |
|
|
198
|
-
other_path.segments.each do |
|
|
199
|
-
return true if
|
|
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
|
|
203
|
+
segments.map(&:length)
|
|
212
204
|
end
|
|
213
205
|
|
|
214
206
|
def segment_bearings
|
|
215
|
-
segments.map
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
418
|
+
boundary_segs.each do |bseg|
|
|
381
419
|
@coordinates.each do |wpt|
|
|
382
|
-
candidate, dist =
|
|
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::
|
|
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::
|
|
477
|
+
when Areas::Polygon, Areas::BoundingBox
|
|
447
478
|
area_boundary(other)
|
|
448
479
|
when Areas::Circle
|
|
449
|
-
other
|
|
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::
|
|
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
|