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,264 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of rendering geodetic coordinates on a map
4
+ # using the libgd-gis gem for native Ruby map generation.
5
+ # Shows how Geodetic::Feature wraps named coordinates and areas.
6
+ #
7
+ # Prerequisites:
8
+ # gem install libgd-gis
9
+ # brew install gd (macOS) or apt install libgd-dev (Linux)
10
+ #
11
+ # Usage:
12
+ # ruby -Ilib examples/05_map_rendering.rb
13
+
14
+ if ARGV.include?("-h") || ARGV.include?("--help")
15
+ puts <<~HELP
16
+ Usage: ./demo.rb [options]
17
+
18
+ Options:
19
+ --light Use light basemap with dark labels
20
+ --dark Use dark basemap with light labels
21
+ Default: follows macOS appearance setting
22
+
23
+ --central-park Show Central Park polygon boundary (default)
24
+ --no-central-park Hide Central Park polygon boundary
25
+
26
+ --icon-scale=N Scale icons by factor N (default: 0.5)
27
+ Examples: --icon-scale=0.25, --icon-scale=1.0
28
+
29
+ -h, --help Show this help message
30
+ HELP
31
+ exit
32
+ end
33
+
34
+ require_relative "../../lib/geodetic"
35
+ require "gd/gis"
36
+
37
+ include Geodetic
38
+ LLA = Coordinate::LLA
39
+
40
+ # --- Theme detection: --light, --dark, or macOS system default ---
41
+
42
+ def detect_macos_dark_mode
43
+ result = `defaults read -g AppleInterfaceStyle 2>/dev/null`.strip
44
+ result == "Dark"
45
+ rescue
46
+ false
47
+ end
48
+
49
+ DARK_MODE = if ARGV.include?("--dark")
50
+ true
51
+ elsif ARGV.include?("--light")
52
+ false
53
+ else
54
+ detect_macos_dark_mode
55
+ end
56
+
57
+ BASEMAP = DARK_MODE ? :carto_dark : :carto_light
58
+ LABEL_COLOR = DARK_MODE ? [255, 255, 255] : [0, 0, 0]
59
+ ICON_BG = DARK_MODE # draw a white disc behind icons on dark basemaps
60
+ CENTRAL_PARK = !ARGV.include?("--no-central-park") # --central-park is the default
61
+ ICON_SCALE = (ARGV.find { |a| a.start_with?("--icon-scale=") }&.split("=", 2)&.last&.to_f) || 0.5
62
+
63
+ # --- 1. Define landmarks as Features with icon paths ---
64
+
65
+ ICONS_DIR = File.join(__dir__, "icons")
66
+
67
+ landmarks = [
68
+ Feature.new(label: "Statue of Liberty", geometry: LLA.new(lat: 40.6892, lng: -74.0445, alt: 0),
69
+ metadata: { category: "monument", year: 1886, icon: File.join(ICONS_DIR, "monument.png") }),
70
+ Feature.new(label: "Empire State Bldg", geometry: LLA.new(lat: 40.7484, lng: -73.9857, alt: 0),
71
+ metadata: { category: "building", floors: 102, icon: File.join(ICONS_DIR, "building.png") }),
72
+ Feature.new(label: "Central Park", geometry: LLA.new(lat: 40.7829, lng: -73.9654, alt: 0),
73
+ metadata: { category: "park", acres: 843, icon: File.join(ICONS_DIR, "park.png") }),
74
+ Feature.new(label: "Brooklyn Bridge", geometry: LLA.new(lat: 40.7061, lng: -73.9969, alt: 0),
75
+ metadata: { category: "bridge", year: 1883, icon: File.join(ICONS_DIR, "bridge.png") }),
76
+ Feature.new(label: "Times Square", geometry: LLA.new(lat: 40.7580, lng: -73.9855, alt: 0),
77
+ metadata: { category: "landmark", icon: File.join(ICONS_DIR, "landmark.png") }),
78
+ ]
79
+
80
+ # An area-based Feature: Central Park as a polygon boundary
81
+ # Vertices trace the park perimeter (59th St to 110th St)
82
+ central_park_area = Feature.new(
83
+ label: "Central Park",
84
+ geometry: Areas::Polygon.new(boundary: [
85
+ # Vertices from OpenStreetMap data (way 427818536), simplified.
86
+ # Central Park is a parallelogram tilted ~29° with Manhattan's grid.
87
+ LLA.new(lat: 40.7679, lng: -73.9818, alt: 0), # SW corner (Columbus Circle, 59th & CPW)
88
+ LLA.new(lat: 40.7649, lng: -73.9727, alt: 0), # SE corner (Grand Army Plaza, 59th & 5th Ave)
89
+ LLA.new(lat: 40.7691, lng: -73.9697, alt: 0), # E edge — 65th St
90
+ LLA.new(lat: 40.7728, lng: -73.9672, alt: 0), # E edge — 70th St
91
+ LLA.new(lat: 40.7766, lng: -73.9648, alt: 0), # E edge — 75th St
92
+ LLA.new(lat: 40.7812, lng: -73.9618, alt: 0), # E edge — 81st St (Met Museum)
93
+ LLA.new(lat: 40.7855, lng: -73.9592, alt: 0), # E edge — 86th St
94
+ LLA.new(lat: 40.7903, lng: -73.9561, alt: 0), # E edge — 92nd St
95
+ LLA.new(lat: 40.7950, lng: -73.9531, alt: 0), # E edge — 97th St
96
+ LLA.new(lat: 40.7994, lng: -73.9500, alt: 0), # E edge — 108th St
97
+ LLA.new(lat: 40.8003, lng: -73.9494, alt: 0), # NE corner (110th & 5th Ave)
98
+ LLA.new(lat: 40.8013, lng: -73.9580, alt: 0), # N edge
99
+ LLA.new(lat: 40.8008, lng: -73.9585, alt: 0), # NW corner (110th & CPW)
100
+ LLA.new(lat: 40.7962, lng: -73.9614, alt: 0), # W edge — 103rd St
101
+ LLA.new(lat: 40.7915, lng: -73.9648, alt: 0), # W edge — 93rd St
102
+ LLA.new(lat: 40.7863, lng: -73.9679, alt: 0), # W edge — 86th St
103
+ LLA.new(lat: 40.7815, lng: -73.9706, alt: 0), # W edge — 79th St
104
+ LLA.new(lat: 40.7769, lng: -73.9731, alt: 0), # W edge — 73rd St
105
+ LLA.new(lat: 40.7726, lng: -73.9755, alt: 0), # W edge — 68th St
106
+ LLA.new(lat: 40.7698, lng: -73.9772, alt: 0), # W edge — 64th St
107
+ ]),
108
+ metadata: { note: "simplified boundary from OpenStreetMap way 427818536" }
109
+ )
110
+
111
+ # --- 2. Compute a bounding box from Feature coordinates ---
112
+
113
+ all_points = landmarks.map { |f| f.geometry }
114
+ all_points += central_park_area.geometry.boundary if CENTRAL_PARK
115
+ lats = all_points.map(&:lat)
116
+ lngs = all_points.map(&:lng)
117
+ padding = 0.03
118
+
119
+ bbox = [
120
+ lngs.min - padding, # west
121
+ lats.min - padding, # south
122
+ lngs.max + padding, # east
123
+ lats.max + padding # north
124
+ ]
125
+
126
+ # --- 3. Create the map ---
127
+
128
+ ZOOM = 12
129
+
130
+ map = GD::GIS::Map.new(
131
+ bbox: bbox,
132
+ zoom: ZOOM,
133
+ basemap: BASEMAP,
134
+ width: 1024,
135
+ height: 768
136
+ )
137
+
138
+ # --- 4. Optionally draw Central Park polygon on the map ---
139
+
140
+ if CENTRAL_PARK
141
+ park_coords = central_park_area.geometry.boundary.map { |pt| [pt.lng, pt.lat] }
142
+
143
+ map.add_polygons(
144
+ [[park_coords]], # polygons > rings > [lng, lat] points
145
+ fill: [80, 255, 120, 60],
146
+ stroke: [50, 200, 80, 255],
147
+ width: 2
148
+ )
149
+ end
150
+
151
+ # Style must be set (add_polygons sets it as a side effect, but just in case)
152
+ map.style ||= GD::GIS::Style.default
153
+
154
+ # --- 6. Show Feature info using delegation ---
155
+
156
+ liberty = landmarks.first
157
+
158
+ puts "Landmarks relative to #{liberty.label}:"
159
+ puts "-" * 60
160
+
161
+ landmarks.each do |feature|
162
+ utm = feature.geometry.to_utm
163
+
164
+ puts <<~INFO
165
+ #{feature.label} [#{feature.metadata[:category]}]
166
+ LLA: #{feature.geometry.to_s(4)}
167
+ UTM: #{utm.to_s(2)}
168
+ Distance to #{liberty.label}: #{feature.distance_to(liberty).to_km}
169
+ Bearing to #{liberty.label}: #{feature.bearing_to(liberty)}
170
+
171
+ INFO
172
+ end
173
+
174
+ # Distance from an area Feature to a point Feature
175
+ puts "#{central_park_area.label} -> #{liberty.label}: #{central_park_area.distance_to(liberty).to_km}"
176
+ puts
177
+
178
+ # --- 7. Render, draw markers/areas/labels, and save ---
179
+
180
+ output_path = File.join(__dir__, "nyc_landmarks.png")
181
+ map.render
182
+
183
+ img = map.image
184
+ map_bbox = map.instance_variable_get(:@bbox)
185
+ zoom = ZOOM
186
+ FONT = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
187
+
188
+ # Draw landmark icons and labels
189
+ landmarks.each do |feature|
190
+ pt = feature.geometry
191
+ px, py = GD::GIS::Geometry.project(pt.lng, pt.lat, map_bbox, zoom)
192
+ x, y = px.round, py.round
193
+
194
+ # Load and scale the icon
195
+ icon = GD::Image.open(feature.metadata[:icon])
196
+ icon.alpha_blending = true
197
+ icon.save_alpha = true
198
+ iw = (icon.width * ICON_SCALE).round
199
+ ih = (icon.height * ICON_SCALE).round
200
+ scaled = icon.scale(iw, ih)
201
+ scaled.alpha_blending = true
202
+ scaled.save_alpha = true
203
+
204
+ # On dark basemaps, add a white disc so black icons are visible
205
+ if ICON_BG
206
+ radius = (iw / 2 * 1.3).round
207
+ img.filled_circle(x, y, radius, [255, 255, 255])
208
+ end
209
+
210
+ img.copy(scaled, x - iw / 2, y - ih / 2, 0, 0, iw, ih)
211
+
212
+ # Label to the right of the icon
213
+ img.text(feature.label, x: x + iw / 2 + 6, y: y + 5,
214
+ font: FONT, size: 16, color: LABEL_COLOR)
215
+ end
216
+
217
+ # --- 8. Draw bearing arrows ---
218
+
219
+ def draw_bearing_arrow(img, from_feature, to_feature, map_bbox, zoom, color, font)
220
+ bearing = from_feature.bearing_to(to_feature)
221
+
222
+ x1, y1 = GD::GIS::Geometry.project(from_feature.geometry.lng, from_feature.geometry.lat, map_bbox, zoom).map(&:round)
223
+ x2, y2 = GD::GIS::Geometry.project(to_feature.geometry.lng, to_feature.geometry.lat, map_bbox, zoom).map(&:round)
224
+
225
+ # Line (doubled for thickness)
226
+ img.line(x1, y1, x2, y2, color)
227
+ img.line(x1 + 1, y1, x2 + 1, y2, color)
228
+
229
+ # Arrowhead at destination
230
+ dx, dy = (x2 - x1).to_f, (y2 - y1).to_f
231
+ len = Math.sqrt(dx * dx + dy * dy)
232
+ ux, uy = dx / len, dy / len
233
+
234
+ tip_x = x2 - (ux * 20).round
235
+ tip_y = y2 - (uy * 20).round
236
+ base_x = tip_x - (ux * 14).round
237
+ base_y = tip_y - (uy * 14).round
238
+ px, py = -uy, ux
239
+ p1x = base_x + (px * 7).round
240
+ p1y = base_y + (py * 7).round
241
+ p2x = base_x - (px * 7).round
242
+ p2y = base_y - (py * 7).round
243
+ img.filled_polygon([[tip_x, tip_y], [p1x, p1y], [p2x, p2y]], color)
244
+
245
+ # Label at midpoint
246
+ mid_x = (x1 + x2) / 2
247
+ mid_y = (y1 + y2) / 2
248
+ img.text("#{bearing.degrees.round(1)}\u00B0", x: mid_x + 8, y: mid_y + 5,
249
+ font: font, size: 14, color: color)
250
+ end
251
+
252
+ img.antialias = true
253
+ brooklyn = landmarks.find { |f| f.label == "Brooklyn Bridge" }
254
+ liberty = landmarks.find { |f| f.label == "Statue of Liberty" }
255
+ empire = landmarks.find { |f| f.label == "Empire State Bldg" }
256
+
257
+ arrow_color = DARK_MODE ? [255, 100, 100] : [200, 30, 30]
258
+
259
+ draw_bearing_arrow(img, brooklyn, liberty, map_bbox, zoom, arrow_color, FONT)
260
+ draw_bearing_arrow(img, brooklyn, empire, map_bbox, zoom, arrow_color, FONT)
261
+
262
+ map.save(output_path)
263
+
264
+ puts "Map saved to #{output_path}"
@@ -0,0 +1,62 @@
1
+ # Geodetic Examples
2
+
3
+ Runnable demo scripts showing progressive usage of the Geodetic library. Run any single-file example from the project root:
4
+
5
+ ```bash
6
+ ruby -Ilib examples/01_basic_conversions.rb
7
+ ```
8
+
9
+ ## 01 - Basic Conversions
10
+
11
+ Converts a single point between LLA, ECEF, UTM, ENU, and NED coordinate systems. Demonstrates roundtrip accuracy and local coordinate frames with a reference point.
12
+
13
+ ## 02 - All Coordinate Systems
14
+
15
+ Walks through all 18 coordinate systems with cross-system conversion chains. Covers spatial hashes (GH, GH36, HAM, OLC, GEOREF, GARS, H3), areas, and neighbor lookups.
16
+
17
+ ## 03 - Distance Calculations
18
+
19
+ Demonstrates the `Distance` class: great-circle and straight-line distances, unit conversions (km, mi, ft, nmi), arithmetic, comparison, and cross-system distance calculations.
20
+
21
+ ## 04 - Bearing Calculations
22
+
23
+ Demonstrates the `Bearing` class: forward azimuth, back azimuth, compass directions (4/8/16-point), elevation angles, chain bearings, and cross-system bearing calculations.
24
+
25
+ ## 05 - Map Rendering
26
+
27
+ Renders geodetic data on a raster map using the [libgd-gis](https://rubygems.org/gems/libgd-gis) gem. Showcases `Geodetic::Feature` for wrapping coordinates and areas with labels and metadata.
28
+
29
+ Features demonstrated:
30
+
31
+ - **Feature objects** with point and polygon geometries
32
+ - **Landmark icons** scaled and composited onto the map
33
+ - **Polygon rendering** of Central Park's boundary
34
+ - **Bearing arrows** with degree labels between landmarks
35
+ - **Distance and bearing delegation** through Feature objects
36
+ - **UTM conversion** output for each landmark
37
+ - **Light/dark theme** switching with macOS system detection
38
+
39
+ Prerequisites:
40
+
41
+ ```bash
42
+ gem install libgd-gis
43
+ brew install gd # macOS
44
+ ```
45
+
46
+ Run from the project root:
47
+
48
+ ```bash
49
+ ruby -Ilib examples/05_map_rendering/demo.rb
50
+ ```
51
+
52
+ CLI flags:
53
+
54
+ ```
55
+ --light / --dark Select basemap theme (default: macOS system setting)
56
+ --central-park Show Central Park polygon (default)
57
+ --no-central-park Hide Central Park polygon
58
+ --icon-scale=N Scale landmark icons (default: 0.5)
59
+ -h, --help Show help
60
+ ```
61
+
62
+ Output: `examples/05_map_rendering/nyc_landmarks.png`
@@ -0,0 +1,119 @@
1
+ # H3 Fiddle Pointer Buffer Pool Optimization
2
+
3
+ ## Problem
4
+
5
+ Every H3 C function call in `lib/geodetic/coordinate/h3.rb` allocates fresh `Fiddle::Pointer` objects that are immediately discarded after use. At high call volumes (1M+ conversions), this creates hundreds of thousands of short-lived heap objects and GC pressure.
6
+
7
+ ## Affected Methods
8
+
9
+ | Method | Pointers Allocated | Sizes |
10
+ |--------|-------------------|-------|
11
+ | `lat_lng_to_cell` | 2 | `SIZEOF_LATLNG` (16B) + `SIZEOF_H3INDEX` (8B) |
12
+ | `cell_to_lat_lng` | 1 | `SIZEOF_LATLNG` (16B) |
13
+ | `cell_to_boundary` | 1 | `SIZEOF_CELL_BOUNDARY` (168B) |
14
+ | `grid_disk` | 2 | `SIZEOF_INT64` (8B) + variable |
15
+ | `cell_area_m2` | 1 | `SIZEOF_DOUBLE` (8B) |
16
+ | `cell_to_parent` | 1 | `SIZEOF_H3INDEX` (8B) |
17
+ | `cell_to_children` | 2 | `SIZEOF_INT64` (8B) + variable |
18
+
19
+ ## Proposed Solution
20
+
21
+ Pre-allocate thread-local reusable buffers for fixed-size structs. Variable-size buffers (`grid_disk` output, `cell_to_children` output) cannot be pre-allocated since their size depends on input.
22
+
23
+ ### Thread-Local Buffer Helper
24
+
25
+ ```ruby
26
+ module LibH3
27
+ def self.thread_buffer(name, size)
28
+ key = :"geodetic_h3_#{name}"
29
+ Thread.current[key] ||= Fiddle::Pointer.malloc(size)
30
+ end
31
+ end
32
+ ```
33
+
34
+ ### Before (current code)
35
+
36
+ ```ruby
37
+ def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
38
+ require_library!
39
+ latlng = Fiddle::Pointer.malloc(SIZEOF_LATLNG, Fiddle::RUBY_FREE)
40
+ latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
41
+ out = Fiddle::Pointer.malloc(SIZEOF_H3INDEX, Fiddle::RUBY_FREE)
42
+ err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
43
+ raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
44
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
45
+ end
46
+ ```
47
+
48
+ ### After (with buffer reuse)
49
+
50
+ ```ruby
51
+ def self.lat_lng_to_cell(lat_rad, lng_rad, resolution)
52
+ require_library!
53
+ latlng = thread_buffer(:latlng, SIZEOF_LATLNG)
54
+ latlng[0, SIZEOF_LATLNG] = [lat_rad, lng_rad].pack('d2')
55
+ out = thread_buffer(:h3index, SIZEOF_H3INDEX)
56
+ err = F_LAT_LNG_TO_CELL.call(latlng, resolution, out)
57
+ raise ArgumentError, "H3 latLngToCell error (code #{err})" unless err == 0
58
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
59
+ end
60
+
61
+ def self.cell_to_lat_lng(h3_index)
62
+ require_library!
63
+ latlng = thread_buffer(:latlng, SIZEOF_LATLNG)
64
+ err = F_CELL_TO_LAT_LNG.call(h3_index, latlng)
65
+ raise ArgumentError, "H3 cellToLatLng error (code #{err})" unless err == 0
66
+ lat_rad, lng_rad = latlng[0, SIZEOF_LATLNG].unpack('d2')
67
+ { lat: lat_rad * DEG_PER_RAD, lng: lng_rad * DEG_PER_RAD }
68
+ end
69
+
70
+ def self.cell_to_boundary(h3_index)
71
+ require_library!
72
+ cb = thread_buffer(:cell_boundary, SIZEOF_CELL_BOUNDARY)
73
+ err = F_CELL_TO_BOUNDARY.call(h3_index, cb)
74
+ raise ArgumentError, "H3 cellToBoundary error (code #{err})" unless err == 0
75
+ num_verts = cb[0, 4].unpack1('i')
76
+ verts = cb[8, num_verts * SIZEOF_LATLNG].unpack("d#{num_verts * 2}")
77
+ (0...num_verts).map do |i|
78
+ { lat: verts[i * 2] * DEG_PER_RAD, lng: verts[i * 2 + 1] * DEG_PER_RAD }
79
+ end
80
+ end
81
+
82
+ def self.cell_area_m2(h3_index)
83
+ require_library!
84
+ out = thread_buffer(:double, Fiddle::SIZEOF_DOUBLE)
85
+ err = F_CELL_AREA_M2.call(h3_index, out)
86
+ raise ArgumentError, "H3 cellAreaM2 error (code #{err})" unless err == 0
87
+ out[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
88
+ end
89
+
90
+ def self.cell_to_parent(h3_index, parent_res)
91
+ require_library!
92
+ out = thread_buffer(:h3index, SIZEOF_H3INDEX)
93
+ err = F_CELL_TO_PARENT.call(h3_index, parent_res, out)
94
+ raise ArgumentError, "H3 cellToParent error (code #{err})" unless err == 0
95
+ out[0, SIZEOF_H3INDEX].unpack1('Q')
96
+ end
97
+ ```
98
+
99
+ ### Reusable Fixed-Size Buffers
100
+
101
+ | Buffer Name | Size | Used By |
102
+ |-------------|------|---------|
103
+ | `:latlng` | 16 bytes | `lat_lng_to_cell`, `cell_to_lat_lng` |
104
+ | `:h3index` | 8 bytes | `lat_lng_to_cell`, `cell_to_parent` |
105
+ | `:cell_boundary` | 168 bytes | `cell_to_boundary` |
106
+ | `:int64` | 8 bytes | `grid_disk`, `cell_to_children` |
107
+ | `:double` | 8 bytes | `cell_area_m2` |
108
+
109
+ ## Thread Safety
110
+
111
+ Using `Thread.current` ensures each thread gets its own buffer. No mutex needed. Buffers are overwritten on each call, so stale data is not a concern — the C function fills the buffer before Ruby reads it.
112
+
113
+ ## When to Implement
114
+
115
+ This optimization is only worthwhile for high-volume batch processing (1M+ H3 conversions). For typical usage the current per-call allocation is fine and simpler to reason about.
116
+
117
+ ## Not Optimizable
118
+
119
+ The `grid_disk` and `cell_to_children` output buffers are variable-sized (depends on `k` ring size or child resolution) and must be allocated per call.
@@ -35,6 +35,13 @@ module Geodetic
35
35
  ['HL', 'HM', 'HN', 'HO', 'HP', 'JL', 'JM']
36
36
  ]
37
37
 
38
+ # Reverse lookup: grid square letters → [x, y] coordinates
39
+ GRID_SQUARE_LOOKUP = GRID_SQUARES.each_with_index.each_with_object({}) do |(row, y), hash|
40
+ row.each_with_index do |square, x|
41
+ hash[square] = [x, 12 - y]
42
+ end
43
+ end.freeze
44
+
38
45
  # OSGB36 datum (approximation - use Airy 1830 ellipsoid)
39
46
  OSGB36 = Struct.new(:name, :a, :b, :f, :e, :e2).new(
40
47
  'OSGB36',
@@ -252,22 +259,9 @@ module Geodetic
252
259
  east_digits = $2
253
260
  north_digits = $3
254
261
 
255
- # Find grid square
256
- grid_x = nil
257
- grid_y = nil
258
-
259
- GRID_SQUARES.each_with_index do |row, y|
260
- row.each_with_index do |square, x|
261
- if square == letters
262
- grid_x = x
263
- grid_y = 12 - y
264
- break
265
- end
266
- end
267
- break if grid_x
268
- end
269
-
270
- raise ArgumentError, "Invalid grid square: #{letters}" unless grid_x
262
+ grid_coords = GRID_SQUARE_LOOKUP[letters]
263
+ raise ArgumentError, "Invalid grid square: #{letters}" unless grid_coords
264
+ grid_x, grid_y = grid_coords
271
265
 
272
266
  # Calculate coordinates
273
267
  precision = east_digits.length
@@ -279,22 +273,9 @@ module Geodetic
279
273
  elsif grid_ref.match(/^([A-Z]{2})$/)
280
274
  letters = $1
281
275
 
282
- # Grid square only - use center point
283
- grid_x = nil
284
- grid_y = nil
285
-
286
- GRID_SQUARES.each_with_index do |row, y|
287
- row.each_with_index do |square, x|
288
- if square == letters
289
- grid_x = x
290
- grid_y = 12 - y
291
- break
292
- end
293
- end
294
- break if grid_x
295
- end
296
-
297
- raise ArgumentError, "Invalid grid square: #{letters}" unless grid_x
276
+ grid_coords = GRID_SQUARE_LOOKUP[letters]
277
+ raise ArgumentError, "Invalid grid square: #{letters}" unless grid_coords
278
+ grid_x, grid_y = grid_coords
298
279
 
299
280
  @easting = grid_x * 100000 + 50000 # Center of square
300
281
  @northing = grid_y * 100000 + 50000 # Center of square
@@ -353,7 +334,7 @@ module Geodetic
353
334
  new(easting: easting, northing: northing)
354
335
  end
355
336
 
356
- Coordinate.register_class(self)
337
+ Coordinate.register_class(self, hash_conversion_style: :with_datum)
357
338
  end
358
339
  end
359
340
  end
@@ -206,6 +206,10 @@ module Geodetic
206
206
  new(x: parts[0].to_f, y: parts[1].to_f, z: parts[2].to_f)
207
207
  end
208
208
 
209
+ def valid?
210
+ @x.finite? && @y.finite? && @z.finite?
211
+ end
212
+
209
213
  def ==(other)
210
214
  return false unless other.is_a?(ECEF)
211
215
 
@@ -217,7 +221,7 @@ module Geodetic
217
221
  end
218
222
 
219
223
 
220
- Coordinate.register_class(self)
224
+ Coordinate.register_class(self, hash_conversion_style: :no_datum)
221
225
  end
222
226
  end
223
227
  end
@@ -212,6 +212,15 @@ module Geodetic
212
212
  from_lla(lla, reference_lla)
213
213
  end
214
214
 
215
+ def to_h3(reference_lla, precision: 7)
216
+ H3.new(to_lla(reference_lla), precision: precision)
217
+ end
218
+
219
+ def self.from_h3(h3_coord, reference_lla)
220
+ lla = h3_coord.to_lla
221
+ from_lla(lla, reference_lla)
222
+ end
223
+
215
224
  def to_s(precision = 2)
216
225
  precision = precision.to_i
217
226
  if precision == 0
@@ -234,6 +243,10 @@ module Geodetic
234
243
  new(e: parts[0].to_f, n: parts[1].to_f, u: parts[2].to_f)
235
244
  end
236
245
 
246
+ def valid?
247
+ @e.finite? && @n.finite? && @u.finite?
248
+ end
249
+
237
250
  def ==(other)
238
251
  return false unless other.is_a?(ENU)
239
252
 
@@ -76,12 +76,12 @@ module Geodetic
76
76
  VALID_LENGTHS.include?(@code.length) && valid_characters?(@code)
77
77
  end
78
78
 
79
+ protected
80
+
79
81
  def code_value
80
82
  @code
81
83
  end
82
84
 
83
- protected
84
-
85
85
  def normalize(string)
86
86
  result = String.new(capacity: string.length)
87
87
  string.each_char.with_index do |ch, i|
@@ -227,7 +227,6 @@ module Geodetic
227
227
  end
228
228
 
229
229
  register_hash_system(:gars, self, default_precision: 7)
230
- Coordinate.register_class(self)
231
230
  end
232
231
  end
233
232
  end
@@ -55,12 +55,12 @@ module Geodetic
55
55
  VALID_LENGTHS.include?(@code.length) && valid_characters?(@code)
56
56
  end
57
57
 
58
+ protected
59
+
58
60
  def code_value
59
61
  @code
60
62
  end
61
63
 
62
- protected
63
-
64
64
  def normalize(string)
65
65
  string.upcase
66
66
  end
@@ -198,7 +198,6 @@ module Geodetic
198
198
  end
199
199
 
200
200
  register_hash_system(:georef, self, default_precision: 8)
201
- Coordinate.register_class(self)
202
201
  end
203
202
  end
204
203
  end
@@ -39,13 +39,12 @@ module Geodetic
39
39
  @geohash.length > 0 && @geohash.each_char.all? { |c| CHAR_INDEX.key?(c) }
40
40
  end
41
41
 
42
- # Expose code_value for base class equality and other shared methods
42
+ protected
43
+
43
44
  def code_value
44
45
  @geohash
45
46
  end
46
47
 
47
- protected
48
-
49
48
  def normalize(string)
50
49
  string.downcase
51
50
  end
@@ -155,7 +154,6 @@ module Geodetic
155
154
  alias_method :validate_code!, :validate_geohash!
156
155
 
157
156
  register_hash_system(:gh, self, default_precision: 12)
158
- Coordinate.register_class(self)
159
157
  end
160
158
  end
161
159
  end
@@ -74,10 +74,6 @@ module Geodetic
74
74
  @geohash.length > 0 && @geohash.each_char.all? { |c| VALID_CHARS_SET.include?(c) }
75
75
  end
76
76
 
77
- def code_value
78
- @geohash
79
- end
80
-
81
77
  # --- GH36-specific overrides (matrix-based algorithms) ---
82
78
 
83
79
  # Uses recursive matrix-based neighbor calculation instead of bounds-based
@@ -105,6 +101,10 @@ module Geodetic
105
101
 
106
102
  protected
107
103
 
104
+ def code_value
105
+ @geohash
106
+ end
107
+
108
108
  def set_code(value)
109
109
  @geohash = value
110
110
  end
@@ -243,7 +243,6 @@ module Geodetic
243
243
  alias_method :validate_code!, :validate_geohash!
244
244
 
245
245
  register_hash_system(:gh36, self, default_precision: 10)
246
- Coordinate.register_class(self)
247
246
  end
248
247
  end
249
248
  end