tg_geometry 0.2.0 → 0.3.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 +37 -0
- data/README.md +107 -14
- data/benchmark/ewkb_roundtrip.rb +29 -0
- data/benchmark/geom_query.rb +33 -0
- data/benchmark/nearest_segment.rb +20 -0
- data/docs/GEOMETRY_QUERIES.md +23 -0
- data/docs/LIMITATIONS.md +12 -10
- data/docs/NEAREST_SEGMENT.md +17 -0
- data/docs/SRID_AND_EWKB.md +23 -0
- data/ext/tg_geometry/tg_geometry_ext.c +1176 -4
- data/lib/tg/geometry/active_record_source.rb +16 -34
- data/lib/tg/geometry/active_record_type.rb +61 -0
- data/lib/tg/geometry/registry.rb +17 -68
- data/lib/tg/geometry/version.rb +1 -1
- data/lib/tg/geometry.rb +85 -0
- data/spec/active_record_type_spec.rb +45 -0
- data/spec/constructors_spec.rb +104 -0
- data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
- data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
- data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
- data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
- data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
- data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
- data/spec/fixtures/postgis/README.md +16 -0
- data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
- data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
- data/spec/fixtures/postgis/point_4326.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
- data/spec/index_geom_query_spec.rb +68 -0
- data/spec/keyword_validation_spec.rb +31 -0
- data/spec/nearest_segment_spec.rb +62 -0
- data/spec/postgis_fixtures_spec.rb +68 -0
- data/spec/srid_spec.rb +43 -0
- data/spec/to_ewkb_spec.rb +37 -0
- metadata +50 -2
|
@@ -6,52 +6,34 @@ module TG
|
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
8
|
def call(scope, id:, geometry:, batch_size: 1_000)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
each_record(scope, batch_size: batch_size) do |record|
|
|
12
|
-
entries << [read_field(record, id), read_field(record, geometry)]
|
|
9
|
+
enumerator(scope, batch_size: batch_size).map do |record|
|
|
10
|
+
[read_field(record, id), read_field(record, geometry)]
|
|
13
11
|
end
|
|
14
|
-
|
|
15
|
-
entries
|
|
16
12
|
end
|
|
17
13
|
|
|
18
14
|
def registry_source(scope, id:, geometry:, batch_size: 1_000)
|
|
19
|
-
|
|
20
|
-
TG::Geometry::ActiveRecordSource.call(
|
|
21
|
-
scope,
|
|
22
|
-
id: id,
|
|
23
|
-
geometry: geometry,
|
|
24
|
-
batch_size: batch_size
|
|
25
|
-
)
|
|
26
|
-
end
|
|
15
|
+
-> { TG::Geometry::ActiveRecordSource.call(scope, id: id, geometry: geometry, batch_size: batch_size) }
|
|
27
16
|
end
|
|
28
17
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
scope.
|
|
18
|
+
class << self
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def enumerator(scope, batch_size:)
|
|
22
|
+
scope.respond_to?(:find_each) ? scope.find_each(batch_size: batch_size) : scope.each
|
|
34
23
|
end
|
|
35
|
-
end
|
|
36
|
-
private_class_method :each_record
|
|
37
24
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
reader.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
record.public_send(reader)
|
|
45
|
-
elsif record.respond_to?(:[])
|
|
46
|
-
record[reader]
|
|
47
|
-
else
|
|
25
|
+
def read_field(record, reader)
|
|
26
|
+
case reader
|
|
27
|
+
in Proc then reader.call(record)
|
|
28
|
+
in Symbol | String if record.respond_to?(reader) then record.public_send(reader)
|
|
29
|
+
in Symbol | String if record.respond_to?(:[]) then record[reader]
|
|
30
|
+
in Symbol | String
|
|
48
31
|
raise TG::Geometry::ArgumentError, "record does not expose #{reader.inspect}"
|
|
32
|
+
else
|
|
33
|
+
raise TG::Geometry::ArgumentError, "field reader must be Symbol, String, or Proc"
|
|
49
34
|
end
|
|
50
|
-
else
|
|
51
|
-
raise TG::Geometry::ArgumentError, "field reader must be Symbol, String, or Proc"
|
|
52
35
|
end
|
|
53
36
|
end
|
|
54
|
-
private_class_method :read_field
|
|
55
37
|
end
|
|
56
38
|
end
|
|
57
39
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "tg/geometry"
|
|
5
|
+
|
|
6
|
+
module TG
|
|
7
|
+
module Geometry
|
|
8
|
+
class ActiveRecordType < ActiveModel::Type::Value
|
|
9
|
+
HEX_PATTERN = /\A[0-9A-Fa-f]+\z/
|
|
10
|
+
|
|
11
|
+
def initialize(strict: true)
|
|
12
|
+
super()
|
|
13
|
+
@strict = strict
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def type = :tg_geometry
|
|
17
|
+
|
|
18
|
+
def deserialize(value)
|
|
19
|
+
case value
|
|
20
|
+
when nil then nil
|
|
21
|
+
when TG::Geometry::Geom then value
|
|
22
|
+
when String then parse_string(value)
|
|
23
|
+
else
|
|
24
|
+
@strict ? raise(TG::Geometry::ArgumentError, "cannot deserialize #{value.class} as TG::Geometry::Geom") : nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
alias cast deserialize
|
|
29
|
+
|
|
30
|
+
def serialize(value)
|
|
31
|
+
case value
|
|
32
|
+
when nil, String then value
|
|
33
|
+
else raise TG::Geometry::ArgumentError, "TG::Geometry::ActiveRecordType is read-only in v0.3.0"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_string(str)
|
|
40
|
+
stripped = str.strip
|
|
41
|
+
|
|
42
|
+
if stripped.start_with?("\\x") && stripped[2..].match?(HEX_PATTERN)
|
|
43
|
+
TG::Geometry.parse_hex(stripped[2..])
|
|
44
|
+
elsif stripped.match?(HEX_PATTERN)
|
|
45
|
+
TG::Geometry.parse_hex(stripped)
|
|
46
|
+
elsif str.encoding == Encoding::ASCII_8BIT
|
|
47
|
+
TG::Geometry.parse_wkb(str)
|
|
48
|
+
elsif stripped.start_with?("{")
|
|
49
|
+
TG::Geometry.parse_geojson(str)
|
|
50
|
+
else
|
|
51
|
+
TG::Geometry.parse_wkt(str)
|
|
52
|
+
end
|
|
53
|
+
rescue TG::Geometry::ParseError => e
|
|
54
|
+
raise if @strict
|
|
55
|
+
|
|
56
|
+
warn "TG::Geometry::ActiveRecordType: parse failed: #{e.message}"
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/tg/geometry/registry.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
3
5
|
module TG
|
|
4
6
|
module Geometry
|
|
5
7
|
class Registry
|
|
8
|
+
extend Forwardable
|
|
9
|
+
|
|
6
10
|
DEFAULT_INDEX_OPTIONS = {
|
|
7
11
|
via: :geojson,
|
|
8
12
|
strategy: :rtree,
|
|
@@ -11,41 +15,24 @@ module TG
|
|
|
11
15
|
}.freeze
|
|
12
16
|
|
|
13
17
|
class << self
|
|
14
|
-
def source(&
|
|
15
|
-
if
|
|
16
|
-
|
|
17
|
-
elsif instance_variable_defined?(:@source)
|
|
18
|
-
@source
|
|
19
|
-
elsif superclass.respond_to?(:source)
|
|
20
|
-
superclass.source
|
|
21
|
-
end
|
|
18
|
+
def source(&block)
|
|
19
|
+
@source = block if block
|
|
20
|
+
@source || (superclass.source if superclass.respond_to?(:source))
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
def index_options(**options)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
inherited.merge(@index_options || {}).freeze
|
|
28
|
-
else
|
|
29
|
-
@index_options = index_options.merge(options).freeze
|
|
30
|
-
end
|
|
31
|
-
end
|
|
24
|
+
inherited = superclass.respond_to?(:index_options) ? superclass.index_options : DEFAULT_INDEX_OPTIONS
|
|
25
|
+
return inherited.merge(@index_options || {}).freeze if options.empty?
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
require_relative "active_record_source"
|
|
35
|
-
|
|
36
|
-
source do
|
|
37
|
-
TG::Geometry::ActiveRecordSource.call(
|
|
38
|
-
scope,
|
|
39
|
-
id: id,
|
|
40
|
-
geometry: geometry,
|
|
41
|
-
batch_size: batch_size
|
|
42
|
-
)
|
|
43
|
-
end
|
|
27
|
+
@index_options = inherited.merge(@index_options || {}, options).freeze
|
|
44
28
|
end
|
|
45
29
|
end
|
|
46
30
|
|
|
47
31
|
attr_reader :index_options
|
|
48
32
|
|
|
33
|
+
def_delegators :index, :size, :bbox, :find_covering, :covering_ids,
|
|
34
|
+
:intersecting_rect, :covering_ids_batch_packed
|
|
35
|
+
|
|
49
36
|
def initialize(entries: nil, source: nil, **index_options)
|
|
50
37
|
@entries = entries
|
|
51
38
|
@source = source
|
|
@@ -56,11 +43,7 @@ module TG
|
|
|
56
43
|
|
|
57
44
|
def reload!
|
|
58
45
|
new_index = TG::Geometry::Index.build(resolve_entries, **@index_options)
|
|
59
|
-
|
|
60
|
-
@reload_mutex.synchronize do
|
|
61
|
-
@index = new_index
|
|
62
|
-
end
|
|
63
|
-
|
|
46
|
+
@reload_mutex.synchronize { @index = new_index }
|
|
64
47
|
new_index
|
|
65
48
|
end
|
|
66
49
|
|
|
@@ -68,51 +51,17 @@ module TG
|
|
|
68
51
|
@index || raise(TG::Geometry::Error, "registry index is not loaded; call reload! first")
|
|
69
52
|
end
|
|
70
53
|
|
|
71
|
-
def loaded?
|
|
72
|
-
!@index.nil?
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def size
|
|
76
|
-
current_index.size
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def bbox
|
|
80
|
-
current_index.bbox
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def find_covering(lon, lat)
|
|
84
|
-
current_index.find_covering(lon, lat)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def covering_ids(lon, lat)
|
|
88
|
-
current_index.covering_ids(lon, lat)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def intersecting_rect(min_x, min_y, max_x, max_y)
|
|
92
|
-
current_index.intersecting_rect(min_x, min_y, max_x, max_y)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def covering_ids_batch_packed(binary_string)
|
|
96
|
-
current_index.covering_ids_batch_packed(binary_string)
|
|
97
|
-
end
|
|
54
|
+
def loaded? = !@index.nil?
|
|
98
55
|
|
|
99
56
|
private
|
|
100
57
|
|
|
101
|
-
def current_index
|
|
102
|
-
@index || raise(TG::Geometry::Error, "registry index is not loaded; call reload! first")
|
|
103
|
-
end
|
|
104
|
-
|
|
105
58
|
def resolve_entries
|
|
106
|
-
return @entries
|
|
59
|
+
return @entries if @entries
|
|
107
60
|
|
|
108
61
|
callable = @source || self.class.source
|
|
109
62
|
raise TG::Geometry::Error, "registry source is not configured" unless callable
|
|
110
63
|
|
|
111
|
-
|
|
112
|
-
instance_exec(&callable)
|
|
113
|
-
else
|
|
114
|
-
raise TG::Geometry::Error, "registry source must be callable"
|
|
115
|
-
end
|
|
64
|
+
instance_exec(&callable)
|
|
116
65
|
end
|
|
117
66
|
end
|
|
118
67
|
end
|
data/lib/tg/geometry/version.rb
CHANGED
data/lib/tg/geometry.rb
CHANGED
|
@@ -4,3 +4,88 @@ require_relative "geometry/version"
|
|
|
4
4
|
require "tg_geometry_ext_geometry_ext"
|
|
5
5
|
require_relative "geometry/registry"
|
|
6
6
|
require_relative "geometry/active_record_source"
|
|
7
|
+
|
|
8
|
+
module TG
|
|
9
|
+
# Fast immutable planar geometry engine with proper indexes and clean
|
|
10
|
+
# EWKB/PostGIS boundaries. Not a GIS. Not an rgeo replacement.
|
|
11
|
+
#
|
|
12
|
+
# All distances and coordinates are in input units. SRID is metadata only;
|
|
13
|
+
# no reprojection is performed.
|
|
14
|
+
module Geometry
|
|
15
|
+
# @!method self.line_string(points, index: :natural, srid: nil)
|
|
16
|
+
# @param points [Array<Array<Float>>]
|
|
17
|
+
# @param index [:default, :none, :natural, :ystripes]
|
|
18
|
+
# @param srid [Integer, nil]
|
|
19
|
+
# @return [TG::Geometry::Geom]
|
|
20
|
+
#
|
|
21
|
+
# @!method self.polygon(exterior, holes: [], index: :ystripes, srid: nil)
|
|
22
|
+
# @param exterior [Array<Array<Float>>]
|
|
23
|
+
# @param holes [Array<Array<Array<Float>>>]
|
|
24
|
+
# @param index [:default, :none, :natural, :ystripes]
|
|
25
|
+
# @param srid [Integer, nil]
|
|
26
|
+
# @return [TG::Geometry::Geom]
|
|
27
|
+
#
|
|
28
|
+
# @!method self.multi_polygon(polygons, index: :ystripes, srid: nil)
|
|
29
|
+
# Each polygon is a Hash with :exterior and optional :holes, or an Array
|
|
30
|
+
# shorthand for an exterior with no holes.
|
|
31
|
+
# @param polygons [Array<Hash, Array>]
|
|
32
|
+
# @return [TG::Geometry::Geom]
|
|
33
|
+
#
|
|
34
|
+
# @!method self.parse_wkb(bytes, index: :ystripes)
|
|
35
|
+
# Parses WKB or EWKB. SRID is preserved when the EWKB SRID flag is set.
|
|
36
|
+
# @return [TG::Geometry::Geom]
|
|
37
|
+
#
|
|
38
|
+
# @!method self.parse_hex(hex, index: :ystripes)
|
|
39
|
+
# Parses HEXWKB or HEXEWKB. SRID is preserved when the EWKB SRID flag is set.
|
|
40
|
+
# @return [TG::Geometry::Geom]
|
|
41
|
+
|
|
42
|
+
class Geom
|
|
43
|
+
# @!method srid
|
|
44
|
+
# @return [Integer, nil] SRID metadata; not used for reprojection
|
|
45
|
+
#
|
|
46
|
+
# @!method to_ewkb(srid: nil)
|
|
47
|
+
# Writes EWKB with the SRID flag set. Uses explicit srid: when provided,
|
|
48
|
+
# otherwise Geom#srid. Raises if no SRID is available. to_wkb remains plain.
|
|
49
|
+
# @return [String] frozen ASCII-8BIT EWKB string
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class Index
|
|
53
|
+
# @!method self.build(entries, via:, strategy:, predicate: :covers, geometry_index: :ystripes)
|
|
54
|
+
# predicate: affects only legacy point query methods: find_covering,
|
|
55
|
+
# covering_ids(x, y), covering_ids_batch_packed. The *_geom_ids methods
|
|
56
|
+
# use their own predicates derived from the method name.
|
|
57
|
+
#
|
|
58
|
+
# @!method intersecting_geom_ids(geom)
|
|
59
|
+
# Stored geometries for which tg_geom_intersects(stored, query) is true.
|
|
60
|
+
# @return [Array<Object>] ids in insertion order
|
|
61
|
+
#
|
|
62
|
+
# @!method covering_geom_ids(geom)
|
|
63
|
+
# Stored geometries for which tg_geom_covers(stored, query) is true.
|
|
64
|
+
# Direction: stored covers query. Boundary points are covered.
|
|
65
|
+
# @return [Array<Object>] ids in insertion order
|
|
66
|
+
#
|
|
67
|
+
# @!method containing_geom_ids(geom)
|
|
68
|
+
# Stored geometries for which tg_geom_contains(stored, query) is true.
|
|
69
|
+
# Direction: stored contains query. Boundary points are not contained.
|
|
70
|
+
# @return [Array<Object>] ids in insertion order
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Line
|
|
74
|
+
# @!method nearest_segment(x, y)
|
|
75
|
+
# @return [TG::Geometry::NearestSegment, nil] planar Euclidean
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Ring
|
|
79
|
+
# @!method nearest_segment(x, y)
|
|
80
|
+
# @return [TG::Geometry::NearestSegment, nil] planar Euclidean
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Result of Line#nearest_segment / Ring#nearest_segment.
|
|
84
|
+
class NearestSegment
|
|
85
|
+
# @!attribute [r] segment @return [TG::Geometry::Segment]
|
|
86
|
+
# @!attribute [r] index @return [Integer]
|
|
87
|
+
# @!attribute [r] distance @return [Float] planar Euclidean in input units
|
|
88
|
+
# @!attribute [r] point @return [Array(Float, Float)] projection
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tg/geometry/active_record_type"
|
|
5
|
+
|
|
6
|
+
RSpec.describe TG::Geometry::ActiveRecordType do
|
|
7
|
+
let(:type) { described_class.new }
|
|
8
|
+
let(:ewkb) { TG::Geometry.parse_wkt("POINT (1 2)").to_ewkb(srid: 4326) }
|
|
9
|
+
let(:geom) { TG::Geometry.parse_wkb(ewkb) }
|
|
10
|
+
|
|
11
|
+
it "deserializes supported read shapes" do
|
|
12
|
+
expect(type.type).to eq(:tg_geometry)
|
|
13
|
+
expect(type.deserialize(nil)).to be_nil
|
|
14
|
+
expect(type.deserialize(geom)).to equal(geom)
|
|
15
|
+
expect(type.deserialize(ewkb).srid).to eq(4326)
|
|
16
|
+
expect(type.deserialize(ewkb.unpack1("H*")).srid).to eq(4326)
|
|
17
|
+
expect(type.deserialize("\\x#{ewkb.unpack1('H*')}").srid).to eq(4326)
|
|
18
|
+
expect(type.deserialize('{"type":"Point","coordinates":[1,2]}').srid).to be_nil
|
|
19
|
+
expect(type.deserialize("POINT (1 2)").srid).to be_nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "detects ASCII-8BIT hex before WKB fallback" do
|
|
23
|
+
hex = ewkb.unpack1("H*").b
|
|
24
|
+
|
|
25
|
+
expect(type.deserialize(hex).srid).to eq(4326)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "is strict by default and can warn+nil in non-strict mode" do
|
|
29
|
+
expect { type.deserialize("not geometry") }.to raise_error(TG::Geometry::ParseError)
|
|
30
|
+
|
|
31
|
+
non_strict = described_class.new(strict: false)
|
|
32
|
+
expect { expect(non_strict.deserialize("not geometry")).to be_nil }
|
|
33
|
+
.to output(/parse failed/).to_stderr
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "is read-only on serialize" do
|
|
37
|
+
expect(type.serialize(nil)).to be_nil
|
|
38
|
+
expect(type.serialize("raw")).to eq("raw")
|
|
39
|
+
expect { type.serialize(geom) }.to raise_error(TG::Geometry::ArgumentError)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "uses cast as deserialize" do
|
|
43
|
+
expect(type.cast(ewkb).srid).to eq(4326)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Constructors" do
|
|
6
|
+
let(:square) { [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]] }
|
|
7
|
+
let(:hole_a) { [[2.0, 2.0], [4.0, 2.0], [4.0, 4.0], [2.0, 4.0], [2.0, 2.0]] }
|
|
8
|
+
let(:hole_b) { [[6.0, 6.0], [8.0, 6.0], [8.0, 8.0], [6.0, 8.0], [6.0, 6.0]] }
|
|
9
|
+
|
|
10
|
+
describe ".line_string" do
|
|
11
|
+
it "builds a frozen two-point linestring" do
|
|
12
|
+
geom = TG::Geometry.line_string([[0.0, 0.0], [1.0, 1.0]])
|
|
13
|
+
|
|
14
|
+
expect(geom).to be_frozen
|
|
15
|
+
expect(geom.type).to eq(:linestring)
|
|
16
|
+
expect(geom.line.num_points).to eq(2)
|
|
17
|
+
expect(geom.srid).to be_nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "builds a 100-point linestring" do
|
|
21
|
+
points = 100.times.map { |i| [i.to_f, (i * 2).to_f] }
|
|
22
|
+
geom = TG::Geometry.line_string(points)
|
|
23
|
+
|
|
24
|
+
expect(geom.line.num_points).to eq(100)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "supports explicit index values and srid metadata" do
|
|
28
|
+
%i[default natural ystripes none].each do |index|
|
|
29
|
+
geom = TG::Geometry.line_string([[0, 0], [1, 1]], index: index, srid: 4326)
|
|
30
|
+
expect(geom.srid).to eq(4326)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "rejects too few points" do
|
|
35
|
+
expect { TG::Geometry.line_string([[0, 0]]) }
|
|
36
|
+
.to raise_error(TG::Geometry::ArgumentError, "line_string requires at least 2 points, got 1")
|
|
37
|
+
expect { TG::Geometry.line_string([]) }
|
|
38
|
+
.to raise_error(TG::Geometry::ArgumentError, "line_string requires at least 2 points, got 0")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "rejects non-finite coordinates with the bad point index in the message" do
|
|
42
|
+
expect { TG::Geometry.line_string([[0, 0], [Float::NAN, 1]]) }
|
|
43
|
+
.to raise_error(TG::Geometry::ArgumentError, /point 1/)
|
|
44
|
+
expect { TG::Geometry.line_string([[0, 0], [1, Float::INFINITY]]) }
|
|
45
|
+
.to raise_error(TG::Geometry::ArgumentError, /point 1/)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "rejects out-of-range srid" do
|
|
49
|
+
expect { TG::Geometry.line_string([[0, 0], [1, 1]], srid: -1) }
|
|
50
|
+
.to raise_error(TG::Geometry::ArgumentError)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe ".polygon" do
|
|
55
|
+
it "builds a square polygon" do
|
|
56
|
+
geom = TG::Geometry.polygon(square)
|
|
57
|
+
|
|
58
|
+
expect(geom.type).to eq(:polygon)
|
|
59
|
+
expect(geom.polygon.num_holes).to eq(0)
|
|
60
|
+
expect(geom.covers_xy?(5, 5)).to be(true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "builds polygons with one or two holes" do
|
|
64
|
+
one_hole = TG::Geometry.polygon(square, holes: [hole_a], srid: 4326)
|
|
65
|
+
two_holes = TG::Geometry.polygon(square, holes: [hole_a, hole_b])
|
|
66
|
+
|
|
67
|
+
expect(one_hole.srid).to eq(4326)
|
|
68
|
+
expect(one_hole.polygon.num_holes).to eq(1)
|
|
69
|
+
expect(one_hole.covers_xy?(3, 3)).to be(false)
|
|
70
|
+
expect(two_holes.polygon.num_holes).to eq(2)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "treats holes: [] like omitted holes" do
|
|
74
|
+
expect(TG::Geometry.polygon(square, holes: []).polygon.num_holes).to eq(0)
|
|
75
|
+
expect(TG::Geometry.polygon(square).polygon.num_holes).to eq(0)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "rejects invalid rings without autoclosing" do
|
|
79
|
+
expect { TG::Geometry.polygon(square[0...-1]) }
|
|
80
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon exterior ring is not closed")
|
|
81
|
+
expect { TG::Geometry.polygon(square[0, 3]) }
|
|
82
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon exterior ring requires at least 4 points")
|
|
83
|
+
expect { TG::Geometry.polygon(square, holes: [hole_a[0...-1]]) }
|
|
84
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon hole 0 is not closed")
|
|
85
|
+
expect { TG::Geometry.polygon(square, holes: [hole_a[0, 3]]) }
|
|
86
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon hole 0 requires at least 4 points")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe ".multi_polygon" do
|
|
91
|
+
it "builds empty and non-empty multipolygons" do
|
|
92
|
+
expect(TG::Geometry.multi_polygon([]).type).to eq(:multipolygon)
|
|
93
|
+
|
|
94
|
+
geom = TG::Geometry.multi_polygon([
|
|
95
|
+
{ exterior: square, holes: [hole_a] },
|
|
96
|
+
[[20.0, 20.0], [21.0, 20.0], [21.0, 21.0], [20.0, 21.0], [20.0, 20.0]]
|
|
97
|
+
], srid: 3857)
|
|
98
|
+
|
|
99
|
+
expect(geom.type).to eq(:multipolygon)
|
|
100
|
+
expect(geom.srid).to eq(3857)
|
|
101
|
+
expect(geom.num_polygons).to eq(2)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{"type":"Feature","properties":{"@id":"ok-a"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]] }},
|
|
5
|
+
{"type":"Feature","properties":{"@id":"bad"},"geometry":{"type":"Polygon","coordinates":"not coordinates"}},
|
|
6
|
+
{"type":"Feature","properties":{"@id":"ok-b"},"geometry":{"type":"Polygon","coordinates":[[[2,2],[2,3],[3,3],[3,2],[2,2]]] }}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"FeatureCollection","features":[
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{"type":"Feature","properties":{"@id":"poly"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}},
|
|
5
|
+
{"type":"Feature","properties":{"@id":"point"},"geometry":{"type":"Point","coordinates":[5,5]}},
|
|
6
|
+
{"type":"Feature","properties":{"@id":"line"},"geometry":{"type":"LineString","coordinates":[[0,0],[1,1]]}}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}},
|
|
5
|
+
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[2,2],[2,3],[3,3],[3,2],[2,2]]]}}
|
|
6
|
+
]
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{
|
|
5
|
+
"type": "Feature",
|
|
6
|
+
"properties": {"@id": "zone/a", "name": "A"},
|
|
7
|
+
"geometry": {"type": "Polygon", "coordinates": [[[0,0],[0,10],[10,10],[10,0],[0,0]]]}
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "Feature",
|
|
11
|
+
"properties": {"@id": 2},
|
|
12
|
+
"geometry": {"type": "MultiPolygon", "coordinates": [[[[20,20],[20,30],[30,30],[30,20],[20,20]]]]}
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# PostGIS / EWKB fixtures
|
|
2
|
+
|
|
3
|
+
These files are committed binary EWKB fixtures used by the v0.3.0 SRID/EWKB tests.
|
|
4
|
+
They are intentionally small and are not generated at spec runtime.
|
|
5
|
+
|
|
6
|
+
Canonical PostGIS generation examples:
|
|
7
|
+
|
|
8
|
+
```sql
|
|
9
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POINT(37.6 55.7)', 4326));
|
|
10
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 4326));
|
|
11
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0),(2 2,4 2,4 4,2 4,2 2))', 4326));
|
|
12
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 3857));
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`multipolygon_large.ewkb` is a synthetic large multipolygon fixture with about 7000 points.
|
|
16
|
+
It stands in for a real imported polygon while keeping the repository small.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{
|
|
5
|
+
"type": "Feature",
|
|
6
|
+
"properties": {
|
|
7
|
+
"name": "inside_simple"
|
|
8
|
+
},
|
|
9
|
+
"geometry": {
|
|
10
|
+
"type": "Point",
|
|
11
|
+
"coordinates": [
|
|
12
|
+
1.0,
|
|
13
|
+
1.0
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "Feature",
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": "boundary_simple"
|
|
21
|
+
},
|
|
22
|
+
"geometry": {
|
|
23
|
+
"type": "Point",
|
|
24
|
+
"coordinates": [
|
|
25
|
+
0.0,
|
|
26
|
+
5.0
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "Feature",
|
|
32
|
+
"properties": {
|
|
33
|
+
"name": "hole_center"
|
|
34
|
+
},
|
|
35
|
+
"geometry": {
|
|
36
|
+
"type": "Point",
|
|
37
|
+
"coordinates": [
|
|
38
|
+
3.0,
|
|
39
|
+
3.0
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"type": "Feature",
|
|
45
|
+
"properties": {
|
|
46
|
+
"name": "between_hole_and_outer"
|
|
47
|
+
},
|
|
48
|
+
"geometry": {
|
|
49
|
+
"type": "Point",
|
|
50
|
+
"coordinates": [
|
|
51
|
+
1.0,
|
|
52
|
+
1.0
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "Feature",
|
|
58
|
+
"properties": {
|
|
59
|
+
"name": "large_inside"
|
|
60
|
+
},
|
|
61
|
+
"geometry": {
|
|
62
|
+
"type": "Point",
|
|
63
|
+
"coordinates": [
|
|
64
|
+
100.0,
|
|
65
|
+
50.0
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "Feature",
|
|
71
|
+
"properties": {
|
|
72
|
+
"name": "large_outside"
|
|
73
|
+
},
|
|
74
|
+
"geometry": {
|
|
75
|
+
"type": "Point",
|
|
76
|
+
"coordinates": [
|
|
77
|
+
200.0,
|
|
78
|
+
50.0
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|