defmodule Scurry.Polygon do
@moduledoc """
Functions related to polygons and lines relevant for 2D map pathfinding.
This provides functions for;
* line of sight between two points
* classify polygon vertices as concave or vertex
* intersections of lines and polygons
* checking if points are inside/outside polygons
* finding nearest point on a polygon and distances
Polygons are
* A list of vertices, `[{x1, y1}, {x2,y2}, ...]` defined as a `list(vector)`.
* They **must not** be closed, ie. last vertex should not be equal to the first.
* They **must be** in clockwise order in screen coordinates, otherwise
convex/concave classification will be inversed as it traverses the egdes.
> ### Order of vertices {: .warning}
>
> Polygons **must** be defined in clockwise order in screen coordinates. This is necessary
> for the convex/concave classification. It counter clock, it will be inversed as it
> traverses the egdes.
>
> Here's a crude drawing of an example of the M shaped polygon used for many tests/docs.
>
> `polygon = [{0, 0}, {10, 0}, {20, 0}, {20, 20}, {10, 10}, {0, 20}]`
> 
"""
alias Scurry.Geo
alias Scurry.Vector
use Scurry.Types
@doc """
Checks if a line intersects a polygon.
This is a boolean version of `intersects/2`. For most cases using
`intersections/3` will be a better choice to get intersections.
## Params
* `polygon` (`t:polygon/0`), the polygon to check.
* `line` (`t:line/0`), the line to check if it intersects `polygon`.
## Returns
`true` or `false` wether the line intersects the polygon or not.
## Examples
# A vaguely M shaped polygon
iex> Polygon.intersects?([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{1, -0.5}, {1, 0.5}})
true
iex> Polygon.intersects?([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{1, -1}, {1, 0}})
true
iex> Polygon.intersects?([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{1, -2}, {1, -1}})
false
"""
@spec intersects?(polygon(), line()) :: boolean()
def intersects?(polygon, line) do
case intersects(polygon, line) do
:nointersection -> false
_ -> true
end
end
@doc """
Checks if a line intersects a polygon.
Find an intersection (if any) between `polygon` and `line`. For most cases
using `intersections/3` will be a better choice to get intersections.
## Params
* `polygon` (`t:polygon/0`), the polygon to check.
* `line` (`t:line/0`), the line to check if it intersects `polygon`.
## Returns
This relates to `Scurry.Geo.line_segment_intersection/2`.
- `:nointersection`, `line` does not intersect `polygon`
- `{:point_intersection, vector}`
- `{;intersection, line}`
- `:on_segment`
## Examples
# A vaguely M shaped polygon
iex> Polygon.intersects([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{1, -0.5}, {1, 0.5}})
{:point_intersection, {1.0, 0.0}}
iex> Polygon.intersects([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{0.5, -0.5}, {0.5, 0.5}})
{:intersection, {0.5, 0.0}}
iex> Polygon.intersects([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{0.5, -1}, {0.5, 0}})
{:point_intersection, {0.5, 0.0}}
iex> Polygon.intersects([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{0.5, -2}, {0.5, -1}})
:nointersection
iex> Polygon.intersects([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}], {{2, 0.1}, {2, 0.9}})
:on_segment
"""
@spec intersects(polygon(), line()) :: :nointersection | :on_segment | {:intersection, vector()} | {:poiont_intersection, vector() }
def intersects(polygon, line) do
prev_point = List.last(polygon)
intersects_helper(polygon, line, prev_point)
end
@doc """
Get all intersections of a line with a polygon including their type.
This function basically calls `Geo.line_segment_intersection/2` for every segment
of the `polygon` against the `line` and filters the results to only include
the list of intersection points.
## Params
* `polygon` (`t:polygon/0`), the polygon to check.
* `line` (`t:line/0`), the line check for intersections against `polygon`.
## Options
* `:allow_points` (default `false`) whether a `on_point` intersection
should be considered an intersection or not. This varies from use
cases. Eg. when building a polygon, points will be connected and thus
intersect if `true`. This may not be the desired result, so `false` won't
consider points intersections.
## Returns
* a list of `t:vector/0` indicating where the line intersects
* `[]` if there's no intersections.
## Examples
iex> polygon = [{0, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
[{0, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
iex> line = {{1, -1}, {1, 3}}
{{1, -1}, {1, 3}}
iex> Polygon.intersections(polygon, line)
[{1.0, 0.0}]
iex> Polygon.intersections(polygon, line, allow_points: true)
[{1.0, 0.0}, {1.0, 0.5}]
"""
@spec intersections(polygon(), line(), [allow_points: boolean()]) :: list(vector())
def intersections(polygon, line, opts \\ []) do
allow_points = Keyword.get(opts, :allow_points, false)
prev_point = List.last(polygon)
intersects_helper(polygon, line, prev_point, [])
|> Enum.filter(fn
{:intersection, _} -> true
{:point_intersection, _} -> allow_points
_ -> false
end)
|> Enum.map(fn {_, point} -> point end)
|> Enum.dedup()
end
@doc """
Get first intersection of a line with a polygon.
The "opposite" of `last_intersection/2`.
## Params
* `polygon` (`t:polygon/0`) a polygon to check for an intersection with `line`.
* `line` (`t:line/0`). The line to check for intersections with `polygon`.
A `t:line/0` is a tuple of two `t:vector/0` and the first is considered the head
of the line and "first" in this context means nearest to that point.
## Returns
* A `t:vector/0` indicating where `line` first intersects `polygon`.
* `nil` if there's no intersection.
## Examples
# A square around 0,0
iex> square = [{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
[{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
iex> Polygon.first_intersection(square, {{0, -2}, {0, 2}})
{0.0, -1.0}
iex> Polygon.first_intersection(square, {{0, 2}, {0, -2}})
{0.0, 1.0}
iex> Polygon.first_intersection(square, {{2, -2}, {2, 2}})
nil
"""
@spec first_intersection(polygon(), line()) :: nil | vector()
def first_intersection(polygon, {a, _b} = line) do
Enum.min_by(
intersections(polygon, line),
fn ip ->
Vector.distance(a, ip)
end,
fn ->
nil
end
)
end
@doc """
Get last intersection of a line with a polygon.
The "opposite" of `first_intersection/2`.
## Params
* `polygon` (`t:polygon/0`) a polygon to check for an intersection with `line`.
* `line` (`t:line/0`). The line to check for intersections with `polygon`.
A `t:line/0` is a tuple of two `t:vector/0` and the last is considered the tail
of the line and "last" in this context means nearest to that point.
## Returns
* A `t:vector/0` indicating where `line` last intersects `polygon`
* `nil` if there's no intersection.
## Examples
# A square around 0,0
iex> square = [{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
[{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
iex> Polygon.last_intersection(square, {{0, -2}, {0, 2}})
{0.0, 1.0}
iex> Polygon.last_intersection(square, {{0, 2}, {0, -2}})
{0.0, -1.0}
iex> Polygon.last_intersection(square, {{2, -2}, {2, 2}})
nil
"""
@spec last_intersection(polygon(), line()) :: nil | vector()
def last_intersection(polygon, {a, _b} = line) do
Enum.max_by(
intersections(polygon, line),
fn ip ->
Vector.distance(a, ip)
end,
fn ->
nil
end
)
end
defp intersects_helper([], _line, _prev_point) do
:nointersection
end
defp intersects_helper([next_point | polygon], line, prev_point) do
case Geo.line_segment_intersection(line, {prev_point, next_point}) do
:parallel -> intersects_helper(polygon, line, next_point)
:none -> intersects_helper(polygon, line, next_point)
:on_segment -> :on_segment
{:intersection, _} = result -> result
{:point_intersection, _} = result -> result
end
end
defp intersects_helper([], _line, _prev_point, acc) do
acc
end
defp intersects_helper([next_point | polygon], line, prev_point, acc) when is_list(acc) do
v = Geo.line_segment_intersection(line, {prev_point, next_point})
intersects_helper(polygon, line, next_point, acc ++ [v])
end
@doc """
Split a polygon into concave and convex vertices.
When doing pathfinding, there will typically be a outer polygon bounding the
"world" and multiple inner polygons describing "holes/obstacles".
The path can only be within the outer polygon and has to "walk around" the
holes.
Classifying the polygons into concave and convex is used to determine the
walkable graph.
* The outer polygon's concave (pointing into the world) vertices are used.
used.
* The holes' convex (point out of the hole, into the world) vertices used used.
In code, this looks like
```
# Get the concave vectices of the bounding polygon
{concave, _convex} = Polygon.classify_vertices(world)
# Get al lthe convex vertices of all the holes
convex = Enum.reduce(holes, [], fn points, acc ->
{_, convex} = Polygon.classify_vertices(points)
acc ++ convex
end)
# The initial walk map is the combined set
vertices = concave ++ convex
```
## Params
* `polygon` (`t:polygon/0`) the polygon to classify.
## Returns
`{list of concave vertices, list of convex}`.
* A tuple of two lists of `t:vector/0`.
* The first list are all the _concave_ vertices or `[]` if none
* The second list are all the _convex_ vertices or `[]` if none
## Note
Three points that fall on the same line (`[{0, 0}, {1, 0}, {2, 0}]`) does not
match neither the concave/convex definition (angle greater-than / less-than
180 degrees) this function will discard these via `classify_vertex/2`.
## Examples
# A vaguely M shaped polygon
iex> Polygon.classify_vertices([{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}])
{[{1, 0.5}], [{0, 0}, {2, 0}, {2, 1}, {0, 1}]}
# A square around 0,0
iex> Polygon.classify_vertices([{-1, -1}, {1, -1}, {1, 1}, {-1, 1}])
{[], [{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]}
# A flat line
iex> Polygon.classify_vertices([{0, 0}, {1, 0}, {2, 0}])
{[], []}
"""
@spec classify_vertices(polygon()) :: {[vector()], [vector()]}
def classify_vertices(polygon) do
{concave, convex} =
Enum.reduce(polygon, {0, []}, fn point, {idx, acc} ->
{idx + 1, acc ++ [{point, classify_vertex(polygon, idx)}]}
end)
|> elem(1)
|> Enum.reject(fn {_point, type} -> type == :neither end)
|> Enum.split_with(fn {_point, type} -> type == :concave end)
# Remove the type
{Enum.map(concave, fn {p, _} -> p end), Enum.map(convex, fn {p, _} -> p end)}
end
@doc """
Check if a specific vertex is concave, convex or neither.
Whehter a vertex is concave or convex is defined by it pointing out - it's
inner angle is less than 180 means convex and more than 180 means concave.
When testing a vertex, keep this in mind and negate appropriately depending
on whether it's the boundary polygon or a hole polygon being tested.
## Params
* `polygon` (`t:polygon/0`) the polygon in which to check a vertex.
* `at` (`t:integer/0`), which vertex in the `polygon` to check.
## Returns
* `:convex` for a convex vertice.
* `:concave` for a concave vertice.
* `:neither` for a vertice that's a straight edge, ie. 180 degrees.
## Examples
# A vaguely M shaped polygon
iex> m = [{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
[{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
iex> Polygon.classify_vertex(m, 0)
:convex
iex> Polygon.classify_vertex(m, 1)
:neither
iex> Polygon.classify_vertex(m, 4)
:concave
"""
@spec classify_vertex(polygon(), integer()) :: :concave | :convex | :neither
def classify_vertex(polygon, at) do
# See https://www.david-gouveia.com/pathfinding-on-a-2d-polygonal-map
next = Enum.at(polygon, rem(at + 1, length(polygon)))
current = Enum.at(polygon, at)
prev = Enum.at(polygon, at - 1)
left = Vector.sub(current, prev)
right = Vector.sub(next, current)
cross = Vector.cross(left, right)
cond do
cross < 0 -> :concave
cross > 0 -> :convex
true -> :neither
end
end
@doc """
Check if a specific vertex in a polygon is concave
## Params
* `polygon` (`t:polygon/0`) the polygon in which to check a vertex.
* `at` (`t:integer/0`), which vertex in the `polygon` to check.
## Returns
`true` if the vertex is concave, otherwise `false`
See also `classify_vertex/2`.
## Examples
# A vaguely M shaped polygon
iex> m = [{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
[{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
iex> Polygon.is_concave?(m, 0)
false
iex> Polygon.is_concave?(m, 1)
false
iex> Polygon.is_concave?(m, 2)
false
iex> Polygon.is_concave?(m, 3)
false
iex> Polygon.is_concave?(m, 4)
true
iex> Polygon.is_concave?(m, 5)
false
"""
@spec is_concave?(polygon(), number()) :: boolean()
def is_concave?(polygon, at) do
classify_vertex(polygon, at) == :concave
end
@doc """
Check if a specific vertex in a polygon is convex
## Params
* `polygon` (`t:polygon/0`) the polygon in which to check a vertex.
* `at` (`t:integer/0`), which vertex in the `polygon` to check.
## Returns
`true` if the vertex is convex, otherwise `false`
See also `classify_vertex/2`.
## Examples
# A vaguely M shaped polygon
iex> m = [{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
[{0, 0}, {1, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}]
iex> Polygon.is_convex?(m, 0)
true
iex> Polygon.is_convex?(m, 1)
false
iex> Polygon.is_convex?(m, 2)
true
iex> Polygon.is_convex?(m, 3)
true
iex> Polygon.is_convex?(m, 4)
false
iex> Polygon.is_convex?(m, 5)
true
"""
@spec is_convex?(polygon(), number()) :: boolean()
def is_convex?(polygon, at) do
classify_vertex(polygon, at) == :convex
end
# Alternate, https://sourceforge.net/p/polyclipping/code/HEAD/tree/trunk/cpp/clipper.cpp#l438
@doc """
Check if a point is inside a polygon or not.
## Params
* `polygon` (`t:polygon/0`) the polygon to check against.
* `point` (`t:vector/0`) the point to check if is inside (or on) `polygon`.
## Options
* `allow_border`, defaults to `true` and allows `point` to be the `polygon` edges.
## Returns
* `true` is the `point` is inside the `polygon`
* `true` is the `point` is on `polygon` edges if `allow_border` is `true`.
## Examples
# A square around 0,0
iex> square = [{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
[{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
iex> Polygon.is_inside?(square, {0, 0})
true
iex> Polygon.is_inside?(square, {-1, 0})
true
iex> Polygon.is_inside?(square, {-1, 0}, [allow_border: false])
false
iex> Polygon.is_inside?(square, {-2, 0})
false
"""
@spec is_inside?(polygon(), vector(), [allow_border: boolean()]) :: boolean()
def is_inside?(polygon, point, opts \\ [])
def is_inside?(polygon, _point, _opts) when length(polygon) < 3 do
false
end
# See https://www.david-gouveia.com/pathfinding-on-a-2d-polygonal-map
def is_inside?(polygon, {_, _} = point, opts) when is_list(polygon) do
epsilon = 0.5
prev = Enum.at(polygon, -1)
prev_sq_dist = Vector.distance_squared(prev, point)
{_, _, is_inside} =
Enum.reduce_while(polygon, {prev, prev_sq_dist, false}, fn current,
{prev, prev_sq_dist, inside} ->
sq_dist = Vector.distance_squared(current, point)
if prev_sq_dist + sq_dist + 2.0 * :math.sqrt(prev_sq_dist * sq_dist) -
Vector.distance_squared(current, prev) < epsilon do
allow = Keyword.get(opts, :allow_border, true)
{:halt, {prev, prev_sq_dist, allow}}
else
{x, y} = point
{px, _py} = prev
{left, right} =
if x > px do
{prev, current}
else
{current, prev}
end
{lx, ly} = left
{rx, ry} = right
inside =
if lx < x and x <= rx and (y - ly) * (rx - lx) < (ry - ly) * (x - lx) do
not inside
else
inside
end
{:cont, {current, sq_dist, inside}}
end
end)
is_inside
end
@doc """
The opposite of `is_inside?/3`, provided for code readability.
See `is_inside?/3` for behaviour and imagine the opposite.
"""
def is_outside?(polygon, point, opts \\ [])
def is_outside?(polygon, _point, _opts) when length(polygon) < 3 do
false
end
def is_outside?(polygon, point, opts) do
not is_inside?(polygon, point, opts)
end
@doc """
Find the edge of a polygon nearest a given point.
Given a `point` that's inside or outside a given `polygon`, this checks each
segment of the polygon, and returns the nearest one.
If multiple lines are "nearest", the first in the polygon is picked.
## Params
* `polygon` (`t:polygon/0`) the polygion to find the nearest edge on.
* `point` (`t:vector/0`) the point to find the nearest edge to.
## Returns
A `t:line/0` that is a segment of `polygon` that is closest to `point`.
## Examples
# A square around 0,0
iex> square = [{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
[{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
iex> Polygon.nearest_edge(square, {0, 0})
{{-1, -1}, {1, -1}}
iex> Polygon.nearest_edge(square, {0.9, 0})
{{1, -1}, {1, 1}}
iex> Polygon.nearest_edge(square, {1.1, 0})
{{1, -1}, {1, 1}}
"""
@spec nearest_edge(polygon(), vector()) :: line()
def nearest_edge(polygon, point) do
# Get the closest segment of the polygon
polygon
|> Enum.chunk_every(2, 1, Enum.slice(polygon, 0, 2))
|> Enum.map(fn [a, b] -> {a, b} end)
|> Enum.min_by(&Geo.distance_to_segment(&1, point))
end
@doc """
Find the point on the edge of a polygon nearest a given point.
Given a `point` that's inside or outside a given `polygon`, this uses
`nearest_edge/2` to find the closest edge and then computes the point on the
edge nearest the given `point`.
## Params
* `polygon` (`t:polygon/0`) the polygion to find the nearest point on.
* `point` (`t:vector/0`) the point to find the nearest point to.
## Returns
A `t:vector/0` that is a point on `polygon` that is closest to `point`.
## Examples
# A square around 0,0
iex> square = [{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
[{-1, -1}, {1, -1}, {1, 1}, {-1, 1}]
iex> Polygon.nearest_point_on_edge(square, {0, 0})
{0.0, -1.0}
iex> Polygon.nearest_point_on_edge(square, {0.9, 0})
{1.0, 0.0}
iex> Polygon.nearest_point_on_edge(square, {1.1, 0})
{1.0, 0.0}
"""
@spec nearest_point_on_edge(polygon(), vector()) :: vector()
def nearest_point_on_edge(polygon, point) do
# Get the closest segment of the polygon
{{x1, y1}, {x2, y2}} = nearest_edge(polygon, point)
{x, y} = point
u =
((x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)) /
((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1))
cond do
u < 0 -> {x1, y1}
u > 1 -> {x2, y2}
true -> {x1 + u * (x2 - x1), y1 + u * (y2 - y1)}
end
end
@doc """
Check if the polygon is clockwise within screen coordinates.
This is provided as a utility to check polygons eg. if loading them from data
files. The library internally _does not_ use this to validate polygons to
avoid a runtime penalty.
## Params
* `polygon` (`t:polygon/0`) the polygon to check to check.
## Returrns
`true` if the points in the polygon are defined in a clockwise orientation.
## Examples
iex> Polygon.is_clockwise?([{0, 0}, {2, 0}, {2, 1}, {1, 0.5}, {0, 1}])
true
iex> Polygon.is_clockwise?([{0, 0}, {0, 1}, {1, 0.5}, {2, 1}, {2, 0}])
false
"""
@spec is_clockwise?(polygon()) :: boolean()
def is_clockwise?(polygon) do
a =
polygon
|> Enum.chunk_every(2, 1, Enum.slice(polygon, 0, 2))
|> Enum.reduce(0, fn [{x1, y1}, {x2, y2}], acc -> acc + (x2 - x1) * (y2 + y1) end)
a < 0
end
end