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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +87 -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 +7 -3
- data/docs/reference/areas.md +140 -14
- data/docs/reference/feature.md +4 -3
- data/docs/reference/path.md +269 -0
- data/docs/reference/segment.md +181 -0
- data/examples/02_all_coordinate_systems.rb +6 -6
- data/examples/06_path_operations.rb +366 -0
- data/examples/07_segments_and_shapes.rb +258 -0
- data/examples/README.md +41 -0
- data/lib/geodetic/areas/bounding_box.rb +56 -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 +54 -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/feature.rb +10 -2
- data/lib/geodetic/path.rb +472 -0
- data/lib/geodetic/segment.rb +172 -0
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +2 -0
- data/mkdocs.yml +2 -0
- metadata +13 -1
|
@@ -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
|
data/lib/geodetic/version.rb
CHANGED
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.
|
|
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
|