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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +43 -1
- data/docs/index.md +3 -1
- data/docs/reference/feature.md +3 -2
- data/docs/reference/path.md +269 -0
- data/examples/06_path_operations.rb +368 -0
- data/examples/README.md +23 -0
- data/lib/geodetic/feature.rb +10 -2
- data/lib/geodetic/path.rb +599 -0
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +1 -0
- data/mkdocs.yml +1 -0
- metadata +4 -1
|
@@ -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
|
data/lib/geodetic/version.rb
CHANGED
data/lib/geodetic.rb
CHANGED
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.
|
|
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
|