Skip to main content

lib/drone/safety/geofence.ex

defmodule Drone.Safety.Geofence do
  @moduledoc """
  Geofence definitions for restricting drone flight area.

  A geofence defines an allowed flight area. Movement commands that would
  take the drone outside the geofence are rejected by the safety pipeline.

  Two geofence shapes are supported:

    - Circle: defined by a center point and radius
    - Polygon: defined by a list of vertices

  Coordinates are in centimeters from the launch point.
  """

  @type t :: %__MODULE__{
          type: :circle | :polygon,
          center: {integer(), integer()} | nil,
          radius_cm: pos_integer() | nil,
          points: [{integer(), integer()}] | nil
        }

  defstruct [:type, :center, :radius_cm, :points]

  @doc """
  Creates a circular geofence centred at the given point with the given radius.
  """
  @spec circle({integer(), integer()}, pos_integer()) :: t()
  def circle(center, radius_cm) do
    %__MODULE__{type: :circle, center: center, radius_cm: radius_cm}
  end

  @doc """
  Creates a polygon geofence from a list of vertices.

  The polygon must have at least 3 vertices. The last vertex is automatically
  connected to the first.
  """
  @spec polygon([{integer(), integer()}]) :: t()
  def polygon([_, _, _ | _] = points) do
    %__MODULE__{type: :polygon, points: points}
  end

  @doc """
  Creates a circular geofence centred at the origin with the given radius.

  This is a convenience for defining a radius around the launch point.
  """
  @spec radius(pos_integer()) :: t()
  def radius(radius_cm) do
    circle({0, 0}, radius_cm)
  end

  @doc """
  Checks whether a point is inside the geofence.

  Returns `true` if the point is inside or on the boundary, `false` otherwise.
  """
  @spec contains?(t() | nil, {integer(), integer()}) :: boolean()
  def contains?(nil, _point), do: true

  def contains?(%__MODULE__{type: :circle, center: {cx, cy}, radius_cm: r}, {px, py}) do
    dx = px - cx
    dy = py - cy
    :math.sqrt(dx * dx + dy * dy) <= r
  end

  def contains?(%__MODULE__{type: :polygon, points: points}, point) do
    point_in_polygon?(point, points)
  end

  defp point_in_polygon?({px, py}, polygon) do
    polygon
    |> Stream.concat(Stream.take(polygon, 1))
    |> Stream.chunk_every(2, 1, :discard)
    |> Enum.reduce(0, fn [{x1, y1}, {x2, y2}], crossings ->
      if edge_crosses?({x1, y1}, {x2, y2}, px, py) do
        crossings + 1
      else
        crossings
      end
    end)
    |> rem(2) == 1
  end

  defp edge_crosses?({x1, y1}, {x2, y2}, px, py) do
    if y1 <= y2 do
      py > y1 and py <= y2 and px < x1 + (py - y1) * (x2 - x1) / (y2 - y1)
    else
      py > y2 and py <= y1 and px < x2 + (py - y2) * (x1 - x2) / (y1 - y2)
    end
  end
end