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}]` > ![Order of vertices](imgs/polygon.png) """ 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