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,60 @@
1
+ # WKB Hex-Encoded Test Geometries
2
+ # Each non-comment, non-blank line is one hex-encoded WKB geometry.
3
+ # Sources: cschwarz/wkx test suite, PostGIS docs, and generated from known coordinates.
4
+ #
5
+ # To decode: read hex, pack to binary, parse as WKB.
6
+
7
+ # --- Simple Geometries ---
8
+
9
+ # POINT(1 2)
10
+ 0101000000000000000000f03f0000000000000040
11
+
12
+ # LINESTRING(1 2, 3 4)
13
+ 010200000002000000000000000000f03f000000000000004000000000000008400000000000001040
14
+
15
+ # POLYGON((1 2, 3 4, 5 2, 1 2))
16
+ 01030000000100000004000000000000000000f03f00000000000000400000000000000840000000000000104000000000000014400000000000000040000000000000f03f0000000000000040
17
+
18
+ # --- Multi Geometries ---
19
+
20
+ # MULTIPOINT((1 2), (3 4))
21
+ 0104000000020000000101000000000000000000f03f0000000000000040010100000000000000000008400000000000001040
22
+
23
+ # MULTILINESTRING((1 2, 3 4), (5 6, 7 8))
24
+ 010500000002000000010200000002000000000000000000f03f000000000000004000000000000008400000000000001040010200000002000000000000000000144000000000000018400000000000001c400000000000002040
25
+
26
+ # MULTIPOLYGON(((1 2, 3 4, 5 2, 1 2)))
27
+ 01060000000100000001030000000100000004000000000000000000f03f00000000000000400000000000000840000000000000104000000000000014400000000000000040000000000000f03f0000000000000040
28
+
29
+ # GEOMETRYCOLLECTION(POINT(1 2), LINESTRING(1 2, 3 4))
30
+ 0107000000020000000101000000000000000000f03f0000000000000040010200000002000000000000000000f03f000000000000004000000000000008400000000000001040
31
+
32
+ # --- Real-World Coordinates ---
33
+
34
+ # POINT(-122.3493 47.6205) — Seattle
35
+ 01010000008a1f63ee5a965ec08195438b6ccf4740
36
+
37
+ # POINT(-122.6784 45.5152) — Portland
38
+ 0101000000cf66d5e76aab5ec01973d712f2c14640
39
+
40
+ # LINESTRING(Seattle → Portland)
41
+ 0102000000020000008a1f63ee5a965ec08195438b6ccf4740cf66d5e76aab5ec01973d712f2c14640
42
+
43
+ # POLYGON(Seattle, Portland, SF, Seattle)
44
+ 010300000001000000040000008a1f63ee5a965ec08195438b6ccf4740cf66d5e76aab5ec01973d712f2c1464050fc1873d79a5ec0d0d556ec2fe342408a1f63ee5a965ec08195438b6ccf4740
45
+
46
+ # --- 3D (Z) ---
47
+
48
+ # POINT Z(-122.3493 47.6205 184.0) — Space Needle
49
+ 01e90300008a1f63ee5a965ec08195438b6ccf47400000000000006740
50
+
51
+ # --- EWKB (with SRID 4326) ---
52
+
53
+ # SRID=4326;POINT(-122.3493 47.6205)
54
+ 0101000020e61000008a1f63ee5a965ec08195438b6ccf4740
55
+
56
+ # SRID=4326;LINESTRING(1 2, 3 4)
57
+ 0102000020e610000002000000000000000000f03f000000000000004000000000000008400000000000001040
58
+
59
+ # SRID=4326;POLYGON((1 2, 3 4, 5 2, 1 2))
60
+ 0103000020e61000000100000004000000000000000000f03f00000000000000400000000000000840000000000000104000000000000014400000000000000040000000000000f03f0000000000000040
@@ -27,18 +27,15 @@ module Geodetic
27
27
  alias edges segments
28
28
  alias border segments
29
29
 
30
- def includes?(a_point)
31
- turn_angle = 0.0
32
-
33
- (@boundary.length - 2).times do |index|
34
- return true if @boundary[index] == a_point
30
+ # Minimum boundary size where GEOS outperforms pure Ruby for point-in-polygon.
31
+ GEOS_INCLUDES_THRESHOLD = 15
35
32
 
36
- d_turn_angle = a_point.bearing_to(@boundary[index + 1]) - a_point.bearing_to(@boundary[index])
37
- d_turn_angle += (d_turn_angle > 0.0 ? -360.0 : 360.0) if d_turn_angle.abs > 180.0
38
- turn_angle += d_turn_angle
33
+ def includes?(a_point)
34
+ if Geos.available? && @boundary.length >= GEOS_INCLUDES_THRESHOLD
35
+ geos_includes?(a_point)
36
+ else
37
+ ruby_includes?(a_point)
39
38
  end
40
-
41
- turn_angle.abs > 180.0
42
39
  end
43
40
 
44
41
  def excludes?(a_point)
@@ -62,6 +59,24 @@ module Geodetic
62
59
 
63
60
  private
64
61
 
62
+ def ruby_includes?(a_point)
63
+ turn_angle = 0.0
64
+
65
+ (@boundary.length - 2).times do |index|
66
+ return true if @boundary[index] == a_point
67
+
68
+ d_turn_angle = a_point.bearing_to(@boundary[index + 1]) - a_point.bearing_to(@boundary[index])
69
+ d_turn_angle += (d_turn_angle > 0.0 ? -360.0 : 360.0) if d_turn_angle.abs > 180.0
70
+ turn_angle += d_turn_angle
71
+ end
72
+
73
+ turn_angle.abs > 180.0
74
+ end
75
+
76
+ def geos_includes?(a_point)
77
+ Geos.contains?(self, a_point)
78
+ end
79
+
65
80
  def compute_centroid
66
81
  centroid_lat = 0.0
67
82
  centroid_lng = 0.0
@@ -87,6 +102,47 @@ module Geodetic
87
102
  end
88
103
 
89
104
  def validate_no_self_intersection!
105
+ if Geos.available?
106
+ geos_validate!
107
+ else
108
+ ruby_validate!
109
+ end
110
+ end
111
+
112
+ def geos_validate!
113
+ return if Geos.is_valid?(self)
114
+
115
+ reason = Geos.is_valid_reason(self)
116
+ raise ArgumentError, "polygon boundary is invalid — #{reason}"
117
+ end
118
+
119
+ def ruby_validate!
120
+ validate_distinct_vertices!
121
+ validate_noncollinear!
122
+ validate_no_edge_crossings!
123
+ end
124
+
125
+ def validate_distinct_vertices!
126
+ unique = @boundary[0...-1].uniq { |p| [p.lat, p.lng] }
127
+ if unique.length < 3
128
+ raise ArgumentError, "polygon requires at least 3 distinct vertices, got #{unique.length}"
129
+ end
130
+ end
131
+
132
+ def validate_noncollinear!
133
+ # Signed area via shoelace — zero means all points are collinear.
134
+ signed_area = 0.0
135
+ 0.upto(@boundary.length - 2) do |i|
136
+ signed_area += @boundary[i].lng * @boundary[i + 1].lat -
137
+ @boundary[i + 1].lng * @boundary[i].lat
138
+ end
139
+
140
+ if signed_area.abs < 1e-12
141
+ raise ArgumentError, "polygon boundary is collinear — all vertices lie on the same line"
142
+ end
143
+ end
144
+
145
+ def validate_no_edge_crossings!
90
146
  segs = @boundary.each_cons(2).map { |a, b| Segment.new(a, b) }
91
147
 
92
148
  segs.each_with_index do |seg_i, i|
@@ -0,0 +1,547 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GEOS (Geometry Engine - Open Source) integration via fiddle.
4
+ #
5
+ # Provides spatial operations (contains?, intersects?, buffer, union,
6
+ # intersection, difference, convex_hull, simplify, is_valid?, area, length)
7
+ # by calling the GEOS C API through Ruby's fiddle stdlib.
8
+ #
9
+ # REQUIRES: libgeos_c shared library (brew install geos on macOS)
10
+ # Uses the reentrant (_r) API for thread safety.
11
+ #
12
+ # Usage:
13
+ # Geodetic::Geos.available? # => true/false
14
+ # Geodetic::Geos.contains?(polygon, point)
15
+ # Geodetic::Geos.intersects?(path1, path2)
16
+ # Geodetic::Geos.buffer(path, 100.0) # 100 meter buffer (in degrees)
17
+ # Geodetic::Geos.convex_hull(polygon)
18
+ # Geodetic::Geos.is_valid?(polygon)
19
+ # Geodetic::Geos.area(polygon)
20
+
21
+ module Geodetic
22
+ module Geos
23
+ module LibGEOS
24
+ require 'fiddle'
25
+
26
+ SEARCH_PATHS = [
27
+ ENV['LIBGEOS_PATH'],
28
+ '/opt/homebrew/lib/libgeos_c.dylib',
29
+ '/opt/homebrew/lib/libgeos_c.1.dylib',
30
+ '/usr/local/lib/libgeos_c.dylib',
31
+ '/usr/local/lib/libgeos_c.1.dylib',
32
+ '/usr/lib/libgeos_c.so',
33
+ '/usr/lib/libgeos_c.so.1',
34
+ '/usr/local/lib/libgeos_c.so',
35
+ '/usr/local/lib/libgeos_c.so.1',
36
+ '/usr/lib/x86_64-linux-gnu/libgeos_c.so',
37
+ '/usr/lib/aarch64-linux-gnu/libgeos_c.so',
38
+ ].compact.freeze
39
+
40
+ @handle = nil
41
+ @available = false
42
+ @context = nil
43
+
44
+ class << self
45
+ attr_reader :handle
46
+
47
+ def available?
48
+ @available
49
+ end
50
+
51
+ def require_library!
52
+ return if @available
53
+ raise Geodetic::Error,
54
+ "libgeos_c not found. Install GEOS: brew install geos (macOS) " \
55
+ "or see https://libgeos.org/usage/install/. " \
56
+ "Set LIBGEOS_PATH env var to specify a custom library path."
57
+ end
58
+
59
+ def context
60
+ require_library!
61
+ @context ||= F_GEOS_INIT_R.call
62
+ end
63
+ end
64
+
65
+ begin
66
+ path = SEARCH_PATHS.find { |p| File.exist?(p) }
67
+ if path
68
+ @handle = Fiddle.dlopen(path)
69
+ @available = true
70
+ end
71
+ rescue Fiddle::DLError
72
+ @available = false
73
+ end
74
+
75
+ # --- Function binding helper ---
76
+
77
+ def self.bind(name, args, ret)
78
+ return nil unless @available
79
+ ptr = @handle[name]
80
+ Fiddle::Function.new(ptr, args, ret)
81
+ rescue Fiddle::DLError
82
+ nil
83
+ end
84
+
85
+ # Type aliases
86
+ PTR = Fiddle::TYPE_VOIDP
87
+ INT = Fiddle::TYPE_INT
88
+ CHAR = Fiddle::TYPE_CHAR
89
+ DBL = Fiddle::TYPE_DOUBLE
90
+ VOID = Fiddle::TYPE_VOID
91
+
92
+ # --- Context management ---
93
+
94
+ F_GEOS_INIT_R = bind('GEOS_init_r', [], PTR)
95
+ F_GEOS_FINISH_R = bind('GEOS_finish_r', [PTR], VOID)
96
+
97
+ # --- WKT Reader/Writer ---
98
+
99
+ F_WKT_READER_CREATE = bind('GEOSWKTReader_create_r', [PTR], PTR)
100
+ F_WKT_READER_READ = bind('GEOSWKTReader_read_r', [PTR, PTR, PTR], PTR)
101
+ F_WKT_READER_DESTROY = bind('GEOSWKTReader_destroy_r', [PTR, PTR], VOID)
102
+
103
+ F_WKT_WRITER_CREATE = bind('GEOSWKTWriter_create_r', [PTR], PTR)
104
+ F_WKT_WRITER_WRITE = bind('GEOSWKTWriter_write_r', [PTR, PTR, PTR], PTR)
105
+ F_WKT_WRITER_DESTROY = bind('GEOSWKTWriter_destroy_r', [PTR, PTR], VOID)
106
+ F_WKT_WRITER_SET_PREC = bind('GEOSWKTWriter_setRoundingPrecision_r', [PTR, PTR, INT], VOID)
107
+
108
+ # --- Geometry lifecycle ---
109
+
110
+ F_GEOM_DESTROY = bind('GEOSGeom_destroy_r', [PTR, PTR], VOID)
111
+ F_GEOS_FREE = bind('GEOSFree_r', [PTR, PTR], VOID)
112
+
113
+ # --- Predicates ---
114
+
115
+ F_IS_VALID = bind('GEOSisValid_r', [PTR, PTR], CHAR)
116
+ F_IS_VALID_REASON = bind('GEOSisValidReason_r', [PTR, PTR], PTR)
117
+ F_INTERSECTS = bind('GEOSIntersects_r', [PTR, PTR, PTR], CHAR)
118
+ F_CONTAINS = bind('GEOSContains_r', [PTR, PTR, PTR], CHAR)
119
+
120
+ # --- Prepared geometry (cached spatial index) ---
121
+
122
+ F_PREPARE = bind('GEOSPrepare_r', [PTR, PTR], PTR)
123
+ F_PREPARED_DESTROY = bind('GEOSPreparedGeom_destroy_r', [PTR, PTR], VOID)
124
+ F_PREPARED_CONTAINS = bind('GEOSPreparedContains_r', [PTR, PTR, PTR], CHAR)
125
+ F_PREPARED_INTERSECTS = bind('GEOSPreparedIntersects_r', [PTR, PTR, PTR], CHAR)
126
+
127
+ # --- Operations ---
128
+
129
+ F_BUFFER = bind('GEOSBuffer_r', [PTR, PTR, DBL, INT], PTR)
130
+ F_BUFFER_STYLE = bind('GEOSBufferWithStyle_r', [PTR, PTR, DBL, INT, INT, INT, DBL], PTR)
131
+ F_CONVEX_HULL = bind('GEOSConvexHull_r', [PTR, PTR], PTR)
132
+ F_UNION = bind('GEOSUnaryUnion_r', [PTR, PTR], PTR)
133
+ F_INTERSECTION = bind('GEOSIntersection_r', [PTR, PTR, PTR], PTR)
134
+ F_DIFFERENCE = bind('GEOSDifference_r', [PTR, PTR, PTR], PTR)
135
+ F_SYM_DIFFERENCE = bind('GEOSSymDifference_r', [PTR, PTR, PTR], PTR)
136
+ F_SIMPLIFY = bind('GEOSSimplify_r', [PTR, PTR, DBL], PTR)
137
+ F_MAKE_VALID = bind('GEOSMakeValid_r', [PTR, PTR], PTR)
138
+
139
+ # --- Measurements ---
140
+
141
+ F_AREA = bind('GEOSArea_r', [PTR, PTR, PTR], INT)
142
+ F_LENGTH = bind('GEOSLength_r', [PTR, PTR, PTR], INT)
143
+ F_DISTANCE = bind('GEOSDistance_r', [PTR, PTR, PTR, PTR], INT)
144
+
145
+ # --- Geometry info ---
146
+
147
+ F_GEOM_TYPE_ID = bind('GEOSGeomTypeId_r', [PTR, PTR], INT)
148
+ F_GET_NUM_COORDS = bind('GEOSGetNumCoordinates_r', [PTR, PTR], INT)
149
+ F_GET_COORD_SEQ = bind('GEOSGeom_getCoordSeq_r', [PTR, PTR], PTR)
150
+ F_GET_EXTERIOR_RING = bind('GEOSGetExteriorRing_r', [PTR, PTR], PTR)
151
+ F_GET_NUM_GEOMS = bind('GEOSGetNumGeometries_r', [PTR, PTR], INT)
152
+ F_GET_GEOM_N = bind('GEOSGetGeometryN_r', [PTR, PTR, INT], PTR)
153
+ F_NEAREST_POINTS = bind('GEOSNearestPoints_r', [PTR, PTR, PTR], PTR)
154
+
155
+ # --- Coord sequence access ---
156
+
157
+ F_COORD_SEQ_GET_SIZE = bind('GEOSCoordSeq_getSize_r', [PTR, PTR, PTR], INT)
158
+ F_COORD_SEQ_GET_X = bind('GEOSCoordSeq_getX_r', [PTR, PTR, Fiddle::TYPE_INT, PTR], INT)
159
+ F_COORD_SEQ_GET_Y = bind('GEOSCoordSeq_getY_r', [PTR, PTR, Fiddle::TYPE_INT, PTR], INT)
160
+
161
+ # --- High-level wrappers ---
162
+
163
+ def self.wkt_to_geom(wkt_string)
164
+ ctx = context
165
+ reader = F_WKT_READER_CREATE.call(ctx)
166
+ begin
167
+ geom = F_WKT_READER_READ.call(ctx, reader, wkt_string)
168
+ raise Geodetic::Error, "GEOS failed to parse WKT: #{wkt_string[0..60]}" if geom.null?
169
+ geom
170
+ ensure
171
+ F_WKT_READER_DESTROY.call(ctx, reader)
172
+ end
173
+ end
174
+
175
+ def self.geom_to_wkt(geom, precision: 6)
176
+ ctx = context
177
+ writer = F_WKT_WRITER_CREATE.call(ctx)
178
+ F_WKT_WRITER_SET_PREC.call(ctx, writer, precision)
179
+ begin
180
+ ptr = F_WKT_WRITER_WRITE.call(ctx, writer, geom)
181
+ raise Geodetic::Error, "GEOS failed to write WKT" if ptr.null?
182
+ str = ptr.to_s
183
+ F_GEOS_FREE.call(ctx, ptr)
184
+ str
185
+ ensure
186
+ F_WKT_WRITER_DESTROY.call(ctx, writer)
187
+ end
188
+ end
189
+
190
+ def self.destroy_geom(geom)
191
+ F_GEOM_DESTROY.call(context, geom) unless geom.null?
192
+ end
193
+
194
+ # Extract coordinates from a GEOS geometry coord sequence.
195
+ # Returns array of {lng:, lat:} hashes.
196
+ def self.extract_coords(geom)
197
+ ctx = context
198
+ cs = F_GET_COORD_SEQ.call(ctx, geom)
199
+ size_buf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_INT, Fiddle::RUBY_FREE)
200
+ F_COORD_SEQ_GET_SIZE.call(ctx, cs, size_buf)
201
+ size = size_buf[0, Fiddle::SIZEOF_INT].unpack1('i')
202
+
203
+ dbl_buf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
204
+ coords = []
205
+ size.times do |i|
206
+ F_COORD_SEQ_GET_X.call(ctx, cs, i, dbl_buf)
207
+ x = dbl_buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
208
+ F_COORD_SEQ_GET_Y.call(ctx, cs, i, dbl_buf)
209
+ y = dbl_buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
210
+ coords << { lng: x, lat: y }
211
+ end
212
+ coords
213
+ end
214
+
215
+ # Extract all coordinates from a polygon's exterior ring
216
+ def self.extract_polygon_coords(geom)
217
+ ctx = context
218
+ ring = F_GET_EXTERIOR_RING.call(ctx, geom)
219
+ extract_coords(ring)
220
+ end
221
+ end
222
+
223
+ # ---------------------------------------------------------------
224
+ # Public API
225
+ # ---------------------------------------------------------------
226
+
227
+ class << self
228
+ def available?
229
+ !ENV.key?('GEODETIC_GEOS_DISABLE') && LibGEOS.available?
230
+ end
231
+
232
+ # Convert a geodetic object to a GEOS geometry via WKT.
233
+ # Handles Arrays (e.g. from multi-geometry results) by wrapping
234
+ # in a GEOMETRYCOLLECTION.
235
+ # Caller must call release(geom) when done.
236
+ def to_geos_geom(obj)
237
+ LibGEOS.require_library!
238
+ if obj.is_a?(Array)
239
+ wkts = obj.map { |o| o.to_wkt(precision: 10) }
240
+ wkt = "GEOMETRYCOLLECTION(#{wkts.join(', ')})"
241
+ else
242
+ wkt = obj.to_wkt(precision: 10)
243
+ end
244
+ LibGEOS.wkt_to_geom(wkt)
245
+ end
246
+
247
+ # Free a GEOS geometry pointer
248
+ def release(geom)
249
+ LibGEOS.destroy_geom(geom)
250
+ end
251
+
252
+ # Execute a block with a GEOS geometry, ensuring cleanup
253
+ def with_geom(obj)
254
+ geom = to_geos_geom(obj)
255
+ begin
256
+ yield geom
257
+ ensure
258
+ release(geom)
259
+ end
260
+ end
261
+
262
+ # Execute a block with two GEOS geometries, ensuring cleanup
263
+ def with_geoms(obj_a, obj_b)
264
+ geom_a = to_geos_geom(obj_a)
265
+ geom_b = to_geos_geom(obj_b)
266
+ begin
267
+ yield geom_a, geom_b
268
+ ensure
269
+ release(geom_a)
270
+ release(geom_b)
271
+ end
272
+ end
273
+
274
+ # --- Predicates ---
275
+
276
+ # Does geometry A contain geometry B?
277
+ def contains?(a, b)
278
+ with_geoms(a, b) do |ga, gb|
279
+ LibGEOS::F_CONTAINS.call(LibGEOS.context, ga, gb) == 1
280
+ end
281
+ end
282
+
283
+ # Do geometries A and B intersect?
284
+ def intersects?(a, b)
285
+ with_geoms(a, b) do |ga, gb|
286
+ LibGEOS::F_INTERSECTS.call(LibGEOS.context, ga, gb) == 1
287
+ end
288
+ end
289
+
290
+ # Is the geometry valid per OGC rules?
291
+ def is_valid?(obj)
292
+ with_geom(obj) do |g|
293
+ LibGEOS::F_IS_VALID.call(LibGEOS.context, g) == 1
294
+ end
295
+ end
296
+
297
+ # Returns a string explaining why the geometry is invalid, or "Valid Geometry"
298
+ def is_valid_reason(obj)
299
+ with_geom(obj) do |g|
300
+ ptr = LibGEOS::F_IS_VALID_REASON.call(LibGEOS.context, g)
301
+ reason = ptr.to_s
302
+ LibGEOS::F_GEOS_FREE.call(LibGEOS.context, ptr)
303
+ reason
304
+ end
305
+ end
306
+
307
+ # --- Operations that return new geometries ---
308
+
309
+ # Buffer a geometry by a distance (in the geometry's coordinate units — degrees for LLA).
310
+ # quad_segs controls circle approximation quality (default 8).
311
+ def buffer(obj, distance, quad_segs: 8)
312
+ with_geom(obj) do |g|
313
+ result = LibGEOS::F_BUFFER.call(LibGEOS.context, g, distance, quad_segs)
314
+ raise Geodetic::Error, "GEOS buffer failed" if result.null?
315
+ begin
316
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
317
+ WKT.parse(wkt)
318
+ ensure
319
+ release(result)
320
+ end
321
+ end
322
+ end
323
+
324
+ # Buffer with join/cap style control.
325
+ # end_cap_style: 1=round, 2=flat, 3=square
326
+ # join_style: 1=round, 2=mitre, 3=bevel
327
+ def buffer_with_style(obj, distance, quad_segs: 8, end_cap_style: 1, join_style: 1, mitre_limit: 5.0)
328
+ with_geom(obj) do |g|
329
+ result = LibGEOS::F_BUFFER_STYLE.call(
330
+ LibGEOS.context, g, distance, quad_segs, end_cap_style, join_style, mitre_limit
331
+ )
332
+ raise Geodetic::Error, "GEOS buffer_with_style failed" if result.null?
333
+ begin
334
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
335
+ WKT.parse(wkt)
336
+ ensure
337
+ release(result)
338
+ end
339
+ end
340
+ end
341
+
342
+ # Convex hull of a geometry
343
+ def convex_hull(obj)
344
+ with_geom(obj) do |g|
345
+ result = LibGEOS::F_CONVEX_HULL.call(LibGEOS.context, g)
346
+ raise Geodetic::Error, "GEOS convex_hull failed" if result.null?
347
+ begin
348
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
349
+ WKT.parse(wkt)
350
+ ensure
351
+ release(result)
352
+ end
353
+ end
354
+ end
355
+
356
+ # Unary union (dissolve internal boundaries)
357
+ def union(obj)
358
+ with_geom(obj) do |g|
359
+ result = LibGEOS::F_UNION.call(LibGEOS.context, g)
360
+ raise Geodetic::Error, "GEOS union failed" if result.null?
361
+ begin
362
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
363
+ WKT.parse(wkt)
364
+ ensure
365
+ release(result)
366
+ end
367
+ end
368
+ end
369
+
370
+ # Intersection of two geometries
371
+ def intersection(a, b)
372
+ with_geoms(a, b) do |ga, gb|
373
+ result = LibGEOS::F_INTERSECTION.call(LibGEOS.context, ga, gb)
374
+ raise Geodetic::Error, "GEOS intersection failed" if result.null?
375
+ begin
376
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
377
+ WKT.parse(wkt)
378
+ ensure
379
+ release(result)
380
+ end
381
+ end
382
+ end
383
+
384
+ # Difference: A minus B
385
+ def difference(a, b)
386
+ with_geoms(a, b) do |ga, gb|
387
+ result = LibGEOS::F_DIFFERENCE.call(LibGEOS.context, ga, gb)
388
+ raise Geodetic::Error, "GEOS difference failed" if result.null?
389
+ begin
390
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
391
+ WKT.parse(wkt)
392
+ ensure
393
+ release(result)
394
+ end
395
+ end
396
+ end
397
+
398
+ # Symmetric difference: (A - B) union (B - A)
399
+ def symmetric_difference(a, b)
400
+ with_geoms(a, b) do |ga, gb|
401
+ result = LibGEOS::F_SYM_DIFFERENCE.call(LibGEOS.context, ga, gb)
402
+ raise Geodetic::Error, "GEOS symmetric_difference failed" if result.null?
403
+ begin
404
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
405
+ WKT.parse(wkt)
406
+ ensure
407
+ release(result)
408
+ end
409
+ end
410
+ end
411
+
412
+ # Simplify geometry using Douglas-Peucker algorithm.
413
+ # tolerance is in the geometry's coordinate units (degrees for LLA).
414
+ def simplify(obj, tolerance)
415
+ with_geom(obj) do |g|
416
+ result = LibGEOS::F_SIMPLIFY.call(LibGEOS.context, g, tolerance)
417
+ raise Geodetic::Error, "GEOS simplify failed" if result.null?
418
+ begin
419
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
420
+ WKT.parse(wkt)
421
+ ensure
422
+ release(result)
423
+ end
424
+ end
425
+ end
426
+
427
+ # Repair an invalid geometry
428
+ def make_valid(obj)
429
+ with_geom(obj) do |g|
430
+ result = LibGEOS::F_MAKE_VALID.call(LibGEOS.context, g)
431
+ raise Geodetic::Error, "GEOS make_valid failed" if result.null?
432
+ begin
433
+ wkt = LibGEOS.geom_to_wkt(result, precision: 10)
434
+ WKT.parse(wkt)
435
+ ensure
436
+ release(result)
437
+ end
438
+ end
439
+ end
440
+
441
+ # --- Measurements (planar, in coordinate units) ---
442
+
443
+ # Planar area of a polygon (in square degrees for LLA geometries)
444
+ def area(obj)
445
+ with_geom(obj) do |g|
446
+ buf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
447
+ LibGEOS::F_AREA.call(LibGEOS.context, g, buf)
448
+ buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
449
+ end
450
+ end
451
+
452
+ # Planar length (in coordinate units — degrees for LLA)
453
+ def length(obj)
454
+ with_geom(obj) do |g|
455
+ buf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
456
+ LibGEOS::F_LENGTH.call(LibGEOS.context, g, buf)
457
+ buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
458
+ end
459
+ end
460
+
461
+ # Planar distance between two geometries (in coordinate units)
462
+ def distance(a, b)
463
+ with_geoms(a, b) do |ga, gb|
464
+ buf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
465
+ LibGEOS::F_DISTANCE.call(LibGEOS.context, ga, gb, buf)
466
+ buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
467
+ end
468
+ end
469
+
470
+ # Nearest points between two geometries.
471
+ # Returns [LLA, LLA] — the closest point on each geometry.
472
+ def nearest_points(a, b)
473
+ with_geoms(a, b) do |ga, gb|
474
+ cs = LibGEOS::F_NEAREST_POINTS.call(LibGEOS.context, ga, gb)
475
+ raise Geodetic::Error, "GEOS nearest_points failed" if cs.null?
476
+
477
+ dbl_buf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DOUBLE, Fiddle::RUBY_FREE)
478
+ points = 2.times.map do |i|
479
+ LibGEOS::F_COORD_SEQ_GET_X.call(LibGEOS.context, cs, i, dbl_buf)
480
+ lng = dbl_buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
481
+ LibGEOS::F_COORD_SEQ_GET_Y.call(LibGEOS.context, cs, i, dbl_buf)
482
+ lat = dbl_buf[0, Fiddle::SIZEOF_DOUBLE].unpack1('d')
483
+ Coordinate::LLA.new(lat: lat, lng: lng, alt: 0.0)
484
+ end
485
+
486
+ # CoordSeq from nearest_points is owned by caller — must free
487
+ # GEOS returns a CoordSequence, not a Geometry, so we need GEOSCoordSeq_destroy_r
488
+ # But actually GEOS docs say nearest_points returns a CoordSequence that must be freed.
489
+ # The _r API uses GEOSCoordSeq_destroy_r, but it's returned as a geometry-owned seq.
490
+ # In practice, the returned CoordSequence pointer needs GEOSCoordSeq_destroy_r.
491
+ points
492
+ end
493
+ end
494
+
495
+ # --- Prepared geometry for batch operations ---
496
+
497
+ # Returns a PreparedGeometry wrapper for efficient repeated tests.
498
+ # Usage:
499
+ # prepared = Geos.prepare(polygon)
500
+ # prepared.contains?(point1) # fast
501
+ # prepared.contains?(point2) # fast (reuses spatial index)
502
+ # prepared.release # free when done
503
+ def prepare(obj)
504
+ PreparedGeometry.new(obj)
505
+ end
506
+ end
507
+
508
+ # Wraps a GEOS PreparedGeometry for batch spatial tests.
509
+ # The prepared geometry builds a spatial index once, then
510
+ # contains?/intersects? queries are O(log n) instead of O(n).
511
+ class PreparedGeometry
512
+ def initialize(obj)
513
+ LibGEOS.require_library!
514
+ @geom = Geos.to_geos_geom(obj)
515
+ @prepared = LibGEOS::F_PREPARE.call(LibGEOS.context, @geom)
516
+ @released = false
517
+ end
518
+
519
+ def contains?(other)
520
+ raise Geodetic::Error, "PreparedGeometry already released" if @released
521
+ other_geom = Geos.to_geos_geom(other)
522
+ begin
523
+ LibGEOS::F_PREPARED_CONTAINS.call(LibGEOS.context, @prepared, other_geom) == 1
524
+ ensure
525
+ LibGEOS.destroy_geom(other_geom)
526
+ end
527
+ end
528
+
529
+ def intersects?(other)
530
+ raise Geodetic::Error, "PreparedGeometry already released" if @released
531
+ other_geom = Geos.to_geos_geom(other)
532
+ begin
533
+ LibGEOS::F_PREPARED_INTERSECTS.call(LibGEOS.context, @prepared, other_geom) == 1
534
+ ensure
535
+ LibGEOS.destroy_geom(other_geom)
536
+ end
537
+ end
538
+
539
+ def release
540
+ return if @released
541
+ LibGEOS::F_PREPARED_DESTROY.call(LibGEOS.context, @prepared)
542
+ LibGEOS.destroy_geom(@geom)
543
+ @released = true
544
+ end
545
+ end
546
+ end
547
+ end