Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ImplHelper::ValidOp Module to Provide Uniform Validity Checks #271

Merged
merged 7 commits into from
Oct 5, 2021
8 changes: 4 additions & 4 deletions doc/Factory-Compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ _Note: This list is not exhaustive of all the methods defined by each factory. T
| `LineString#touches?(MultiLineString)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LineString#touches?(MultiPolygon)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LineString#crosses?(Point)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LineString#crosses?(LineString)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LineString#crosses?(LinearRing)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LineString#crosses?(LineString)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LineString#crosses?(LinearRing)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LineString#crosses?(Polygon)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LineString#crosses?(Collection)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LineString#crosses?(MultiPoint)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
Expand Down Expand Up @@ -267,8 +267,8 @@ _Note: This list is not exhaustive of all the methods defined by each factory. T
| `LinearRing#touches?(MultiLineString)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LinearRing#touches?(MultiPolygon)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LinearRing#crosses?(Point)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LinearRing#crosses?(LineString)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LinearRing#crosses?(LinearRing)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LinearRing#crosses?(LineString)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LinearRing#crosses?(LinearRing)` | ✅ | ✅ | ✅ | ✅ | | ✅ | ❌ |
| `LinearRing#crosses?(Polygon)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LinearRing#crosses?(Collection)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| `LinearRing#crosses?(MultiPoint)` | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
Expand Down
10 changes: 10 additions & 0 deletions lib/rgeo/cartesian/feature_classes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class PointImpl # :nodoc:
include RGeo::Feature::Point
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicPointMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
include RGeo::Cartesian::PointMethods
end
Expand All @@ -20,6 +21,7 @@ class LineStringImpl # :nodoc:
include RGeo::Feature::LineString
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicLineStringMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
include RGeo::Cartesian::LineStringMethods
end
Expand All @@ -29,6 +31,7 @@ class LineImpl # :nodoc:
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicLineStringMethods
include RGeo::ImplHelper::BasicLineMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
include RGeo::Cartesian::LineStringMethods
end
Expand All @@ -38,6 +41,7 @@ class LinearRingImpl # :nodoc:
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicLineStringMethods
include RGeo::ImplHelper::BasicLinearRingMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
include RGeo::Cartesian::LineStringMethods
end
Expand All @@ -46,13 +50,16 @@ class PolygonImpl # :nodoc:
include RGeo::Feature::Polygon
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicPolygonMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
include RGeo::Cartesian::PolygonMethods
end

class GeometryCollectionImpl # :nodoc:
include RGeo::Feature::GeometryCollection
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicGeometryCollectionMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
end

Expand All @@ -61,6 +68,7 @@ class MultiPointImpl # :nodoc:
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicGeometryCollectionMethods
include RGeo::ImplHelper::BasicMultiPointMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
end

Expand All @@ -69,6 +77,7 @@ class MultiLineStringImpl # :nodoc:
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicGeometryCollectionMethods
include RGeo::ImplHelper::BasicMultiLineStringMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
include RGeo::Cartesian::MultiLineStringMethods
end
Expand All @@ -78,6 +87,7 @@ class MultiPolygonImpl # :nodoc:
include RGeo::ImplHelper::BasicGeometryMethods
include RGeo::ImplHelper::BasicGeometryCollectionMethods
include RGeo::ImplHelper::BasicMultiPolygonMethods
include RGeo::ImplHelper::ValidOp
include RGeo::Cartesian::GeometryMethods
end
end
Expand Down
170 changes: 150 additions & 20 deletions lib/rgeo/cartesian/feature_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,31 +50,161 @@ def segments
end

def is_simple?
len = segments.length
return false if segments.any?(&:degenerate?)
return true if len == 1
return segments[0].s != segments[1].e if len == 2
segments.each_with_index do |seg, index|
nindex = index + 1
nindex = nil if nindex == len
return false if nindex && seg.contains_point?(segments[nindex].e)
pindex = index - 1
pindex = nil if pindex < 0
return false if pindex && seg.contains_point?(segments[pindex].s)
next unless nindex
oindex = nindex + 1
while oindex < len
oseg = segments[oindex]
return false if !(index == 0 && oindex == len - 1 && seg.s == oseg.e) && seg.intersects_segment?(oseg)
oindex += 1
end
end
true
# TODO: should we replace with graph.incident_edges.length == segments.length
# since graph isn't used elsewhere yet it might be more overhead.
Comment on lines +59 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not 100% sure I get your point there ^^

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same simplicity check (self-intersection) can be performed by creating the GeometryGraph of the LineString and doing the described comparison. But doing the check with graph will be slower since it has to perform an intersection check and then link all of the nodes whereas the current implementation only does an intersection check.

And since graph is not used elsewhere in any LineString method I figured the additional overhead is not necessary, but I could change it if we think it's better to do so.

li = SweeplineIntersector.new(segments)
li.proper_intersections.size.zero?
keithdoggett marked this conversation as resolved.
Show resolved Hide resolved
end

def length
segments.inject(0.0) { |sum, seg| sum + seg.length }
end

def crosses?(rhs)
case rhs
when Feature::LineString
crosses_line_string?(rhs)
else
super
end
end

private

# Determines if a cross occurs with another linestring.
# Process is to get the number of proper intersections in each geom
# then overlay and get the number of proper intersections from that.
# If the overlaid number is higher than the sum of individual self-ints
# then there is an intersection. Finally, we need to check the intersection
# to see that it is not a boundary point of either segment.
#
# @param rhs [Feature::LineString]
#
# @return [Boolean]
def crosses_line_string?(rhs)
self_ints = SweeplineIntersector.new(segments).proper_intersections
self_ints += SweeplineIntersector.new(rhs.segments).proper_intersections
overlay_ints = SweeplineIntersector.new(segments + rhs.segments).proper_intersections

(overlay_ints - self_ints).each do |int|
s1s = int.s1.s
s1e = int.s1.e
s2s = int.s2.s
s2e = int.s2.e
return true unless [s1s, s1e, s2s, s2e].include?(int.point)
end

false
end
end

module PolygonMethods
def graph
@graph ||= GeometryGraph.new(self)
end
keithdoggett marked this conversation as resolved.
Show resolved Hide resolved

private

# Similar implementation to the default version from the ValidOp
# module, but it overrides a few methods in favor of a graph for
# easier checks. Eventually most of these checks can be overriden
# and rely on the graph.
#
# TODO: will update these with more checks once we decide
# if this structure makes sense or if there's more metaprogramming
# we could do.
def check_valid_polygon
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why you copy this method: shouldn't the leaf methods only be overriden?

I mean that since check_consistent_area and check_connected_interiors are overriden, you don't need to override that bit.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I did that is that in the originally implemented method in ImplHelper the leaf methods accept the geometry as an input, while these can just use self. But that was causing an issue where I'd be passing an argument into the new leaf methods that don't expect one.

I can change the leaf methods to something like this def check_connected_interiors(_) so that they won't break upon receiving an argument. What are your thoughts @BuonOmo?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm doing something weird with the OO aspect of this. Since I'm including the ValidOp module every geometry instance will get these methods, but the leaf methods are designed to be more of a function than a method in the sense that they need the object they're checking passed in as a parameter and aren't checking the caller.

For example, a polygon calling check_connected_interior is essentially doing this

polygon.check_connected_interior(polygon)

where I feel like it should just be

polygon.check_connected_interior

Maybe I need to break this down into more submodules like ValidOp::PolygonChecks and include those into the relevant classes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I need to break this down into more submodules like ValidOp::PolygonChecks and include those into the relevant classes?

Apart from this bit which is not clear to me, I totally agree with your last comment!


It feels like there are three different things here:

  • check_valid, that branches on type to know what to check,
  • check_valid_#{type}, that contains the root logic of what to check for a given type,
  • any other methods, that are basically helper functions that can be used by any check_valid_*.

With that said, wouldn't it be a good idea to have the check_valid and check_valid_* in some place, taking no argument since they must work on self. And then every branch / leaves in a utility module, working as functions, taking arguments.

This separation helps for reusability of branches and leaves, and DRY roots.

I may have overlooked something though..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I broke it up into ValidOp and ValidOpHelpers where the helpers contain the functions and ValidOp has just the valid?, invalid_reason, and check_valid/check_valid_* methods.

I also show how the helpers can be overridden for custom implementations of the leaf functions (lib/cartesian/valid_op.rb). The new helpers module has to "inherit" the default implementation and re-implement any methods it needs then the new valid op module defines a method which specifies the helper module it will make calls to. It is a bit of a weird pattern and I can clarify more if need be, but it seems to provide flexibility with minimal code duplication.

# check coordinates are all valid
exterior_ring.points.each do |pt|
check = check_invalid_coordinate(pt)
return check unless check.nil?
end
interior_rings.each do |ring|
ring.points.each do |pt|
check = check_invalid_coordinate(pt)
return check unless check.nil?
end
end

# check closed
return ImplHelper::TopologyErrors::UNCLOSED_RING unless exterior_ring.is_closed?
return ImplHelper::TopologyErrors::UNCLOSED_RING unless interior_rings.all?(&:is_closed?)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When i see the is_closed usage there, I feel like we'll have some trouble rebase with the master branch if we wait too much

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah once it gets merged I can go back and change it.


# check more than 3 points in each ring
return ImplHelper::TopologyErrors::TOO_FEW_POINTS unless exterior_ring.num_points > 3
return ImplHelper::TopologyErrors::TOO_FEW_POINTS unless interior_rings.all? { |r| r.num_points > 3 }

# can skip this check if there's no holes
if interior_rings.size.positive?
check = check_consistent_area
return check unless check.nil?
end

# check that there are no self-intersections
check = check_no_self_intersecting_rings(self)
return check unless check.nil?

# can skip these checks if there's no holes
if interior_rings.size.positive?
check = check_holes_in_shell(self)
return check unless check.nil?

check = check_holes_not_nested(self)
return check unless check.nil?

check = check_connected_interiors
return check unless check.nil?
end

nil
end

# Checks that there are no invalid intersections between the components
# of a polygon.
#
# @return [String] invalid_reason
def check_consistent_area
# Get set of unique coords
pts = exterior_ring.coordinates.to_set
interior_rings.each do |ring|
pts += ring.coordinates
end
num_points = pts.size

# if additional nodes were added, there must be an intersection
# through a boundary.
if graph.incident_edges.size > num_points
return ImplHelper::TopologyErrors::SELF_INTERSECTION
end

rings = [exterior_ring] + interior_rings
return ImplHelper::TopologyErrors::DUPLICATE_RINGS if rings.uniq.size != rings.size

nil
end

# Checks that the interior of a polygon is connected.
#
# Process to do this is to walk around an interior cycle of the
# exterior shell in the polygon's geometry graph. It will keep track
# of all the nodes it visited and if that set is a superset of the
# coordinates in the exterior_ring, the interior is connected, otherwise
# it is split somewhere.
#
# @return [String] invalid_reason
def check_connected_interiors
exterior_coords = exterior_ring.coordinates.to_set

visited = []
keithdoggett marked this conversation as resolved.
Show resolved Hide resolved
graph.geom_edges.first.exterior_edge.and_connected do |hedge|
visited << hedge.origin.coordinates
end
visited = visited.to_set

return ImplHelper::TopologyErrors::DISCONNECTED_INTERIOR unless exterior_coords.subset?(visited)

nil
end
end

module MultiLineStringMethods # :nodoc:
Expand Down
16 changes: 12 additions & 4 deletions lib/rgeo/geographic/projected_feature_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ def is_simple?
projection.is_simple?
end

def valid?
projection.valid?
end

def invalid_reason
projection.invalid_reason
end

def boundary
boundary = projection.boundary
boundary ? factory.unproject(boundary) : nil
Expand Down Expand Up @@ -144,7 +152,7 @@ def self.included(klass)

private

def validate_geometry
def prepare_geometry
@y = 85.0511287 if @y > 85.0511287
@y = -85.0511287 if @y < -85.0511287
super
Expand All @@ -160,7 +168,7 @@ def length
module ProjectedLineStringMethods # :nodoc:
private

def validate_geometry
def prepare_geometry
@points = @points.map(&:canonical_point)
super
end
Expand All @@ -185,7 +193,7 @@ def centroid
module ProjectedPolygonMethods # :nodoc:
private

def validate_geometry
def prepare_geometry
super
unless projection
raise Error::InvalidGeometry, "Polygon failed assertions"
Expand All @@ -196,7 +204,7 @@ def validate_geometry
module ProjectedMultiPolygonMethods # :nodoc:
private

def validate_geometry
def prepare_geometry
super
unless projection
raise Error::InvalidGeometry, "MultiPolygon failed assertions"
Expand Down
9 changes: 9 additions & 0 deletions lib/rgeo/geographic/spherical_feature_classes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class SphericalPointImpl # :nodoc:
include Feature::Point
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicPointMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
include SphericalPointMethods
end
Expand All @@ -20,6 +21,7 @@ class SphericalLineStringImpl # :nodoc:
include Feature::LineString
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicLineStringMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
include SphericalLineStringMethods
end
Expand All @@ -29,6 +31,7 @@ class SphericalLineImpl # :nodoc:
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicLineStringMethods
include ImplHelper::BasicLineMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
include SphericalLineStringMethods
end
Expand All @@ -38,6 +41,7 @@ class SphericalLinearRingImpl # :nodoc:
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicLineStringMethods
include ImplHelper::BasicLinearRingMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
include SphericalLineStringMethods
end
Expand All @@ -46,6 +50,7 @@ class SphericalPolygonImpl # :nodoc:
include Feature::Polygon
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicPolygonMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
include SphericalPolygonMethods
end
Expand All @@ -54,6 +59,7 @@ class SphericalGeometryCollectionImpl # :nodoc:
include Feature::GeometryCollection
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicGeometryCollectionMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
end

Expand All @@ -62,6 +68,7 @@ class SphericalMultiPointImpl # :nodoc:
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicGeometryCollectionMethods
include ImplHelper::BasicMultiPointMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
end

Expand All @@ -70,6 +77,7 @@ class SphericalMultiLineStringImpl # :nodoc:
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicGeometryCollectionMethods
include ImplHelper::BasicMultiLineStringMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
include SphericalMultiLineStringMethods
end
Expand All @@ -79,6 +87,7 @@ class SphericalMultiPolygonImpl # :nodoc:
include ImplHelper::BasicGeometryMethods
include ImplHelper::BasicGeometryCollectionMethods
include ImplHelper::BasicMultiPolygonMethods
include ImplHelper::ValidOp
include SphericalGeometryMethods
end
end
Expand Down