geodetic 0.5.0 → 0.5.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 +62 -3
- data/README.md +95 -48
- data/docs/coordinate-systems/index.md +18 -18
- data/docs/index.md +49 -27
- data/docs/reference/arithmetic.md +1 -1
- data/docs/reference/conversions.md +3 -3
- data/docs/reference/feature.md +1 -1
- data/docs/reference/geojson.md +388 -0
- data/examples/09_geojson_export.rb +255 -0
- data/examples/README.md +14 -0
- data/examples/geodetic_demo.geojson +305 -0
- data/lib/geodetic/areas/regular_polygon.rb +1 -21
- data/lib/geodetic/coordinate/bng.rb +26 -26
- data/lib/geodetic/coordinate/ecef.rb +12 -12
- data/lib/geodetic/coordinate/enu.rb +26 -26
- data/lib/geodetic/coordinate/lla.rb +16 -16
- data/lib/geodetic/coordinate/mgrs.rb +26 -26
- data/lib/geodetic/coordinate/ned.rb +26 -26
- data/lib/geodetic/coordinate/spatial_hash.rb +22 -22
- data/lib/geodetic/coordinate/state_plane.rb +42 -42
- data/lib/geodetic/coordinate/ups.rb +21 -21
- data/lib/geodetic/coordinate/usng.rb +25 -25
- data/lib/geodetic/coordinate/utm.rb +12 -12
- data/lib/geodetic/coordinate/web_mercator.rb +21 -21
- data/lib/geodetic/coordinate.rb +3 -2
- data/lib/geodetic/geojson.rb +303 -0
- data/lib/geodetic/path.rb +12 -15
- data/lib/geodetic/segment.rb +2 -18
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic.rb +5 -4
- data/mkdocs.yml +10 -0
- metadata +5 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Geodetic
|
|
6
|
+
class GeoJSON
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(*objects)
|
|
10
|
+
@objects = []
|
|
11
|
+
list = objects.length == 1 && objects[0].is_a?(Array) ? objects[0] : objects
|
|
12
|
+
list.each { |obj| add(obj) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# --- Accumulate ---
|
|
16
|
+
|
|
17
|
+
def <<(object)
|
|
18
|
+
if object.is_a?(Array)
|
|
19
|
+
object.each { |obj| add(obj) }
|
|
20
|
+
else
|
|
21
|
+
add(object)
|
|
22
|
+
end
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# --- Query ---
|
|
27
|
+
|
|
28
|
+
def size
|
|
29
|
+
@objects.size
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
alias length size
|
|
33
|
+
|
|
34
|
+
def empty?
|
|
35
|
+
@objects.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def each(&block)
|
|
39
|
+
@objects.each(&block)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- Remove ---
|
|
43
|
+
|
|
44
|
+
def delete(object)
|
|
45
|
+
@objects.delete(object)
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clear
|
|
50
|
+
@objects.clear
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Export ---
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
{
|
|
58
|
+
"type" => "FeatureCollection",
|
|
59
|
+
"features" => @objects.map { |obj| wrap_as_feature(obj) }
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_json(pretty: false)
|
|
64
|
+
if pretty
|
|
65
|
+
JSON.pretty_generate(to_h)
|
|
66
|
+
else
|
|
67
|
+
JSON.generate(to_h)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def save(path, pretty: false)
|
|
72
|
+
File.write(path, to_json(pretty: pretty))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# --- Display ---
|
|
76
|
+
|
|
77
|
+
def to_s
|
|
78
|
+
"GeoJSON::FeatureCollection(#{size} features)"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inspect
|
|
82
|
+
"#<Geodetic::GeoJSON size=#{size}>"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def add(object)
|
|
88
|
+
@objects << object
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def wrap_as_feature(obj)
|
|
92
|
+
if obj.is_a?(Feature)
|
|
93
|
+
obj.to_geojson
|
|
94
|
+
else
|
|
95
|
+
{ "type" => "Feature", "geometry" => obj.to_geojson, "properties" => {} }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------
|
|
100
|
+
# Geometry helpers (module-level, used by to_geojson on each type)
|
|
101
|
+
# ---------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
class << self
|
|
104
|
+
# --- Export helpers ---
|
|
105
|
+
|
|
106
|
+
def position(lla)
|
|
107
|
+
if lla.alt != 0.0
|
|
108
|
+
[lla.lng, lla.lat, lla.alt]
|
|
109
|
+
else
|
|
110
|
+
[lla.lng, lla.lat]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def point_hash(lla)
|
|
115
|
+
{ "type" => "Point", "coordinates" => position(lla) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def line_string_hash(lla_array)
|
|
119
|
+
{ "type" => "LineString", "coordinates" => lla_array.map { |p| position(p) } }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def polygon_hash(rings)
|
|
123
|
+
{ "type" => "Polygon", "coordinates" => rings.map { |ring| ring.map { |p| position(p) } } }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- Import ---
|
|
127
|
+
|
|
128
|
+
def load(path)
|
|
129
|
+
data = JSON.parse(File.read(path))
|
|
130
|
+
parse(data)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse(data)
|
|
134
|
+
case data["type"]
|
|
135
|
+
when "FeatureCollection"
|
|
136
|
+
data["features"].flat_map { |f| parse(f) }
|
|
137
|
+
when "Feature"
|
|
138
|
+
parse_feature(data)
|
|
139
|
+
when "GeometryCollection"
|
|
140
|
+
data["geometries"].flat_map { |g| parse_geometry(g) }
|
|
141
|
+
else
|
|
142
|
+
[parse_geometry(data)]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def parse_feature(data)
|
|
149
|
+
geometry = parse_geometry(data["geometry"])
|
|
150
|
+
properties = data["properties"] || {}
|
|
151
|
+
|
|
152
|
+
results = geometry.is_a?(Array) ? geometry : [geometry]
|
|
153
|
+
|
|
154
|
+
results.map do |geom|
|
|
155
|
+
name = properties["name"]
|
|
156
|
+
metadata = properties.reject { |k, _| k == "name" }
|
|
157
|
+
|
|
158
|
+
if name || !metadata.empty?
|
|
159
|
+
Feature.new(
|
|
160
|
+
label: name,
|
|
161
|
+
geometry: geom,
|
|
162
|
+
metadata: metadata.transform_keys(&:to_sym)
|
|
163
|
+
)
|
|
164
|
+
else
|
|
165
|
+
geom
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_geometry(data)
|
|
171
|
+
case data["type"]
|
|
172
|
+
when "Point"
|
|
173
|
+
parse_point(data["coordinates"])
|
|
174
|
+
when "LineString"
|
|
175
|
+
parse_line_string(data["coordinates"])
|
|
176
|
+
when "Polygon"
|
|
177
|
+
parse_polygon(data["coordinates"])
|
|
178
|
+
when "MultiPoint"
|
|
179
|
+
data["coordinates"].map { |pos| parse_point(pos) }
|
|
180
|
+
when "MultiLineString"
|
|
181
|
+
data["coordinates"].map { |coords| parse_line_string(coords) }
|
|
182
|
+
when "MultiPolygon"
|
|
183
|
+
data["coordinates"].map { |rings| parse_polygon(rings) }
|
|
184
|
+
when "GeometryCollection"
|
|
185
|
+
data["geometries"].flat_map { |g| parse_geometry(g) }
|
|
186
|
+
else
|
|
187
|
+
raise ArgumentError, "unknown GeoJSON geometry type: #{data["type"]}"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def parse_point(coords)
|
|
192
|
+
lng, lat = coords[0], coords[1]
|
|
193
|
+
alt = coords[2] || 0.0
|
|
194
|
+
Coordinate::LLA.new(lat: lat, lng: lng, alt: alt)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_line_string(coords)
|
|
198
|
+
points = coords.map { |pos| parse_point(pos) }
|
|
199
|
+
if points.length == 2
|
|
200
|
+
Segment.new(points[0], points[1])
|
|
201
|
+
else
|
|
202
|
+
Path.new(coordinates: points)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def parse_polygon(rings)
|
|
207
|
+
outer = rings[0].map { |pos| parse_point(pos) }
|
|
208
|
+
# Remove closing point if it duplicates the first (Polygon#initialize adds it)
|
|
209
|
+
outer.pop if outer.length > 1 && outer.first == outer.last
|
|
210
|
+
Areas::Polygon.new(boundary: outer)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------
|
|
215
|
+
# Mixin for coordinate classes (applied at bottom of this file)
|
|
216
|
+
# ---------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
module CoordinateMethods
|
|
219
|
+
def to_geojson
|
|
220
|
+
if is_a?(Coordinate::ENU) || is_a?(Coordinate::NED)
|
|
221
|
+
raise ArgumentError,
|
|
222
|
+
"#{self.class.name.split('::').last} is a relative coordinate system " \
|
|
223
|
+
"and cannot be exported to GeoJSON without a reference point. " \
|
|
224
|
+
"Convert to an absolute system (e.g., LLA) first."
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
lla = is_a?(Coordinate::LLA) ? self : to_lla
|
|
228
|
+
GeoJSON.point_hash(lla)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------
|
|
235
|
+
# Add to_geojson to non-coordinate geometry types
|
|
236
|
+
# ---------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
module Geodetic
|
|
239
|
+
class Segment
|
|
240
|
+
def to_geojson
|
|
241
|
+
GeoJSON.line_string_hash([@start_point, @end_point])
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
class Path
|
|
246
|
+
def to_geojson(as: :line_string)
|
|
247
|
+
raise ArgumentError, "path is empty" if empty?
|
|
248
|
+
|
|
249
|
+
if as == :polygon
|
|
250
|
+
raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
|
|
251
|
+
ring = @coordinates.dup
|
|
252
|
+
ring << ring.first unless ring.first == ring.last
|
|
253
|
+
GeoJSON.polygon_hash([ring])
|
|
254
|
+
else
|
|
255
|
+
raise ArgumentError, "need at least 2 coordinates for a line string" if size < 2
|
|
256
|
+
GeoJSON.line_string_hash(@coordinates)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
class Feature
|
|
262
|
+
def to_geojson
|
|
263
|
+
properties = {}
|
|
264
|
+
properties["name"] = @label if @label
|
|
265
|
+
properties.merge!(@metadata.transform_keys(&:to_s)) if @metadata && !@metadata.empty?
|
|
266
|
+
{ "type" => "Feature", "geometry" => @geometry.to_geojson, "properties" => properties }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
module Areas
|
|
271
|
+
class Polygon
|
|
272
|
+
def to_geojson
|
|
273
|
+
GeoJSON.polygon_hash([@boundary])
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
class Circle
|
|
278
|
+
def to_geojson(segments: 32)
|
|
279
|
+
step = 360.0 / segments
|
|
280
|
+
ring = segments.times.map do |i|
|
|
281
|
+
Vector.new(distance: @radius, bearing: step * i).destination_from(@centroid)
|
|
282
|
+
end
|
|
283
|
+
ring << ring.first
|
|
284
|
+
GeoJSON.polygon_hash([ring])
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
class BoundingBox
|
|
289
|
+
def to_geojson
|
|
290
|
+
ring = [nw, ne, @se, sw, nw]
|
|
291
|
+
GeoJSON.polygon_hash([ring])
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------
|
|
298
|
+
# Apply coordinate mixin to all registered coordinate classes
|
|
299
|
+
# ---------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
Geodetic::Coordinate.systems.each do |klass|
|
|
302
|
+
klass.include(Geodetic::GeoJSON::CoordinateMethods)
|
|
303
|
+
end
|
data/lib/geodetic/path.rb
CHANGED
|
@@ -161,8 +161,9 @@ module Geodetic
|
|
|
161
161
|
def bounds
|
|
162
162
|
raise ArgumentError, "path is empty" if empty?
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
llas = @coordinates.map { |c| c.is_a?(Coordinate::LLA) ? c : c.to_lla }
|
|
165
|
+
lats = llas.map(&:lat)
|
|
166
|
+
lngs = llas.map(&:lng)
|
|
166
167
|
|
|
167
168
|
Areas::BoundingBox.new(
|
|
168
169
|
nw: Coordinate::LLA.new(lat: lats.max, lng: lngs.min, alt: 0),
|
|
@@ -186,6 +187,8 @@ module Geodetic
|
|
|
186
187
|
def intersects?(other_path)
|
|
187
188
|
raise ArgumentError, "expected a Path" unless other_path.is_a?(Path)
|
|
188
189
|
|
|
190
|
+
return false unless bounds_overlap?(bounds, other_path.bounds)
|
|
191
|
+
|
|
189
192
|
segments.each do |seg1|
|
|
190
193
|
other_path.segments.each do |seg2|
|
|
191
194
|
return true if seg1.intersects?(seg2)
|
|
@@ -494,19 +497,6 @@ module Geodetic
|
|
|
494
497
|
end
|
|
495
498
|
end
|
|
496
499
|
|
|
497
|
-
def resolve_point_from(other)
|
|
498
|
-
case other
|
|
499
|
-
when Feature
|
|
500
|
-
geo = other.geometry
|
|
501
|
-
geo.respond_to?(:centroid) ? geo.centroid : geo
|
|
502
|
-
when Path
|
|
503
|
-
raise ArgumentError, "path is empty" if other.empty?
|
|
504
|
-
other.first
|
|
505
|
-
else
|
|
506
|
-
other.respond_to?(:centroid) ? other.centroid : other
|
|
507
|
-
end
|
|
508
|
-
end
|
|
509
|
-
|
|
510
500
|
def dup_path
|
|
511
501
|
self.class.new(coordinates: @coordinates.dup)
|
|
512
502
|
end
|
|
@@ -519,6 +509,13 @@ module Geodetic
|
|
|
519
509
|
Vector.new(distance: distance_m, bearing: bearing_deg).destination_from(lla)
|
|
520
510
|
end
|
|
521
511
|
|
|
512
|
+
def bounds_overlap?(a, b)
|
|
513
|
+
a.nw.lat >= b.se.lat &&
|
|
514
|
+
b.nw.lat >= a.se.lat &&
|
|
515
|
+
a.se.lng >= b.nw.lng &&
|
|
516
|
+
b.se.lng >= a.nw.lng
|
|
517
|
+
end
|
|
518
|
+
|
|
522
519
|
def mean_bearing(b1, b2)
|
|
523
520
|
r1 = b1 * RAD_PER_DEG
|
|
524
521
|
r2 = b2 * RAD_PER_DEG
|
data/lib/geodetic/segment.rb
CHANGED
|
@@ -77,24 +77,8 @@ module Geodetic
|
|
|
77
77
|
|
|
78
78
|
# Tests if a point lies on this segment within a tolerance (meters).
|
|
79
79
|
def contains?(point, tolerance: 10.0)
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
80
|
+
_closest, distance = project(point)
|
|
81
|
+
distance <= tolerance
|
|
98
82
|
end
|
|
99
83
|
|
|
100
84
|
# True if the point is a vertex (start or end point).
|
data/lib/geodetic/version.rb
CHANGED
data/lib/geodetic.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
module Geodetic
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
end
|
|
6
|
+
|
|
3
7
|
require_relative "geodetic/version"
|
|
4
8
|
require_relative "geodetic/datum"
|
|
5
9
|
require_relative "geodetic/distance"
|
|
@@ -11,7 +15,4 @@ require_relative "geodetic/vector"
|
|
|
11
15
|
require_relative "geodetic/segment"
|
|
12
16
|
require_relative "geodetic/path"
|
|
13
17
|
require_relative "geodetic/feature"
|
|
14
|
-
|
|
15
|
-
module Geodetic
|
|
16
|
-
class Error < StandardError; end
|
|
17
|
-
end
|
|
18
|
+
require_relative "geodetic/geojson"
|
data/mkdocs.yml
CHANGED
|
@@ -132,6 +132,13 @@ nav:
|
|
|
132
132
|
- UPS: coordinate-systems/ups.md
|
|
133
133
|
- State Plane: coordinate-systems/state-plane.md
|
|
134
134
|
- BNG: coordinate-systems/bng.md
|
|
135
|
+
- GH36: coordinate-systems/gh36.md
|
|
136
|
+
- GH: coordinate-systems/gh.md
|
|
137
|
+
- HAM: coordinate-systems/ham.md
|
|
138
|
+
- OLC: coordinate-systems/olc.md
|
|
139
|
+
- GEOREF: coordinate-systems/georef.md
|
|
140
|
+
- GARS: coordinate-systems/gars.md
|
|
141
|
+
- H3: coordinate-systems/h3.md
|
|
135
142
|
- Reference:
|
|
136
143
|
- Datums: reference/datums.md
|
|
137
144
|
- Geoid Height: reference/geoid-height.md
|
|
@@ -141,4 +148,7 @@ nav:
|
|
|
141
148
|
- Feature: reference/feature.md
|
|
142
149
|
- Serialization: reference/serialization.md
|
|
143
150
|
- Conversions: reference/conversions.md
|
|
151
|
+
- Vector: reference/vector.md
|
|
152
|
+
- Arithmetic: reference/arithmetic.md
|
|
153
|
+
- GeoJSON Export: reference/geojson.md
|
|
144
154
|
- Map Rendering: reference/map-rendering.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.5.
|
|
4
|
+
version: 0.5.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dewayne VanHoozer
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- docs/reference/datums.md
|
|
57
57
|
- docs/reference/feature.md
|
|
58
58
|
- docs/reference/geoid-height.md
|
|
59
|
+
- docs/reference/geojson.md
|
|
59
60
|
- docs/reference/map-rendering.md
|
|
60
61
|
- docs/reference/path.md
|
|
61
62
|
- docs/reference/segment.md
|
|
@@ -76,7 +77,9 @@ files:
|
|
|
76
77
|
- examples/06_path_operations.rb
|
|
77
78
|
- examples/07_segments_and_shapes.rb
|
|
78
79
|
- examples/08_geodetic_arithmetic.rb
|
|
80
|
+
- examples/09_geojson_export.rb
|
|
79
81
|
- examples/README.md
|
|
82
|
+
- examples/geodetic_demo.geojson
|
|
80
83
|
- fiddle_pointer_buffer_pool.md
|
|
81
84
|
- lib/geodetic.rb
|
|
82
85
|
- lib/geodetic/areas.rb
|
|
@@ -114,6 +117,7 @@ files:
|
|
|
114
117
|
- lib/geodetic/distance.rb
|
|
115
118
|
- lib/geodetic/feature.rb
|
|
116
119
|
- lib/geodetic/geoid_height.rb
|
|
120
|
+
- lib/geodetic/geojson.rb
|
|
117
121
|
- lib/geodetic/path.rb
|
|
118
122
|
- lib/geodetic/segment.rb
|
|
119
123
|
- lib/geodetic/vector.rb
|