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.
data/lib/geodetic/path.rb CHANGED
@@ -187,15 +187,11 @@ module Geodetic
187
187
  def intersects?(other_path)
188
188
  raise ArgumentError, "expected a Path" unless other_path.is_a?(Path)
189
189
 
190
- return false unless bounds_overlap?(bounds, other_path.bounds)
191
-
192
- segments.each do |seg1|
193
- other_path.segments.each do |seg2|
194
- return true if seg1.intersects?(seg2)
195
- end
190
+ if Geos.available?
191
+ geos_intersects?(other_path)
192
+ else
193
+ ruby_intersects?(other_path)
196
194
  end
197
-
198
- false
199
195
  end
200
196
 
201
197
  def total_distance
@@ -524,5 +520,21 @@ module Geodetic
524
520
  Math.cos(r1) + Math.cos(r2)
525
521
  ) * DEG_PER_RAD
526
522
  end
523
+
524
+ def geos_intersects?(other_path)
525
+ Geos.intersects?(self, other_path)
526
+ end
527
+
528
+ def ruby_intersects?(other_path)
529
+ return false unless bounds_overlap?(bounds, other_path.bounds)
530
+
531
+ segments.each do |seg1|
532
+ other_path.segments.each do |seg2|
533
+ return true if seg1.intersects?(seg2)
534
+ end
535
+ end
536
+
537
+ false
538
+ end
527
539
  end
528
540
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Geodetic
4
- VERSION = "0.5.2"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Geodetic
4
+ module WKB
5
+ # Type codes
6
+ TYPE_POINT = 1
7
+ TYPE_LINE_STRING = 2
8
+ TYPE_POLYGON = 3
9
+ TYPE_MULTI_POINT = 4
10
+ TYPE_MULTI_LINE_STRING = 5
11
+ TYPE_MULTI_POLYGON = 6
12
+ TYPE_GEOMETRY_COLLECTION = 7
13
+
14
+ # Z offset (ISO WKB)
15
+ ISO_Z_OFFSET = 1000
16
+
17
+ # EWKB flags
18
+ EWKB_SRID_FLAG = 0x20000000
19
+ EWKB_Z_FLAG = 0x80000000
20
+
21
+ # Output is always little-endian (NDR). This matches PostGIS, GEOS, RGeo,
22
+ # Shapely, and virtually all modern GIS tools. Big-endian (XDR) input is
23
+ # fully supported by the parser.
24
+ BYTE_ORDER_LE = 0x01
25
+
26
+ class << self
27
+ # --- Export helpers (private, used by to_wkb on each type) ---
28
+
29
+ def encode_point(lla, srid: nil)
30
+ has_z = lla.alt != 0.0
31
+ pack_header(TYPE_POINT, srid: srid, has_z: has_z) +
32
+ pack_coords(lla, has_z: has_z)
33
+ end
34
+
35
+ def encode_line_string(points, srid: nil)
36
+ has_z = has_altitude?(points)
37
+ pack_header(TYPE_LINE_STRING, srid: srid, has_z: has_z) +
38
+ [points.length].pack("V") +
39
+ points.map { |p| pack_coords(p, has_z: has_z) }.join
40
+ end
41
+
42
+ def encode_polygon(rings, srid: nil)
43
+ has_z = rings.any? { |ring| has_altitude?(ring) }
44
+ pack_header(TYPE_POLYGON, srid: srid, has_z: has_z) +
45
+ [rings.length].pack("V") +
46
+ rings.map { |ring|
47
+ [ring.length].pack("V") +
48
+ ring.map { |p| pack_coords(p, has_z: has_z) }.join
49
+ }.join
50
+ end
51
+
52
+ # --- File I/O ---
53
+
54
+ def save!(path, *objects, srid: nil)
55
+ list = objects.length == 1 && objects[0].is_a?(Array) ? objects[0] : objects
56
+ File.open(path, "wb") do |f|
57
+ f.write([list.length].pack("V"))
58
+ list.each do |obj|
59
+ bytes = obj.to_wkb(srid: srid)
60
+ f.write([bytes.bytesize].pack("V"))
61
+ f.write(bytes)
62
+ end
63
+ end
64
+ end
65
+
66
+ def load(path)
67
+ data = File.binread(path)
68
+ count = data[0, 4].unpack1("V")
69
+ pos = 4
70
+ count.times.map do
71
+ size = data[pos, 4].unpack1("V")
72
+ pos += 4
73
+ geom_bytes = data[pos, size]
74
+ pos += size
75
+ parse(geom_bytes)
76
+ end
77
+ end
78
+
79
+ def save_hex!(path, *objects, srid: nil)
80
+ list = objects.length == 1 && objects[0].is_a?(Array) ? objects[0] : objects
81
+ File.open(path, "w") do |f|
82
+ list.each { |obj| f.puts obj.to_wkb_hex(srid: srid) }
83
+ end
84
+ end
85
+
86
+ def load_hex(path)
87
+ File.readlines(path, chomp: true)
88
+ .reject { |line| line.strip.empty? || line.strip.start_with?("#") }
89
+ .map { |line| parse(line.strip) }
90
+ end
91
+
92
+ # --- Import ---
93
+
94
+ def parse(input)
95
+ reader = Reader.new(to_binary(input))
96
+ reader.read_geometry
97
+ end
98
+
99
+ def parse_with_srid(input)
100
+ reader = Reader.new(to_binary(input))
101
+ geom = reader.read_geometry
102
+ [geom, reader.srid]
103
+ end
104
+
105
+ private
106
+
107
+ def has_altitude?(points)
108
+ points.any? { |p| p.alt != 0.0 }
109
+ end
110
+
111
+ def pack_header(type, srid: nil, has_z: false)
112
+ type_int = has_z ? type + ISO_Z_OFFSET : type
113
+ type_int |= EWKB_SRID_FLAG if srid
114
+ result = [BYTE_ORDER_LE].pack("C") + [type_int].pack("V")
115
+ result += [srid].pack("V") if srid
116
+ result
117
+ end
118
+
119
+ def pack_coords(lla, has_z:)
120
+ if has_z
121
+ [lla.lng, lla.lat, lla.alt].pack("E3")
122
+ else
123
+ [lla.lng, lla.lat].pack("E2")
124
+ end
125
+ end
126
+
127
+ def to_binary(input)
128
+ if input.encoding == Encoding::ASCII_8BIT
129
+ # Could be binary or hex that happens to be ASCII-8BIT
130
+ if input.bytes.any? { |b| b > 127 } || input.bytesize < 5
131
+ return input
132
+ end
133
+ # Check if it looks like hex
134
+ if input.match?(/\A[0-9a-fA-F]+\z/)
135
+ return [input].pack("H*")
136
+ end
137
+ input
138
+ else
139
+ # String encoding — treat as hex
140
+ stripped = input.strip
141
+ if stripped.match?(/\A[0-9a-fA-F]+\z/) && stripped.length.even?
142
+ [stripped].pack("H*")
143
+ else
144
+ stripped.b
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ # ---------------------------------------------------------------
151
+ # Reader for parsing WKB binary data
152
+ # ---------------------------------------------------------------
153
+
154
+ class Reader
155
+ attr_reader :srid
156
+
157
+ def initialize(data)
158
+ @data = data.b
159
+ @pos = 0
160
+ @srid = nil
161
+ end
162
+
163
+ def read_geometry
164
+ byte_order = read_bytes(1).ord
165
+ @le = byte_order == 0x01
166
+
167
+ type_int = read_uint32
168
+ has_srid = (type_int & EWKB_SRID_FLAG) != 0
169
+ has_z_ewkb = (type_int & EWKB_Z_FLAG) != 0
170
+
171
+ type_int &= ~EWKB_SRID_FLAG & ~EWKB_Z_FLAG
172
+
173
+ @srid = read_uint32 if has_srid
174
+
175
+ has_z = has_z_ewkb
176
+ if type_int >= ISO_Z_OFFSET && type_int < 2000
177
+ has_z = true
178
+ type_int -= ISO_Z_OFFSET
179
+ end
180
+
181
+ case type_int
182
+ when TYPE_POINT then read_point(has_z)
183
+ when TYPE_LINE_STRING then read_line_string(has_z)
184
+ when TYPE_POLYGON then read_polygon(has_z)
185
+ when TYPE_MULTI_POINT then read_multi
186
+ when TYPE_MULTI_LINE_STRING then read_multi
187
+ when TYPE_MULTI_POLYGON then read_multi
188
+ when TYPE_GEOMETRY_COLLECTION then read_multi
189
+ else
190
+ raise ArgumentError, "unknown WKB type code: #{type_int}"
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def read_bytes(n)
197
+ result = @data[@pos, n]
198
+ @pos += n
199
+ result
200
+ end
201
+
202
+ def read_uint32
203
+ bytes = read_bytes(4)
204
+ bytes.unpack1(@le ? "V" : "N")
205
+ end
206
+
207
+ def read_double
208
+ bytes = read_bytes(8)
209
+ bytes.unpack1(@le ? "E" : "G")
210
+ end
211
+
212
+ def read_point(has_z)
213
+ lng = read_double
214
+ lat = read_double
215
+ alt = has_z ? read_double : 0.0
216
+ Coordinate::LLA.new(lat: lat, lng: lng, alt: alt)
217
+ end
218
+
219
+ def read_line_string(has_z)
220
+ count = read_uint32
221
+ points = count.times.map { read_point(has_z) }
222
+ if points.length == 2
223
+ Segment.new(points[0], points[1])
224
+ else
225
+ Path.new(coordinates: points)
226
+ end
227
+ end
228
+
229
+ def read_polygon(has_z)
230
+ ring_count = read_uint32
231
+ rings = ring_count.times.map do
232
+ point_count = read_uint32
233
+ point_count.times.map { read_point(has_z) }
234
+ end
235
+ outer = rings[0]
236
+ outer.pop if outer.length > 1 && outer.first == outer.last
237
+ Areas::Polygon.new(boundary: outer)
238
+ end
239
+
240
+ def read_multi
241
+ count = read_uint32
242
+ count.times.map { read_geometry }
243
+ end
244
+ end
245
+
246
+ # ---------------------------------------------------------------
247
+ # Mixin for coordinate classes
248
+ # ---------------------------------------------------------------
249
+
250
+ module CoordinateMethods
251
+ def to_wkb(srid: nil)
252
+ if is_a?(Coordinate::ENU) || is_a?(Coordinate::NED)
253
+ raise ArgumentError,
254
+ "#{self.class.name.split('::').last} is a relative coordinate system " \
255
+ "and cannot be exported to WKB without a reference point. " \
256
+ "Convert to an absolute system (e.g., LLA) first."
257
+ end
258
+
259
+ lla = is_a?(Coordinate::LLA) ? self : to_lla
260
+ WKB.encode_point(lla, srid: srid)
261
+ end
262
+
263
+ def to_wkb_hex(srid: nil)
264
+ to_wkb(srid: srid).unpack1("H*")
265
+ end
266
+ end
267
+ end
268
+ end
269
+
270
+ # ---------------------------------------------------------------
271
+ # Add to_wkb / to_wkb_hex to non-coordinate geometry types
272
+ # ---------------------------------------------------------------
273
+
274
+ module Geodetic
275
+ class Segment
276
+ def to_wkb(srid: nil)
277
+ WKB.encode_line_string([@start_point, @end_point], srid: srid)
278
+ end
279
+
280
+ def to_wkb_hex(srid: nil)
281
+ to_wkb(srid: srid).unpack1("H*")
282
+ end
283
+ end
284
+
285
+ class Path
286
+ def to_wkb(as: :line_string, srid: nil)
287
+ raise ArgumentError, "path is empty" if empty?
288
+
289
+ if as == :polygon
290
+ raise ArgumentError, "need at least 3 coordinates for a polygon" if size < 3
291
+ ring = @coordinates.dup
292
+ ring << ring.first unless ring.first == ring.last
293
+ WKB.encode_polygon([ring], srid: srid)
294
+ else
295
+ raise ArgumentError, "need at least 2 coordinates for a line string" if size < 2
296
+ WKB.encode_line_string(@coordinates, srid: srid)
297
+ end
298
+ end
299
+
300
+ def to_wkb_hex(as: :line_string, srid: nil)
301
+ to_wkb(as: as, srid: srid).unpack1("H*")
302
+ end
303
+ end
304
+
305
+ class Feature
306
+ def to_wkb(srid: nil)
307
+ @geometry.to_wkb(srid: srid)
308
+ end
309
+
310
+ def to_wkb_hex(srid: nil)
311
+ to_wkb(srid: srid).unpack1("H*")
312
+ end
313
+ end
314
+
315
+ module Areas
316
+ class Polygon
317
+ def to_wkb(srid: nil)
318
+ WKB.encode_polygon([@boundary], srid: srid)
319
+ end
320
+
321
+ def to_wkb_hex(srid: nil)
322
+ to_wkb(srid: srid).unpack1("H*")
323
+ end
324
+ end
325
+
326
+ class Circle
327
+ def to_wkb(segments: 32, srid: nil)
328
+ step = 360.0 / segments
329
+ ring = segments.times.map do |i|
330
+ Vector.new(distance: @radius, bearing: step * i).destination_from(@centroid)
331
+ end
332
+ ring << ring.first
333
+ WKB.encode_polygon([ring], srid: srid)
334
+ end
335
+
336
+ def to_wkb_hex(segments: 32, srid: nil)
337
+ to_wkb(segments: segments, srid: srid).unpack1("H*")
338
+ end
339
+ end
340
+
341
+ class BoundingBox
342
+ def to_wkb(srid: nil)
343
+ ring = [nw, ne, @se, sw, nw]
344
+ WKB.encode_polygon([ring], srid: srid)
345
+ end
346
+
347
+ def to_wkb_hex(srid: nil)
348
+ to_wkb(srid: srid).unpack1("H*")
349
+ end
350
+ end
351
+ end
352
+ end
353
+
354
+ # ---------------------------------------------------------------
355
+ # Apply coordinate mixin to all registered coordinate classes
356
+ # ---------------------------------------------------------------
357
+
358
+ Geodetic::Coordinate.systems.each do |klass|
359
+ klass.include(Geodetic::WKB::CoordinateMethods)
360
+ end