geodetic 0.5.2 → 0.7.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.
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geodetic
4
+ module WKT
5
+ # --- Export helpers ---
6
+
7
+ class << self
8
+ def position(lla, precision:, use_z:)
9
+ if use_z
10
+ "#{lla.lng.round(precision)} #{lla.lat.round(precision)} #{lla.alt.round(precision)}"
11
+ else
12
+ "#{lla.lng.round(precision)} #{lla.lat.round(precision)}"
13
+ end
14
+ end
15
+
16
+ def has_altitude?(points)
17
+ points.any? { |p| p.alt != 0.0 }
18
+ end
19
+
20
+ def srid_prefix(srid)
21
+ srid ? "SRID=#{srid};" : ""
22
+ end
23
+
24
+ def point_wkt(lla, precision: 6, srid: nil)
25
+ z = lla.alt != 0.0
26
+ type = z ? "POINT Z" : "POINT"
27
+ "#{srid_prefix(srid)}#{type}(#{position(lla, precision: precision, use_z: z)})"
28
+ end
29
+
30
+ def line_string_wkt(points, precision: 6, srid: nil)
31
+ z = has_altitude?(points)
32
+ type = z ? "LINESTRING Z" : "LINESTRING"
33
+ coords = points.map { |p| position(p, precision: precision, use_z: z) }.join(", ")
34
+ "#{srid_prefix(srid)}#{type}(#{coords})"
35
+ end
36
+
37
+ def polygon_wkt(rings, precision: 6, srid: nil)
38
+ z = rings.any? { |ring| has_altitude?(ring) }
39
+ type = z ? "POLYGON Z" : "POLYGON"
40
+ ring_strs = rings.map do |ring|
41
+ coords = ring.map { |p| position(p, precision: precision, use_z: z) }.join(", ")
42
+ "(#{coords})"
43
+ end
44
+ "#{srid_prefix(srid)}#{type}(#{ring_strs.join(', ')})"
45
+ end
46
+
47
+ # --- File I/O ---
48
+
49
+ def save!(path, *objects, srid: nil, precision: 6)
50
+ list = objects.length == 1 && objects[0].is_a?(Array) ? objects[0] : objects
51
+ File.open(path, "w") do |f|
52
+ list.each { |obj| f.puts obj.to_wkt(precision: precision, srid: srid) }
53
+ end
54
+ end
55
+
56
+ def load(path)
57
+ File.readlines(path, chomp: true)
58
+ .reject { |line| line.strip.empty? }
59
+ .map { |line| parse(line) }
60
+ end
61
+
62
+ # --- Import ---
63
+
64
+ def parse(wkt_string)
65
+ str = wkt_string.strip
66
+ # Strip SRID prefix if present
67
+ str = str.sub(/\ASRID=\d+;/i, "")
68
+ parse_geometry(str)
69
+ end
70
+
71
+ def parse_with_srid(wkt_string)
72
+ str = wkt_string.strip
73
+ srid = nil
74
+ if str =~ /\ASRID=(\d+);/i
75
+ srid = $1.to_i
76
+ str = $'
77
+ end
78
+ [parse_geometry(str), srid]
79
+ end
80
+
81
+ private
82
+
83
+ def parse_geometry(str)
84
+ str = str.strip
85
+ case str
86
+ when /\APOINT\s*Z\s*\((.+)\)\z/im
87
+ parse_point($1, has_z: true)
88
+ when /\APOINT\s*\((.+)\)\z/im
89
+ parse_point($1, has_z: false)
90
+ when /\ALINESTRING\s*Z\s*\((.+)\)\z/im
91
+ parse_line_string($1, has_z: true)
92
+ when /\ALINESTRING\s*\((.+)\)\z/im
93
+ parse_line_string($1, has_z: false)
94
+ when /\APOLYGON\s*Z\s*\((.+)\)\z/im
95
+ parse_polygon($1, has_z: true)
96
+ when /\APOLYGON\s*\((.+)\)\z/im
97
+ parse_polygon($1, has_z: false)
98
+ when /\AMULTIPOINT\s*(Z?)\s*\((.+)\)\z/im
99
+ parse_multi_point($2, has_z: !$1.empty?)
100
+ when /\AMULTILINESTRING\s*(Z?)\s*\((.+)\)\z/im
101
+ parse_multi_line_string($2, has_z: !$1.empty?)
102
+ when /\AMULTIPOLYGON\s*(Z?)\s*\((.+)\)\z/im
103
+ parse_multi_polygon($2, has_z: !$1.empty?)
104
+ when /\AGEOMETRYCOLLECTION\s*Z?\s*\((.+)\)\z/im
105
+ parse_geometry_collection($1)
106
+
107
+ else
108
+ raise ArgumentError, "unknown WKT type: #{str[0..30]}"
109
+ end
110
+ end
111
+
112
+ def parse_point(coords_str, has_z:)
113
+ parts = coords_str.strip.split(/\s+/)
114
+ lng = parts[0].to_f
115
+ lat = parts[1].to_f
116
+ alt = has_z && parts[2] ? parts[2].to_f : 0.0
117
+ Coordinate::LLA.new(lat: lat, lng: lng, alt: alt)
118
+ end
119
+
120
+ def parse_line_string(coords_str, has_z:)
121
+ points = coords_str.split(",").map { |c| parse_point(c, has_z: has_z) }
122
+ if points.length == 2
123
+ Segment.new(points[0], points[1])
124
+ else
125
+ Path.new(coordinates: points)
126
+ end
127
+ end
128
+
129
+ def parse_polygon(rings_str, has_z:)
130
+ rings = split_rings(rings_str)
131
+ outer = rings[0].split(",").map { |c| parse_point(c, has_z: has_z) }
132
+ # Remove closing point if it duplicates the first
133
+ outer.pop if outer.length > 1 && outer.first == outer.last
134
+ Areas::Polygon.new(boundary: outer)
135
+ end
136
+
137
+ def parse_multi_point(coords_str, has_z:)
138
+ # MULTIPOINT can be ((x y), (x y)) or (x y, x y)
139
+ if coords_str.include?("(")
140
+ coords_str.scan(/\(([^)]+)\)/).map { |m| parse_point(m[0], has_z: has_z) }
141
+ else
142
+ coords_str.split(",").map { |c| parse_point(c, has_z: has_z) }
143
+ end
144
+ end
145
+
146
+ def parse_multi_line_string(coords_str, has_z:)
147
+ split_rings(coords_str).map { |ring| parse_line_string(ring, has_z: has_z) }
148
+ end
149
+
150
+ def parse_multi_polygon(coords_str, has_z:)
151
+ polygons = split_top_level(coords_str)
152
+ polygons.map { |poly_str| parse_polygon(poly_str, has_z: has_z) }
153
+ end
154
+
155
+ def parse_geometry_collection(inner)
156
+ geometries = split_geometries(inner)
157
+ geometries.map { |g| parse_geometry(g) }
158
+ end
159
+
160
+ # Split "(a, b), (c, d)" into ["a, b", "c, d"]
161
+ def split_rings(str)
162
+ str.scan(/\(([^()]+)\)/).map(&:first)
163
+ end
164
+
165
+ # Split top-level parenthesized groups: "((a),(b)), ((c),(d))" → ["(a),(b)", "(c),(d)"]
166
+ def split_top_level(str)
167
+ results = []
168
+ depth = 0
169
+ current = +""
170
+ str.each_char do |ch|
171
+ case ch
172
+ when "("
173
+ depth += 1
174
+ if depth == 1
175
+ current = +""
176
+ else
177
+ current << ch
178
+ end
179
+ when ")"
180
+ depth -= 1
181
+ if depth == 0
182
+ results << current
183
+ else
184
+ current << ch
185
+ end
186
+ when ","
187
+ current << ch if depth > 0
188
+ when " "
189
+ current << ch if depth > 0
190
+ else
191
+ current << ch
192
+ end
193
+ end
194
+ results
195
+ end
196
+
197
+ # Split geometry collection: "POINT(1 2), LINESTRING(1 2, 3 4)"
198
+ # Must handle nested parens in each geometry
199
+ def split_geometries(str)
200
+ results = []
201
+ depth = 0
202
+ current = +""
203
+ str.each_char do |ch|
204
+ case ch
205
+ when "("
206
+ depth += 1
207
+ current << ch
208
+ when ")"
209
+ depth -= 1
210
+ current << ch
211
+ when ","
212
+ if depth == 0
213
+ results << current.strip
214
+ current = +""
215
+ else
216
+ current << ch
217
+ end
218
+ else
219
+ current << ch
220
+ end
221
+ end
222
+ results << current.strip unless current.strip.empty?
223
+ results
224
+ end
225
+ end
226
+
227
+ # ---------------------------------------------------------------
228
+ # Mixin for coordinate classes
229
+ # ---------------------------------------------------------------
230
+
231
+ module CoordinateMethods
232
+ def to_wkt(precision: 6, srid: nil)
233
+ if is_a?(Coordinate::ENU) || is_a?(Coordinate::NED)
234
+ raise ArgumentError,
235
+ "#{self.class.name.split('::').last} is a relative coordinate system " \
236
+ "and cannot be exported to WKT without a reference point. " \
237
+ "Convert to an absolute system (e.g., LLA) first."
238
+ end
239
+
240
+ lla = is_a?(Coordinate::LLA) ? self : to_lla
241
+ WKT.point_wkt(lla, precision: precision, srid: srid)
242
+ end
243
+ end
244
+ end
245
+ end
246
+
247
+ # ---------------------------------------------------------------
248
+ # Add to_wkt to non-coordinate geometry types
249
+ # ---------------------------------------------------------------
250
+
251
+ module Geodetic
252
+ class Segment
253
+ def to_wkt(precision: 6, srid: nil)
254
+ WKT.line_string_wkt([@start_point, @end_point], precision: precision, srid: srid)
255
+ end
256
+ end
257
+
258
+ class Path
259
+ def to_wkt(as: :line_string, precision: 6, srid: nil)
260
+ raise ArgumentError, "path is empty" if empty?
261
+
262
+ if as == :polygon
263
+ raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
264
+ ring = @coordinates.dup
265
+ ring << ring.first unless ring.first == ring.last
266
+ WKT.polygon_wkt([ring], precision: precision, srid: srid)
267
+ else
268
+ raise ArgumentError, "need at least 2 coordinates for a line string" if size < 2
269
+ WKT.line_string_wkt(@coordinates, precision: precision, srid: srid)
270
+ end
271
+ end
272
+ end
273
+
274
+ class Feature
275
+ def to_wkt(precision: 6, srid: nil)
276
+ @geometry.to_wkt(precision: precision, srid: srid)
277
+ end
278
+ end
279
+
280
+ module Areas
281
+ class Polygon
282
+ def to_wkt(precision: 6, srid: nil)
283
+ WKT.polygon_wkt([@boundary], precision: precision, srid: srid)
284
+ end
285
+ end
286
+
287
+ class Circle
288
+ def to_wkt(segments: 32, precision: 6, srid: nil)
289
+ step = 360.0 / segments
290
+ ring = segments.times.map do |i|
291
+ Vector.new(distance: @radius, bearing: step * i).destination_from(@centroid)
292
+ end
293
+ ring << ring.first
294
+ WKT.polygon_wkt([ring], precision: precision, srid: srid)
295
+ end
296
+ end
297
+
298
+ class BoundingBox
299
+ def to_wkt(precision: 6, srid: nil)
300
+ ring = [nw, ne, @se, sw, nw]
301
+ WKT.polygon_wkt([ring], precision: precision, srid: srid)
302
+ end
303
+ end
304
+ end
305
+ end
306
+
307
+ # ---------------------------------------------------------------
308
+ # Apply coordinate mixin to all registered coordinate classes
309
+ # ---------------------------------------------------------------
310
+
311
+ Geodetic::Coordinate.systems.each do |klass|
312
+ klass.include(Geodetic::WKT::CoordinateMethods)
313
+ end
data/lib/geodetic.rb CHANGED
@@ -16,3 +16,6 @@ require_relative "geodetic/segment"
16
16
  require_relative "geodetic/path"
17
17
  require_relative "geodetic/feature"
18
18
  require_relative "geodetic/geojson"
19
+ require_relative "geodetic/wkt"
20
+ require_relative "geodetic/wkb"
21
+ require_relative "geodetic/geos"
data/mkdocs.yml CHANGED
@@ -151,4 +151,6 @@ nav:
151
151
  - Vector: reference/vector.md
152
152
  - Arithmetic: reference/arithmetic.md
153
153
  - GeoJSON Export: reference/geojson.md
154
+ - WKT Serialization: reference/wkt.md
155
+ - WKB Serialization: reference/wkb.md
154
156
  - 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.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -57,11 +57,14 @@ files:
57
57
  - docs/reference/feature.md
58
58
  - docs/reference/geoid-height.md
59
59
  - docs/reference/geojson.md
60
+ - docs/reference/geos-acceleration.md
60
61
  - docs/reference/map-rendering.md
61
62
  - docs/reference/path.md
62
63
  - docs/reference/segment.md
63
64
  - docs/reference/serialization.md
64
65
  - docs/reference/vector.md
66
+ - docs/reference/wkb.md
67
+ - docs/reference/wkt.md
65
68
  - examples/01_basic_conversions.rb
66
69
  - examples/02_all_coordinate_systems.rb
67
70
  - examples/03_distance_calculations.rb
@@ -78,8 +81,17 @@ files:
78
81
  - examples/07_segments_and_shapes.rb
79
82
  - examples/08_geodetic_arithmetic.rb
80
83
  - examples/09_geojson_export.rb
84
+ - examples/10_wkt_serialization.rb
85
+ - examples/11_wkb_serialization.rb
86
+ - examples/12_geos_benchmark.rb
87
+ - examples/13_geos_operations.rb
81
88
  - examples/README.md
82
89
  - examples/geodetic_demo.geojson
90
+ - examples/geodetic_demo.wkb
91
+ - examples/geodetic_demo.wkt
92
+ - examples/geodetic_demo_output.wkb.hex
93
+ - examples/sample_geometries.wkb
94
+ - examples/sample_geometries.wkb.hex
83
95
  - fiddle_pointer_buffer_pool.md
84
96
  - lib/geodetic.rb
85
97
  - lib/geodetic/areas.rb
@@ -118,10 +130,13 @@ files:
118
130
  - lib/geodetic/feature.rb
119
131
  - lib/geodetic/geoid_height.rb
120
132
  - lib/geodetic/geojson.rb
133
+ - lib/geodetic/geos.rb
121
134
  - lib/geodetic/path.rb
122
135
  - lib/geodetic/segment.rb
123
136
  - lib/geodetic/vector.rb
124
137
  - lib/geodetic/version.rb
138
+ - lib/geodetic/wkb.rb
139
+ - lib/geodetic/wkt.rb
125
140
  - mkdocs.yml
126
141
  - sig/geodetic.rbs
127
142
  - spatial_hash_idea.md