lib/seg_seg.ex

defmodule SegSeg do
  @moduledoc ~S"""
  Calculates the type of relationship between two line segments **AB** and
  **CD** and the location of intersection (if applicable).

  ![Classification of segment-segment intersection](http://i.imgbox.com/hO3zHfNR.png)

  """

  @type point :: {number, number}
  @type intersection_type ::
          :interior
          | :disjoint
          | :edge
          | :vertex
  @type intersection_result :: {boolean, intersection_type, point | nil}

  # When the `epsilon` option is passed as `true` or a number, certain comparisons are broadened
  # based on an epsilon value. 
  # If `true` is passed, the eps value is defined as the smaller of the distances
  # of each segment multiplied by the `@eps_factor` below.
  @eps_factor 0.000000001

  @doc ~S"""
  Returns a tuple representing the segment-segment intersection with three
  elements:

  1. Boolean `true` if the two segments intersect at all, `false` if they are
     disjoint
  2. An atom representing the classification of the intersection:
    * `:interior` - the segments intersect at a point that is interior to both
    * `:vertex` - the segments intersect at an endpoint of one or both segments
    * `:edge` - the segments are parallel, collinear, and overlap for some non-zero length
    * `:disjoint` - no intersection exists between the two segments
  3. A tuple `{x, y}` representing the point of intersection if the intersection
     is classified as `:interior` or `:vertex`, otherwise `nil`.

  ## Float Precision Issues

  It is possible that floating point math imprecision can cause incorrect results for
  certain inputs.  In situations where this may cause issues, an `epsilon` options is
  available.  When set to `true` intersection comparisons are made with a very small `epsilon` based on the minimum
  of the lengths of the provided segment times a very small number (currently 0.0000000001). `epsilon` can also be set to a specific number that will be used as the epsilon value.
  This eliminates most rounding error, but of course could cause false results in certain
  situations. This currently only effects `:vertex` results but might be expanded to `:edge`
  in the future.

  ```elixir
  SegSeg.intersection({4, 3}, {4, 7}, {6.05, 9.05}, {3.95, 6.95}) #=> {true, :interior, {4.0, 6.999999999999998}}
  SegSeg.intersection({4, 3}, {4, 7}, {6.05, 9.05}, {3.95, 6.95}, epsilon: true) #=> {true, :vertex, {4, 7}}
  ```

  ## Examples

      iex> SegSeg.intersection({2, -3}, {4, -1}, {2, -1}, {4, -3})
      {true, :interior, {3.0, -2.0}}
      iex> SegSeg.intersection({-1, 3}, {2, 4}, {-1, 4}, {-1, 5})
      {false, :disjoint, nil}
      iex> SegSeg.intersection({-1, 0}, {0, 2}, {0, 2}, {1, -1})
      {true, :vertex, {0, 2}}
      iex> SegSeg.intersection({-1, 0}, {0, 2}, {1, 4}, {-1, 0})
      {true, :edge, nil}

      # This doesn't behave as expected because of floating point error.
      iex> SegSeg.intersection({4, 3}, {4, 7}, {6.05, 9.05}, {3.95, 6.95})
      {true, :interior, {4.0, 6.999999999999998}}

      # Adding the default epsilon causes this to act more as expected.
      iex> SegSeg.intersection({4, 3}, {4, 7}, {6.05, 9.05}, {3.95, 6.95}, epsilon: true)
      {true, :vertex, {4, 7}}

      # Specifying a very small epsilon shows the floating point error again.
      iex> SegSeg.intersection({4, 3}, {4, 7}, {6.05, 9.05}, {3.95, 6.95}, epsilon: 0.00000000000000000001)
      {true, :interior, {4.0, 6.999999999999998}}
  """
  @spec intersection(point, point, point, point, keyword()) :: intersection_result
  def intersection(a, b, c, d, options \\ []) do
    if envelope_check(a, b, c, d) do
      denom = calc_denom(a, b, c, d)
      eps = calc_eps(a, b, c, d, options)
      do_intersection(a, b, c, d, denom, eps)
    else
      {false, :disjoint, nil}
    end
  end

  defp do_intersection(a, b, c, d, denom, _) when denom == 0, do: parallel_int(a, b, c, d)
  defp do_intersection(a, _, c, d, _, _) when a == c or a == d, do: {true, :vertex, a}
  defp do_intersection(_, b, c, d, _, _) when b == c or b == d, do: {true, :vertex, b}

  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  defp do_intersection({ax, ay}, {bx, by}, {cx, cy}, {dx, dy}, denom, eps) do
    s = (ax * (dy - cy) + cx * (ay - dy) + dx * (cy - ay)) / denom
    t = -(ax * (cy - by) + bx * (ay - cy) + cx * (by - ay)) / denom

    t_zero = t >= -eps && t <= eps
    t_one = t >= 1.0 - eps && t <= 1.0 + eps
    s_zero = s >= -eps && s <= eps
    s_one = s >= 1.0 - eps && s <= 1.0 + eps

    cond do
      (t_zero || t_one) && collinear_not_between({ax, ay}, {bx, by}, {cx, cy}) ->
        {false, :disjoint, nil}

      (t_zero || t_one) && collinear_not_between({ax, ay}, {bx, by}, {dx, dy}) ->
        {false, :disjoint, nil}

      (s_zero || s_one) && collinear_not_between({cx, cy}, {dx, dy}, {ax, ay}) ->
        {false, :disjoint, nil}

      (s_zero || s_one) && collinear_not_between({cx, cy}, {dx, dy}, {bx, by}) ->
        {false, :disjoint, nil}

      s_zero ->
        {true, :vertex, {ax, ay}}

      s_one ->
        {true, :vertex, {bx, by}}

      t_zero ->
        {true, :vertex, {cx, cy}}

      t_one ->
        {true, :vertex, {dx, dy}}

      s > 0.0 && s < 1.0 && t > 0.0 && t < 1.0 ->
        {true, :interior, {ax + s * (bx - ax), ay + s * (by - ay)}}

      true ->
        {false, :disjoint, nil}
    end
  end

  defp calc_eps(a, b, c, d, options) do
    case Keyword.get(options, :epsilon, false) do
      false -> 0.0
      true -> eps_factor(a, b, c, d)
      num when is_number(num) -> num
    end
  end

  defp eps_factor({ax, ay}, {bx, by}, {cx, cy}, {dx, dy}) do
    ab_dist = (ax - bx) * (ax - bx) + (ay - by) * (ay - by)
    cd_dist = (cx - dx) * (cx - dx) + (cy - dy) * (cy - dy)
    :math.sqrt(min(ab_dist, cd_dist)) * @eps_factor
  end

  defp calc_denom({ax, ay}, {bx, by}, {cx, cy}, {dx, dy}) do
    ax * (dy - cy) + bx * (cy - dy) + dx * (by - ay) + cx * (ay - by)
  end

  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  defp envelope_check({ax, ay}, {bx, by}, {cx, cy}, {dx, dy}) do
    cond do
      ax < cx && ax < dx && bx < cx && bx < dx -> false
      ax > cx && ax > dx && bx > cx && bx > dx -> false
      ay < cy && ay < dy && by < cy && by < dy -> false
      ay > cy && ay > dy && by > cy && by > dy -> false
      true -> true
    end
  end

  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  defp parallel_int(a, b, c, d) do
    cond do
      !collinear(a, b, c) -> {false, :disjoint, nil}
      a == c && (between(a, b, d) || between(a, d, b)) -> {true, :edge, nil}
      a == d && (between(a, b, c) || between(a, c, b)) -> {true, :edge, nil}
      b == c && (between(a, b, d) || between(b, d, a)) -> {true, :edge, nil}
      b == d && (between(a, b, c) || between(b, c, a)) -> {true, :edge, nil}
      a == c || a == d -> {true, :vertex, a}
      b == c || b == d -> {true, :vertex, b}
      true -> {true, :edge, nil}
    end
  end

  defp collinear_not_between(a, b, c) do
    collinear(a, b, c) && !between(a, b, c)
  end

  defp collinear({ax, ay}, {bx, by}, {cx, cy}) do
    Vector.cross({ax - cx, ay - cy}, {bx - cx, by - cy}) == {0, 0, 0}
  end

  defp between({ax, ay}, {bx, by}, {_, py}) when ax == bx,
    do: (ay <= py && py <= by) || (ay >= py && py >= by)

  defp between({ax, _}, {bx, _}, {px, _}), do: (ax <= px && px <= bx) || (ax >= px && px >= bx)
end