diff --git a/README.md b/README.md index 24fe03c..6cc12cc 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,28 @@ julia> # get the underlying NamedTuple julia> bounds(ext1) (X = (1.0, 2.0), Y = (3.0, 4.0)) -julia> # compare different extents +julia> Extents.intersection(ext1, ext2) +Extent(X = (1.5, 2.0), Y = (3.0, 4.0)) +julia> Extents.union(ext1, ext2) +Extent(X = (1.0, 2.5), Y = (3.0, 4.0)) +``` + +Extents.jl also defines spatial predicates following the +[DE-9IM](https://en.wikipedia.org/wiki/DE-9IM) standard. + +```julia-repl julia> Extents.intersects(ext1, ext2) true -julia> Extents.intersect(ext1, ext2) -Extent(X = (1.5, 2.0), Y = (3.0, 4.0)) +julia> Extents.disjoint(ext1, ext2) +false -julia> Extents.union(ext1, ext2) -Extent(X = (1.0, 2.5), Y = (3.0, 4.0)) +julia> Extents.touches(ext1, ext2) +false + +julia> Extents.overlaps(ext1, ext2) +true ``` + +See [the docs](https://rafaqz.github.io/Extents.jl/stable) for all available methods. diff --git a/src/Extents.jl b/src/Extents.jl index 8f17387..ea7c145 100644 --- a/src/Extents.jl +++ b/src/Extents.jl @@ -4,6 +4,10 @@ export Extent, extent, bounds ## DO NOT export anything else ## +const ORDER_DOC = """ +The order of dimensions is ignored in all cases. +""" + """ Extent @@ -67,7 +71,7 @@ Base.length(ext::Extent) = length(bounds(ext)) Base.iterate(ext::Extent, args...) = iterate(bounds(ext), args...) function Base.isapprox(a::Extent{K1}, b::Extent{K2}; kw...) where {K1,K2} - _keys_match(a, b) || return false + _check_keys_match(a, b) || return false values_match = map(K1) do k bounds_a = a[k] bounds_b = b[k] @@ -83,7 +87,7 @@ function Base.isapprox(a::Extent{K1}, b::Extent{K2}; kw...) where {K1,K2} end function Base.:(==)(a::Extent{K1}, b::Extent{K2}) where {K1,K2} - _keys_match(a, b) || return false + _check_keys_match(a, b) || return false values_match = map(K1) do k bounds_a = a[k] bounds_b = b[k] @@ -107,49 +111,16 @@ function extent end extent(extent) = nothing extent(extent::Extent) = extent -""" - intersects(ext1::Extent, ext2::Extent; strict=false) - -Check if two `Extent` objects intersect. - -Returns `true` if the extents of all common dimensions share some values -including just the edge values of their range. - -Dimensions that are not shared are ignored by default, with `strict=false`. -When `strict=true`, any unshared dimensions cause the function to return `false`. - -The order of dimensions is ignored in both cases. - -If there are no common dimensions, `false` is returned. -""" -function intersects(ext1::Extent, ext2::Extent; strict=false) - _maybe_keys_match(ext1, ext2, strict) || return false - keys = _shared_keys(ext1, ext2) - if length(keys) == 0 - return false # Otherwise `all` returns `true` for empty tuples - else - dimintersections = map(keys) do k - _bounds_intersect(ext1[_unwrap(k)], ext2[_unwrap(k)]) - end - return all(dimintersections) - end -end -intersects(obj1, obj2) = intersects(extent(obj1), extent(obj2)) -intersects(obj1::Extent, obj2::Nothing) = false -intersects(obj1::Nothing, obj2::Extent) = false -intersects(obj1::Nothing, obj2::Nothing) = false - """ union(ext1::Extent, ext2::Extent; strict=false) Get the union of two extents, e.g. the combined extent of both objects for all dimensions. -Dimensions that are not shared are ignored by default, with `strict=false`. -When `strict=true`, any unshared dimensions cause the function to return `nothing`. +$ORDER_DOC """ function union(ext1::Extent, ext2::Extent; strict=false) - _maybe_keys_match(ext1, ext2, strict) || return nothing + _maybe_check_keys_match(ext1, ext2, strict) || return nothing keys = _shared_keys(ext1, ext2) if length(keys) == 0 return nothing @@ -164,11 +135,11 @@ function union(ext1::Extent, ext2::Extent; strict=false) return Extent{map(_unwrap, keys)}(values) end end -union(obj1::Extent, ::Nothing) = obj1 -union(::Nothing, obj2::Extent) = obj2 -union(::Nothing, ::Nothing) = nothing -union(obj1, obj2) = union(extent(obj1), extent(obj2)) -union(obj1, obj2, obj3, objs...) = union(union(obj1, obj2), obj3, objs...) +union(a::Extent, ::Nothing; strict=false) = strict ? nothing : a +union(::Nothing, b::Extent; strict=false) = strict ? nothing : b +union(::Nothing, ::Nothing; kw...) = nothing +union(a, b; kw...) = union(extent(a), extent(b)) +union(a, b, c, args...; kw...) = union(union(a, b), c, args...) """ intersection(ext1::Extent, ext2::Extent; strict=false) @@ -177,26 +148,29 @@ Get the intersection of two extents as another `Extent`, e.g. the area covered by the shared dimensions for both extents. If there is no intersection for any shared dimension, `nothing` will be returned. -When `strict=true`, any unshared dimensions cause the function to return `nothing`. + +$ORDER_DOC """ -function intersection(ext1::Extent, ext2::Extent; strict=false) - _maybe_keys_match(ext1, ext2, strict) || return nothing - intersects(ext1, ext2) || return nothing - keys = _shared_keys(ext1, ext2) +function intersection(a::Extent, b::Extent; strict=false) + _maybe_check_keys_match(a, b, strict) || return nothing + intersects(a, b) || return nothing + keys = _shared_keys(a, b) values = map(keys) do k + # Get a symbol from `Val{:k}` k = _unwrap(k) - k_exts = (ext1[k], ext2[k]) - a = max(map(first, k_exts)...) - b = min(map(last, k_exts)...) - (a, b) + # Acces the k symbol of `a` and `b` + k_exts = (a[k], b[k]) + maxs = max(map(first, k_exts)...) + mins = min(map(last, k_exts)...) + (maxs, mins) end return Extent{map(_unwrap, keys)}(values) end -intersection(obj1::Extent, obj2::Nothing) = nothing -intersection(obj1::Nothing, obj2::Extent) = nothing -intersection(obj1::Nothing, obj2::Nothing) = nothing -intersection(obj1, obj2) = intersection(extent(obj1), extent(obj2)) -intersection(obj1, obj2, obj3, objs...) = intersection(intersection(obj1, obj2), obj3, objs...) +intersection(a::Extent, b::Nothing; kw...) = nothing +intersection(a::Nothing, b::Extent; kw...) = nothing +intersection(a::Nothing, b::Nothing; kw...) = nothing +intersection(a, b; kw...) = intersection(extent(a), extent(b); kw...) +intersection(a, b, c, args...; kw...) = intersection(intersection(a, b), c, args...; kw...) """ buffer(ext::Extent, buff::NamedTuple) @@ -225,20 +199,246 @@ buffer(ext::Nothing, buff) = nothing @deprecate inersect instersection + +# DE_9IM predicates + +const STRICT_DOC = """ +Dimensions that are not shared are ignored by default with `strict=false`. +When `strict=true`, any unshared dimensions cause the function to return `nothng`. +""" + +const DE_9IM_DOC = """ +Conforms to the DE-9IM spatial predicates standard +https://en.wikipedia.org/wiki/DE-9IM +""" + +""" + contains(a::Extent, b::Extent; strict=false) + +`a` contains `b` if no points of `b` lie in the exterior of `a`, and +at least one point of the interior of `b` lies in the interior of `a`. +If `b` has no interior points it is not contained in `a`. + +Identical to [`within`](@ref) with argument order reversed. + +$STRICT_DOC + +If there are no common dimensions, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +contains(a::Extent, b::Extent; strict=false) = _do_bounds(all, _contain, a, b, strict) + +# Must contain interior points, not just boundary +_contain(a::Tuple, b::Tuple) = _cover(a, b) && _hasinterior(b) + +""" + within(a::Extent, b::Extent; strict=false) + +`a` is within `b` if no points of `a` lie in the exterior of `b`, and +at least one point of the interior of `a` lies in the interior of `b`. +If `a` has no interior points it is not contained in `b`. + +Identical to [`contains`](@ref) with argument order reversed. + +$STRICT_DOC + +If there are no common dimensions, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +within(a, b; kw...) = contains(b, a; kw...) # swapped order of `contains` + +""" + intersects(a::Extent, b::Extent; strict=false) + +`a` intersects `b` if `a` and `b` have at least one point in common +(the inverse of [`disjoint`](@ref)). + +Returns `true` if the extents of all common dimensions share some values, +including just the edge values of their range. + +$STRICT_DOC + +If there are no common dimensions with `strict=false`, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +intersects(a::Extent, b::Extent; strict=false) = _do_bounds(all, _intersect, a, b, strict) + +_intersect((min_a, max_a)::Tuple, (min_b, max_b)::Tuple) = + (min_a <= min_b && max_a >= min_b) || (min_b <= min_a && max_b >= min_a) + +""" + disjoint(a::Extent, b::Extent; strict=false) + +`a` and `b` are disjoint if they have no point in common +(the inverse of [`intersects`](@ref)). + +Returns `false` if the extents of all common dimensions share some values, +including just the edge values of their range. + +$STRICT_DOC + +If there are no common dimensions when `strict=false`, `true` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +disjoint(a, b; kw...) = !intersects(a, b; kw...) + +""" + touches(a::Extent, b::Extent; strict=false) + +`a` and `b` have at least one point in common, but their interiors do not intersect. + +Returns `true` if the extents of any common dimensions share boundaries. + +$STRICT_DOC + +If there are no common dimensions with `strict=false`, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +function touches(a::Extent, b::Extent; strict=false) + if intersects(a, b) + # At least one bound must just touch + _do_bounds(any, _touch, a, b, strict) + else + false + end +end + +_touch((min_a, max_a)::Tuple, (min_b, max_b)::Tuple) = (min_a == max_b || max_a == min_b) + + +""" + covers(a::Extent, b::Extent; strict=false) + +At least one point of `b` lies in `a`, and no point of `b` lies in the exterior of `a`, +Every point of `b` is a point in the interior or boundary of `a`. + +Identical to [`coveredby`](@ref) with argument order reversed. + +$STRICT_DOC + +If there are no common dimensions with `strict=false`, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +covers(a::Extent, b::Extent; strict=false) = _do_bounds(all, _cover, a, b, strict) + +_cover((min_a, max_a)::Tuple, (min_b, max_b)::Tuple) = (min_a <= min_b && max_a >= max_b) + +""" + coveredby(a::Extent, b::Extent; strict=false) + +At least one point of `a` lies in `b`, and no point of `a` lies in the exterior of `b`, +Every point of `a` is a point in the interior or boundary of `b`. + +Identical to [`covers`](@ref) with argument order reversed. + +$STRICT_DOC + +If there are no common dimensions with `strict=false`, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +coveredby(a, b; kw...) = covers(b, a; kw...) # swapped order of `covers` + + +""" + overlaps(a::Extent, b::Extent; strict=false) + +`a` overlaps `b`: they have some but not all points in common, +they have the same dimension, and the intersection of the interiors +of the two geometries has the same dimension as the geometries themselves. + +Returns `true` if the extents of common dimensions overlap. + +$STRICT_DOC + +If there are no common dimensions with `strict=false`, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +function overlaps(a::Extent, b::Extent; strict=false) + if intersects(a, b; strict) + # Bounds can't just touch, they must share interior points + !_do_bounds(any, _touch, a, b, strict) + else + false + end +end + +""" + equals(a::Extent, b::Extent; strict=false) + +`a` and `b` are topologically equal: their interiors intersect and no part +of the interior or boundary of one intersects the exterior of the other. + +$STRICT_DOC + +If there are no common dimensions with `strict=false`, `false` is returned. + +$ORDER_DOC + +$DE_9IM_DOC +""" +equals(a::Extent, b::Extent; strict=false) = _do_bounds(all, _equal, a, b, strict) + +_equal(a::Tuple, b::Tuple) = a == b + +# Handle `nothing` bounds for all methods +for f in (:_intersect, :_cover, :_contain, :_touch, :_equal) + @eval begin + $f(::Nothing, ::Tuple) = nothing + $f(::Tuple, ::Nothing) = nothing + $f(::Nothing, ::Nothing) = nothing + end +end + +# Handle objects with `extent` methods and `nothing` extents +for f in (:intersects, :covers, :contains, :touches, :equals, :overlaps) + @eval begin + $f(a, b; kw...) = $f(extent(a), extent(b); kw...) + $f(a::Extent, b::Nothing; kw...) = false + $f(a::Nothing, b::Extent; kw...) = false + $f(a::Nothing, b::Nothing; kw...) = false + end +end + + # Internal utils + _maybe_keys_match(ext1, ext2, strict) = !strict || _keys_match(ext1, ext2) -function _keys_match(::Extent{K1}, ::Extent{K2}) where {K1,K2} +# Keys + +_maybe_check_keys_match(a, b, strict) = !strict || _check_keys_match(a, b) + +function _check_keys_match(::Extent{K1}, ::Extent{K2}) where {K1,K2} length(K1) == length(K2) || return false keys_match = map(K2) do k k in K1 end |> all end -function _bounds_intersect(b1::Tuple, b2::Tuple) - (b1[1] <= b2[2] && b1[2] >= b2[1]) -end - # _shared_keys uses a static `Val{k}` instead of a `Symbol` to # represent keys, because constant propagation fails through `reduce` # meaning most of the time of `union` or `intersect` is doing the `Symbol` lookup. @@ -253,4 +453,36 @@ end _unwrap(::Val{X}) where {X} = X + +# Bounds comparisons + +# compare all bounds and reduce the result to a Bool or `nothing` +# Running `compare` and `boolreduce` should be the only runtime costs +function _do_bounds(boolreduce::Function, compare::Function, a::Extent, b::Extent, strict::Bool) + _maybe_check_keys_match(a, b, strict) || return false + keys = _shared_keys(a, b) + if length(keys) == 0 + # There are no shared dimensions. Maybe this should return `nothing`? + # But we need to handle it otherwise `all` returns `true` for empty tuples + return false + else + maybe_comparisons = map(keys) do k + compare(a[_unwrap(k)], b[_unwrap(k)]) + end + comparisons = _skipnothing(maybe_comparisons...) + if length(comparisons) == 0 + return nothing + else + return boolreduce(comparisons) + end + end +end + +_skipnothing(v1, vals...) = (v1, _skipnothing(vals...)...) +_skipnothing(::Nothing, vals...) = _skipnothing(vals...) +_skipnothing() = () + +_hasinterior(ex::Extent) = all(map(_hasinterior, bounds(ex))) +_hasinterior((min, max)::Tuple) = min != max + end diff --git a/test/runtests.jl b/test/runtests.jl index 5bcc4b5..3522f4c 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,11 +1,15 @@ using Extents using Test using Dates +const E = Extent ex1 = Extent(X=(1, 2), Y=(3, 4)) ex2 = Extent(Y=(3, 4), X=(1, 2)) ex3 = Extent(X=(1, 2), Y=(3, 4), Z=(5.0, 6.0)) +struct HasExtent end +Extents.extent(::HasExtent) = Extent(X=(0, 1), Y=(0, 1)) + @testset "getindex" begin @test ex3[1] == ex3[:X] == (1, 2) @test ex3[[:X, :Z]] == ex3[(:X, :Z)] == Extent{(:X, :Z)}(((1, 2), (5.0, 6.0))) @@ -21,13 +25,16 @@ end @test bounds(ex3) === (X=(1, 2), Y=(3, 4), Z=(5.0, 6.0)) end -@testset "extent" begin +@testset "extent can be called on Extent, returning itself" begin @test extent(ex1) === ex1 end -@testset "equality" begin +@testset "Base julia equality works in any order for the same dimensions" begin + # Extents equal themselves @test ex1 == ex1 + # Order doesn't matter @test ex1 == ex2 + # Extra dimensions are not equal @test ex1 != ex3 end @@ -40,35 +47,314 @@ end @test isapprox(ex1, ex5; atol=0.11) end -@testset "properties" begin +@testset "keys and values are just like a NamedTuple" begin @test keys(ex1) == (:X, :Y) @test values(ex1) == ((1, 2), (3, 4)) end @testset "union" begin - a = Extent(X=(0.1, 0.5), Y=(1.0, 2.0)) - b = Extent(X=(2.1, 2.5), Y=(3.0, 4.0), Z=(0.0, 1.0)) - c = Extent(Z=(0.2, 2.0)) - @test Extents.union(a, b) == Extent(X=(0.1, 2.5), Y=(1.0, 4.0)) + a = E(X=(0.1, 0.5), Y=(1.0, 2.0)) + b = E(X=(2.1, 2.5), Y=(3.0, 4.0), Z=(0.0, 1.0)) + c = E(Z=(0.2, 2.0)) + @test Extents.union(a, b) == Extents.union(a, b, a) == E(X=(0.1, 2.5), Y=(1.0, 4.0)) @test Extents.union(a, b; strict=true) === nothing @test Extents.union(a, c) === nothing + + # If either argument is nothing, return the other @test Extents.union(a, nothing) === a - @test Extents.union(nothing, a) === a + @test Extents.union(nothing, b) === b + @test Extents.union(a, nothing; strict=true) === nothing + @test Extents.union(nothing, b; strict=true) === nothing + # If both arguments are nothing, return nothing @test Extents.union(nothing, nothing) === nothing + @test Extents.union(nothing, nothing; strict=true) === nothing end -@testset "intersection/intersects" begin - a = Extent(X=(0.1, 0.5), Y=(1.0, 2.0)) - b = Extent(X=(2.1, 2.5), Y=(3.0, 4.0), Z=(0.0, 1.0)) - c = Extent(X=(0.4, 2.5), Y=(1.5, 4.0), Z=(0.0, 1.0)) - d = Extent(A=(0.0, 1.0)) +@testset "covers" begin + # An extent contains itself + @test Extents.covers(E(X=(2, 3), Y=(3, 4)), E(X=(2, 3), Y=(3, 4))) == true + # A larger extent covers a smaller one inside it + @test Extents.covers(E(X=(0, 4), Y=(1, 5)), E(X=(2, 3), Y=(3, 4))) == true + # Intersecting but not covers in one dimension + @test Extents.covers(E(X=(0, 5), Y=(1, 3)), E(X=(2, 3), Y=(3, 4))) == false + @test Extents.covers(E(X=(3, 5), Y=(1, 5)), E(X=(2, 3), Y=(3, 4))) == false + # An extent with no interior is still covered + @test Extents.covers(E(X=(0, 4), Y=(1, 5)), E(X=(2, 2), Y=(3, 4))) == true + # Not containing in any dimensions + @test Extents.covers(E(X=(0, 1), Y=(1, 2)), E(X=(2, 3), Y=(3, 4))) == false + @test Extents.covers(E(X=(4, 5), Y=(5, 6)), E(X=(2, 3), Y=(3, 4))) == false + # We just ignore missing dimensions + @test Extents.covers(E(X=(2, 4), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(3, 4))) == true + @test Extents.covers(E(X=(2, 4), Y=(1, 4)), E(X=(2, 3), Y=(3, 4), Z=(1, 2))) == true + # Except when `strict` is true + @test Extents.covers(E(X=(2, 4), Y=(1, 4)), E(X=(2, 3), Y=(3, 4), Z=(1, 2)); strict=true) == false + # When they are present *they can change the result* + @test Extents.covers(E(X=(2, 4), Y=(1, 4), Z=(0, 1)), E(X=(2, 3), Y=(3, 4), Z=(1, 2))) == false + # Nothing returns false + @test Extents.covers(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.covers(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.covers(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.covers(1, :x) == false + # Objects that have extents can be used + @test Extents.covers(HasExtent(), Extents.extent(HasExtent())) == true +end + +@testset "coveredby" begin + # An extent contains itself + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), E(X=(2, 3), Y=(3, 4))) == true + # A larger extent coveredby a smaller one inside it + @test Extents.contains(E(X=(0, 4), Y=(1, 5)), E(X=(2, 3), Y=(3, 4))) == true + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), E(X=(0, 4), Y=(1, 5))) == true + # Intersecting but not coveredby in one dimension + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), E(X=(0, 5), Y=(1, 3))) == false + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), E(X=(3, 5), Y=(1, 5))) == false + # An extent with no interior is still coveredby + @test Extents.coveredby(E(X=(2, 2), Y=(3, 4)), E(X=(0, 4), Y=(1, 5))) == true + # Not coveredby in any dimensions + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), E(X=(0, 1), Y=(1, 2))) == false + @test Extents.coveredby(E(X=(4, 5), Y=(5, 6)), E(X=(1, 3), Y=(3, 4))) == false + # We just ignore missing dimensions + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), E(X=(2, 4), Y=(1, 4), Z=(1, 2))) == true + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4), Z=(1, 2)), E(X=(2, 4), Y=(1, 4))) == true + # Except when `strict` is true + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4), Z=(1, 2)), E(X=(2, 4), Y=(1, 4)); strict=true) == false + # When they are present *they can change the result* + @test Extents.coveredby(E(X=(2, 4), Y=(1, 4), Z=(0, 1)), E(X=(2, 3), Y=(3, 4), Z=(1, 2))) == false + # Nothing returns false + @test Extents.coveredby(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.coveredby(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.coveredby(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.coveredby(1, :x) == false + # Objects that have extents can be used + @test Extents.coveredby(HasExtent(), Extents.extent(HasExtent())) == true +end + +@testset "contains" begin + # An extent contains itself + @test Extents.contains(E(X=(2, 3), Y=(3, 4)), E(X=(2, 3), Y=(3, 4))) == true + # A larger extent contains a smaller one inside it + @test Extents.contains(E(X=(0, 4), Y=(1, 5)), E(X=(2, 3), Y=(3, 4))) == true + # Intersecting but not contains in one dimension + @test Extents.contains(E(X=(0, 5), Y=(1, 3)), E(X=(2, 3), Y=(3, 4))) == false + @test Extents.contains(E(X=(3, 5), Y=(1, 5)), E(X=(2, 3), Y=(3, 4))) == false + # An extent with no interior is not contained + @test Extents.contains(E(X=(0, 4), Y=(1, 5)), E(X=(2, 2), Y=(3, 4))) == false + # Not containing in any dimensions + @test Extents.contains(E(X=(0, 1), Y=(1, 2)), E(X=(2, 3), Y=(3, 4))) == false + @test Extents.contains(E(X=(4, 5), Y=(5, 6)), E(X=(2, 3), Y=(3, 4))) == false + # We just ignore missing dimensions + @test Extents.contains(E(X=(2, 4), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(3, 4))) == true + @test Extents.contains(E(X=(2, 4), Y=(1, 4)), E(X=(2, 3), Y=(3, 4), Z=(1, 2))) == true + # Except when `strict` is true + @test Extents.contains(E(X=(2, 4), Y=(1, 4)), E(X=(2, 3), Y=(3, 4), Z=(1, 2)); strict=true) == false + # When they are present *they can change the result* + @test Extents.contains(E(X=(2, 4), Y=(1, 4), Z=(0, 1)), E(X=(2, 3), Y=(3, 4), Z=(1, 2))) == false + # Nothing returns false + @test Extents.contains(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.contains(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.contains(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.contains(1, :x) == false + # Objects that have extents can be used + @test Extents.contains(HasExtent(), Extents.extent(HasExtent())) == true +end + +@testset "within" begin + # An extent contains itself + @test Extents.within(E(X=(2, 3), Y=(3, 4)), E(X=(2, 3), Y=(3, 4))) == true + # A larger extent within a smaller one inside it + @test Extents.contains(E(X=(0, 4), Y=(1, 5)), E(X=(2, 3), Y=(3, 4))) == true + @test Extents.within(E(X=(2, 3), Y=(3, 4)), E(X=(0, 4), Y=(1, 5))) == true + # Intersecting but not within in one dimension + @test Extents.within(E(X=(2, 3), Y=(3, 4)), E(X=(0, 5), Y=(1, 3))) == false + @test Extents.within(E(X=(2, 3), Y=(3, 4)), E(X=(3, 5), Y=(1, 5))) == false + # An extent with no interior is not within + @test Extents.within(E(X=(2, 2), Y=(3, 4)), E(X=(0, 4), Y=(1, 5))) == false + # Not within in any dimensions + @test Extents.within(E(X=(2, 3), Y=(3, 4)), E(X=(0, 1), Y=(1, 2))) == false + @test Extents.within(E(X=(4, 5), Y=(5, 6)), E(X=(1, 3), Y=(3, 4))) == false + # We just ignore missing dimensions + @test Extents.within(E(X=(2, 3), Y=(3, 4)), E(X=(2, 4), Y=(1, 4), Z=(1, 2))) == true + @test Extents.within(E(X=(2, 3), Y=(3, 4), Z=(1, 2)), E(X=(2, 4), Y=(1, 4))) == true + # Except when `strict` is true + @test Extents.within(E(X=(2, 3), Y=(3, 4), Z=(1, 2)), E(X=(2, 4), Y=(1, 4)); strict=true) == false + # When they are present *they can change the result* + @test Extents.within(E(X=(2, 4), Y=(1, 4), Z=(0, 1)), E(X=(2, 3), Y=(3, 4), Z=(1, 2))) == false + # Nothing returns false + @test Extents.within(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.within(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.within(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.within(1, :x) == false + # Objects that have extents can be used + @test Extents.within(HasExtent(), Extents.extent(HasExtent())) == true +end + +@testset "overlaps" begin + x = E(X=(2, 3), Y=(3, 5)) + # These overlap + a = E(X=(0, 3), Y=(1, 4)) + @test Extents.overlaps(a, x) == Extents.overlaps(x, a) == true + # On the line is not overlapping + b = E(X=(0, 3), Y=(1, 3)) + @test Extents.overlaps(b, x) == Extents.overlaps(x, b) == false + # Corner touching is also not overlapping + c = E(X=(0, 2), Y=(1, 3)) + @test Extents.overlaps(c, x) == Extents.overlaps(x, c) == false + # Same on the high side of the bounds + d = E(X=(0, 6), Y=(5, 5)) + @test Extents.overlaps(d, x) == Extents.overlaps(x, d) == false + e = E(X=(0.0, 2.1), Y=(3.0, 5.0)) + @test Extents.overlaps(e, x) == Extents.overlaps(x, e) == true + # We just ignore missing dimensions + @test Extents.overlaps(E(X=(0, 3), Y=(1, 4)), E(X=(2, 3), Y=(3, 5), Z=(9, 10))) == true + @test Extents.overlaps(E(X=(0, 3), Y=(1, 4), Z=(9, 10)), E(X=(2, 3), Y=(3, 5))) == true + # Except when `strict` is true + @test Extents.overlaps(E(X=(0, 3), Y=(1, 4), Z=(9, 10)), E(X=(2, 3), Y=(3, 5)); strict=true) == false + # When they are present in both *they can change the result* + @test Extents.overlaps(E(X=(0, 3), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(3, 5), Z=(9, 10))) == false + # Nothing returns false + @test Extents.overlaps(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.overlaps(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.overlaps(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.overlaps(1, :x) == false + # Objects that have extents can be used + @test Extents.overlaps(HasExtent(), Extent(X=(0.5, 1.0), Y=(0.5, 1.5))) == true +end + +@testset "touches" begin + # These touch at the corner of (X=2.0, Y=3.0) + @test Extents.touches(E(X=(0.0, 2.0), Y=(1.0, 3.0)), E(X=(2.0, 3.0), Y=(3.0, 5.0))) == true + # These touch on the side (X=2.0, Y=3.0) - (X=2.0, Y=5.0) + @test Extents.touches(E(X=(0.0, 2.0), Y=(3.0, 5.0)), E(X=(2.0, 3.0), Y=(3.0, 5.0))) + # Overlapping is not touching + @test Extents.touches(E(X=(0.0, 3.0), Y=(1.0, 4.0)), E(X=(2.0, 3.0), Y=(3.0, 5.0))) == false + # We just ignore missing dimensions + @test Extents.touches(E(X=(0, 3), Y=(1, 3)), E(X=(2, 3), Y=(3, 5), Z=(9, 10))) == true + @test Extents.touches(E(X=(0, 3), Y=(1, 3), Z=(9, 10)), E(X=(2, 3), Y=(3, 5))) == true + # Except when `strict` is true + @test Extents.touches(E(X=(0, 3), Y=(1, 3)), E(X=(2, 3), Y=(3, 5), Z=(9, 10)); strict=true) == false + @test Extents.touches(E(X=(0, 3), Y=(1, 3), Z=(9, 10)), E(X=(2, 3), Y=(3, 5)); strict=true) == false + # When they are present in both they can change the result + @test Extents.touches(E(X=(0, 3), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(3, 5), Z=(9, 10))) == false + # Nothing returns false + @test Extents.touches(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.touches(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.touches(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.touches(1, :x) == false + # Objects that have extents can be used + @test Extents.touches(HasExtent(), Extent(X=(1.0, 1.0), Y=(1.0, 1.0))) == true +end + +@testset "equals" begin + # Matching bounds are `equal` + @test Extents.equals(E(X=(2, 3), Y=(1, 4)), E(X=(2, 3), Y=(1, 4))) == true + # Their order doesn't matter + @test Extents.equals(E(X=(2, 3), Y=(1, 4)), E(Y=(1, 4), X=(2, 3))) == true + # We just ignore missing dimensions + @test Extents.equals(E(X=(2, 3), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(1, 4))) == true + # Except when `strict` is true + @test Extents.equals(E(X=(2, 3), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(1, 4)); strict=true) == false + # Adding a dimension can change the result + @test Extents.equals(E(X=(2, 3), Y=(1, 4), Z=(1, 2)), E(X=(2, 3), Y=(1, 4), Z=(0, 2))) == false + # Nothing returns false + @test Extents.equals(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.equals(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.equals(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.equals(1, :x) == false + # Objects that have extents can be used + @test Extents.equals(HasExtent(), Extents.extent(HasExtent())) == true + @test Extents.equals(Extents.extent(HasExtent()), HasExtent()) == true +end + +@testset "intersection/intersects/disjoint" begin + a = E(X=(0.1, 0.5), Y=(1.0, 2.0)) + b = E(X=(2.1, 2.5), Y=(3.0, 4.0)) + c = E(X=(0.4, 2.5), Y=(1.5, 4.0), Z=(0.0, 1.0)) + d = E(X=(0.2, 0.45)) + e = E(A=(0.0, 1.0)) + # a and b don't intersect @test Extents.intersects(a, b) == false - @test Extents.intersection(a, b) === nothing + @test Extents.intersects(b, a) == false + # c and d do, despite the extra Z dimension + @test Extents.intersects(c, a) == true @test Extents.intersects(a, c) == true + # Unless strict is true @test Extents.intersects(a, c; strict=true) == false - @test Extents.intersection(a, c) == Extent(X=(0.4, 0.5), Y=(1.5, 2.0)) - @test Extents.intersection(a, d) === nothing + @test Extents.intersects(c, a; strict=true) == false + # a and d do despite missing Y + @test Extents.intersects(a, d) == true + @test Extents.intersects(d, a) == true + # Unless strict is true + @test Extents.intersects(a, d; strict=true) == false + @test Extents.intersects(d, a; strict=true) == false + # If there are no dimensions, intersects is false + @test Extents.intersects(a, e) == false + @test Extents.intersects(e, a) == false + # Nothing returns false + @test Extents.intersects(E(X=(2, 3), Y=(3, 4)), nothing) == false + @test Extents.intersects(nothing, E(X=(2, 3), Y=(3, 4))) == false + @test Extents.intersects(nothing, nothing) == false + # Objects that don't have extents also return false + @test Extents.intersects(1, :x) == false + # Objects that have extents can be used + @test Extents.intersects(HasExtent(), Extents.extent(HasExtent())) == true + @test Extents.intersects(Extents.extent(HasExtent()), HasExtent()) == true + + # a and b are disjoint + @test Extents.disjoint(a, b) == true + @test Extents.disjoint(b, a) == true + # c and d do, despite the extra Z dimension + @test Extents.disjoint(c, a) == false + @test Extents.disjoint(a, c) == false + # Unless strict is true + @test Extents.disjoint(a, c; strict=true) == true + @test Extents.disjoint(c, a; strict=true) == true + # a and d do despite missing Y + @test Extents.disjoint(a, d) == false + @test Extents.disjoint(d, a) == false + # Unless strict is true + @test Extents.disjoint(a, d; strict=true) == true + @test Extents.disjoint(d, a; strict=true) == true + # If there are no dimensions, disjoint is false + @test Extents.disjoint(a, e) == true + @test Extents.disjoint(e, a) == true + # Nothing returns true + @test Extents.disjoint(E(X=(2, 3), Y=(3, 4)), nothing) == true + @test Extents.disjoint(nothing, E(X=(2, 3), Y=(3, 4))) == true + @test Extents.disjoint(nothing, nothing) == true + # Objects that don't have extents also return false + @test Extents.disjoint(1, :x) == true + # Objects that have extents can be used + @test Extents.disjoint(HasExtent(), Extent(X=(2, 3), Y=(4, 5))) == true + @test Extents.disjoint(Extent(X=(2, 3), Y=(4, 5)), HasExtent()) == true + + # a and b do not intersect + @test Extents.intersection(a, b) === nothing + @test Extents.intersection(b, a) === nothing + # a and c do, if we ignore the extra Z dimension + @test Extents.intersection(a, c) == + Extents.intersection(c, a) == + Extents.intersection(a, c, a) == + Extents.intersection(a, c, a, c) == + Extent(X=(0.4, 0.5), Y=(1.5, 2.0)) + @test Extents.intersection(c, a) == Extents.intersection(c, a) == Extent(X=(0.4, 0.5), Y=(1.5, 2.0)) + # Unless strict is true @test Extents.intersection(a, c; strict=true) === nothing + @test Extents.intersection(c, a; strict=true) === nothing + + # a and d intersect on X + @test Extents.intersection(a, d) == Extents.intersection(d, a) == Extent(X=(0.2, 0.45)) + # If there are no dimensions, there are no intersections + @test Extents.intersection(a, e) === nothing + @test Extents.intersection(e, a) === nothing + + # If either argument is nothing, return nothing @test Extents.intersection(a, nothing) === nothing @test Extents.intersection(nothing, nothing) === nothing @test Extents.intersection(nothing, b) === nothing