Skip to content

Commit dca151b

Browse files
authored
Spatial tree interface (#297)
Co-authored-by: Rafael Schouten <[email protected]> This PR performs all the necessary actions to add a spatial tree interface and surrounding machinery to GeometryOps. Actually accelerating GeometryOps functions with the spatial trees is left for another PR - we don't tackle that in this one. This implements, at a high level, functional, non-allocating single-tree and dual tree queries over arbitrary predicates. ## SpatialTreeInterface Introduces a new SpatialTreeInterface module that provides an interface for spatial trees, built somewhat on top of AbstractTrees.jl. ## LoopStateMachine Introduces a new `LoopStateMachine` module that provides utilities for returning state from functions running inside loops. This is particularly useful for operations like clipping where state transitions or early termination may be needed. The module includes: - `Action` struct for representing different control flow actions - `@controlflow` macro for processing actions within loops - Support for `:continue`, `:break`, `:return`, and `:full_return` actions Comprehensive tests verify all supported actions work correctly.
1 parent fa4876a commit dca151b

16 files changed

+1256
-3
lines changed

Project.toml

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ authors = ["Anshul Singhvi <[email protected]>", "Rafael Schouten <rafaels
44
version = "0.1.18"
55

66
[deps]
7+
AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c"
78
AdaptivePredicates = "35492f91-a3bd-45ad-95db-fcad7dcfedb7"
89
CoordinateTransformations = "150eb455-5306-5404-9cee-2592286d6298"
910
DataAPI = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a"
@@ -32,6 +33,7 @@ GeometryOpsProjExt = "Proj"
3233
GeometryOpsTGGeometryExt = "TGGeometry"
3334

3435
[compat]
36+
AbstractTrees = "0.4"
3537
AdaptivePredicates = "1.2"
3638
CoordinateTransformations = "0.5, 0.6"
3739
DataAPI = "1"
@@ -65,6 +67,7 @@ JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
6567
LibGEOS = "a90b1aa1-3769-5649-ba7e-abc5a9d163eb"
6668
NaturalEarth = "436b0209-26ab-4e65-94a9-6526d86fea76"
6769
OffsetArrays = "6fe1bfb0-de20-5000-8ca7-80f57d26f881"
70+
Polylabel = "49a44318-e865-4b63-9842-695152d634c1"
6871
Proj = "c94c279d-25a6-4763-9509-64d165bea63e"
6972
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
7073
Rasters = "a3a2b9e3-a471-40c9-b274-f788e487c689"
@@ -74,4 +77,4 @@ TGGeometry = "d7e755d2-3c95-4bcf-9b3c-79ab1a78647b"
7477
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
7578

7679
[targets]
77-
test = ["ArchGDAL", "CoordinateTransformations", "DataFrames", "Distributions", "DimensionalData", "Downloads", "FlexiJoins", "GeoJSON", "Proj", "JLD2", "LibGEOS", "Random", "Rasters", "NaturalEarth", "OffsetArrays", "SafeTestsets", "Shapefile", "TGGeometry", "Test"]
80+
test = ["ArchGDAL", "CoordinateTransformations", "DataFrames", "Distributions", "DimensionalData", "Downloads", "FlexiJoins", "GeoJSON", "Proj", "JLD2", "LibGEOS", "Random", "Rasters", "NaturalEarth", "OffsetArrays", "Polylabel", "SafeTestsets", "Shapefile", "TGGeometry", "Test"]

docs/.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ node_modules/
22
build/
33
package-lock.json
44
src/source/
5-
Manifest.toml
5+
Manifest.toml
6+
7+
src/call_notes.md

docs/Project.toml

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
1616
DoubleFloats = "497a8b3b-efae-58df-a0af-a86822472b78"
1717
Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
1818
ExactPredicates = "429591f6-91af-11e9-00e2-59fbe8cec110"
19+
Extents = "411431e0-e8b7-467b-b5e0-f676ba4f2910"
1920
FlexiJoins = "e37f2e79-19fa-4eb7-8510-b63b51fe0a37"
2021
GADM = "a8dd9ffe-31dc-4cf5-a379-ea69100a8233"
2122
GeoDataFrames = "62cb38b5-d8d2-4862-a48e-6a340996859f"
@@ -42,6 +43,7 @@ Proj = "c94c279d-25a6-4763-9509-64d165bea63e"
4243
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
4344
Shapefile = "8e980c4a-a4fe-5da2-b3a7-4b4b0353a2f4"
4445
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
46+
SortTileRecursiveTree = "746ee33f-1797-42c2-866d-db2fce69d14d"
4547
TGGeometry = "d7e755d2-3c95-4bcf-9b3c-79ab1a78647b"
4648

4749
[sources]

src/GeometryOps.jl

+7-1
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,15 @@ const Edge{T} = Tuple{TuplePoint{T},TuplePoint{T}} where T
3636

3737
include("types.jl")
3838
include("primitives.jl")
39-
include("utils.jl")
4039
include("not_implemented_yet.jl")
4140

41+
include("utils/utils.jl")
42+
include("utils/LoopStateMachine/LoopStateMachine.jl")
43+
include("utils/SpatialTreeInterface/SpatialTreeInterface.jl")
44+
45+
using .LoopStateMachine, .SpatialTreeInterface
46+
47+
4248
include("methods/angles.jl")
4349
include("methods/area.jl")
4450
include("methods/barycentric.jl")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
LoopStateMachine
3+
4+
Utilities for returning state from functions that run inside a loop.
5+
6+
This is used in e.g clipping, where we may need to break or transition states.
7+
8+
The main entry point is to return an [`Action`](@ref) from a function that
9+
is wrapped in a `@controlflow f(...)` macro in a loop. When a known `Action`
10+
(currently, `:continue`, `:break`, `:return`, or `:full_return` actions) is returned,
11+
it is processed by the `@controlflow` macro, which allows the function to break out of the loop
12+
early, continue to the next iteration, or return a value, basically a way to provoke syntactic
13+
behaviour from a function called from a inside a loop, where you do not have access to that loop.
14+
15+
## Example
16+
17+
```julia
18+
```
19+
"""
20+
module LoopStateMachine
21+
22+
export Action, @controlflow
23+
24+
import ..GeometryOps as GO
25+
26+
const ALL_ACTION_DESCRIPTIONS = """
27+
- `:continue`: continue to the next iteration of the loop.
28+
This is the `continue` keyword in Julia. The contents of the action are not used.
29+
- `:break`: break out of the loop.
30+
This is the `break` keyword in Julia. The contents of the action are not used.
31+
- `:return`: cause the function executing the loop to return with the wrapped value.
32+
- `:full_return`: cause the function executing the loop to return `Action(:full_return, x)`.
33+
This is very useful to terminate recursive funtions, like tree queries terminating after you
34+
have found a single intersecting segment.
35+
"""
36+
37+
"""
38+
Action(name::Symbol, [x])
39+
40+
Create an `Action` with the name `name` and optional contents `x`.
41+
42+
`Action`s are returned from functions wrapped in a `@controlflow` macro, which
43+
does something based on the return value of that function if it is an `Action`.
44+
45+
## Available actions
46+
47+
$ALL_ACTION_DESCRIPTIONS
48+
"""
49+
struct Action{T}
50+
name::Symbol
51+
x::T
52+
end
53+
54+
Action() = Action{Nothing}(:unnamed, nothing)
55+
Action(x::T) where T = Action{T}(:unnamed, x)
56+
Action(x::Symbol) = Action(x, nothing)
57+
58+
function Base.show(io::IO, action::Action{T}) where T
59+
print(io, "Action")
60+
print(io, "(:$(action.name)")
61+
if isnothing(action.x)
62+
print(io, ")")
63+
else
64+
print(io, ", ",action.x, ")")
65+
end
66+
end
67+
68+
struct UnrecognizedActionException <: Base.Exception
69+
name::Symbol
70+
end
71+
72+
function Base.showerror(io::IO, e::UnrecognizedActionException)
73+
print(io, "Unrecognized action: ")
74+
printstyled(io, e.name; color = :red, bold = true)
75+
println(io, ".")
76+
println(io, "Valid actions are:")
77+
println(io, ALL_ACTION_DESCRIPTIONS)
78+
end
79+
80+
# We exclude the macro definition from code coverage computations,
81+
# because I know it's tested but Codecov doesn't seem to think so.
82+
# COV_EXCL_START
83+
"""
84+
@controlflow f(...)
85+
86+
Process the result of `f(...)` and return the result if it's not an `Action`(@ref LoopStateMachine.Action).
87+
88+
If it is an `Action`, then process it according to the following rules, and throw an error if it's not recognized.
89+
`:continue`, `:break`, `:return`, or `:full_return` are valid actions.
90+
91+
$ALL_ACTION_DESCRIPTIONS
92+
93+
!!! warning
94+
Only use this inside a loop, otherwise you'll get a syntax error, especially if you use `:continue` or `:break`.
95+
96+
## Examples
97+
"""
98+
macro controlflow(expr)
99+
varname = gensym("loop-state-machine-returned-value")
100+
return quote
101+
$varname = $(esc(expr))
102+
if $varname isa Action
103+
if $varname.name == :continue
104+
continue
105+
elseif $varname.name == :break
106+
break
107+
elseif $varname.name == :return
108+
return $varname.x
109+
elseif $varname.name == :full_return
110+
return $varname
111+
else
112+
throw(UnrecognizedActionException($varname.name))
113+
end
114+
else
115+
$varname
116+
end
117+
end
118+
end
119+
# COV_EXCL_STOP
120+
121+
# You can define more actions as you desire.
122+
123+
end
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# SpatialTreeInterface.jl
2+
3+
A simple interface for spatial tree types.
4+
5+
## What is a spatial tree?
6+
7+
- 2 dimensional extents
8+
- Parent nodes encompass all leaf nodes
9+
- Leaf nodes contain references to the geometries they represent as indices (or so we assume here)
10+
11+
## Why is this useful?
12+
13+
- It allows us to write algorithms that can work with any spatial tree type, without having to know the details of the tree type.
14+
- for example, dual tree traversal / queries
15+
- It allows us to flexibly and easily swap out and use different tree types, depending on the problem at hand.
16+
17+
This is also a zero cost interface if implemented correctly! Verified implementations exist for "flat" trees like the "Natural Index" from `tg`, and "hierarchical" trees like the `STRtree` from `SortTileRecursiveTree.jl`.
18+
19+
## Interface
20+
21+
- `isspatialtree(tree)::Bool`
22+
- `isleaf(node)::Bool` - is the node a leaf node? In this context, a leaf node is a node that does not have other nodes as its children, but stores a list of indices and extents (even if implicit).
23+
- `getchild(node)` - get the children of a node. This may be materialized if necessary or available, but can also be lazy (like a generator).
24+
- `getchild(node, i)` - get the `i`-th child of a node.
25+
- `nchild(node)::Int` - the number of children of a node.
26+
- `child_indices_extents(node)` - an iterator over the indices and extents of the children of a **leaf** node.
27+
28+
These are the only methods that are required to be implemented.
29+
30+
Optionally, one may define:
31+
- `node_extent(node)` - get the extent of a node. This falls back to `GI.extent` but can potentially be overridden if you want to return a different but extent-like object.
32+
33+
They enable the generic query functions described below:
34+
35+
## Query functions
36+
37+
- `do_query(f, predicate, node)` - call `f(i)` for each index `i` in `node` that satisfies `predicate(extent(i))`.
38+
- `do_dual_query(f, predicate, tree1, tree2)` - call `f(i1, i2)` for each index `i1` in `tree1` and `i2` in `tree2` that satisfies `predicate(extent(i1), extent(i2))`.
39+
40+
These are both completely non-allocating, and will only call `f` for indices that satisfy the predicate.
41+
You can of course build a standard query interface on top of `do_query` if you want - that's simply:
42+
```julia
43+
a = Int[]
44+
do_query(Base.Fix1(push!, a), predicate, node)
45+
```
46+
where `predicate` might be `Base.Fix1(Extents.intersects, extent_to_query)`.
47+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
module SpatialTreeInterface
2+
3+
import ..LoopStateMachine: @controlflow
4+
5+
import Extents
6+
import GeoInterface as GI
7+
import AbstractTrees
8+
9+
# public isspatialtree, isleaf, getchild, nchild, child_indices_extents, node_extent
10+
export query, do_query
11+
export FlatNoTree
12+
13+
# The spatial tree interface and its implementations are defined here.
14+
include("interface.jl")
15+
include("implementations.jl")
16+
17+
# Here we have some algorithms that use the spatial tree interface.
18+
# The first file holds a single depth-first search, i.e., a single-tree query.
19+
include("depth_first_search.jl")
20+
21+
# The second file holds a dual depth-first search, i.e., a dual-tree query.
22+
# This iterates over two trees simultaneously, and is substantially more efficient
23+
# than two separate single-tree queries since it can prune branches in tandem as it
24+
# descends into the trees.
25+
include("dual_depth_first_search.jl")
26+
27+
28+
"""
29+
query(tree, predicate)
30+
31+
Return a sorted list of indices of the tree that satisfy the predicate.
32+
"""
33+
function query(tree, predicate)
34+
a = Int[]
35+
depth_first_search(Base.Fix1(push!, a), sanitize_predicate(predicate), tree)
36+
return sort!(a)
37+
end
38+
39+
40+
"""
41+
sanitize_predicate(pred)
42+
43+
Convert a predicate to a function that returns a Boolean.
44+
45+
If `pred` is an Extent, convert it to a function that returns a Boolean by intersecting with the extent.
46+
If `pred` is a geometry, convert it to an extent first, then wrap in Extents.intersects.
47+
48+
Otherwise, return the predicate unchanged.
49+
50+
51+
Users and developers may overload this function to provide custom behaviour when something is passed in.
52+
"""
53+
sanitize_predicate(pred) = sanitize_predicate(GI.trait(pred), pred)
54+
sanitize_predicate(::Nothing, pred) = pred
55+
sanitize_predicate(::GI.AbstractTrait, pred) = sanitize_predicate(GI.extent(pred))
56+
sanitize_predicate(pred::Extents.Extent) = Base.Fix1(Extents.intersects, pred)
57+
58+
59+
end # module SpatialTreeInterface

0 commit comments

Comments
 (0)