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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +94 -0
- data/docs/index.md +4 -0
- data/docs/reference/geos-acceleration.md +170 -0
- data/docs/reference/wkb.md +385 -0
- data/docs/reference/wkt.md +354 -0
- data/examples/10_wkt_serialization.rb +248 -0
- data/examples/11_wkb_serialization.rb +261 -0
- data/examples/12_geos_benchmark.rb +258 -0
- data/examples/13_geos_operations.rb +538 -0
- data/examples/README.md +82 -0
- data/examples/geodetic_demo.wkb +0 -0
- data/examples/geodetic_demo.wkt +5 -0
- data/examples/geodetic_demo_output.wkb.hex +5 -0
- data/examples/sample_geometries.wkb +0 -0
- data/examples/sample_geometries.wkb.hex +60 -0
- data/lib/geodetic/areas/polygon.rb +66 -10
- data/lib/geodetic/geos.rb +547 -0
- data/lib/geodetic/path.rb +20 -8
- data/lib/geodetic/version.rb +1 -1
- data/lib/geodetic/wkb.rb +360 -0
- data/lib/geodetic/wkt.rb +313 -0
- data/lib/geodetic.rb +3 -0
- data/mkdocs.yml +2 -0
- metadata +16 -1
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|