geodetic 0.2.0 → 0.3.1

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +83 -28
  4. data/docs/coordinate-systems/gars.md +0 -4
  5. data/docs/coordinate-systems/georef.md +0 -4
  6. data/docs/coordinate-systems/gh.md +0 -4
  7. data/docs/coordinate-systems/gh36.md +0 -4
  8. data/docs/coordinate-systems/h3.md +308 -0
  9. data/docs/coordinate-systems/ham.md +0 -4
  10. data/docs/coordinate-systems/index.md +25 -23
  11. data/docs/coordinate-systems/olc.md +0 -4
  12. data/docs/index.md +7 -3
  13. data/docs/reference/conversions.md +15 -15
  14. data/docs/reference/feature.md +116 -0
  15. data/docs/reference/map-rendering.md +32 -0
  16. data/docs/reference/serialization.md +4 -4
  17. data/examples/02_all_coordinate_systems.rb +0 -3
  18. data/examples/03_distance_calculations.rb +1 -0
  19. data/examples/04_bearing_calculations.rb +1 -0
  20. data/examples/05_map_rendering/.gitignore +2 -0
  21. data/examples/05_map_rendering/demo.rb +264 -0
  22. data/examples/05_map_rendering/icons/bridge.png +0 -0
  23. data/examples/05_map_rendering/icons/building.png +0 -0
  24. data/examples/05_map_rendering/icons/landmark.png +0 -0
  25. data/examples/05_map_rendering/icons/monument.png +0 -0
  26. data/examples/05_map_rendering/icons/park.png +0 -0
  27. data/examples/05_map_rendering/nyc_landmarks.png +0 -0
  28. data/examples/README.md +62 -0
  29. data/fiddle_pointer_buffer_pool.md +119 -0
  30. data/lib/geodetic/coordinate/bng.rb +14 -33
  31. data/lib/geodetic/coordinate/ecef.rb +5 -1
  32. data/lib/geodetic/coordinate/enu.rb +13 -0
  33. data/lib/geodetic/coordinate/gars.rb +2 -3
  34. data/lib/geodetic/coordinate/georef.rb +2 -3
  35. data/lib/geodetic/coordinate/gh.rb +2 -4
  36. data/lib/geodetic/coordinate/gh36.rb +4 -5
  37. data/lib/geodetic/coordinate/h3.rb +412 -0
  38. data/lib/geodetic/coordinate/ham.rb +2 -3
  39. data/lib/geodetic/coordinate/lla.rb +15 -1
  40. data/lib/geodetic/coordinate/mgrs.rb +1 -1
  41. data/lib/geodetic/coordinate/ned.rb +13 -0
  42. data/lib/geodetic/coordinate/olc.rb +0 -1
  43. data/lib/geodetic/coordinate/spatial_hash.rb +2 -2
  44. data/lib/geodetic/coordinate/state_plane.rb +9 -0
  45. data/lib/geodetic/coordinate/ups.rb +1 -1
  46. data/lib/geodetic/coordinate/usng.rb +1 -1
  47. data/lib/geodetic/coordinate/utm.rb +1 -1
  48. data/lib/geodetic/coordinate/web_mercator.rb +1 -1
  49. data/lib/geodetic/coordinate.rb +31 -26
  50. data/lib/geodetic/feature.rb +44 -0
  51. data/lib/geodetic/geoid_height.rb +11 -6
  52. data/lib/geodetic/version.rb +1 -1
  53. data/lib/geodetic.rb +1 -0
  54. data/mkdocs.yml +2 -0
  55. metadata +20 -5
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ # H3 (Uber's Hexagonal Hierarchical Spatial Index) Coordinate
4
+ #
5
+ # A hierarchical geospatial index that divides the globe into hexagonal cells
6
+ # (and 12 pentagons) at 16 resolution levels (0-15). Each cell is identified
7
+ # by a 64-bit integer (H3Index), typically displayed as a 15-character hex string.
8
+ #
9
+ # REQUIRES: libh3 shared library (brew install h3)
10
+ # Uses Ruby's fiddle (stdlib) to call H3 v4 C API — no gem dependency.
11
+ #
12
+ # Key differences from other spatial hashes:
13
+ # - Cells are hexagons (6 vertices), not rectangles
14
+ # - to_area returns Areas::Polygon, not Areas::Rectangle
15
+ # - neighbors returns an Array (6 cells), not a directional Hash
16
+ # - "precision" maps to H3 resolution (0-15), not string length
17
+ #
18
+ # Usage:
19
+ # H3.new("872a1072bffffff") # from hex string
20
+ # H3.new(0x872a1072bffffff) # from integer
21
+ # H3.new(lla_coord) # from any coordinate
22
+ # H3.new(lla_coord, precision: 9) # resolution 9
23
+
24
+ require_relative 'spatial_hash'
25
+
26
+ module Geodetic
27
+ module Coordinate
28
+ class H3 < SpatialHash
29
+ # --- FFI bindings to libh3 via fiddle ---
30
+
31
+ module LibH3
32
+ require 'fiddle'
33
+
34
+ SEARCH_PATHS = [
35
+ ENV['LIBH3_PATH'],
36
+ '/opt/homebrew/lib/libh3.dylib',
37
+ '/opt/homebrew/lib/libh3.1.dylib',
38
+ '/usr/local/lib/libh3.dylib',
39
+ '/usr/local/lib/libh3.1.dylib',
40
+ '/usr/lib/libh3.so',
41
+ '/usr/lib/libh3.so.1',
42
+ '/usr/local/lib/libh3.so',
43
+ '/usr/local/lib/libh3.so.1',
44
+ '/usr/lib/x86_64-linux-gnu/libh3.so',
45
+ '/usr/lib/aarch64-linux-gnu/libh3.so',
46
+ ].compact.freeze
47
+
48
+ @handle = nil
49
+ @available = false
50
+
51
+ class << self
52
+ attr_reader :handle
53
+
54
+ def available?
55
+ @available
56
+ end
57
+
58
+ def require_library!
59
+ return if @available
60
+ raise Geodetic::Error,
61
+ "libh3 not found. Install H3: brew install h3 (macOS) " \
62
+ "or see https://h3geo.org/docs/installation. " \
63
+ "Set LIBH3_PATH env var to specify a custom library path."
64
+ end
65
+ end
66
+
67
+ begin
68
+ path = SEARCH_PATHS.find { |p| File.exist?(p) }
69
+ if path
70
+ @handle = Fiddle.dlopen(path)
71
+ @available = true
72
+ end
73
+ rescue Fiddle::DLError
74
+ @available = false
75
+ end
76
+
77
+ # Struct sizes
78
+ SIZEOF_LATLNG = 2 * Fiddle::SIZEOF_DOUBLE # 16 bytes
79
+ SIZEOF_CELL_BOUNDARY = 8 + 10 * SIZEOF_LATLNG # 168 bytes (int + pad + 10 LatLngs)
80
+ SIZEOF_H3INDEX = Fiddle::SIZEOF_LONG_LONG # 8 bytes
81
+ SIZEOF_INT64 = Fiddle::SIZEOF_LONG_LONG # 8 bytes
82
+
83
+ # Function bindings (lazy-loaded)
84
+ def self.bind(name, args, ret)
85
+ return nil unless @available
86
+ ptr = @handle[name]
87
+ Fiddle::Function.new(ptr, args, ret)
88
+ rescue Fiddle::DLError
89
+ nil
90
+ end
91
+
92
+ # H3Error latLngToCell(const LatLng *g, int res, H3Index *out)
93
+ F_LAT_LNG_TO_CELL = bind('latLngToCell',
94
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
95
+ Fiddle::TYPE_INT)
96
+
97
+ # H3Error cellToLatLng(H3Index h3, LatLng *g)
98
+ F_CELL_TO_LAT_LNG = bind('cellToLatLng',
99
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
100
+ Fiddle::TYPE_INT)
101
+
102
+ # H3Error cellToBoundary(H3Index h3, CellBoundary *gp)
103
+ F_CELL_TO_BOUNDARY = bind('cellToBoundary',
104
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
105
+ Fiddle::TYPE_INT)
106
+
107
+ # int getResolution(H3Index h)
108
+ F_GET_RESOLUTION = bind('getResolution',
109
+ [Fiddle::TYPE_LONG_LONG],
110
+ Fiddle::TYPE_INT)
111
+
112
+ # int isValidCell(H3Index h)
113
+ F_IS_VALID_CELL = bind('isValidCell',
114
+ [Fiddle::TYPE_LONG_LONG],
115
+ Fiddle::TYPE_INT)
116
+
117
+ # int isPentagon(H3Index h)
118
+ F_IS_PENTAGON = bind('isPentagon',
119
+ [Fiddle::TYPE_LONG_LONG],
120
+ Fiddle::TYPE_INT)
121
+
122
+ # H3Error gridDisk(H3Index origin, int k, H3Index *out)
123
+ F_GRID_DISK = bind('gridDisk',
124
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
125
+ Fiddle::TYPE_INT)
126
+
127
+ # H3Error maxGridDiskSize(int k, int64_t *out)
128
+ F_MAX_GRID_DISK_SIZE = bind('maxGridDiskSize',
129
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
130
+ Fiddle::TYPE_INT)
131
+
132
+ # H3Error cellAreaM2(H3Index h, double *out)
133
+ F_CELL_AREA_M2 = bind('cellAreaM2',
134
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
135
+ Fiddle::TYPE_INT)
136
+
137
+ # H3Error cellToParent(H3Index h, int parentRes, H3Index *out)
138
+ F_CELL_TO_PARENT = bind('cellToParent',
139
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
140
+ Fiddle::TYPE_INT)
141
+
142
+ # H3Error cellToChildrenSize(H3Index h, int childRes, int64_t *out)
143
+ F_CELL_TO_CHILDREN_SIZE = bind('cellToChildrenSize',
144
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
145
+ Fiddle::TYPE_INT)
146
+
147
+ # H3Error cellToChildren(H3Index h, int childRes, H3Index *out)
148
+ F_CELL_TO_CHILDREN = bind('cellToChildren',
149
+ [Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
150
+ Fiddle::TYPE_INT)
151
+
152
+ # --- High-level wrappers ---
153
+
154
+ def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
155
+ require_library!
156
+ latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
157
+ latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
158
+ out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
159
+ err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
160
+ raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
161
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
162
+ end
163
+
164
+ def self.cell_to_lat_lng(h3_index)
165
+ require_library!
166
+ latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
167
+ err = F_CELL_TO_LAT_LNG.call(h3_index, latlng)
168
+ raise ArgumentError, "H3 cellToLatLng error (code #{err})" unless err == 0
169
+ lat_rad, lng_rad = latlng[0, SIZEOF_LATLNG].unpack('d2')
170
+ { lat: lat_rad * DEG_PER_RAD, lng: lng_rad * DEG_PER_RAD }
171
+ end
172
+
173
+ def self.cell_to_boundary(h3_index)
174
+ require_library!
175
+ cb = Fiddle::Pointer.malloc(SIZEOF_CELL_BOUNDARY, Fiddle::RUBY_FREE)
176
+ err = F_CELL_TO_BOUNDARY.call(h3_index, cb)
177
+ raise ArgumentError, "H3 cellToBoundary error (code #{err})" unless err == 0
178
+ num_verts = cb[0, 4].unpack1('i')
179
+ # Vertices start at offset 8 (4 byte int + 4 byte padding)
180
+ verts = cb[8, num_verts * SIZEOF_LATLNG].unpack("d#{num_verts * 2}")
181
+ (0...num_verts).map do |i|
182
+ { lat: verts[i * 2] * DEG_PER_RAD, lng: verts[i * 2 + 1] * DEG_PER_RAD }
183
+ end
184
+ end
185
+
186
+ def self.get_resolution(h3_index)
187
+ require_library!
188
+ F_GET_RESOLUTION.call(h3_index)
189
+ end
190
+
191
+ def self.is_valid_cell(h3_index)
192
+ require_library!
193
+ F_IS_VALID_CELL.call(h3_index) == 1
194
+ end
195
+
196
+ def self.is_pentagon(h3_index)
197
+ require_library!
198
+ F_IS_PENTAGON.call(h3_index) == 1
199
+ end
200
+
201
+ def self.grid_disk(h3_index, k)
202
+ require_library!
203
+ size_ptr = Fiddle::Pointer.malloc(SIZEOF_INT64, Fiddle::RUBY_FREE)
204
+ err = F_MAX_GRID_DISK_SIZE.call(k, size_ptr)
205
+ raise ArgumentError, "H3 maxGridDiskSize error (code #{err})" unless err == 0
206
+ max_size = size_ptr[0, SIZEOF_INT64].unpack1('q')
207
+
208
+ out = Fiddle::Pointer.malloc(max_size * SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
209
+ err = F_GRID_DISK.call(h3_index, k, out)
210
+ raise ArgumentError, "H3 gridDisk error (code #{err})" unless err == 0
211
+
212
+ out[0, max_size * SIZEOF_H3INDEX].unpack("Q#{max_size}").reject(&:zero?)
213
+ end
214
+
215
+ def self.cell_area_m2(h3_index)
216
+ require_library!
217
+ out = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
218
+ err = F_CELL_AREA_M2.call(h3_index, out)
219
+ raise ArgumentError, "H3 cellAreaM2 error (code #{err})" unless err == 0
220
+ out[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
221
+ end
222
+
223
+ def self.cell_to_parent(h3_index, parent_res)
224
+ require_library!
225
+ out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
226
+ err = F_CELL_TO_PARENT.call(h3_index, parent_res, out)
227
+ raise ArgumentError, "H3 cellToParent error (code #{err})" unless err == 0
228
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
229
+ end
230
+
231
+ def self.cell_to_children(h3_index, child_res)
232
+ require_library!
233
+ size_ptr = Fiddle::Pointer.malloc(SIZEOF_INT64, Fiddle::RUBY_FREE)
234
+ err = F_CELL_TO_CHILDREN_SIZE.call(h3_index, child_res, size_ptr)
235
+ raise ArgumentError, "H3 cellToChildrenSize error (code #{err})" unless err == 0
236
+ count = size_ptr[0, SIZEOF_INT64].unpack1('q')
237
+
238
+ out = Fiddle::Pointer.malloc(count * SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
239
+ err = F_CELL_TO_CHILDREN.call(h3_index, child_res, out)
240
+ raise ArgumentError, "H3 cellToChildren error (code #{err})" unless err == 0
241
+
242
+ out[0, count * SIZEOF_H3INDEX].unpack("Q#{count}").reject(&:zero?)
243
+ end
244
+ end
245
+
246
+ # --- Class configuration ---
247
+
248
+ attr_reader :code
249
+
250
+ def self.default_precision = 7
251
+ def self.hash_system_name = :h3
252
+
253
+ def self.available?
254
+ LibH3.available?
255
+ end
256
+
257
+ # --- Constructor ---
258
+
259
+ def initialize(source, precision: self.class.default_precision)
260
+ case source
261
+ when Integer
262
+ hex = format('%x', source)
263
+ validate_code!(hex)
264
+ set_code(hex)
265
+ when String
266
+ normalized = normalize(source.strip)
267
+ validate_code!(normalized)
268
+ set_code(normalized)
269
+ when LLA
270
+ set_code(encode(source.lat, source.lng, precision))
271
+ else
272
+ if source.respond_to?(:to_lla)
273
+ lla = source.to_lla
274
+ set_code(encode(lla.lat, lla.lng, precision))
275
+ else
276
+ raise ArgumentError,
277
+ "Expected an H3 hex String, Integer, or coordinate object, got #{source.class}"
278
+ end
279
+ end
280
+ end
281
+
282
+ # --- H3-specific methods ---
283
+
284
+ # The 64-bit H3 cell index
285
+ def h3_index
286
+ @h3_index ||= code.to_i(16)
287
+ end
288
+
289
+ # H3 resolution (0-15)
290
+ def precision
291
+ @resolution ||= LibH3.get_resolution(h3_index)
292
+ end
293
+ alias_method :resolution, :precision
294
+
295
+ # Is this cell a pentagon? (12 per resolution level)
296
+ def pentagon?
297
+ LibH3.is_pentagon(h3_index)
298
+ end
299
+
300
+ # Parent cell at a coarser resolution
301
+ def parent(parent_resolution)
302
+ raise ArgumentError, "Parent resolution must be < #{resolution}" unless parent_resolution < resolution
303
+ parent_idx = LibH3.cell_to_parent(h3_index, parent_resolution)
304
+ self.class.new(parent_idx)
305
+ end
306
+
307
+ # Child cells at a finer resolution
308
+ def children(child_resolution)
309
+ raise ArgumentError, "Child resolution must be > #{resolution}" unless child_resolution > resolution
310
+ child_indices = LibH3.cell_to_children(h3_index, child_resolution)
311
+ child_indices.map { |idx| self.class.new(idx) }
312
+ end
313
+
314
+ # All cells within k steps (k=0 returns self, k=1 returns 7 cells, etc.)
315
+ def grid_disk(k = 1)
316
+ indices = LibH3.grid_disk(h3_index, k)
317
+ indices.map { |idx| self.class.new(idx) }
318
+ end
319
+
320
+ # Cell area in square meters
321
+ def cell_area
322
+ LibH3.cell_area_m2(h3_index)
323
+ end
324
+
325
+ # --- Override SpatialHash methods ---
326
+
327
+ def valid?
328
+ LibH3.is_valid_cell(h3_index)
329
+ end
330
+
331
+ # Returns all adjacent cells as an Array of H3 instances.
332
+ # Hexagons have 6 neighbors; pentagons have 5.
333
+ # Note: returns Array, not directional Hash (hexagons have no cardinal directions).
334
+ def neighbors
335
+ indices = LibH3.grid_disk(h3_index, 1)
336
+ indices.reject { |idx| idx == h3_index }.map { |idx| self.class.new(idx) }
337
+ end
338
+
339
+ # Returns the hexagonal cell boundary as an Areas::Polygon.
340
+ def to_area
341
+ verts = LibH3.cell_to_boundary(h3_index)
342
+ boundary = verts.map { |v| LLA.new(lat: v[:lat], lng: v[:lng], alt: 0.0) }
343
+ Areas::Polygon.new(boundary: boundary)
344
+ end
345
+
346
+ # Returns approximate precision in meters as { lat:, lng:, area_m2: }
347
+ def precision_in_meters
348
+ area = cell_area
349
+ edge = Math.sqrt(area)
350
+ { lat: edge, lng: edge, area_m2: area }
351
+ end
352
+
353
+ def to_s(format = nil)
354
+ if format == :integer
355
+ h3_index
356
+ else
357
+ code
358
+ end
359
+ end
360
+
361
+ protected
362
+
363
+ def code_value
364
+ @code
365
+ end
366
+
367
+ def normalize(string)
368
+ string.downcase.delete_prefix('0x')
369
+ end
370
+
371
+ def set_code(value)
372
+ @code = value
373
+ @h3_index = nil
374
+ @resolution = nil
375
+ end
376
+
377
+ private
378
+
379
+ def encode(lat, lng, resolution = self.class.default_precision)
380
+ resolution = resolution.clamp(0, 15)
381
+ lat_rad = lat * RAD_PER_DEG
382
+ lng_rad = lng * RAD_PER_DEG
383
+ idx = LibH3.lat_lng_to_cell(lat_rad, lng_rad, resolution)
384
+ format('%x', idx)
385
+ end
386
+
387
+ def decode(code_string)
388
+ idx = code_string.to_i(16)
389
+ LibH3.cell_to_lat_lng(idx)
390
+ end
391
+
392
+ def decode_bounds(code_string)
393
+ idx = code_string.to_i(16)
394
+ verts = LibH3.cell_to_boundary(idx)
395
+ lats = verts.map { |v| v[:lat] }
396
+ lngs = verts.map { |v| v[:lng] }
397
+ { min_lat: lats.min, max_lat: lats.max, min_lng: lngs.min, max_lng: lngs.max }
398
+ end
399
+
400
+ def validate_h3!(code_string)
401
+ raise ArgumentError, "H3 code cannot be empty" if code_string.empty?
402
+ raise ArgumentError, "Invalid H3 hex string: #{code_string}" unless code_string.match?(/\A[0-9a-f]+\z/)
403
+ idx = code_string.to_i(16)
404
+ raise ArgumentError, "Invalid H3 cell index: #{code_string}" unless LibH3.is_valid_cell(idx)
405
+ end
406
+
407
+ alias_method :validate_code!, :validate_h3!
408
+
409
+ register_hash_system(:h3, self, default_precision: 7)
410
+ end
411
+ end
412
+ end
@@ -74,12 +74,12 @@ module Geodetic
74
74
  valid_characters?(@locator)
75
75
  end
76
76
 
77
+ protected
78
+
77
79
  def code_value
78
80
  @locator
79
81
  end
80
82
 
81
- protected
82
-
83
83
  def normalize(string)
84
84
  result = String.new(capacity: string.length)
85
85
  string.each_char.with_index do |ch, i|
@@ -220,7 +220,6 @@ module Geodetic
220
220
  end
221
221
 
222
222
  register_hash_system(:ham, self, default_precision: 6)
223
- Coordinate.register_class(self)
224
223
  end
225
224
  end
226
225
  end
@@ -270,6 +270,14 @@ module Geodetic
270
270
  gars_coord.to_lla(datum)
271
271
  end
272
272
 
273
+ def to_h3(precision: 7)
274
+ H3.new(self, precision: precision)
275
+ end
276
+
277
+ def self.from_h3(h3_coord, datum = WGS84)
278
+ h3_coord.to_lla(datum)
279
+ end
280
+
273
281
  def self.from_lla(lla_coord, datum = WGS84)
274
282
  lla_coord
275
283
  end
@@ -297,6 +305,12 @@ module Geodetic
297
305
  new(lat: parts[0].to_f, lng: parts[1].to_f, alt: parts[2].to_f)
298
306
  end
299
307
 
308
+ def valid?
309
+ @lat.finite? && @lng.finite? && @alt.finite? &&
310
+ @lat >= -90 && @lat <= 90 &&
311
+ @lng >= -180 && @lng <= 180
312
+ end
313
+
300
314
  def ==(other)
301
315
  return false unless other.is_a?(LLA)
302
316
 
@@ -317,7 +331,7 @@ module Geodetic
317
331
  raise ArgumentError, "Longitude must be between -180 and 180 degrees" if @lng < -180 || @lng > 180
318
332
  end
319
333
 
320
- Coordinate.register_class(self)
334
+ Coordinate.register_class(self, hash_conversion_style: :no_datum)
321
335
  end
322
336
  end
323
337
  end
@@ -305,7 +305,7 @@ module Geodetic
305
305
  bands[index]
306
306
  end
307
307
 
308
- Coordinate.register_class(self)
308
+ Coordinate.register_class(self, hash_conversion_style: :with_datum_and_precision)
309
309
  end
310
310
  end
311
311
  end
@@ -193,6 +193,15 @@ module Geodetic
193
193
  from_lla(lla, reference_lla)
194
194
  end
195
195
 
196
+ def to_h3(reference_lla, precision: 7)
197
+ H3.new(to_lla(reference_lla), precision: precision)
198
+ end
199
+
200
+ def self.from_h3(h3_coord, reference_lla)
201
+ lla = h3_coord.to_lla
202
+ from_lla(lla, reference_lla)
203
+ end
204
+
196
205
  def to_s(precision = 2)
197
206
  precision = precision.to_i
198
207
  if precision == 0
@@ -215,6 +224,10 @@ module Geodetic
215
224
  new(n: parts[0].to_f, e: parts[1].to_f, d: parts[2].to_f)
216
225
  end
217
226
 
227
+ def valid?
228
+ @n.finite? && @e.finite? && @d.finite?
229
+ end
230
+
218
231
  def ==(other)
219
232
  return false unless other.is_a?(NED)
220
233
 
@@ -267,7 +267,6 @@ module Geodetic
267
267
  end
268
268
 
269
269
  register_hash_system(:olc, self, default_precision: 10)
270
- Coordinate.register_class(self)
271
270
  end
272
271
  end
273
272
  end
@@ -12,7 +12,7 @@
12
12
  # decode_bounds(code_string) → { min_lat:, max_lat:, min_lng:, max_lng: }
13
13
  # validate_code!(string) → raises ArgumentError or nil
14
14
  # set_code(normalized_string) → sets the internal ivar (@geohash, @code, etc.)
15
- # code_value → returns the internal ivar
15
+ # code_value → returns the internal ivar (protected)
16
16
  # self.default_precision → Integer
17
17
  # self.hash_system_name → Symbol (:gh, :gh36, :ham, :olc)
18
18
  #
@@ -52,6 +52,7 @@ module Geodetic
52
52
  class_name: klass.name.split('::').last,
53
53
  default_precision: default_precision
54
54
  }
55
+ Coordinate.register_class(klass)
55
56
  end
56
57
 
57
58
  # Called once after all spatial hash subclasses are loaded.
@@ -285,7 +286,6 @@ module Geodetic
285
286
  code_value == other.code_value
286
287
  end
287
288
 
288
- alias_method :to_slug, :to_s
289
289
 
290
290
  # Returns all 8 neighboring cells
291
291
  # Keys: :N, :S, :E, :W, :NE, :NW, :SE, :SW
@@ -305,6 +305,15 @@ module Geodetic
305
305
  from_lla(lla_coord, zone_code, datum)
306
306
  end
307
307
 
308
+ def to_h3(datum = nil, precision: 7)
309
+ H3.new(to_lla(datum), precision: precision)
310
+ end
311
+
312
+ def self.from_h3(h3_coord, zone_code, datum = WGS84)
313
+ lla_coord = h3_coord.to_lla(datum)
314
+ from_lla(lla_coord, zone_code, datum)
315
+ end
316
+
308
317
  # Unit conversion methods
309
318
  def to_meters
310
319
  zone_info = ZONES[@zone_code]
@@ -312,7 +312,7 @@ module Geodetic
312
312
  end
313
313
  end
314
314
 
315
- Coordinate.register_class(self)
315
+ Coordinate.register_class(self, hash_conversion_style: :with_datum)
316
316
  end
317
317
  end
318
318
  end
@@ -262,7 +262,7 @@ module Geodetic
262
262
  end
263
263
  end
264
264
 
265
- Coordinate.register_class(self)
265
+ Coordinate.register_class(self, hash_conversion_style: :with_datum_and_precision)
266
266
  end
267
267
  end
268
268
  end
@@ -249,7 +249,7 @@ module Geodetic
249
249
  raise ArgumentError, "Northing must be positive" if @northing < 0
250
250
  end
251
251
 
252
- Coordinate.register_class(self)
252
+ Coordinate.register_class(self, hash_conversion_style: :no_datum)
253
253
  end
254
254
  end
255
255
  end
@@ -230,7 +230,7 @@ module Geodetic
230
230
  }
231
231
  end
232
232
 
233
- Coordinate.register_class(self)
233
+ Coordinate.register_class(self, hash_conversion_style: :no_datum)
234
234
  end
235
235
  end
236
236
  end
@@ -3,13 +3,39 @@
3
3
  module Geodetic
4
4
  module Coordinate
5
5
  # Registry for coordinate classes — each class registers itself at load time
6
+ # Each entry is [klass, options_hash] where options may include :hash_conversion_style
6
7
  @registered_classes = []
8
+ @finalized = false
7
9
 
8
10
  class << self
9
11
  attr_reader :registered_classes
10
12
 
11
- def register_class(klass)
12
- @registered_classes << klass
13
+ def systems
14
+ @registered_classes.map(&:first).freeze
15
+ end
16
+
17
+ def register_class(klass, hash_conversion_style: nil)
18
+ @registered_classes << [klass, { hash_conversion_style: hash_conversion_style }]
19
+ end
20
+
21
+ def finalize!
22
+ raise "Geodetic::Coordinate already finalized!" if @finalized
23
+ @finalized = true
24
+
25
+ # Phase 1: Generate cross-hash conversion methods between spatial hash subclasses
26
+ SpatialHash.finalize_cross_hash_conversions!
27
+
28
+ # Phase 2: Generate hash conversion methods on non-hash coordinate classes
29
+ @registered_classes.each do |klass, opts|
30
+ next unless opts[:hash_conversion_style]
31
+ SpatialHash.generate_hash_conversions_for(klass, style: opts[:hash_conversion_style])
32
+ end
33
+
34
+ # Phase 3: Include distance/bearing mixins in all registered coordinate classes
35
+ @registered_classes.each do |klass, _opts|
36
+ klass.include(DistanceMethods)
37
+ klass.include(BearingMethods)
38
+ end
13
39
  end
14
40
  end
15
41
  end
@@ -33,6 +59,7 @@ require_relative "coordinate/ham"
33
59
  require_relative "coordinate/olc"
34
60
  require_relative "coordinate/georef"
35
61
  require_relative "coordinate/gars"
62
+ require_relative "coordinate/h3"
36
63
 
37
64
  module Geodetic
38
65
  module Coordinate
@@ -256,27 +283,5 @@ module Geodetic
256
283
  end
257
284
  end
258
285
 
259
- # Generate cross-hash conversion methods (to_gh, to_ham, etc.) between all spatial hash subclasses
260
- Geodetic::Coordinate::SpatialHash.finalize_cross_hash_conversions!
261
-
262
- # Generate hash conversion methods (to_gh, from_gh, etc.) on non-hash coordinate classes
263
- sh = Geodetic::Coordinate::SpatialHash
264
- sh.generate_hash_conversions_for(Geodetic::Coordinate::LLA, style: :no_datum)
265
- sh.generate_hash_conversions_for(Geodetic::Coordinate::ECEF, style: :no_datum)
266
- sh.generate_hash_conversions_for(Geodetic::Coordinate::UTM, style: :no_datum)
267
- sh.generate_hash_conversions_for(Geodetic::Coordinate::WebMercator, style: :no_datum)
268
- sh.generate_hash_conversions_for(Geodetic::Coordinate::BNG, style: :with_datum)
269
- sh.generate_hash_conversions_for(Geodetic::Coordinate::UPS, style: :with_datum)
270
- sh.generate_hash_conversions_for(Geodetic::Coordinate::MGRS, style: :with_datum_and_precision)
271
- sh.generate_hash_conversions_for(Geodetic::Coordinate::USNG, style: :with_datum_and_precision)
272
-
273
- # Include distance/bearing mixins in all registered coordinate classes
274
- ALL_COORD_CLASSES = Geodetic::Coordinate.registered_classes.freeze
275
-
276
- ALL_COORD_CLASSES.each do |klass|
277
- klass.include(Geodetic::Coordinate::DistanceMethods)
278
- klass.include(Geodetic::Coordinate::BearingMethods)
279
- end
280
-
281
- # GCS is a convenience alias for Geodetic::Coordinate, available after require "geodetic"
282
- GCS = Geodetic::Coordinate
286
+ # All classes loaded and registered finalize conversions and mixins
287
+ Geodetic::Coordinate.finalize!